dsh-manage-session 0.4.8 → 0.5.3

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.en.md +4 -0
  3. package/README.md +4 -0
  4. package/cordis.patch.yml +6 -1
  5. package/dsh.plugin.json +22 -22
  6. package/lib/client.js +3848 -202
  7. package/lib/index.d.ts +759 -29
  8. package/lib/index.js +2604 -93
  9. package/locale/de.json +6 -0
  10. package/locale/en.json +6 -0
  11. package/locale/es.json +6 -0
  12. package/locale/fr.json +6 -0
  13. package/locale/it.json +6 -0
  14. package/locale/ja.json +6 -0
  15. package/locale/ko.json +6 -0
  16. package/locale/ru.json +6 -0
  17. package/locale/zh.json +6 -0
  18. package/package.json +43 -32
  19. package/src/client/bridge-scope.ts +213 -0
  20. package/src/client/index.ts +154 -26
  21. package/src/client/locales.ts +857 -223
  22. package/src/client/panel.tsx +139 -118
  23. package/src/client/search/card.tsx +563 -0
  24. package/src/client/search/host-api.ts +208 -0
  25. package/src/client/search/locales.ts +780 -0
  26. package/src/client/search/manage.tsx +400 -0
  27. package/src/client/search/panel.tsx +815 -0
  28. package/src/client/search/platform.ts +136 -0
  29. package/src/config.ts +54 -5
  30. package/src/host/history/archive-source.ts +48 -0
  31. package/src/host/history/archive.ts +56 -1
  32. package/src/host/history/favorites.ts +71 -0
  33. package/src/host/index/archive-source.ts +90 -0
  34. package/src/host/index/engine.ts +571 -0
  35. package/src/host/index/extract.ts +272 -0
  36. package/src/host/index/peers.ts +62 -0
  37. package/src/host/index/rebuild.ts +368 -0
  38. package/src/host/index/schema.ts +136 -0
  39. package/src/host/index/snapshot.ts +135 -0
  40. package/src/host/index/sync.ts +286 -0
  41. package/src/host/registry/archive-registry.ts +317 -0
  42. package/src/host/settings-bridge.ts +237 -0
  43. package/src/index.ts +1421 -532
@@ -0,0 +1,815 @@
1
+ /**
2
+ * 搜索子域 client 组件(原 dsh-search-index client 半身的面板部分)。
3
+ *
4
+ * 注册职责(侧栏入口 / 家族 tab / 插件页配置卡)在合并包的 client/index.ts;
5
+ * 本文件只持有:样式表、悬浮搜索面板(标题/内容双模式)、侧栏 footer 按钮、
6
+ * 唤出和弦绑定。
7
+ *
8
+ * The `locale` and config services are consumed structurally: when
9
+ * the host release lacks them the footer falls back to the bundled zh
10
+ * dictionary and the host-composition config layer.
11
+ */
12
+ import { createElement, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactElement } from 'react'
13
+ import { createPortal } from 'react-dom'
14
+ import type { SwitchSearchConfig } from '../../config.ts'
15
+ // 0.1.7:插件族共用设置节(dsh-thinking-levels 持有并声明 `dsh-family.tab`
16
+ // 子席位;本仓不依赖 ui-slots 类型包,席位键以运行时 children 表为准)。
17
+ import { callHost, callHostAny, type HostContentHit, type HostIndexStatus, type HostSessionItem, type HostSortMode, type SwitchSessionsService, type SwitchUiWorkspaceService } from './host-api.ts'
18
+ import type { SwitchCardScope } from './card.tsx'
19
+ import { translate, type LocaleKey } from './locales.ts'
20
+ import { LABELS, isInvokeChord } from './platform.ts'
21
+
22
+ /** ------------------------------------------------------------------ types */
23
+
24
+ /** The client slots service face (structural subset used here). */
25
+ interface SwitchSlotsService {
26
+ inject(key: string, callback: () => () => void, label?: string): () => void
27
+ register(options: {
28
+ name: string
29
+ id?: string
30
+ key?: string
31
+ order?: number
32
+ store?: unknown
33
+ locale?: string
34
+ label?: string | (() => string)
35
+ inject?: (actions: unknown) => unknown
36
+ }, component: unknown): () => void
37
+ }
38
+
39
+ /**
40
+ * The client sessions service face: open a session from a search result.
41
+ * Defined in `host-api.ts` (shared with `openSessionThrough`); re-imported as
42
+ * a type here for the cordis Context augmentation below.
43
+ */
44
+
45
+ /** The client settings-scope service face (structural subset). */
46
+ interface SwitchSettingsScope<C> {
47
+ bind(spec: { namespace: string }): SwitchScopeLike<C>
48
+ }
49
+ /** configForms 服务面(0.1.7:以 profile entry id 取句柄)。 */
50
+ interface SwitchConfigForms<T> {
51
+ get(entryId: string): SwitchScopeLike<T>
52
+ }
53
+ interface SwitchScopeLike<T> {
54
+ getSnapshot(): {
55
+ status: 'loading' | 'ready' | 'unavailable'
56
+ value: T | undefined
57
+ revision: number | undefined
58
+ writable: boolean
59
+ }
60
+ subscribe(listener: () => void): () => void
61
+ set(field: string, value: unknown): Promise<void>
62
+ }
63
+
64
+ /**
65
+ * The client locale service face (structural mirror; the plugin must not
66
+ * value- or type-import a single release of the official locale package).
67
+ */
68
+ interface SwitchLocaleService {
69
+ register(ns: string, dicts: Record<string, Record<string, string>>): () => void
70
+ register(ns: string, localeId: string, dicts: Record<string, string>): () => void
71
+ /**
72
+ * Read-time translator bound to a namespace (host `dsh-client-locale`).
73
+ * Needed for slot `label` thunks, which the owner re-reads per render so the
74
+ * tab text follows locale switches without re-registration. Optional: older
75
+ * hosts may expose `register` only, hence the guarded call site.
76
+ */
77
+ bind?(ns: string): (key: string, params?: Record<string, unknown>) => string
78
+ }
79
+
80
+ /** The locale dictionary face the renderer may hand the card as `t`. */
81
+ type CardLocale = (key: LocaleKey, params?: Record<string, unknown>) => string
82
+
83
+ /** Coarse content-type filter carried to the host content-search. */
84
+ type ContentType = 'all' | 'user' | 'reply' | 'tool'
85
+
86
+ /** 检索域(R1:归档正文入索引后的 chip 维度)。 */
87
+ type ArchivedDomain = 'all' | 'active' | 'archived'
88
+
89
+ /** 面板模式:标题 / 内容(管理台归管家面板,见 search/manage.tsx)。 */
90
+ type PanelMode = 'title' | 'content'
91
+
92
+ /** The coarse filter chips rendered above content results. */
93
+ const CONTENT_TYPE_CHIPS: readonly { id: ContentType; labelKey: LocaleKey }[] = [
94
+ { id: 'all', labelKey: 'filter.all' },
95
+ { id: 'user', labelKey: 'filter.user' },
96
+ { id: 'reply', labelKey: 'filter.reply' },
97
+ { id: 'tool', labelKey: 'filter.tool' },
98
+ ]
99
+
100
+ /** 检索域 chips(标题/内容两模式共用)。 */
101
+ const ARCHIVED_CHIPS: readonly { id: ArchivedDomain; labelKey: LocaleKey }[] = [
102
+ { id: 'all', labelKey: 'domain.all' },
103
+ { id: 'active', labelKey: 'domain.active' },
104
+ { id: 'archived', labelKey: 'domain.archived' },
105
+ ]
106
+
107
+ /** Result-ordering chips rendered at the right of the same filter row. */
108
+ const SORT_CHIPS: readonly { id: HostSortMode; labelKey: LocaleKey }[] = [
109
+ { id: 'relevance', labelKey: 'sort.relevance' },
110
+ { id: 'time', labelKey: 'sort.time' },
111
+ ]
112
+
113
+ /**
114
+ * The identity of one content-search request, derived from every input that
115
+ * changes the result set.
116
+ *
117
+ * This is the single owner of that identity. The effect that issues the
118
+ * request and the render path that decides whether the stored result belongs
119
+ * to the current inputs must both come through here: two hand-written copies
120
+ * of the same template literal drift apart silently, and the render path then
121
+ * falls through to its empty `loading` state for every query — results arrive,
122
+ * parse, and are never shown.
123
+ *
124
+ * @param normalized - the trimmed query text.
125
+ * @param contentType - the active content-type filter.
126
+ * @param sortBy - the active result ordering.
127
+ * @param archivedFilter - the retrieval-domain chip (all/active/archived).
128
+ * @returns an opaque key, stable for equal inputs.
129
+ */
130
+ function contentRequestKey(normalized: string, contentType: ContentType, sortBy: HostSortMode, archivedFilter: ArchivedDomain, dateRange: { from: string; to: string }): string {
131
+ return `${normalized}\u0000${contentType}\u0000${sortBy}\u0000${archivedFilter}\u0000${dateRange.from}\u0000${dateRange.to}`
132
+ }
133
+
134
+ /**
135
+ * Persisted ordering preference. Unlike `lastPanelMode` this one survives a
136
+ * page reload — an ordering is a durable preference, not a session mood.
137
+ */
138
+ const SORT_STORE_KEY = 'dsh-search-index.sortBy'
139
+
140
+ /** Read the persisted ordering; private mode or a bad value degrades to relevance. */
141
+ function readStoredSort(): HostSortMode {
142
+ try {
143
+ return window.localStorage.getItem(SORT_STORE_KEY) === 'time' ? 'time' : 'relevance'
144
+ } catch {
145
+ return 'relevance'
146
+ }
147
+ }
148
+
149
+ /** Persist the ordering; a storage failure still leaves this session working. */
150
+ function writeStoredSort(next: HostSortMode): void {
151
+ try {
152
+ window.localStorage.setItem(SORT_STORE_KEY, next)
153
+ } catch {
154
+ // ignore: the choice applies for the rest of this session regardless
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Local re-sort so the page honours the chosen ordering even against an older
160
+ * host half that ignores `sortBy` and therefore omits `updatedAt` entirely —
161
+ * in that case the host order is left untouched rather than scrambled to NaN.
162
+ */
163
+ function sortHits(items: readonly HostContentHit[], sortBy: HostSortMode): HostContentHit[] {
164
+ if (sortBy !== 'time') return [...items]
165
+ if (!items.every(item => typeof item.updatedAt === 'number' && Number.isFinite(item.updatedAt))) return [...items]
166
+ return [...items].sort((a, b) => b.updatedAt - a.updatedAt)
167
+ }
168
+
169
+ /** The footer-action owner share (structural subset). */
170
+ interface SwitchFooterProps {
171
+ wide: boolean
172
+ }
173
+
174
+ /** Last panel mode used this web session (mode memory, not persisted). */
175
+ let lastPanelMode: PanelMode = 'title'
176
+
177
+ declare module 'cordis' {
178
+ interface Context {
179
+ slots: SwitchSlotsService
180
+ sessions?: SwitchSessionsService
181
+ uiWorkspace?: SwitchUiWorkspaceService
182
+ settingsScope?: SwitchSettingsScope<SwitchSearchConfig>
183
+ locale?: SwitchLocaleService
184
+ }
185
+ }
186
+
187
+ /** ------------------------------------------------------------------ styles */
188
+
189
+ const CSS = `
190
+ /* 侧栏 footer 槽位公约(2026-09-26;2026-10-01 收紧行距并强制居中):一行多
191
+ 入口(第三方 dsh-context 等)会互相挤占 —— 宿主 .footerActions 是 nowrap
192
+ flex 行。这里允许容器换行,并把本插件入口钉成独占一整行(flex-basis:100%);
193
+ 其余入口(含第三方)自然落到后续行。justify-content:center 让行内所有入口
194
+ (含不占满行的)统一居中;row-gap:0 配合入口自身 32px 高度压缩纵向占位。
195
+ 类名用 [class*=] 中段匹配:宿主是 CSS Module 哈希类名(实测形如
196
+ hHd-Xa_footerActions —— <hash>_<name>,哈希在前),中段跨版本稳定。 */
197
+ [class*="footerActions"]{flex-wrap:wrap;justify-content:center;row-gap:0;height:auto;min-height:0}
198
+ /* —— 第三方矫正:dsh-context「上下文洞察」入口(2026-10-01)——
199
+ .lc-ov-entry 按"独占整行"设计(width:calc(100% + 4px)、无 justify-content、
200
+ 42px 高、不对称 padding),与本槽位公约(每个入口独占一行、行内居中、32px)
201
+ 冲突,表现为文字靠左、纵向松散。这里按公约强制矫正;:not() 排除收起轨道的
202
+ 36px 圆钮形态。lc-ov-* 是 dsh-context 源码硬编码类名(非构建哈希),跨版本
203
+ 稳定(实测 0.56.1 / 0.60.0 规则一致)。 */
204
+ .lc-ov-entry:not(.lc-ov-entry-rail){width:auto!important;flex:0 0 100%!important;min-width:0!important;justify-content:center!important;height:32px!important;margin:0!important;padding:0 10px!important}
205
+ .lc-ov-entry-label{flex:0 1 auto!important}
206
+ .dsws_root{box-sizing:border-box;position:relative;display:flex;align-items:center;justify-content:center;flex:0 0 100%;width:100%;min-width:0;container-type:inline-size}
207
+ /* 收起轨道:回落自然宽度(根类的 100% 基准只属于宽栏形态),放弃收缩。 */
208
+ .dsws_rootRail{flex:none;width:auto;container-type:normal}
209
+ /* 高度 32px(内容 22px 行高 + 上下各 5px):宿主默认 42px 的上下裕度在
210
+ 多行堆叠后过于松散;左右内边距对称(10px/10px),否则整行居中时按钮内容
211
+ 会因不对称 padding 向左偏 1px。 */
212
+ .dsws_button{box-sizing:border-box;display:inline-flex;align-items:center;flex:0 0 auto;min-width:0;gap:8px;height:32px;border:none;border-radius:12px;background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;padding:0 10px;font-family:inherit;font-size:14px;line-height:22px;white-space:nowrap;overflow:hidden;transition:background-color 160ms ease-out,color 160ms ease-out}
213
+ .dsws_buttonRail{flex:none;width:28px;height:28px;padding:0;gap:0;justify-content:center;border-radius:50%}
214
+ .dsws_button:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
215
+ .dsws_button svg{flex:none}
216
+ .dsws_trigger{position:fixed;z-index:2147483000;left:50%;top:50%;transform:translate(-50%,-50%);width:860px;max-width:calc(100vw - 32px);max-height:min(84vh,880px);box-sizing:border-box;background:var(--dsw-specific-tip);border:1px solid var(--dsw-alias-border-l1);border-radius:12px;box-shadow:var(--dsw-shadow-lv3,0 8px 28px rgba(0,0,0,.16));overflow:hidden;display:flex;flex-direction:column;font-family:Inter,var(--dsw-font-family)}
217
+ .dsws_toolrow{display:flex;align-items:center;gap:8px;padding:10px 10px 0}
218
+ .dsws_mode{display:inline-flex;align-items:center;gap:2px;flex:none;background:var(--dsw-alias-interactive-bg-hover);border-radius:8px;padding:2px}
219
+ .dsws_modeBtn{height:24px;border:none;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;border-radius:6px;padding:0 8px;font-size:12px;font-weight:500;line-height:20px}
220
+ .dsws_modeBtn:hover{color:var(--dsw-alias-label-primary)}
221
+ .dsws_modeBtnActive{background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);box-shadow:0 1px 2px rgba(0,0,0,.08)}
222
+ .dsws_search{flex:auto;min-width:0;height:30px;box-sizing:border-box;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;outline:none;padding:0 10px;font:inherit;font-size:13px;line-height:20px}
223
+ .dsws_search:focus{border-color:var(--dsw-alias-state-business-primary)}
224
+ .dsws_search::placeholder{color:var(--dsw-alias-label-caption)}
225
+ .dsws_chips{display:flex;align-items:center;gap:6px;padding:8px 10px 0;flex:none}
226
+ .dsws_chipGap{flex:1;min-width:0}
227
+ .dsws_sortGroup{display:flex;align-items:center;gap:6px;flex:none}
228
+ .dsws_chip{height:24px;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;border-radius:999px;padding:0 10px;font-size:12px;font-weight:500;line-height:22px;white-space:nowrap}
229
+ .dsws_chip:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
230
+ .dsws_chipActive{background:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-primary)}
231
+ .dsws_list{flex:1 1 auto;min-height:0;overflow-y:auto;margin:8px 0 0;padding:0 6px 8px;list-style:none}
232
+ .dsws_row{box-sizing:border-box;border-radius:8px;width:100%;padding:7px 8px;cursor:pointer;text-align:left;border:none;background:transparent;color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;gap:2px;min-width:0}
233
+ .dsws_row:hover{background:var(--dsw-alias-interactive-bg-hover)}
234
+ .dsws_rowTitle{display:flex;align-items:center;gap:8px;min-width:0}
235
+ .dsws_titleText{flex:auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:500;line-height:18px}
236
+ .dsws_tag{flex:none;color:var(--dsw-alias-label-caption);font-size:11px;line-height:16px;white-space:nowrap;font-variant-numeric:tabular-nums}
237
+ .dsws_snippet{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:17px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;word-break:break-word}
238
+ .dsws_meta{color:var(--dsw-alias-label-caption);font-size:11px;line-height:16px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
239
+ .dsws_status{color:var(--dsw-alias-label-tertiary);padding:10px 8px 8px;font-size:12px;line-height:18px}
240
+ .dsws_error{color:var(--dsw-alias-state-error-primary);padding:8px;font-size:12px;line-height:18px}
241
+ .dsws_empty{color:var(--dsw-alias-label-tertiary);padding:10px 8px 8px;font-size:12px;line-height:18px}
242
+ .dsws_backdrop{position:fixed;inset:0;z-index:2147482999;background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.24));backdrop-filter:blur(var(--dsw-mask-blur,4px))}
243
+ .dsws_setRow{display:flex;align-items:center;gap:12px;padding:12px 0;border-bottom:1px solid var(--dsw-alias-border-l2)}
244
+ .dsws_setRow:last-child{border-bottom:none}
245
+ .dsws_setText{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
246
+ .dsws_setTitle{color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px}
247
+ .dsws_setDesc{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
248
+ .dsws_seg{display:inline-flex;align-items:center;gap:2px;background:var(--dsw-alias-interactive-bg-hover);border-radius:8px;padding:2px;flex:none}
249
+ .dsws_segBtn{height:24px;border:none;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;border-radius:6px;padding:0 10px;font-size:12px;font-weight:500;line-height:20px}
250
+ .dsws_segBtn:hover{color:var(--dsw-alias-label-primary)}
251
+ .dsws_segBtn:disabled{cursor:not-allowed;opacity:.5}
252
+ .dsws_segBtnActive{background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);box-shadow:0 1px 2px rgba(0,0,0,.08)}
253
+ .dsws_actBtn{height:26px;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;border-radius:8px;padding:0 10px;font-size:12px;line-height:24px;white-space:nowrap}
254
+ .dsws_actBtn:hover{background:var(--dsw-alias-interactive-bg-hover)}
255
+ .dsws_actBtn:disabled{cursor:not-allowed;opacity:.5}
256
+ .dsws_btnRow{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
257
+ .dsws_progressWrap{display:flex;flex-direction:column;gap:4px;padding:8px 10px 0}
258
+ .dsws_progress{height:6px;border-radius:3px;background:var(--dsw-alias-interactive-bg-hover);overflow:hidden}
259
+ .dsws_progressFill{height:100%;border-radius:3px;background:var(--dsw-alias-state-business-primary);transition:width .4s ease}
260
+ .dsws_progressLabel{color:var(--dsw-alias-state-business-primary);font-size:12px;line-height:18px;font-weight:600;font-variant-numeric:tabular-nums}
261
+ .dsws_panelFoot{flex:none;display:flex;align-items:center;gap:14px;padding:8px 14px;border-top:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}
262
+ .dsws_footItem{display:inline-flex;align-items:center}
263
+ .dsws_footItem>.dsws_kbd{margin-right:5px}
264
+ .dsws_footGap{flex:1;min-width:0}
265
+ .dsws_buttonLabel{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
266
+ .dsws_kbd{flex:none;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;min-width:18px;width:auto;height:18px;padding:0 4px;border:1px solid var(--dsw-alias-border-l2);border-radius:5px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font-size:10px;line-height:1;white-space:nowrap;font-variant-numeric:tabular-nums}
267
+ /*
268
+ * The row shares its width with the session-steward entry (order 12), so at
269
+ * narrow sidebar widths the shortcut chip, not the label, is what yields: the
270
+ * chip is a hint repeated by the entry tooltip and the panel footer, while the
271
+ * label is the entry's identity. Measured in Chrome: the pair needs 230px for
272
+ * both labels plus the chip; below that the chip's 43px is what makes the
273
+ * difference. The query is scoped to the wide form: container-type also
274
+ * applies inline-size containment, which zeroes a flex:none rail root.
275
+ */
276
+ @container (max-width: 132px){.dsws_kbd{display:none}}
277
+ .dsws_pill{flex:none;display:inline-grid;grid-template-columns:14px max-content;align-items:center;column-gap:4px;height:26px;padding:0 10px;box-sizing:border-box;border:none;border-radius:8px;font-size:12px;font-weight:500;line-height:18px;white-space:nowrap;transition:background-color 160ms ease-out,color 160ms ease-out}
278
+ .dsws_pill .dsws_pillIcon{display:grid;place-items:center;width:14px;height:14px}
279
+ .dsws_pill .dsws_pillLabel{display:grid;text-align:left}
280
+ .dsws_pill .dsws_pillLabel>span{grid-area:1/1}
281
+ .dsws_pillReady{background:var(--dsw-alias-state-success-tertiary);color:var(--dsw-alias-state-success-primary)}
282
+ .dsws_pillWarn{background:var(--dsw-alias-state-warn-tertiary);color:var(--dsw-alias-state-warn-label)}
283
+ .dsws_pillError{background:var(--dsw-alias-state-error-tertiary,var(--dsw-alias-state-warn-tertiary));color:var(--dsw-alias-state-error-primary,var(--dsw-alias-state-warn-label))}
284
+ .dsws_pillNeutral{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}
285
+ .dsws_pillDots span{opacity:0;animation:dsws-reveal-dot 1.5s step-end infinite}
286
+ .dsws_pillDots span:nth-child(2){animation-delay:.5s}
287
+ .dsws_pillDots span:nth-child(3){animation-delay:1s}
288
+ @keyframes dsws-reveal-dot{0%,32%{opacity:0}33%,100%{opacity:1}}
289
+ @media (prefers-reduced-motion:reduce){.dsws_pillDots span{animation:none;opacity:1}}
290
+ `
291
+
292
+ /** Inject the plugin stylesheet once per activation (removed on disposal). */
293
+ export function injectStyles(): () => void {
294
+ if (typeof document === 'undefined') return () => {}
295
+ if (document.querySelector('style[data-plugin-css="dsw-session-search-toggle/styles"]') !== null) return () => {}
296
+ const tag = document.createElement('style')
297
+ tag.dataset.plugin = 'dsh-search-index'
298
+ tag.dataset.pluginCss = 'dsw-session-search-toggle/styles'
299
+ tag.textContent = CSS
300
+ document.head.appendChild(tag)
301
+ return () => {
302
+ if (tag.parentNode !== null) tag.parentNode.removeChild(tag)
303
+ }
304
+ }
305
+
306
+ /** ------------------------------------------------------------------ view */
307
+
308
+ /** The floating search panel. */
309
+ function SwitchPanel({
310
+ t,
311
+ onClose,
312
+ open,
313
+ }: {
314
+ t?: CardLocale
315
+ onClose: () => void
316
+ open: (sessionId: string) => void
317
+ }): ReactElement {
318
+ // Mode memory: the panel reopens in the mode last used in this web session
319
+ // (first open falls back to 'title'). Session-scoped on purpose — no
320
+ // persistence, the settings card's defaultMode stays the durable preference.
321
+ const [mode, setModeState] = useState<PanelMode>(lastPanelMode)
322
+ const setMode = (next: PanelMode): void => {
323
+ lastPanelMode = next
324
+ setModeState(next)
325
+ }
326
+ const [query, setQuery] = useState('')
327
+ const [contentType, setContentType] = useState<ContentType>('all')
328
+ // 检索域 chip:标题/内容两模式共用(R1——归档正文已入索引)。
329
+ const [archivedFilter, setArchivedFilter] = useState<ArchivedDomain>('all')
330
+ // 日期范围(R6):YYYY-MM-DD,空串 = 不限;结束日含当天(宿主半排他上界 +1d)。
331
+ const [dateFrom, setDateFrom] = useState('')
332
+ const [dateTo, setDateTo] = useState('')
333
+ const [sortBy, setSortByState] = useState<HostSortMode>(readStoredSort)
334
+ const setSortBy = (next: HostSortMode): void => {
335
+ writeStoredSort(next)
336
+ setSortByState(next)
337
+ }
338
+ const [sessions, setSessions] = useState<HostSessionItem[] | null>(null)
339
+ const [sessionsError, setSessionsError] = useState<string | null>(null)
340
+ const [content, setContent] = useState<{ query: string; status: 'idle' | 'loading' | 'ready' | 'error'; items: HostContentHit[]; error?: string }>({
341
+ query: '',
342
+ status: 'idle',
343
+ items: [],
344
+ })
345
+ // Independent-index availability probe + rebuild progress.
346
+ const [searchStatus, setSearchStatus] = useState<{
347
+ available: boolean | null
348
+ reason?: string
349
+ rebuilding: boolean
350
+ done: number
351
+ total: number
352
+ count: number
353
+ }>({ available: null, rebuilding: false, done: 0, total: 0, count: 0 })
354
+ const inputRef = useRef<HTMLInputElement | null>(null)
355
+ const listRef = useRef<HTMLUListElement | null>(null)
356
+
357
+ const normalized = query.trim().toLowerCase()
358
+
359
+ // Probe the independent index status on open, and keep polling while a
360
+ // rebuild ("整理") is in flight so the panel flips to ready on completion.
361
+ useEffect(() => {
362
+ let cancelled = false
363
+ let timer: number | undefined
364
+ const probe = (): void => {
365
+ callHostAny<HostIndexStatus>('index-status', {}).then((res) => {
366
+ if (cancelled) return
367
+ if (!res.ok) {
368
+ // Old host half without this route (browser refresh keeps the old
369
+ // process): show an actionable state instead of a blank panel.
370
+ setSearchStatus({ available: false, reason: 'unreachable', rebuilding: false, done: 0, total: 0, count: 0 })
371
+ return
372
+ }
373
+ const status = res as HostIndexStatus
374
+ const rebuilding = status.rebuild?.state === 'building' || status.rebuild?.state === 'swapping'
375
+ setSearchStatus({
376
+ available: status.available ?? false,
377
+ reason: status.reason,
378
+ rebuilding,
379
+ done: status.rebuild?.done ?? 0,
380
+ total: status.rebuild?.total ?? 0,
381
+ count: status.sync?.indexed ?? 0,
382
+ })
383
+ if (rebuilding) timer = window.setTimeout(probe, 1500)
384
+ })
385
+ }
386
+ probe()
387
+ return () => {
388
+ cancelled = true
389
+ if (timer !== undefined) window.clearTimeout(timer)
390
+ }
391
+ }, [])
392
+
393
+ const startIndexBuild = (): void => {
394
+ setSearchStatus(prev => ({ ...prev, rebuilding: true }))
395
+ void callHostAny<HostIndexStatus>('index-rebuild', {})
396
+ }
397
+
398
+ // Load the title-search corpus once on open.
399
+ useEffect(() => {
400
+ if (sessions !== null) return
401
+ let cancelled = false
402
+ callHost<HostSessionItem>('list-sessions', {}).then((res) => {
403
+ if (cancelled) return
404
+ if (res.ok) { setSessions(res.items); setSessionsError(null) }
405
+ else setSessionsError(res.error ?? '读取会话列表失败')
406
+ })
407
+ return () => { cancelled = true }
408
+ }, [sessions])
409
+
410
+ // Content search debounces against the host route.
411
+ useEffect(() => {
412
+ if (mode !== 'content' || normalized === '') {
413
+ if (mode !== 'content') setContent({ query: normalized, status: 'idle', items: [] })
414
+ return
415
+ }
416
+ let cancelled = false
417
+ const requestType: ContentType = contentType
418
+ const requestKey = contentRequestKey(normalized, requestType, sortBy, archivedFilter, { from: dateFrom, to: dateTo })
419
+ setContent(prev => ({ query: requestKey, status: 'loading', items: prev.query === requestKey ? prev.items : [] }))
420
+ const timer = window.setTimeout(() => {
421
+ callHost<HostContentHit>('content-search', {
422
+ query: normalized,
423
+ limit: 50,
424
+ types: requestType === 'all' ? undefined : [requestType],
425
+ sortBy,
426
+ archived: archivedFilter,
427
+ from: dateFrom === '' ? undefined : dateFrom,
428
+ to: dateTo === '' ? undefined : dateTo,
429
+ }).then((res) => {
430
+ if (cancelled) return
431
+ setContent({
432
+ query: requestKey,
433
+ status: res.ok ? 'ready' : 'error',
434
+ items: res.ok ? sortHits(res.items, sortBy) : [],
435
+ error: res.ok ? undefined : (res.error ?? '搜索失败'),
436
+ })
437
+ })
438
+ }, 250)
439
+ return () => {
440
+ cancelled = true
441
+ window.clearTimeout(timer)
442
+ }
443
+ }, [mode, normalized, contentType, sortBy, archivedFilter, dateFrom, dateTo])
444
+
445
+ // Focus the input on open; reset mode on every open.
446
+ useEffect(() => {
447
+ inputRef.current?.focus()
448
+ }, [])
449
+
450
+ // Escape closes the panel.
451
+ useEffect(() => {
452
+ const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') onClose() }
453
+ document.addEventListener('keydown', onKey)
454
+ return () => { document.removeEventListener('keydown', onKey) }
455
+ }, [onClose])
456
+
457
+ // Title-mode rows: local substring filter over the corpus + domain chip.
458
+ // 标题模式的日期过滤走本地(语料行自带 updatedAt;结束日含当天)。
459
+ const dateRangeMs = useMemo<{ from?: number; to?: number }>(() => {
460
+ const parse = (value: string, nextDay: boolean): number | undefined => {
461
+ if (value === '') return undefined
462
+ const [year, month, day] = value.split('-').map(Number)
463
+ if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return undefined
464
+ const date = new Date(year, month - 1, day)
465
+ if (nextDay) date.setDate(date.getDate() + 1)
466
+ return date.getTime()
467
+ }
468
+ const from = parse(dateFrom, false)
469
+ const to = parse(dateTo, true)
470
+ return { ...(from === undefined ? {} : { from }), ...(to === undefined ? {} : { to }) }
471
+ }, [dateFrom, dateTo])
472
+
473
+ const titleRows = useMemo<HostSessionItem[]>(() => {
474
+ if (sessions === null) return []
475
+ const byDomain = sessions.filter(item =>
476
+ archivedFilter === 'active' ? item.archived !== true
477
+ : archivedFilter === 'archived' ? item.archived === true
478
+ : true)
479
+ const byDate = byDomain.filter(item =>
480
+ (dateRangeMs.from === undefined || item.updatedAt >= dateRangeMs.from)
481
+ && (dateRangeMs.to === undefined || item.updatedAt < dateRangeMs.to))
482
+ if (normalized === '') return byDate
483
+ return byDomain.filter(item =>
484
+ item.title.toLowerCase().includes(normalized)
485
+ || item.cwd.toLowerCase().includes(normalized))
486
+ }, [sessions, normalized, archivedFilter, dateRangeMs])
487
+
488
+ const children: ReactElement[] = []
489
+ if (sessionsError !== null) {
490
+ children.push(createElement('div', { key: 'err', className: 'dsws_error' }, translate(t, 'panel.sessionsError', { error: sessionsError })))
491
+ }
492
+ const activeRequestKey = contentRequestKey(normalized, contentType, sortBy, archivedFilter, { from: dateFrom, to: dateTo })
493
+ const activeContent = content.query === activeRequestKey ? content : { query: activeRequestKey, status: 'loading' as const, items: [] }
494
+ if (mode === 'title') {
495
+ if (sessions === null) {
496
+ children.push(createElement('div', { key: 'loading', className: 'dsws_status' }, translate(t, 'panel.loadingSessions')))
497
+ } else if (titleRows.length === 0) {
498
+ children.push(createElement('div', { key: 'empty', className: 'dsws_empty' }, normalized === '' ? translate(t, 'panel.noSessions') : translate(t, 'panel.noMatch')))
499
+ } else {
500
+ children.push(createElement('ul', {
501
+ key: 'list',
502
+ ref: listRef,
503
+ className: 'dsws_list',
504
+ role: 'listbox',
505
+ 'aria-label': translate(t, 'panel.titleSearch'),
506
+ }, titleRows.slice(0, 200).map(item => createElement('li', { key: item.sessionId, role: 'option' }, createElement('button', {
507
+ type: 'button',
508
+ className: 'dsws_row',
509
+ onClick: () => { open(item.sessionId) },
510
+ }, [
511
+ createElement('span', { key: 't', className: 'dsws_rowTitle' }, [
512
+ createElement('span', { key: 'x', className: 'dsws_titleText' }, item.title || translate(t, 'panel.untitled')),
513
+ item.archived === true && createElement('span', { key: 'arch', className: 'dsws_tagArch' }, translate(t, 'tag.archived')),
514
+ createElement('span', { key: 'tag', className: 'dsws_tag' }, fmtTime(item.updatedAt)),
515
+ ]),
516
+ item.cwd !== '' && createElement('span', { key: 'c', className: 'dsws_meta' }, item.cwd),
517
+ ])))))
518
+ }
519
+ } else {
520
+ if (searchStatus.rebuilding) {
521
+ const pct = searchStatus.total > 0
522
+ ? Math.min(100, Math.round((searchStatus.done / searchStatus.total) * 100))
523
+ : undefined
524
+ children.push(createElement('div', { key: 'rebuilding', className: 'dsws_progressWrap' }, [
525
+ createElement('div', { key: 'bar', className: 'dsws_progress' },
526
+ createElement('div', {
527
+ className: 'dsws_progressFill',
528
+ style: pct === undefined ? { width: '30%', opacity: 0.6 } : { width: `${pct}%` },
529
+ })),
530
+ createElement('span', { key: 'label', className: 'dsws_progressLabel' },
531
+ translate(t, 'panel.rebuilding', { done: searchStatus.done, total: searchStatus.total || '?' })),
532
+ ]))
533
+ } else if (searchStatus.available === false) {
534
+ children.push(createElement('div', { key: 'unavailable', className: 'dsws_error' }, [
535
+ createElement('div', { key: 'msg' },
536
+ searchStatus.reason === 'unreachable'
537
+ ? '索引状态不可达:Host 半可能是旧进程。请完全重启 dsh web(浏览器刷新不重载 Host)后重试。'
538
+ : searchStatus.reason === 'unavailable'
539
+ ? translate(t, 'panel.unavailable')
540
+ : translate(t, 'panel.notBuilt')),
541
+ createElement('button', {
542
+ key: 'build',
543
+ type: 'button',
544
+ className: 'dsws_actBtn',
545
+ style: { marginTop: '6px' },
546
+ onClick: startIndexBuild,
547
+ }, translate(t, 'panel.buildIndex')),
548
+ ]))
549
+ } else if (activeContent.status === 'loading') {
550
+ children.push(createElement('div', { key: 'loading', className: 'dsws_status' }, translate(t, 'panel.loadingContent')))
551
+ } else if (activeContent.status === 'error') {
552
+ children.push(createElement('div', { key: 'error', className: 'dsws_error' }, translate(t, 'panel.contentError', { error: activeContent.error ?? '未知错误' })))
553
+ } else if (activeContent.items.length === 0) {
554
+ children.push(createElement('div', { key: 'empty', className: 'dsws_empty' }, normalized === '' ? translate(t, 'panel.contentHint') : translate(t, 'panel.noContent')))
555
+ } else {
556
+ children.push(createElement('ul', {
557
+ key: 'list',
558
+ ref: listRef,
559
+ className: 'dsws_list',
560
+ role: 'listbox',
561
+ 'aria-label': translate(t, 'panel.contentSearch'),
562
+ }, activeContent.items.slice(0, 200).map(item => createElement('li', { key: item.sessionId, role: 'option' }, createElement('button', {
563
+ type: 'button',
564
+ className: 'dsws_row',
565
+ onClick: () => { open(item.sessionId) },
566
+ }, [
567
+ createElement('span', { key: 't', className: 'dsws_rowTitle' }, [
568
+ createElement('span', { key: 'x', className: 'dsws_titleText' }, item.title || translate(t, 'panel.untitled')),
569
+ createElement('span', { key: 'tag', className: 'dsws_tag' }, typeLabel(t, item.type)),
570
+ // The session clock — the very field the 「时间」 ordering sorts by and
571
+ // the one the title rows already show. Ordering rows by a time the
572
+ // rows never display is illegible: you cannot tell what the sort did.
573
+ createElement('span', { key: 'time', className: 'dsws_tag' }, fmtTime(item.updatedAt)),
574
+ ]),
575
+ createElement('span', { key: 's', className: 'dsws_snippet' }, item.snippet || translate(t, 'panel.noText')),
576
+ ])))))
577
+ }
578
+ }
579
+
580
+ return createPortal(createElement('div', { key: 'switch-root' }, [
581
+ createElement('div', { key: 'backdrop', className: 'dsws_backdrop', onClick: onClose }),
582
+ createElement('div', { key: 'panel', className: 'dsws_trigger', role: 'dialog', 'aria-label': translate(t, 'card.title') }, [
583
+ createElement('div', { key: 'tools', className: 'dsws_toolrow' }, [
584
+ createElement('div', { key: 'mode', className: 'dsws_mode', role: 'group', 'aria-label': translate(t, 'card.defaultMode') }, [
585
+ createElement('button', {
586
+ key: 'title',
587
+ type: 'button',
588
+ className: `dsws_modeBtn${mode === 'title' ? ' dsws_modeBtnActive' : ''}`,
589
+ onClick: () => { setMode('title') },
590
+ }, translate(t, 'panel.titleSearch')),
591
+ createElement('button', {
592
+ key: 'content',
593
+ type: 'button',
594
+ className: `dsws_modeBtn${mode === 'content' ? ' dsws_modeBtnActive' : ''}`,
595
+ onClick: () => { setMode('content') },
596
+ }, translate(t, 'panel.contentSearch')),
597
+ ]),
598
+ createElement('input', {
599
+ key: 'search',
600
+ ref: inputRef,
601
+ className: 'dsws_search',
602
+ type: 'text',
603
+ placeholder: mode === 'title' ? translate(t, 'panel.searchTitle') : translate(t, 'panel.searchContent'),
604
+ value: query,
605
+ onChange: (e: { target: { value: string } }) => setQuery(e.target.value),
606
+ }),
607
+ ]),
608
+ createElement('div', { key: 'chips', className: 'dsws_chips', role: 'group', 'aria-label': translate(t, 'domain.all') }, [
609
+ // 检索域 chips(全部/活跃/归档)在前——标题与内容两模式共用。
610
+ ...ARCHIVED_CHIPS.map(chip => createElement('button', {
611
+ key: chip.id,
612
+ type: 'button',
613
+ className: `dsws_chip${archivedFilter === chip.id ? ' dsws_chipActive' : ''}`,
614
+ 'aria-pressed': archivedFilter === chip.id,
615
+ onClick: () => { setArchivedFilter(chip.id) },
616
+ }, translate(t, chip.labelKey))),
617
+ // 日期范围(R6):两个 date 输入,标题/内容共用;空 = 不限。
618
+ createElement('input', {
619
+ key: 'from', type: 'date', className: 'dsws_search', style: { flex: 'none', width: '128px', height: '24px', padding: '0 6px', fontSize: '12px' },
620
+ value: dateFrom, title: translate(t, 'date.from'),
621
+ onChange: (e: { target: { value: string } }) => { setDateFrom(e.target.value) },
622
+ }),
623
+ createElement('input', {
624
+ key: 'to', type: 'date', className: 'dsws_search', style: { flex: 'none', width: '128px', height: '24px', padding: '0 6px', fontSize: '12px' },
625
+ value: dateTo, title: translate(t, 'date.to'),
626
+ onChange: (e: { target: { value: string } }) => { setDateTo(e.target.value) },
627
+ }),
628
+ // 类型筛选与排序只属于内容模式。
629
+ ...(mode === 'content' ? CONTENT_TYPE_CHIPS.map(chip => createElement('button', {
630
+ key: chip.id,
631
+ type: 'button',
632
+ className: `dsws_chip${contentType === chip.id ? ' dsws_chipActive' : ''}`,
633
+ 'aria-pressed': contentType === chip.id,
634
+ onClick: () => { setContentType(chip.id) },
635
+ }, translate(t, chip.labelKey))) : []),
636
+ // Ordering sits on the same row, pushed right: it filters the same
637
+ // result set the type chips do, so it is not a separate toolbar.
638
+ createElement('span', { key: 'gap', className: 'dsws_chipGap' }),
639
+ ...(mode === 'content' ? [createElement('span', { key: 'sort', className: 'dsws_sortGroup', role: 'group', 'aria-label': translate(t, 'sort.label') },
640
+ SORT_CHIPS.map(chip => createElement('button', {
641
+ key: chip.id,
642
+ type: 'button',
643
+ className: `dsws_chip${sortBy === chip.id ? ' dsws_chipActive' : ''}`,
644
+ 'aria-pressed': sortBy === chip.id,
645
+ title: chip.id === 'time' ? translate(t, 'sort.time.hint') : translate(t, 'sort.relevance.hint'),
646
+ onClick: () => { setSortBy(chip.id) },
647
+ }, translate(t, chip.labelKey))))] : []),
648
+ ]),
649
+ children,
650
+ // The key bar. It advertises only chords that are actually bound: the
651
+ // invoke chord (see `SwitchFooter`) and Escape (see the effect above).
652
+ // A key cap for a key nothing listens to is a lie the user has to
653
+ // discover by pressing it.
654
+ createElement('div', { key: 'foot', className: 'dsws_panelFoot' }, [
655
+ createElement('span', { key: 'index', className: 'dsws_footItem' }, [
656
+ createElement('span', { key: 'label' }, searchStatus.rebuilding
657
+ ? translate(t, 'panel.index.rebuilding', { done: searchStatus.done, total: searchStatus.total || '?' })
658
+ : searchStatus.available === false
659
+ ? translate(t, 'panel.index.none')
660
+ : translate(t, 'panel.index.count', { n: searchStatus.count })),
661
+ createElement('button', {
662
+ key: 'rebuild',
663
+ type: 'button',
664
+ className: 'dsws_actBtn',
665
+ style: { height: '20px', padding: '0 8px', fontSize: '11px', lineHeight: '18px' },
666
+ disabled: searchStatus.rebuilding,
667
+ title: translate(t, 'card.index.rebuild.hint'),
668
+ onClick: startIndexBuild,
669
+ }, searchStatus.rebuilding ? translate(t, 'card.index.rebuilding.btn') : translate(t, 'card.index.rebuild')),
670
+ ]),
671
+ createElement('span', { key: 'invoke', className: 'dsws_footItem' }, [
672
+ createElement('kbd', { key: 'k', className: 'dsws_kbd' }, LABELS.invokeLabel),
673
+ translate(t, 'panel.footer.invoke'),
674
+ ]),
675
+ createElement('span', { key: 'gap', className: 'dsws_footGap' }),
676
+ createElement('span', { key: 'close', className: 'dsws_footItem' }, [
677
+ createElement('kbd', { key: 'k', className: 'dsws_kbd' }, LABELS.escLabel),
678
+ translate(t, 'panel.footer.close'),
679
+ ]),
680
+ ]),
681
+ ]),
682
+ ]), document.body)
683
+ }
684
+
685
+ /**
686
+ * Whether the user has the sidebar entry switched on.
687
+ *
688
+ * The `enabled` field has existed since the card shipped, but nothing read it:
689
+ * the switch promised "show the search entry at the bottom of the sidebar" and
690
+ * controlled nothing at all. Reading it here is the whole fix.
691
+ *
692
+ * An absent or unreadable scope keeps the entry visible — a settings service we
693
+ * cannot read is not a user asking for the feature off, and hiding the entry
694
+ * would also hide the only way back to the panel.
695
+ *
696
+ * @param scope - the bound `switch-search` namespace scope, when available.
697
+ * @returns `false` only when a readable scope says the entry is switched off.
698
+ */
699
+ function useEntryEnabled(scope: SwitchCardScope | undefined): boolean {
700
+ const snapshot = useSyncExternalStore(
701
+ (listener) => scope?.subscribe(listener) ?? (() => {}),
702
+ () => scope?.getSnapshot(),
703
+ )
704
+ return snapshot?.value?.enabled !== false
705
+ }
706
+
707
+ /** The footer entry: one icon button that opens the search panel. */
708
+ export function SwitchFooter({
709
+ t,
710
+ wide,
711
+ open,
712
+ scope,
713
+ }: SwitchFooterProps & { t?: CardLocale; open: (sessionId: string) => void; scope?: SwitchCardScope }): ReactElement | null {
714
+ const [openPanel, setOpenPanel] = useState(false)
715
+ const enabled = useEntryEnabled(scope)
716
+
717
+ // The invoke chord. Bound here, next to the entry, so the shortcut exists
718
+ // exactly while the entry does: a sidebar button that the `enabled` switch
719
+ // hid, but a chord that still opened a panel out of nowhere, would be two
720
+ // answers to "is this plugin on". The match itself lives in `platform.ts`
721
+ // so the chord and the `⌘K`/`Ctrl K` chip cannot disagree.
722
+ useEffect(() => {
723
+ if (!enabled) return undefined
724
+ const onKey = (event: KeyboardEvent): void => {
725
+ if (!isInvokeChord(event, LABELS.isMac)) return
726
+ event.preventDefault()
727
+ setOpenPanel(true)
728
+ }
729
+ document.addEventListener('keydown', onKey)
730
+ return () => { document.removeEventListener('keydown', onKey) }
731
+ }, [enabled])
732
+
733
+ // Hook order is fixed above; the early return sits after every hook.
734
+ if (!enabled) return null
735
+
736
+ return createElement('div', { className: wide ? 'dsws_root' : 'dsws_root dsws_rootRail' }, [
737
+ createElement('button', {
738
+ key: 'btn',
739
+ type: 'button',
740
+ className: wide ? 'dsws_button' : 'dsws_button dsws_buttonRail',
741
+ title: `${translate(t, 'panel.entry')}(${LABELS.invokeLabel})`,
742
+ 'aria-label': `${translate(t, 'panel.entry')}(${translate(t, 'panel.titleSearch')} / ${translate(t, 'panel.contentSearch')},${LABELS.invokeLabel})`,
743
+ 'aria-expanded': openPanel,
744
+ onClick: () => { setOpenPanel(true) },
745
+ }, [
746
+ searchIcon(),
747
+ // The label and the key chip carry the same `wide` condition: a chip
748
+ // beside an icon-only entry would be the only thing in the collapsed
749
+ // rail, and `.dsws_button` clips rather than wraps.
750
+ wide && createElement('span', { key: 'label', className: 'dsws_buttonLabel' }, translate(t, 'panel.entry')),
751
+ wide && createElement('kbd', { key: 'key', className: 'dsws_kbd' }, LABELS.invokeLabel),
752
+ ]),
753
+ openPanel && createElement(SwitchPanel, {
754
+ key: 'panel',
755
+ t,
756
+ onClose: () => { setOpenPanel(false) },
757
+ open: (sessionId: string) => {
758
+ setOpenPanel(false)
759
+ open(sessionId)
760
+ },
761
+ }),
762
+ ])
763
+ }
764
+
765
+ /** ------------------------------------------------------------------ helpers */
766
+
767
+ /** Format an epoch-ms timestamp: today → HH:mm, else YYYY-MM-DD HH:mm. */
768
+ function fmtTime(ms: number): string {
769
+ if (!ms || typeof ms !== 'number') return ''
770
+ try {
771
+ const d = new Date(ms)
772
+ const now = new Date()
773
+ const pad = (n: number): string => String(n).padStart(2, '0')
774
+ const sameDay = d.getFullYear() === now.getFullYear()
775
+ && d.getMonth() === now.getMonth()
776
+ && d.getDate() === now.getDate()
777
+ const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`
778
+ if (sameDay) return time
779
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${time}`
780
+ } catch {
781
+ return ''
782
+ }
783
+ }
784
+
785
+ /** Short label for a content-hit event type. */
786
+ function typeLabel(t: CardLocale | undefined, type: string): string {
787
+ if (type === 'user/message' || type === 'assistant/message'
788
+ || type === 'tool/call' || type === 'tool/result') {
789
+ return translate(t, `type.${type}` as LocaleKey)
790
+ }
791
+ return type
792
+ }
793
+
794
+ /** Inline search icon (stroke aligned with the product's 1.75 hairline). */
795
+ function searchIcon(): ReactElement {
796
+ return createElement('svg', {
797
+ width: 14,
798
+ height: 14,
799
+ viewBox: '0 0 16 16',
800
+ fill: 'none',
801
+ 'aria-hidden': true,
802
+ }, createElement('circle', {
803
+ cx: 7,
804
+ cy: 7,
805
+ r: 4.5,
806
+ stroke: 'currentColor',
807
+ strokeWidth: 1.75,
808
+ fill: 'none',
809
+ }), createElement('path', {
810
+ d: 'M10.5 10.5 L14 14',
811
+ stroke: 'currentColor',
812
+ strokeWidth: 1.75,
813
+ strokeLinecap: 'round',
814
+ }))
815
+ }