dsh-tacit 0.2.0

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,1691 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 hackernotfound — https://github.com/hackernotfound/dsh-tacit
3
+ /**
4
+ * dsh-tacit — Client half (installed package bundle entry, zero-build).
5
+ *
6
+ * Registered through window.__ModuleLoader__ like every web client bundle;
7
+ * `react` and the UI primitives resolve from the browser module table via the
8
+ * injected require. Everything else is inlined plain JavaScript.
9
+ *
10
+ * UI surface:
11
+ * - a "Tacit" tab in the conversation view ring (`conversation.view`
12
+ * slot, beside Chat/Trajectory) listing every turn's digest with an
13
+ * Analyze button and the analysis report;
14
+ * - a small ✨ Improve button in the composer tool row
15
+ * (`conversation.input.left`) once Tacit has learned enough patterns;
16
+ * - a before/after preview popup in `conversation.input.overlay` whose
17
+ * Apply action replaces the composer draft via `inputActions.setDraft`.
18
+ *
19
+ * Data plane: the host half pushes the trajectory digest through the
20
+ * harness's session-projection pipeline (`tacitTimeline`, read with the
21
+ * standard `useProjection` prop) and serves the model calls over the
22
+ * plugin's own /api/tacit/* JSON routes on the harness web server.
23
+ * No custom server, no bundled zod, no secrets on this side.
24
+ */
25
+
26
+ // Non-browser imports are a no-op (the harness loads this bundle in the
27
+ // browser only; the guard also keeps Node-based tests of this file safe).
28
+ if (typeof window === 'undefined' || window.__ModuleLoader__ === undefined || typeof window.__ModuleLoader__.load !== 'function') {
29
+ // eslint-disable-next-line no-void
30
+ void 0
31
+ } else window.__ModuleLoader__.load({
32
+ id: 'dsh-tacit',
33
+ factory: (require) => {
34
+ const React = require('react')
35
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
36
+ const MarkdownText = primitives !== undefined && primitives.MarkdownText !== undefined
37
+ ? primitives.MarkdownText
38
+ : null
39
+ const h = React.createElement
40
+ const { useState, useEffect } = React
41
+
42
+ const NS = 'dsh-tacit'
43
+
44
+ // ── i18n ────────────────────────────────────────────────────────────────
45
+
46
+ const DICT_ZH = {
47
+ tab: 'Tacit',
48
+ 'status.learned': '已从 {count} 个提示词中学习',
49
+ 'status.autoOn': '自动学习已开启 · 今日 {today}/{budget}',
50
+ 'status.autoOff': '自动学习已关闭 — 仅在你点击「分析」时学习。',
51
+ 'status.autoHint': '混乱的轮次(重试、工具错误、压缩)以及你纠正智能体的下一条消息会被自动分析,无需点击。',
52
+ 'status.trendMessy': '混乱轮次:{a} → {b}',
53
+ 'status.trendTokens': '每轮 tokens:{a} → {b}',
54
+ 'status.trendHint': '(最早 {n} 轮 vs 最近 {n} 轮,真实数据)',
55
+ 'turn.enrichment': '发送前补充的上下文',
56
+ 'trigger.auto': '自动',
57
+ 'trigger.correction': '纠正',
58
+ 'trigger.manual': '手动',
59
+ 'trigger.bootstrap': '引导',
60
+ 'bootstrap.btn': '从我最近 20 轮中学习',
61
+ 'bootstrap.running': '学习中… {done}/{total}',
62
+ 'bootstrap.hint': '≈ $0.02,一次性。已顺利完成的提示词会被跳过。',
63
+ 'steer.enrich': '发送前补充学到的上下文(实验性,每次发送一次小调用)',
64
+ 'steer.title': '智能体被告知的关于你的信息',
65
+ 'steer.desc': '这些指令会注入每个新会话的系统提示词,让智能体替你补上你通常没说的内容。',
66
+ 'steer.toggle': '将学到的指令注入系统提示词',
67
+ 'steer.empty': '还没有指令——分析几个提示词后会自动提炼,或在下方添加你自己的。',
68
+ 'steer.add': '添加你自己的指令',
69
+ 'steer.addPlaceholder': '例如:「你觉得呢」只是要意见,不要动手实现。',
70
+ 'steer.remove': '删除',
71
+ 'steer.preview': '实际注入的文本',
72
+ 'steer.distilled': '自动提炼',
73
+ 'steer.trial': '试用 {n}/{total}',
74
+ 'steer.active': '已生效',
75
+ 'steer.retired': '已淘汰 · {reason}',
76
+ 'steer.user': '你添加的',
77
+ 'settings.auto': '自动分析混乱轮次',
78
+ 'settings.budget': '每日自动分析上限',
79
+ settings: '设置',
80
+ 'settings.title': 'Tacit 设置',
81
+ 'settings.sectionLabel': 'Tacit',
82
+ 'panel.title': 'Tacit',
83
+ 'panel.coached': '已分析的提示词',
84
+ 'panel.coachedEmpty': '还没有分析过的提示词——在对话的「Tacit」标签里勾选提示词并点击「分析所选」。',
85
+ 'panel.hint': '挑选提示词:打开任意对话,点击「Tacit」标签。',
86
+ 'settings.model': '分析模型',
87
+ 'settings.live': '启用输入框改进按钮',
88
+ 'settings.apply': '应用',
89
+ 'settings.clear': '清除所有会话的分析报告',
90
+ 'settings.cleared': '已清除 {n} 份报告',
91
+ 'settings.close': '收起',
92
+ empty: '发送消息后,每一轮对话的轨迹摘要会出现在这里——勾选你想分析的提示词,然后点击「分析所选」。',
93
+ 'coach.selected': '分析所选({n})',
94
+ 'coach.select': '选择提示词…',
95
+ 'coach.selectDone': '完成选择',
96
+ 'manual.hint': '觉得某个提示词写得不好,但那一轮却顺利完成了?可以手动分析——一次小调用。',
97
+ 'turn.interrupted': '已中断',
98
+ 'turn.report': '报告',
99
+ 'coach.batchRunning': '正在分析…',
100
+ 'turn.select': '选择此提示词以分析',
101
+ 'turn.tools': '{n} 个工具调用',
102
+ 'turn.steps': '{n} 步',
103
+ 'turn.tokens': '≈{n} tokens',
104
+ 'turn.retries': '重试 {n}',
105
+ 'turn.expand': '展开',
106
+ 'turn.collapse': '收起',
107
+ 'turn.analyze': '分析',
108
+ 'turn.analyzing': '分析中…',
109
+ 'turn.reanalyze': '重新分析',
110
+ 'turn.running': '进行中',
111
+ 'report.problems': '发现的问题',
112
+ 'report.improved': '改进后的提示词',
113
+ 'report.explanation': '说明',
114
+ 'report.copy': '复制',
115
+ 'report.copied': '已复制',
116
+ 'report.emptyImproved': '(模型未给出改写版本)',
117
+ 'sev.high': '高',
118
+ 'sev.medium': '中',
119
+ 'sev.low': '低',
120
+ 'sev.info': '提示',
121
+ 'improve.btn': '改进提示词',
122
+ 'preview.title': '改进预览',
123
+ 'preview.original': '原文',
124
+ 'preview.improved': '改进后',
125
+ 'preview.rationale': '改动说明',
126
+ 'preview.pending': '正在分析草稿…',
127
+ 'preview.apply': '应用改进',
128
+ 'preview.cancel': '取消',
129
+ 'feedback.title': '这次改进有帮助吗?',
130
+ 'feedback.up': '有帮助',
131
+ 'feedback.down': '没有帮助',
132
+ 'feedback.reasonPlaceholder': '一句话说明哪里不好…',
133
+ 'feedback.send': '发送反馈',
134
+ 'feedback.noted': '已记录,谢谢!',
135
+ 'panel.styleRules': '已学习的风格规则',
136
+ 'panel.styleRulesEmpty': '还没有学习到风格规则——应用改进后给出 👎 反馈(附一句话原因),累计 3 次即可提炼出规则。',
137
+ 'err.bad-request': '请求无效。',
138
+ 'err.bad-json': '请求体无效。',
139
+ 'err.no-session': '当前会话不可用(可能尚未在本进程恢复)。',
140
+ 'err.not-retained': '该轮次已超出保留窗口,无法分析。',
141
+ 'err.busy': '该轮次的分析已在运行中。',
142
+ 'err.no-llm': '无法访问模型服务。',
143
+ 'err.no-api-key': 'DeepSeek API Key 缺失或无效(请在 设置 → 模型 中配置)。',
144
+ 'err.rate-limited': '请求被限流,请稍后重试。',
145
+ 'err.timeout': '模型调用超时,请重试。',
146
+ 'err.empty-response': '模型没有返回内容,请重试。',
147
+ 'err.call-failed': '模型调用失败:{detail}',
148
+ 'err.network': '无法连接 Tacit 服务。',
149
+ 'err.internal': '内部错误:{detail}',
150
+ 'err.continuation': '这是一条纯粹的「继续」类消息(没有可分析的意图)——请分析它之前的那一轮。',
151
+ }
152
+
153
+ const DICT_EN = {
154
+ tab: 'Tacit',
155
+ 'status.learned': 'Learned from {count} prompt(s)',
156
+ 'status.autoOn': 'Auto-learning on · {today}/{budget} today',
157
+ 'status.autoOff': 'Auto-learning off — Tacit only learns when you click Analyze.',
158
+ 'status.autoHint': 'Messy turns (retries, tool errors, compactions) and turns you correct in your next message are analyzed automatically — no clicks.',
159
+ 'status.trendMessy': 'Messy turns: {a} → {b}',
160
+ 'status.trendTokens': 'Tokens/turn: {a} → {b}',
161
+ 'status.trendHint': '(first {n} turns vs. latest {n}, measured)',
162
+ 'turn.enrichment': 'Context added before the send',
163
+ 'trigger.auto': 'auto',
164
+ 'trigger.correction': 'correction',
165
+ 'trigger.manual': 'manual',
166
+ 'trigger.bootstrap': 'bootstrap',
167
+ 'bootstrap.btn': 'Learn from my last 20 turns',
168
+ 'bootstrap.running': 'Learning… {done}/{total}',
169
+ 'bootstrap.hint': '≈ $0.02, one time. Skips prompts that already went fine on their own.',
170
+ 'steer.enrich': 'Add learned context before each send (experimental — one small call per send)',
171
+ 'steer.title': 'What the agent is told about you',
172
+ 'steer.desc': 'These directives ride every new session\'s system prompt so the agent fills in what you usually leave unsaid.',
173
+ 'steer.toggle': 'Inject learned directives into the system prompt',
174
+ 'steer.empty': 'No directives yet — they are distilled automatically after a few analyses, or add your own below.',
175
+ 'steer.add': 'Add your own directive',
176
+ 'steer.addPlaceholder': 'e.g. "What do you think" means opinion only — do not build anything.',
177
+ 'steer.remove': 'Remove',
178
+ 'steer.preview': 'Exact text injected',
179
+ 'steer.distilled': 'distilled',
180
+ 'steer.trial': 'trial {n}/{total}',
181
+ 'steer.active': 'active',
182
+ 'steer.retired': 'retired · {reason}',
183
+ 'steer.user': 'yours',
184
+ 'settings.auto': 'Auto-analyze messy turns',
185
+ 'settings.budget': 'Daily cap on automatic analyses',
186
+ settings: 'Settings',
187
+ 'settings.title': 'Tacit settings',
188
+ 'settings.sectionLabel': 'Tacit',
189
+ 'panel.title': 'Tacit',
190
+ 'panel.coached': 'Analyzed prompts',
191
+ 'panel.coachedEmpty': 'No analyzed prompts yet — tick prompts in the Tacit tab of a conversation and click "Analyze selected".',
192
+ 'panel.hint': 'To pick prompts: open any conversation and click the "Tacit" tab.',
193
+ 'settings.model': 'Analysis model',
194
+ 'settings.live': 'Enable the composer Improve button',
195
+ 'settings.apply': 'Apply',
196
+ 'settings.clear': 'Clear all analysis reports (every session)',
197
+ 'settings.cleared': 'Cleared {n} report(s)',
198
+ 'settings.close': 'Collapse',
199
+ empty: 'After you send a message, each turn\'s trajectory digest appears here — tick the prompts you want analyzed, then click "Analyze selected".',
200
+ 'coach.selected': 'Analyze selected ({n})',
201
+ 'coach.select': 'Select prompts…',
202
+ 'coach.selectDone': 'Done selecting',
203
+ 'manual.hint': 'Think a prompt was weak even though the turn went fine? Analyze it manually — one small call.',
204
+ 'turn.interrupted': 'interrupted',
205
+ 'turn.report': 'Report',
206
+ 'coach.batchRunning': 'Analyzing…',
207
+ 'turn.select': 'Select this prompt to analyze',
208
+ 'turn.tools': '{n} tool calls',
209
+ 'turn.steps': '{n} steps',
210
+ 'turn.tokens': '≈{n} tokens',
211
+ 'turn.retries': 'retries {n}',
212
+ 'turn.expand': 'Expand',
213
+ 'turn.collapse': 'Collapse',
214
+ 'turn.analyze': 'Analyze',
215
+ 'turn.analyzing': 'Analyzing…',
216
+ 'turn.reanalyze': 'Re-analyze',
217
+ 'turn.running': 'In progress',
218
+ 'report.problems': 'Problems found',
219
+ 'report.improved': 'Improved prompt',
220
+ 'report.explanation': 'Explanation',
221
+ 'report.copy': 'Copy',
222
+ 'report.copied': 'Copied',
223
+ 'report.emptyImproved': '(the model returned no rewrite)',
224
+ 'sev.high': 'High',
225
+ 'sev.medium': 'Medium',
226
+ 'sev.low': 'Low',
227
+ 'sev.info': 'Note',
228
+ 'improve.btn': 'Improve prompt',
229
+ 'preview.title': 'Improvement preview',
230
+ 'preview.original': 'Original',
231
+ 'preview.improved': 'Improved',
232
+ 'preview.rationale': 'What changed',
233
+ 'preview.pending': 'Analyzing your draft…',
234
+ 'preview.apply': 'Apply improvement',
235
+ 'preview.cancel': 'Cancel',
236
+ 'feedback.title': 'Was this better?',
237
+ 'feedback.up': 'Yes',
238
+ 'feedback.down': 'No',
239
+ 'feedback.reasonPlaceholder': 'What was wrong? (one line)',
240
+ 'feedback.send': 'Send feedback',
241
+ 'feedback.noted': 'Noted — thanks!',
242
+ 'panel.styleRules': 'Learned style rules',
243
+ 'panel.styleRulesEmpty': 'No style rules yet — apply an improvement and rate it 👎 with a one-line reason; after 3, Tacit distills durable rules from them.',
244
+ 'err.bad-request': 'Invalid request.',
245
+ 'err.bad-json': 'Invalid request body.',
246
+ 'err.no-session': 'The session is not available (it may not be restored in this process).',
247
+ 'err.not-retained': 'This turn is outside the retained history window and cannot be analyzed.',
248
+ 'err.busy': 'An analysis for this turn is already running.',
249
+ 'err.no-llm': 'The model service is unavailable.',
250
+ 'err.no-api-key': 'The DeepSeek API key is missing or invalid (configure it in Settings → Models).',
251
+ 'err.rate-limited': 'The request was rate-limited; try again shortly.',
252
+ 'err.timeout': 'The model call timed out; please retry.',
253
+ 'err.empty-response': 'The model returned no content; please retry.',
254
+ 'err.call-failed': 'The model call failed: {detail}',
255
+ 'err.network': 'Could not reach the Tacit service.',
256
+ 'err.internal': 'Internal error: {detail}',
257
+ 'err.continuation': 'This is a bare continuation ("continue", "go ahead") with nothing to analyze — analyze the turn before it instead.',
258
+ }
259
+
260
+ // ── API client (the host half's own routes on the harness origin) ──────
261
+
262
+ async function api(pathName, payload) {
263
+ const response = await fetch('/api/tacit' + pathName, {
264
+ method: 'POST',
265
+ headers: { 'content-type': 'application/json' },
266
+ body: JSON.stringify(payload === undefined ? {} : payload),
267
+ })
268
+ let data = null
269
+ try {
270
+ data = await response.json()
271
+ } catch {
272
+ data = null
273
+ }
274
+ if (!response.ok) {
275
+ // Server envelopes carry a code (forbidden, bad-request, internal…);
276
+ // surface it so the dictionary can explain instead of a generic "network".
277
+ const error = new Error('http ' + String(response.status))
278
+ error.code = data !== null && typeof data === 'object' && typeof data.code === 'string' && data.code.length > 0 ? data.code : 'network'
279
+ error.detail = data !== null && typeof data === 'object' && typeof data.detail === 'string' ? data.detail : ''
280
+ throw error
281
+ }
282
+ if (data === null || typeof data !== 'object') throw new Error('bad response')
283
+ return data
284
+ }
285
+
286
+ /** {code, detail} for the UI from any thrown value (network failures → 'network'). */
287
+ function errorOf(error) {
288
+ return {
289
+ code: error !== null && typeof error === 'object' && typeof error.code === 'string' ? error.code : 'network',
290
+ detail: error !== null && typeof error === 'object' && typeof error.detail === 'string' ? error.detail : '',
291
+ }
292
+ }
293
+
294
+ // ── Shared per-session store (tab + composer button + preview) ─────────
295
+
296
+ function createSessionStore(sessionId) {
297
+ return {
298
+ sessionId,
299
+ config: null,
300
+ profile: null,
301
+ auto: null, // {today, budget}
302
+ bootstrap: null, // {running, done, total}
303
+ reports: {},
304
+ inFlight: {}, // String(turn) -> true
305
+ selection: new Set(), // turn numbers ticked for batch analysis
306
+ selecting: false, // selection mode (checkboxes + batch button) shown?
307
+ expanded: new Set(), // turn numbers whose report is unfolded
308
+ batchRunning: false,
309
+ preview: { open: false, pending: false, original: '', data: null, error: null },
310
+ // Feedback strip state for the last APPLIED improve response:
311
+ // {open, verdict: null|'up'|'down', reason, sending, noted, rewriteId, fading}
312
+ feedback: { open: false, verdict: null, reason: '', sending: false, noted: false, rewriteId: null, fading: false },
313
+ error: null, // transient {code, detail} for the tab
314
+ notice: null, // transient {code} after a successful settings action
315
+ initStarted: false,
316
+ initDone: false,
317
+ listeners: new Set(),
318
+ }
319
+ }
320
+
321
+ const sessionStores = new Map()
322
+ function storeFor(sessionId) {
323
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return null
324
+ let store = sessionStores.get(sessionId)
325
+ if (store === undefined) {
326
+ store = createSessionStore(sessionId)
327
+ sessionStores.set(sessionId, store)
328
+ }
329
+ return store
330
+ }
331
+
332
+ function notify(store) {
333
+ for (const listener of store.listeners) listener()
334
+ }
335
+
336
+ function useStoreVersion(store) {
337
+ const [version, setVersion] = useState(0)
338
+ useEffect(() => {
339
+ if (store === null) return undefined
340
+ const listener = () => setVersion((value) => value + 1)
341
+ store.listeners.add(listener)
342
+ return () => {
343
+ store.listeners.delete(listener)
344
+ }
345
+ }, [store])
346
+ return version
347
+ }
348
+
349
+ // ── Root store (sidebar action + settings section + frame overlay) ─────
350
+
351
+ const rootStore = {
352
+ config: null,
353
+ profile: null,
354
+ auto: null,
355
+ steering: null, // {enabled, text}
356
+ trend: null, // measured early-vs-recent trend
357
+ bootstrap: null, // {running, done, total}
358
+ coached: [], // cross-session coached-prompt entries
359
+ initStarted: false,
360
+ initDone: false,
361
+ error: null,
362
+ listeners: new Set(),
363
+ }
364
+
365
+ function notifyRoot() {
366
+ for (const listener of rootStore.listeners) listener()
367
+ }
368
+
369
+ function useRootVersion() {
370
+ const [version, setVersion] = useState(0)
371
+ useEffect(() => {
372
+ const listener = () => setVersion((value) => value + 1)
373
+ rootStore.listeners.add(listener)
374
+ return () => {
375
+ rootStore.listeners.delete(listener)
376
+ }
377
+ }, [])
378
+ return version
379
+ }
380
+
381
+ async function initRootStore() {
382
+ if (rootStore.initStarted) return
383
+ rootStore.initStarted = true
384
+ try {
385
+ const state = await api('/state', {})
386
+ if (state !== null && typeof state === 'object') {
387
+ rootStore.config = state.config !== null && typeof state.config === 'object' ? state.config : null
388
+ rootStore.profile = state.profile !== null && typeof state.profile === 'object' ? state.profile : null
389
+ rootStore.auto = state.auto !== null && typeof state.auto === 'object' ? state.auto : null
390
+ rootStore.steering = state.steering !== null && typeof state.steering === 'object' ? state.steering : null
391
+ rootStore.bootstrap = state.bootstrap !== null && typeof state.bootstrap === 'object' ? state.bootstrap : null
392
+ }
393
+ const coached = await api('/history', { limit: 50 })
394
+ if (coached !== null && typeof coached === 'object' && coached.ok && Array.isArray(coached.entries)) {
395
+ rootStore.coached = coached.entries
396
+ }
397
+ const stats = await api('/stats', {})
398
+ if (stats !== null && typeof stats === 'object' && stats.ok && stats.trend !== null && typeof stats.trend === 'object') {
399
+ rootStore.trend = stats.trend
400
+ }
401
+ rootStore.initDone = true
402
+ } catch (error) {
403
+ rootStore.error = errorOf(error)
404
+ rootStore.initDone = true
405
+ }
406
+ notifyRoot()
407
+ }
408
+
409
+ /** Re-fetch profile/config so freshly distilled style rules show up. */
410
+ async function refreshRootState() {
411
+ try {
412
+ const state = await api('/state', {})
413
+ if (state !== null && typeof state === 'object') {
414
+ rootStore.config = state.config !== null && typeof state.config === 'object' ? state.config : null
415
+ rootStore.profile = state.profile !== null && typeof state.profile === 'object' ? state.profile : null
416
+ rootStore.auto = state.auto !== null && typeof state.auto === 'object' ? state.auto : null
417
+ rootStore.steering = state.steering !== null && typeof state.steering === 'object' ? state.steering : null
418
+ rootStore.bootstrap = state.bootstrap !== null && typeof state.bootstrap === 'object' ? state.bootstrap : null
419
+ }
420
+ } catch {
421
+ // Stale view beats a broken panel.
422
+ }
423
+ notifyRoot()
424
+ }
425
+
426
+ async function updateRootConfig(patch) {
427
+ rootStore.error = null
428
+ try {
429
+ const result = await api('/config', { patch })
430
+ if (result !== null && typeof result === 'object' && result.ok && result.config !== null && typeof result.config === 'object') {
431
+ rootStore.config = result.config
432
+ } else {
433
+ rootStore.error = {
434
+ code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'bad-request',
435
+ detail: result !== null && typeof result === 'object' && typeof result.detail === 'string' ? result.detail : '',
436
+ }
437
+ }
438
+ } catch (error) {
439
+ rootStore.error = errorOf(error)
440
+ }
441
+ notifyRoot()
442
+ }
443
+
444
+ async function clearAllRoot() {
445
+ rootStore.error = null
446
+ try {
447
+ const result = await api('/clear', {})
448
+ if (result !== null && typeof result === 'object' && result.ok) {
449
+ rootStore.coached = []
450
+ } else {
451
+ rootStore.error = { code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'bad-request', detail: '' }
452
+ }
453
+ } catch (error) {
454
+ rootStore.error = errorOf(error)
455
+ }
456
+ notifyRoot()
457
+ }
458
+
459
+ /** Poll /state while a bootstrap runs so the counter moves; `apply` receives each snapshot. */
460
+ function pollBootstrap(apply) {
461
+ const tick = async () => {
462
+ try {
463
+ const state = await api('/state', {})
464
+ const running = state !== null && typeof state === 'object' && state.bootstrap !== null && typeof state.bootstrap === 'object' && state.bootstrap.running === true
465
+ apply(state)
466
+ if (running) setTimeout(tick, 2000)
467
+ } catch {
468
+ // Give up quietly; the final response of the bootstrap call still lands.
469
+ }
470
+ }
471
+ setTimeout(tick, 1500)
472
+ }
473
+
474
+ async function bootstrapSession(store) {
475
+ if (store.bootstrap !== null && typeof store.bootstrap === 'object' && store.bootstrap.running) return
476
+ store.bootstrap = { running: true, done: 0, total: 0 }
477
+ store.error = null
478
+ notify(store)
479
+ pollBootstrap((state) => {
480
+ if (state !== null && typeof state === 'object' && state.bootstrap !== null && typeof state.bootstrap === 'object') store.bootstrap = state.bootstrap
481
+ notify(store)
482
+ })
483
+ try {
484
+ const result = await api('/bootstrap', { sessionId: store.sessionId, limit: 20 })
485
+ if (!(result !== null && typeof result === 'object' && result.ok)) {
486
+ store.error = { code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'call-failed', detail: '' }
487
+ }
488
+ const reports = await api('/reports', { sessionId: store.sessionId })
489
+ if (reports !== null && typeof reports === 'object' && reports.ok && reports.reports !== null && typeof reports.reports === 'object') store.reports = reports.reports
490
+ const state = await api('/state', {})
491
+ if (state !== null && typeof state === 'object') {
492
+ store.profile = state.profile !== null && typeof state.profile === 'object' ? state.profile : store.profile
493
+ store.bootstrap = state.bootstrap !== null && typeof state.bootstrap === 'object' ? state.bootstrap : null
494
+ }
495
+ } catch (error) {
496
+ store.error = errorOf(error)
497
+ store.bootstrap = null
498
+ }
499
+ notify(store)
500
+ }
501
+
502
+ async function bootstrapAll() {
503
+ if (rootStore.bootstrap !== null && typeof rootStore.bootstrap === 'object' && rootStore.bootstrap.running) return
504
+ rootStore.bootstrap = { running: true, done: 0, total: 0 }
505
+ rootStore.error = null
506
+ notifyRoot()
507
+ pollBootstrap((state) => {
508
+ if (state !== null && typeof state === 'object' && state.bootstrap !== null && typeof state.bootstrap === 'object') rootStore.bootstrap = state.bootstrap
509
+ notifyRoot()
510
+ })
511
+ try {
512
+ const result = await api('/bootstrap', { limit: 20 })
513
+ if (!(result !== null && typeof result === 'object' && result.ok)) {
514
+ rootStore.error = { code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'call-failed', detail: '' }
515
+ }
516
+ } catch (error) {
517
+ rootStore.error = errorOf(error)
518
+ }
519
+ rootStore.initStarted = false
520
+ rootStore.initDone = false
521
+ await initRootStore()
522
+ }
523
+
524
+ async function editDirectives(payload) {
525
+ rootStore.error = null
526
+ try {
527
+ const result = await api('/directives', payload)
528
+ if (result !== null && typeof result === 'object' && result.ok) {
529
+ if (result.profile !== null && typeof result.profile === 'object') rootStore.profile = result.profile
530
+ if (result.steering !== null && typeof result.steering === 'object') rootStore.steering = result.steering
531
+ } else {
532
+ rootStore.error = { code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'bad-request', detail: '' }
533
+ }
534
+ } catch (error) {
535
+ rootStore.error = errorOf(error)
536
+ }
537
+ notifyRoot()
538
+ }
539
+
540
+ async function initStore(store) {
541
+ if (store === null || store.initStarted) return
542
+ store.initStarted = true
543
+ try {
544
+ const state = await api('/state', {})
545
+ if (state !== null && typeof state === 'object') {
546
+ store.config = state.config !== null && typeof state.config === 'object' ? state.config : null
547
+ store.profile = state.profile !== null && typeof state.profile === 'object' ? state.profile : null
548
+ store.auto = state.auto !== null && typeof state.auto === 'object' ? state.auto : null
549
+ store.bootstrap = state.bootstrap !== null && typeof state.bootstrap === 'object' ? state.bootstrap : null
550
+ }
551
+ const reports = await api('/reports', { sessionId: store.sessionId })
552
+ if (reports !== null && typeof reports === 'object' && reports.ok && reports.reports !== null && typeof reports.reports === 'object') {
553
+ store.reports = reports.reports
554
+ }
555
+ store.initDone = true
556
+ } catch (error) {
557
+ store.error = errorOf(error)
558
+ store.initDone = true
559
+ }
560
+ notify(store)
561
+ }
562
+
563
+ function analyzeTurn(store, turn) {
564
+ if (store.inFlight[String(turn)]) return
565
+ store.inFlight[String(turn)] = true
566
+ store.expanded.add(turn)
567
+ store.error = null
568
+ notify(store)
569
+ api('/analyze', { sessionId: store.sessionId, turn })
570
+ .then((result) => {
571
+ if (result !== null && typeof result === 'object' && result.ok && result.report !== null && typeof result.report === 'object') {
572
+ store.reports[String(turn)] = result.report
573
+ store.error = null
574
+ } else {
575
+ store.error = {
576
+ code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'call-failed',
577
+ detail: result !== null && typeof result === 'object' && typeof result.detail === 'string' ? result.detail : '',
578
+ }
579
+ }
580
+ if (result !== null && typeof result === 'object' && result.profile !== null && typeof result.profile === 'object') {
581
+ store.profile = result.profile
582
+ }
583
+ })
584
+ .catch((error) => {
585
+ store.error = errorOf(error)
586
+ })
587
+ .finally(() => {
588
+ delete store.inFlight[String(turn)]
589
+ notify(store)
590
+ })
591
+ }
592
+
593
+ /** Run one /analyze call and settle when it finishes (batch building block). */
594
+ function analyzeTurnAsync(store, turn) {
595
+ return new Promise((resolve) => {
596
+ store.inFlight[String(turn)] = true
597
+ notify(store)
598
+ api('/analyze', { sessionId: store.sessionId, turn })
599
+ .then((result) => {
600
+ if (result !== null && typeof result === 'object' && result.ok && result.report !== null && typeof result.report === 'object') {
601
+ store.reports[String(turn)] = result.report
602
+ store.selection.delete(turn)
603
+ store.expanded.add(turn)
604
+ store.error = null
605
+ } else {
606
+ store.error = {
607
+ code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'call-failed',
608
+ detail: result !== null && typeof result === 'object' && typeof result.detail === 'string' ? result.detail : '',
609
+ }
610
+ }
611
+ if (result !== null && typeof result === 'object' && result.profile !== null && typeof result.profile === 'object') {
612
+ store.profile = result.profile
613
+ }
614
+ })
615
+ .catch((error) => {
616
+ store.error = errorOf(error)
617
+ })
618
+ .finally(() => {
619
+ delete store.inFlight[String(turn)]
620
+ notify(store)
621
+ resolve()
622
+ })
623
+ })
624
+ }
625
+
626
+ /** Coach every ticked prompt sequentially (the user-chosen 20). */
627
+ async function coachSelected(store) {
628
+ if (store.batchRunning) return
629
+ const turns = [...store.selection].sort((a, b) => a - b)
630
+ if (turns.length === 0) return
631
+ store.batchRunning = true
632
+ store.error = null
633
+ notify(store)
634
+ for (const turn of turns) {
635
+ if (store.inFlight[String(turn)]) continue
636
+ await analyzeTurnAsync(store, turn)
637
+ }
638
+ store.batchRunning = false
639
+ store.selecting = false
640
+ notify(store)
641
+ }
642
+
643
+ function toggleSelecting(store) {
644
+ store.selecting = !store.selecting
645
+ if (!store.selecting) store.selection.clear()
646
+ notify(store)
647
+ }
648
+
649
+ function toggleReport(store, turn) {
650
+ if (store.expanded.has(turn)) store.expanded.delete(turn)
651
+ else store.expanded.add(turn)
652
+ notify(store)
653
+ }
654
+
655
+ function improveDraft(store, draft) {
656
+ if (store.preview.open || typeof draft !== 'string' || draft.trim().length === 0) return
657
+ store.preview = { open: true, pending: true, original: draft, data: null, error: null }
658
+ notify(store)
659
+ api('/improve', { sessionId: store.sessionId, draft })
660
+ .then((result) => {
661
+ if (result !== null && typeof result === 'object' && result.ok && typeof result.improved === 'string' && result.improved.trim().length > 0) {
662
+ store.preview = { ...store.preview, pending: false, data: result }
663
+ } else {
664
+ store.preview = {
665
+ ...store.preview,
666
+ pending: false,
667
+ error: {
668
+ code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'call-failed',
669
+ detail: result !== null && typeof result === 'object' && typeof result.detail === 'string' ? result.detail : '',
670
+ },
671
+ }
672
+ }
673
+ })
674
+ .catch((error) => {
675
+ store.preview = { ...store.preview, pending: false, error: errorOf(error) }
676
+ })
677
+ .finally(() => notify(store))
678
+ }
679
+
680
+ /** Is the live improve feature enabled for this store? (No learning gate.) */
681
+ function storeReady(store) {
682
+ if (store === null || !store.initDone) return false
683
+ const config = configOf(store.config)
684
+ return config !== null && config.liveSuggestions !== false
685
+ }
686
+
687
+ function applyImproved(store, inputActions) {
688
+ const data = store.preview.data
689
+ if (data !== null && typeof data === 'object' && typeof data.improved === 'string'
690
+ && inputActions !== undefined && inputActions !== null && typeof inputActions.setDraft === 'function') {
691
+ inputActions.setDraft(data.improved)
692
+ }
693
+ // The feedback strip rides every applied rewrite while the feature is on.
694
+ const rewriteId = data !== null && typeof data === 'object' && typeof data.rewriteId === 'string' && data.rewriteId.length > 0
695
+ ? data.rewriteId
696
+ : null
697
+ if (storeReady(store) && rewriteId !== null) {
698
+ store.feedback = { open: true, verdict: null, reason: '', sending: false, noted: false, rewriteId, fading: false }
699
+ // Free bookkeeping on an existing call: host bumps `applied` and
700
+ // captures the verification baseline for the next finished turn.
701
+ api('/applied', { sessionId: store.sessionId, rewriteId }).catch(() => {})
702
+ } else {
703
+ store.feedback = { ...store.feedback, open: false }
704
+ }
705
+ store.preview = { open: false, pending: false, original: '', data: null, error: null }
706
+ notify(store)
707
+ }
708
+
709
+ function closeFeedback(store) {
710
+ store.feedback = { open: false, verdict: null, reason: '', sending: false, noted: false, rewriteId: null, fading: false }
711
+ notify(store)
712
+ }
713
+
714
+ /** The strip fades out after the next send (observed via the input machine phase). */
715
+ function fadeFeedback(store) {
716
+ if (!store.feedback.open || store.feedback.fading) return
717
+ store.feedback = { ...store.feedback, fading: true }
718
+ notify(store)
719
+ setTimeout(() => {
720
+ if (store.feedback.open && store.feedback.fading) closeFeedback(store)
721
+ }, 350)
722
+ }
723
+
724
+ /** 👍 posts immediately; 👎 expands the one-line reason field first. */
725
+ function voteFeedback(store, verdict) {
726
+ if (!store.feedback.open || store.feedback.sending || store.feedback.rewriteId === null) return
727
+ if (verdict === 'down') {
728
+ store.feedback = { ...store.feedback, verdict: 'down' }
729
+ notify(store)
730
+ return
731
+ }
732
+ sendFeedback(store, 'up')
733
+ }
734
+
735
+ function sendFeedback(store, verdict) {
736
+ if (!store.feedback.open || store.feedback.sending || store.feedback.rewriteId === null) return
737
+ const reason = verdict === 'down' ? store.feedback.reason.trim() : ''
738
+ if (verdict === 'down' && reason.length === 0) return
739
+ store.feedback = { ...store.feedback, sending: true }
740
+ notify(store)
741
+ const payload = { rewriteId: store.feedback.rewriteId, verdict }
742
+ if (verdict === 'down') payload.reason = reason.slice(0, 300)
743
+ api('/feedback', payload)
744
+ .then((result) => {
745
+ if (result !== null && typeof result === 'object' && result.ok && result.profile !== null && typeof result.profile === 'object') {
746
+ store.profile = result.profile
747
+ store.feedback = { ...store.feedback, sending: false, noted: true }
748
+ notify(store)
749
+ setTimeout(() => {
750
+ if (store.feedback.noted) closeFeedback(store)
751
+ }, 1500)
752
+ } else {
753
+ closeFeedback(store)
754
+ }
755
+ })
756
+ .catch(() => closeFeedback(store))
757
+ }
758
+
759
+ function closePreview(store) {
760
+ store.preview = { open: false, pending: false, original: '', data: null, error: null }
761
+ notify(store)
762
+ }
763
+
764
+ async function updateConfig(store, patch) {
765
+ try {
766
+ const result = await api('/config', { patch })
767
+ if (result !== null && typeof result === 'object' && result.ok && result.config !== null && typeof result.config === 'object') {
768
+ store.config = result.config
769
+ } else {
770
+ store.error = {
771
+ code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'bad-request',
772
+ detail: result !== null && typeof result === 'object' && typeof result.detail === 'string' ? result.detail : '',
773
+ }
774
+ }
775
+ } catch (error) {
776
+ store.error = errorOf(error)
777
+ }
778
+ notify(store)
779
+ }
780
+
781
+ async function clearReports(store) {
782
+ store.error = null
783
+ try {
784
+ const result = await api('/clear', {})
785
+ if (result !== null && typeof result === 'object' && result.ok) {
786
+ store.reports = {}
787
+ store.notice = { code: 'settings.cleared', n: typeof result.removed === 'number' ? result.removed : 0 }
788
+ } else {
789
+ store.error = { code: result !== null && typeof result === 'object' && typeof result.code === 'string' ? result.code : 'bad-request', detail: '' }
790
+ }
791
+ } catch (error) {
792
+ store.error = errorOf(error)
793
+ }
794
+ notify(store)
795
+ }
796
+
797
+ // ── Formatting & defensive narrowing ───────────────────────────────────
798
+
799
+ function fmt(n) {
800
+ const value = Number(n)
801
+ if (!Number.isFinite(value)) return '—'
802
+ const sign = value < 0 ? '-' : ''
803
+ const abs = Math.abs(value)
804
+ if (abs >= 1e6) return sign + (abs / 1e6).toFixed(1) + 'M'
805
+ if (abs >= 1e3) return sign + (abs / 1e3).toFixed(1) + 'k'
806
+ return sign + String(Math.round(abs))
807
+ }
808
+
809
+ function fmtTime(ms) {
810
+ const date = new Date(Number(ms))
811
+ if (Number.isNaN(date.getTime())) return '—'
812
+ return date.toLocaleTimeString('en-GB', { hour12: false })
813
+ }
814
+
815
+ function turnsOf(value) {
816
+ if (value === null || typeof value !== 'object' || !Array.isArray(value.turns)) return []
817
+ return value.turns.filter((turn) => turn !== null && typeof turn === 'object' && typeof turn.turn === 'number')
818
+ }
819
+
820
+ function profileOf(value) {
821
+ if (value === null || typeof value !== 'object') return null
822
+ return {
823
+ analyzedCount: typeof value.analyzedCount === 'number' ? value.analyzedCount : 0,
824
+ }
825
+ }
826
+
827
+ function configOf(value) {
828
+ if (value === null || typeof value !== 'object') return null
829
+ return value
830
+ }
831
+
832
+ function reportOf(value) {
833
+ if (value === null || typeof value !== 'object') return null
834
+ const problems = Array.isArray(value.problems)
835
+ ? value.problems.filter((p) => p !== null && typeof p === 'object')
836
+ : []
837
+ return {
838
+ ok: value.ok === true,
839
+ turn: typeof value.turn === 'number' ? value.turn : 0,
840
+ model: typeof value.model === 'string' ? value.model : '',
841
+ problems,
842
+ improvedPrompt: typeof value.improvedPrompt === 'string' ? value.improvedPrompt : '',
843
+ explanation: typeof value.explanation === 'string' ? value.explanation : '',
844
+ trigger: value.trigger === 'auto' || value.trigger === 'correction' || value.trigger === 'bootstrap' ? value.trigger : 'manual',
845
+ followUp: typeof value.followUp === 'string' ? value.followUp : '',
846
+ }
847
+ }
848
+
849
+ function copyText(text) {
850
+ if (navigator.clipboard !== undefined && typeof navigator.clipboard.writeText === 'function') {
851
+ navigator.clipboard.writeText(text).catch(() => fallbackCopy(text))
852
+ return
853
+ }
854
+ fallbackCopy(text)
855
+ }
856
+
857
+ function fallbackCopy(text) {
858
+ try {
859
+ const textarea = document.createElement('textarea')
860
+ textarea.value = text
861
+ textarea.style.position = 'fixed'
862
+ textarea.style.opacity = '0'
863
+ document.body.appendChild(textarea)
864
+ textarea.select()
865
+ document.execCommand('copy')
866
+ document.body.removeChild(textarea)
867
+ } catch {
868
+ // Clipboard unavailable; the text remains selectable in the card.
869
+ }
870
+ }
871
+
872
+ // ── Components ─────────────────────────────────────────────────────────
873
+
874
+ function makeKit(t) {
875
+ return { t, fmt, fmtTime }
876
+ }
877
+
878
+ /** "Learned from N prompts · Auto-learning on · x/y today" status card. */
879
+ /** "Learn from my last 20 turns" — spinner label while a bootstrap runs. */
880
+ function BootstrapButton(kit, { bootstrap, onClick }) {
881
+ const { t } = kit
882
+ const running = bootstrap !== null && bootstrap !== undefined && typeof bootstrap === 'object' && bootstrap.running === true
883
+ return h('span', { className: 'tacit-bootstrap' },
884
+ h('button', {
885
+ type: 'button',
886
+ className: 'tacit-btn tacit-btn-sm',
887
+ disabled: running,
888
+ title: t('bootstrap.hint'),
889
+ onClick,
890
+ }, running
891
+ ? t('bootstrap.running', { done: String(typeof bootstrap.done === 'number' ? bootstrap.done : 0), total: String(typeof bootstrap.total === 'number' ? bootstrap.total : 0) })
892
+ : t('bootstrap.btn')))
893
+ }
894
+
895
+ function pct(value) {
896
+ return String(Math.round((Number(value) || 0) * 100)) + '%'
897
+ }
898
+
899
+ function StatusCard(kit, { config, profile, auto, trend }) {
900
+ const { t } = kit
901
+ const hasTrend = trend !== null && trend !== undefined && typeof trend === 'object' && trend.enough === true
902
+ && trend.early !== null && typeof trend.early === 'object' && trend.recent !== null && typeof trend.recent === 'object'
903
+ const analyzed = profile !== null ? profile.analyzedCount : 0
904
+ const autoOn = config !== null && config.autoAnalyze !== false
905
+ const today = auto !== null && typeof auto === 'object' && typeof auto.today === 'number' ? auto.today : 0
906
+ const budget = auto !== null && typeof auto === 'object' && typeof auto.budget === 'number'
907
+ ? auto.budget
908
+ : (config !== null && typeof config.autoDailyBudget === 'number' ? config.autoDailyBudget : 30)
909
+ return h('div', { className: 'tacit-progress' },
910
+ h('div', { className: 'tacit-progress-head' },
911
+ h('span', { className: 'tacit-progress-title' }, t('status.learned', { count: String(analyzed) })),
912
+ h('span', { className: 'tacit-progress-count' }, autoOn ? t('status.autoOn', { today: String(today), budget: String(budget) }) : '')),
913
+ h('div', { className: 'tacit-progress-text' }, autoOn ? t('status.autoHint') : t('status.autoOff')),
914
+ hasTrend
915
+ ? h('div', { className: 'tacit-trend' },
916
+ h('span', { className: 'tacit-chip' }, t('status.trendMessy', { a: pct(trend.early.messyRate), b: pct(trend.recent.messyRate) })),
917
+ h('span', { className: 'tacit-chip' }, t('status.trendTokens', { a: fmt(trend.early.tokensPerTurn), b: fmt(trend.recent.tokensPerTurn) })),
918
+ h('span', { className: 'tacit-progress-text' }, t('status.trendHint', { n: String(trend.window) })))
919
+ : null)
920
+ }
921
+
922
+ function ProblemRow(kit, problem, index) {
923
+ const { t } = kit
924
+ const severity = String(problem.severity === 'high' || problem.severity === 'medium' || problem.severity === 'low' ? problem.severity : 'info')
925
+ const sevClass = 'tacit-sev-' + (severity === 'info' ? 'info' : severity)
926
+ return h('div', { key: 'p' + index, className: 'tacit-problem' },
927
+ h('div', { className: 'tacit-problem-head' },
928
+ h('span', { className: 'tacit-problem-kind' }, String(problem.kind || 'general')),
929
+ h('span', { className: 'tacit-problem-sev ' + sevClass }, t('sev.' + severity))),
930
+ h('div', { className: 'tacit-problem-what' }, String(problem.what || '')),
931
+ h('div', { className: 'tacit-problem-why' }, String(problem.why || '')))
932
+ }
933
+
934
+ function ReportCard(props) {
935
+ const { kit, report } = props
936
+ const { t } = kit
937
+ const [copied, setCopied] = useState(false)
938
+ if (report === null) return null
939
+ const improved = report.improvedPrompt
940
+ const onCopy = () => {
941
+ copyText(improved)
942
+ setCopied(true)
943
+ setTimeout(() => setCopied(false), 1500)
944
+ }
945
+ return h('div', { className: 'tacit-report' },
946
+ h('div', { className: 'tacit-report-title' },
947
+ t('report.problems'),
948
+ ' ',
949
+ h('span', { className: 'tacit-chip tacit-chip-trigger' }, t('trigger.' + report.trigger))),
950
+ report.problems.length === 0
951
+ ? h('div', { className: 'tacit-report-note' }, '—')
952
+ : report.problems.map((problem, index) => ProblemRow(kit, problem, index)),
953
+ h('div', { className: 'tacit-report-title' }, t('report.improved')),
954
+ improved.length > 0
955
+ ? h('div', { className: 'tacit-report-improved' },
956
+ h('div', { className: 'tacit-report-actions' },
957
+ h('button', {
958
+ type: 'button',
959
+ className: 'tacit-btn tacit-btn-sm',
960
+ onClick: onCopy,
961
+ }, copied ? t('report.copied') : t('report.copy'))),
962
+ h('div', { className: 'tacit-report-body' },
963
+ MarkdownText !== null
964
+ ? h(MarkdownText, { text: improved })
965
+ : h('pre', { className: 'tacit-pre' }, improved)))
966
+ : h('div', { className: 'tacit-report-note' }, t('report.emptyImproved')),
967
+ typeof report.explanation === 'string' && report.explanation.length > 0
968
+ ? h('div', null,
969
+ h('div', { className: 'tacit-report-title' }, t('report.explanation')),
970
+ h('div', { className: 'tacit-report-body' },
971
+ MarkdownText !== null
972
+ ? h(MarkdownText, { text: report.explanation })
973
+ : h('pre', { className: 'tacit-pre' }, report.explanation)))
974
+ : null)
975
+ }
976
+
977
+ function TurnRow(props) {
978
+ const { kit, store, turn, isNewest } = props
979
+ const { t, fmtTime } = kit
980
+ const [expanded, setExpanded] = useState(false)
981
+ const analyzing = store.inFlight[String(turn.turn)] === true
982
+ const report = reportOf(store.reports[String(turn.turn)])
983
+ const selected = store.selection.has(turn.turn)
984
+ const reportOpen = store.expanded.has(turn.turn)
985
+ const usage = turn.usage !== null && typeof turn.usage === 'object' ? turn.usage : {}
986
+ const tokens = (typeof usage.inputTokens === 'number' ? usage.inputTokens : 0)
987
+ + (typeof usage.outputTokens === 'number' ? usage.outputTokens : 0)
988
+ const tools = Array.isArray(turn.toolCalls) ? turn.toolCalls.length : 0
989
+ const prompt = typeof turn.prompt === 'string' ? turn.prompt : ''
990
+ const promptPreview = prompt.length > 120 ? prompt.slice(0, 120) + '…' : prompt
991
+
992
+ const toggleSelected = () => {
993
+ if (selected) store.selection.delete(turn.turn)
994
+ else store.selection.add(turn.turn)
995
+ notify(store)
996
+ }
997
+
998
+ // A turn without turn/end is live only when it is the newest one; an
999
+ // older unfinished turn belonged to a session that was interrupted.
1000
+ const status = turn.finished === true
1001
+ ? null
1002
+ : (isNewest === true
1003
+ ? h('span', { className: 'tacit-row-live' }, t('turn.running'))
1004
+ : h('span', { className: 'tacit-chip tacit-chip-muted' }, t('turn.interrupted')))
1005
+
1006
+ return h('div', { className: 'tacit-row' + (report !== null ? ' tacit-row-analyzed' : '') },
1007
+ h('div', { className: 'tacit-row-head' },
1008
+ store.selecting
1009
+ ? h('input', {
1010
+ type: 'checkbox',
1011
+ className: 'tacit-check',
1012
+ checked: selected,
1013
+ title: t('turn.select'),
1014
+ onChange: toggleSelected,
1015
+ })
1016
+ : null,
1017
+ h('div', { className: 'tacit-row-heading' },
1018
+ h('span', { className: 'tacit-row-turn' }, '# ' + String(turn.turn)),
1019
+ h('span', { className: 'tacit-row-time' }, fmtTime(turn.startedAt)),
1020
+ report !== null ? h('span', { className: 'tacit-chip tacit-chip-trigger' }, t('trigger.' + report.trigger)) : null,
1021
+ status),
1022
+ h('div', { className: 'tacit-row-chips' },
1023
+ h('span', { className: 'tacit-chip' }, t('turn.tools', { n: String(tools) })),
1024
+ h('span', { className: 'tacit-chip' }, t('turn.steps', { n: String(typeof turn.steps === 'number' ? turn.steps : 0) })),
1025
+ h('span', { className: 'tacit-chip' }, t('turn.tokens', { n: fmt(tokens) })),
1026
+ typeof turn.retries === 'number' && turn.retries > 0
1027
+ ? h('span', { className: 'tacit-chip tacit-chip-warn' }, t('turn.retries', { n: String(turn.retries) }))
1028
+ : null,
1029
+ report !== null
1030
+ ? h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', onClick: () => toggleReport(store, turn.turn) },
1031
+ t('turn.report') + (reportOpen ? ' ▾' : ' ▸'))
1032
+ : null,
1033
+ h('button', {
1034
+ type: 'button',
1035
+ className: 'tacit-btn tacit-btn-sm tacit-btn-quiet',
1036
+ disabled: analyzing,
1037
+ onClick: () => analyzeTurn(store, turn.turn),
1038
+ }, analyzing ? t('turn.analyzing') : (report !== null ? t('turn.reanalyze') : t('turn.analyze'))))),
1039
+ prompt.length > 0
1040
+ ? h('div', { className: 'tacit-row-prompt' },
1041
+ h('button', {
1042
+ type: 'button',
1043
+ className: 'tacit-row-prompt-text',
1044
+ onClick: () => setExpanded((open) => !open),
1045
+ }, expanded ? prompt : promptPreview),
1046
+ prompt.length > 120
1047
+ ? h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', onClick: () => setExpanded((open) => !open) },
1048
+ expanded ? t('turn.collapse') : t('turn.expand'))
1049
+ : null)
1050
+ : null,
1051
+ typeof turn.enrichment === 'string' && turn.enrichment.length > 0
1052
+ ? h('div', { className: 'tacit-row-enrichment' },
1053
+ h('span', { className: 'tacit-report-title' }, t('turn.enrichment')),
1054
+ h('div', { className: 'tacit-pre' }, turn.enrichment))
1055
+ : null,
1056
+ report !== null && reportOpen ? h(ReportCard, { kit, report }) : null)
1057
+ }
1058
+
1059
+ function SettingsPanel(props) {
1060
+ const { kit, store } = props
1061
+ const { t } = kit
1062
+ const config = configOf(store.config)
1063
+ const [model, setModel] = useState(config !== null && typeof config.model === 'string' ? config.model : 'deepseek-v4-flash')
1064
+ const [budgetText, setBudgetText] = useState(config !== null && typeof config.autoDailyBudget === 'number' ? String(config.autoDailyBudget) : '30')
1065
+ const [auto, setAuto] = useState(config !== null && config.autoAnalyze !== false)
1066
+ const [live, setLive] = useState(config !== null && config.liveSuggestions !== false)
1067
+ const [notice, setNotice] = useState(null)
1068
+
1069
+ useEffect(() => {
1070
+ if (config !== null) {
1071
+ if (typeof config.model === 'string') setModel(config.model)
1072
+ if (typeof config.autoDailyBudget === 'number') setBudgetText(String(config.autoDailyBudget))
1073
+ if (typeof config.autoAnalyze === 'boolean') setAuto(config.autoAnalyze)
1074
+ if (typeof config.liveSuggestions === 'boolean') setLive(config.liveSuggestions)
1075
+ }
1076
+ }, [config])
1077
+
1078
+ const applyModel = (value) => {
1079
+ setModel(value)
1080
+ updateConfig(store, { model: value })
1081
+ }
1082
+ const applyBudget = () => {
1083
+ const number = Math.max(0, Math.min(1000, Math.round(Number(budgetText) || 0)))
1084
+ setBudgetText(String(number))
1085
+ updateConfig(store, { autoDailyBudget: number })
1086
+ }
1087
+ const toggleAuto = () => {
1088
+ const next = !auto
1089
+ setAuto(next)
1090
+ updateConfig(store, { autoAnalyze: next })
1091
+ }
1092
+ const toggleLive = () => {
1093
+ const next = !live
1094
+ setLive(next)
1095
+ updateConfig(store, { liveSuggestions: next })
1096
+ }
1097
+ const onClear = async () => {
1098
+ await clearReports(store)
1099
+ if (store.notice !== null && store.notice.code === 'settings.cleared') {
1100
+ setNotice(t(store.notice.code, { n: String(store.notice.n) }))
1101
+ setTimeout(() => setNotice(null), 3000)
1102
+ }
1103
+ }
1104
+
1105
+ return h('div', { className: 'tacit-settings' },
1106
+ h('div', { className: 'tacit-settings-title' }, t('settings.title')),
1107
+ h('div', { className: 'tacit-settings-row' },
1108
+ h('label', { className: 'tacit-settings-label' }, t('settings.model')),
1109
+ h('select', {
1110
+ className: 'tacit-select',
1111
+ value: model,
1112
+ onChange: (event) => applyModel(event.target.value),
1113
+ },
1114
+ h('option', { value: 'deepseek-v4-flash' }, 'deepseek-v4-flash'),
1115
+ h('option', { value: 'deepseek-v4-pro' }, 'deepseek-v4-pro'))),
1116
+ h('div', { className: 'tacit-settings-row' },
1117
+ h('label', { className: 'tacit-settings-label' }, t('settings.auto')),
1118
+ h('input', { type: 'checkbox', checked: auto, onChange: toggleAuto })),
1119
+ h('div', { className: 'tacit-settings-row' },
1120
+ h('label', { className: 'tacit-settings-label' }, t('settings.budget')),
1121
+ h('input', {
1122
+ className: 'tacit-input',
1123
+ type: 'number',
1124
+ min: 0,
1125
+ value: budgetText,
1126
+ onChange: (event) => setBudgetText(event.target.value),
1127
+ }),
1128
+ h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', onClick: applyBudget }, t('settings.apply'))),
1129
+ h('div', { className: 'tacit-settings-row' },
1130
+ h('label', { className: 'tacit-settings-label' }, t('settings.live')),
1131
+ h('input', { type: 'checkbox', checked: live, onChange: toggleLive })),
1132
+ h('div', { className: 'tacit-settings-row' },
1133
+ h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', onClick: onClear }, t('settings.clear')),
1134
+ notice !== null ? h('span', { className: 'tacit-settings-notice' }, notice) : null))
1135
+ }
1136
+
1137
+ function CoachTab(kit) {
1138
+ const { t } = kit
1139
+ return function CoachTabView(props) {
1140
+ const sessionId = props.sessionId
1141
+ const store = storeFor(sessionId)
1142
+ useStoreVersion(store)
1143
+ useEffect(() => {
1144
+ if (store !== null) initStore(store)
1145
+ }, [store])
1146
+ const [settingsOpen, setSettingsOpen] = useState(false)
1147
+
1148
+ if (store === null) {
1149
+ return h('div', { className: 'tacit-root' }, h('div', { className: 'tacit-empty' }, t('err.no-session')))
1150
+ }
1151
+
1152
+ const rawTurns = typeof props.useProjection === 'function' ? props.useProjection('tacitTimeline') : undefined
1153
+ const turns = turnsOf(rawTurns).slice().reverse()
1154
+ const config = configOf(store.config)
1155
+ const profile = profileOf(store.profile)
1156
+ const selectedCount = store.selection.size
1157
+ const error = store.error
1158
+
1159
+ return h('div', { className: 'tacit-root' },
1160
+ h('div', { className: 'tacit-head' },
1161
+ h('div', { className: 'tacit-head-title' }, t('tab')),
1162
+ h('button', {
1163
+ type: 'button',
1164
+ className: 'tacit-btn',
1165
+ onClick: () => setSettingsOpen((open) => !open),
1166
+ }, settingsOpen ? t('settings.close') : t('settings'))),
1167
+ StatusCard(kit, { config, profile, auto: store.auto }),
1168
+ settingsOpen ? h(SettingsPanel, { kit, store }) : null,
1169
+ error !== null && typeof error === 'object'
1170
+ ? h('div', { className: 'tacit-error' }, t('err.' + String(error.code), { detail: String(error.detail || '') }))
1171
+ : null,
1172
+ turns.length === 0
1173
+ ? h('div', { className: 'tacit-empty' }, t('empty'))
1174
+ : h('div', null,
1175
+ h('div', { className: 'tacit-toolbar' },
1176
+ h('span', { className: 'tacit-panel-hint tacit-toolbar-hint' }, t('manual.hint')),
1177
+ BootstrapButton(kit, { bootstrap: store.bootstrap, onClick: () => bootstrapSession(store) }),
1178
+ h('button', {
1179
+ type: 'button',
1180
+ className: 'tacit-btn tacit-btn-sm',
1181
+ onClick: () => toggleSelecting(store),
1182
+ }, store.selecting ? t('coach.selectDone') : t('coach.select')),
1183
+ store.selecting
1184
+ ? h('button', {
1185
+ type: 'button',
1186
+ className: 'tacit-btn tacit-btn-sm tacit-btn-primary',
1187
+ disabled: selectedCount === 0 || store.batchRunning,
1188
+ onClick: () => coachSelected(store),
1189
+ }, store.batchRunning ? t('coach.batchRunning') : t('coach.selected', { n: String(selectedCount) }))
1190
+ : null),
1191
+ turns.map((turn, index) => h(TurnRow, { key: String(turn.turn), kit, store, turn, isNewest: index === 0 }))))
1192
+ }
1193
+ }
1194
+
1195
+ function ImproveButton(kit) {
1196
+ const { t } = kit
1197
+ return function ImproveButtonView(props) {
1198
+ const sessionId = props.sessionId
1199
+ const store = storeFor(sessionId)
1200
+ useStoreVersion(store)
1201
+ useEffect(() => {
1202
+ if (store !== null) initStore(store)
1203
+ }, [store])
1204
+
1205
+ if (!storeReady(store)) return null
1206
+
1207
+ const draft = props.input !== null && typeof props.input === 'object' && typeof props.input.draft === 'string'
1208
+ ? props.input.draft
1209
+ : ''
1210
+
1211
+ return h('button', {
1212
+ type: 'button',
1213
+ className: 'tacit-improve-btn',
1214
+ title: t('improve.btn'),
1215
+ onClick: () => improveDraft(store, draft),
1216
+ }, '✨ ' + t('improve.btn'))
1217
+ }
1218
+ }
1219
+
1220
+ function PreviewOverlay(kit) {
1221
+ const { t } = kit
1222
+ return function PreviewOverlayView(props) {
1223
+ const hooks = props.hooks !== null && typeof props.hooks === 'object' ? props.hooks : null
1224
+ const store = hooks !== null ? hooks.tacitStore : null
1225
+ useStoreVersion(store)
1226
+ if (store === null || !store.preview.open) return null
1227
+
1228
+ const preview = store.preview
1229
+ const onApply = () => applyImproved(store, props.inputActions)
1230
+ const onCancel = () => closePreview(store)
1231
+
1232
+ return h('div', { className: 'tacit-modal-backdrop' },
1233
+ h('div', { className: 'tacit-modal-card' },
1234
+ h('div', { className: 'tacit-modal-head' },
1235
+ h('span', { className: 'tacit-modal-title' }, t('preview.title')),
1236
+ h('button', { type: 'button', className: 'tacit-modal-close', onClick: onCancel }, '×')),
1237
+ preview.pending
1238
+ ? h('div', { className: 'tacit-modal-pending' }, t('preview.pending'))
1239
+ : preview.error !== null
1240
+ ? h('div', { className: 'tacit-error' },
1241
+ t('err.' + String(preview.error.code), { detail: String(preview.error.detail || '') }))
1242
+ : h('div', null,
1243
+ h('div', { className: 'tacit-modal-cols' },
1244
+ h('div', { className: 'tacit-modal-col' },
1245
+ h('div', { className: 'tacit-modal-col-title' }, t('preview.original')),
1246
+ h('pre', { className: 'tacit-pre' }, preview.original)),
1247
+ h('div', { className: 'tacit-modal-col' },
1248
+ h('div', { className: 'tacit-modal-col-title' }, t('preview.improved')),
1249
+ h('pre', { className: 'tacit-pre' }, preview.data !== null && typeof preview.data.improved === 'string' ? preview.data.improved : ''))),
1250
+ preview.data !== null && typeof preview.data === 'object' && typeof preview.data.rationale === 'string' && preview.data.rationale.length > 0
1251
+ ? h('div', { className: 'tacit-modal-rationale' },
1252
+ h('div', { className: 'tacit-modal-col-title' }, t('preview.rationale')),
1253
+ h('div', { className: 'tacit-modal-rationale-text' }, preview.data.rationale))
1254
+ : null,
1255
+ h('div', { className: 'tacit-modal-actions' },
1256
+ h('button', { type: 'button', className: 'tacit-btn tacit-btn-primary', onClick: onApply }, t('preview.apply')),
1257
+ h('button', { type: 'button', className: 'tacit-btn', onClick: onCancel }, t('preview.cancel'))))))
1258
+ }
1259
+ }
1260
+
1261
+ // ── Feedback strip (under the composer, live stage only) ────────────────
1262
+
1263
+ function FeedbackStrip(kit) {
1264
+ const { t } = kit
1265
+ return function FeedbackStripView(props) {
1266
+ const store = storeFor(props.sessionId)
1267
+ useStoreVersion(store)
1268
+ useEffect(() => {
1269
+ if (store !== null) initStore(store)
1270
+ }, [store])
1271
+ // The input machine's phase: 'submitting' means the user sent — the
1272
+ // strip fades out right after the next send.
1273
+ const phase = typeof props.useInput === 'function'
1274
+ ? props.useInput((state) => (state === null || typeof state !== 'object' ? '' : state.phase))
1275
+ : ''
1276
+ useEffect(() => {
1277
+ if (phase === 'submitting' && store !== null && store.feedback.open && !store.feedback.sending) {
1278
+ fadeFeedback(store)
1279
+ }
1280
+ }, [phase, store])
1281
+
1282
+ if (store === null || !store.feedback.open) return null
1283
+ const feedback = store.feedback
1284
+ const reason = feedback.reason
1285
+
1286
+ return h('div', {
1287
+ className: 'tacit-feedback' + (feedback.fading ? ' tacit-feedback-fading' : ''),
1288
+ 'data-testid': 'tacit-feedback',
1289
+ },
1290
+ feedback.noted
1291
+ ? h('span', { className: 'tacit-feedback-noted' }, t('feedback.noted'))
1292
+ : h('div', { className: 'tacit-feedback-row' },
1293
+ h('span', { className: 'tacit-feedback-title' }, t('feedback.title')),
1294
+ h('button', {
1295
+ type: 'button',
1296
+ className: 'tacit-feedback-vote',
1297
+ title: t('feedback.up'),
1298
+ 'aria-label': t('feedback.up'),
1299
+ onClick: () => voteFeedback(store, 'up'),
1300
+ }, '👍'),
1301
+ h('button', {
1302
+ type: 'button',
1303
+ className: 'tacit-feedback-vote' + (feedback.verdict === 'down' ? ' tacit-feedback-vote-active' : ''),
1304
+ title: t('feedback.down'),
1305
+ 'aria-label': t('feedback.down'),
1306
+ onClick: () => voteFeedback(store, 'down'),
1307
+ }, '👎'),
1308
+ feedback.verdict === 'down'
1309
+ ? h('div', { className: 'tacit-feedback-reason' },
1310
+ h('input', {
1311
+ className: 'tacit-input tacit-feedback-input',
1312
+ type: 'text',
1313
+ maxLength: 300,
1314
+ placeholder: t('feedback.reasonPlaceholder'),
1315
+ value: reason,
1316
+ onChange: (event) => {
1317
+ store.feedback = { ...store.feedback, reason: event.target.value }
1318
+ notify(store)
1319
+ },
1320
+ }),
1321
+ h('button', {
1322
+ type: 'button',
1323
+ className: 'tacit-btn tacit-btn-sm',
1324
+ disabled: feedback.sending || reason.trim().length === 0,
1325
+ onClick: () => sendFeedback(store, 'down'),
1326
+ }, feedback.sending ? '…' : t('feedback.send')))
1327
+ : null))
1328
+ }
1329
+ }
1330
+
1331
+ // ── Global panel (settings section, sidebar action, frame overlay) ─────
1332
+
1333
+ function DirectivesEditor(kit) {
1334
+ const { t } = kit
1335
+ return function DirectivesEditorView(props) {
1336
+ const { config, directives, steering } = props
1337
+ const [draft, setDraft] = useState('')
1338
+ const [steerOn, setSteerOn] = useState(config !== null && config.steerAgent !== false)
1339
+ const [enrichOn, setEnrichOn] = useState(config !== null && config.enrichPrompts === true)
1340
+ useEffect(() => {
1341
+ if (config !== null && typeof config.steerAgent === 'boolean') setSteerOn(config.steerAgent)
1342
+ if (config !== null && typeof config.enrichPrompts === 'boolean') setEnrichOn(config.enrichPrompts)
1343
+ }, [config])
1344
+ const toggleSteer = () => {
1345
+ const next = !steerOn
1346
+ setSteerOn(next)
1347
+ updateRootConfig({ steerAgent: next })
1348
+ }
1349
+ const toggleEnrich = () => {
1350
+ const next = !enrichOn
1351
+ setEnrichOn(next)
1352
+ updateRootConfig({ enrichPrompts: next })
1353
+ }
1354
+ const onAdd = () => {
1355
+ const text = draft.trim()
1356
+ if (text.length === 0) return
1357
+ setDraft('')
1358
+ editDirectives({ action: 'add', text: text.slice(0, 300) })
1359
+ }
1360
+ return h('div', { className: 'tacit-panel-section', 'data-testid': 'tacit-steering' },
1361
+ h('div', { className: 'tacit-report-title' }, t('steer.title')),
1362
+ h('div', { className: 'tacit-panel-hint' }, t('steer.desc')),
1363
+ h('div', { className: 'tacit-settings-row' },
1364
+ h('label', { className: 'tacit-settings-label' }, t('steer.toggle')),
1365
+ h('input', { type: 'checkbox', checked: steerOn, onChange: toggleSteer })),
1366
+ h('div', { className: 'tacit-settings-row' },
1367
+ h('label', { className: 'tacit-settings-label' }, t('steer.enrich')),
1368
+ h('input', { type: 'checkbox', checked: enrichOn, onChange: toggleEnrich })),
1369
+ directives.length === 0
1370
+ ? h('div', { className: 'tacit-empty' }, t('steer.empty'))
1371
+ : h('div', { className: 'tacit-rules-list' },
1372
+ directives.map((entry) => h('div', { key: entry.id, className: 'tacit-rule tacit-directive' + (entry.enabled === false ? ' tacit-directive-off' : '') },
1373
+ h('input', {
1374
+ type: 'checkbox',
1375
+ className: 'tacit-check',
1376
+ 'data-testid': 'tacit-directive-toggle',
1377
+ checked: entry.enabled !== false,
1378
+ onChange: () => editDirectives({ action: 'toggle', id: entry.id, enabled: entry.enabled === false }),
1379
+ }),
1380
+ h('span', { className: 'tacit-directive-text' }, String(entry.text)),
1381
+ h('span', { className: 'tacit-chip' }, entry.source === 'user' ? t('steer.user') : t('steer.distilled')),
1382
+ entry.status === 'candidate'
1383
+ ? h('span', { className: 'tacit-chip tacit-chip-trial' }, t('steer.trial', {
1384
+ n: String(entry.trial !== null && typeof entry.trial === 'object' && typeof entry.trial.turns === 'number' ? entry.trial.turns : 0),
1385
+ total: String(config !== null && typeof config.directiveTrialTurns === 'number' ? config.directiveTrialTurns : 10),
1386
+ }))
1387
+ : entry.status === 'retired'
1388
+ ? h('span', { className: 'tacit-chip tacit-chip-warn' }, t('steer.retired', { reason: String(entry.retiredReason || '') }))
1389
+ : h('span', { className: 'tacit-chip tacit-chip-ok' }, t('steer.active')),
1390
+ h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', onClick: () => editDirectives({ action: 'remove', id: entry.id }) }, t('steer.remove'))))),
1391
+ h('div', { className: 'tacit-settings-row' },
1392
+ h('input', {
1393
+ className: 'tacit-input tacit-directive-input',
1394
+ type: 'text',
1395
+ maxLength: 300,
1396
+ placeholder: t('steer.addPlaceholder'),
1397
+ 'aria-label': t('steer.add'),
1398
+ value: draft,
1399
+ onChange: (event) => setDraft(event.target.value),
1400
+ onKeyDown: (event) => {
1401
+ if (event.key === 'Enter') onAdd()
1402
+ },
1403
+ }),
1404
+ h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', disabled: draft.trim().length === 0, onClick: onAdd }, t('steer.add'))),
1405
+ steering !== null && typeof steering === 'object' && typeof steering.text === 'string' && steering.text.length > 0
1406
+ ? h('details', { className: 'tacit-preview' },
1407
+ h('summary', null, t('steer.preview')),
1408
+ h('pre', { className: 'tacit-pre' }, steering.text))
1409
+ : null)
1410
+ }
1411
+ }
1412
+
1413
+ function CoachPanel(kit) {
1414
+ const { t, fmtTime } = kit
1415
+ const DirectivesEditorView = DirectivesEditor(kit)
1416
+ return function CoachPanelView() {
1417
+ useRootVersion()
1418
+ useEffect(() => {
1419
+ initRootStore()
1420
+ }, [])
1421
+
1422
+ const config = configOf(rootStore.config)
1423
+ const profile = profileOf(rootStore.profile)
1424
+ const [model, setModel] = useState(config !== null && typeof config.model === 'string' ? config.model : 'deepseek-v4-flash')
1425
+ const [budgetText, setBudgetText] = useState(config !== null && typeof config.autoDailyBudget === 'number' ? String(config.autoDailyBudget) : '30')
1426
+ const [auto, setAuto] = useState(config !== null && config.autoAnalyze !== false)
1427
+ const [live, setLive] = useState(config !== null && config.liveSuggestions !== false)
1428
+
1429
+ useEffect(() => {
1430
+ if (config !== null) {
1431
+ if (typeof config.model === 'string') setModel(config.model)
1432
+ if (typeof config.autoDailyBudget === 'number') setBudgetText(String(config.autoDailyBudget))
1433
+ if (typeof config.autoAnalyze === 'boolean') setAuto(config.autoAnalyze)
1434
+ if (typeof config.liveSuggestions === 'boolean') setLive(config.liveSuggestions)
1435
+ }
1436
+ }, [config])
1437
+
1438
+ const applyModel = (value) => {
1439
+ setModel(value)
1440
+ updateRootConfig({ model: value })
1441
+ }
1442
+ const applyBudget = () => {
1443
+ const number = Math.max(0, Math.min(1000, Math.round(Number(budgetText) || 0)))
1444
+ setBudgetText(String(number))
1445
+ updateRootConfig({ autoDailyBudget: number })
1446
+ }
1447
+ const toggleAuto = () => {
1448
+ const next = !auto
1449
+ setAuto(next)
1450
+ updateRootConfig({ autoAnalyze: next })
1451
+ }
1452
+ const toggleLive = () => {
1453
+ const next = !live
1454
+ setLive(next)
1455
+ updateRootConfig({ liveSuggestions: next })
1456
+ }
1457
+
1458
+ const coached = Array.isArray(rootStore.coached) ? rootStore.coached : []
1459
+ const error = rootStore.error
1460
+ const styleRules = rootStore.profile !== null && typeof rootStore.profile === 'object' && Array.isArray(rootStore.profile.styleRules)
1461
+ ? rootStore.profile.styleRules.filter((entry) => entry !== null && typeof entry === 'object' && typeof entry.rule === 'string' && entry.rule.length > 0)
1462
+ : []
1463
+ const directives = rootStore.profile !== null && typeof rootStore.profile === 'object' && Array.isArray(rootStore.profile.directives)
1464
+ ? rootStore.profile.directives.filter((entry) => entry !== null && typeof entry === 'object' && typeof entry.id === 'string' && typeof entry.text === 'string')
1465
+ : []
1466
+
1467
+ return h('div', { className: 'tacit-panel' },
1468
+ StatusCard(kit, { config, profile, auto: rootStore.auto, trend: rootStore.trend }),
1469
+ h('div', { className: 'tacit-settings-row' },
1470
+ BootstrapButton(kit, { bootstrap: rootStore.bootstrap, onClick: () => bootstrapAll() }),
1471
+ h('span', { className: 'tacit-panel-hint' }, t('bootstrap.hint'))),
1472
+ h('div', { className: 'tacit-settings' },
1473
+ h('div', { className: 'tacit-settings-title' }, t('settings.title')),
1474
+ h('div', { className: 'tacit-settings-row' },
1475
+ h('label', { className: 'tacit-settings-label' }, t('settings.model')),
1476
+ h('select', {
1477
+ className: 'tacit-select',
1478
+ value: model,
1479
+ onChange: (event) => applyModel(event.target.value),
1480
+ },
1481
+ h('option', { value: 'deepseek-v4-flash' }, 'deepseek-v4-flash'),
1482
+ h('option', { value: 'deepseek-v4-pro' }, 'deepseek-v4-pro'))),
1483
+ h('div', { className: 'tacit-settings-row' },
1484
+ h('label', { className: 'tacit-settings-label' }, t('settings.auto')),
1485
+ h('input', { type: 'checkbox', checked: auto, onChange: toggleAuto })),
1486
+ h('div', { className: 'tacit-settings-row' },
1487
+ h('label', { className: 'tacit-settings-label' }, t('settings.budget')),
1488
+ h('input', {
1489
+ className: 'tacit-input',
1490
+ type: 'number',
1491
+ min: 0,
1492
+ value: budgetText,
1493
+ onChange: (event) => setBudgetText(event.target.value),
1494
+ }),
1495
+ h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', onClick: applyBudget }, t('settings.apply'))),
1496
+ h('div', { className: 'tacit-settings-row' },
1497
+ h('label', { className: 'tacit-settings-label' }, t('settings.live')),
1498
+ h('input', { type: 'checkbox', checked: live, onChange: toggleLive })),
1499
+ h('div', { className: 'tacit-settings-row' },
1500
+ h('button', { type: 'button', className: 'tacit-btn tacit-btn-sm', onClick: () => clearAllRoot() }, t('settings.clear')))),
1501
+ error !== null && typeof error === 'object'
1502
+ ? h('div', { className: 'tacit-error' }, t('err.' + String(error.code), { detail: String(error.detail || '') }))
1503
+ : null,
1504
+ h(DirectivesEditorView, { config, directives, steering: rootStore.steering }),
1505
+ h('div', { className: 'tacit-panel-section' },
1506
+ h('div', { className: 'tacit-report-title' }, t('panel.styleRules')),
1507
+ styleRules.length === 0
1508
+ ? h('div', { className: 'tacit-empty' }, t('panel.styleRulesEmpty'))
1509
+ : h('div', { className: 'tacit-rules-list' },
1510
+ styleRules.map((entry, index) => h('div', { key: 'rule-' + index, className: 'tacit-rule' }, String(entry.rule))))),
1511
+ h('div', { className: 'tacit-panel-section' },
1512
+ h('div', { className: 'tacit-report-title' }, t('panel.coached')),
1513
+ coached.length === 0
1514
+ ? h('div', { className: 'tacit-empty' }, t('panel.coachedEmpty'))
1515
+ : h('div', { className: 'tacit-coached-list' },
1516
+ coached.map((entry, index) => h('div', { key: 'c' + index, className: 'tacit-coached-row' },
1517
+ h('div', { className: 'tacit-coached-meta' },
1518
+ h('span', { className: 'tacit-coached-turn' }, (typeof entry.sessionLabel === 'string' && entry.sessionLabel.length > 0 ? entry.sessionLabel + ' · ' : '') + '# ' + String(entry.turn)),
1519
+ h('span', { className: 'tacit-coached-time' }, fmtTime(entry.time)),
1520
+ h('span', { className: 'tacit-chip tacit-coached-trigger' }, t('trigger.' + (entry.trigger === 'auto' || entry.trigger === 'correction' || entry.trigger === 'bootstrap' ? entry.trigger : 'manual')))),
1521
+ typeof entry.promptExcerpt === 'string' && entry.promptExcerpt.length > 0
1522
+ ? h('div', { className: 'tacit-coached-excerpt' }, entry.promptExcerpt)
1523
+ : null,
1524
+ typeof entry.improvedPrompt === 'string' && entry.improvedPrompt.length > 0
1525
+ ? h('div', { className: 'tacit-coached-improved' }, entry.improvedPrompt.slice(0, 240) + (entry.improvedPrompt.length > 240 ? '…' : ''))
1526
+ : null)))),
1527
+ h('div', { className: 'tacit-panel-hint' }, t('panel.hint')))
1528
+ }
1529
+ }
1530
+
1531
+ function SettingsSection(kit) {
1532
+ const { t } = kit
1533
+ const CoachPanelView = CoachPanel(kit)
1534
+ return function SettingsSectionView() {
1535
+ useEffect(() => {
1536
+ initRootStore()
1537
+ // Fresh read so freshly distilled style rules appear on open.
1538
+ refreshRootState()
1539
+ }, [])
1540
+ return h('div', { className: 'tacit-root' },
1541
+ h('div', { className: 'tacit-head' }, h('div', { className: 'tacit-head-title' }, t('panel.title'))),
1542
+ h(CoachPanelView))
1543
+ }
1544
+ }
1545
+
1546
+ // ── CSS (injected once) ────────────────────────────────────────────────
1547
+
1548
+ const css = '.tacit-root{box-sizing:border-box;height:100%;color:var(--dsw-alias-label-primary);padding:16px 20px 32px;font-size:13px;overflow-y:auto}'
1549
+ + '.tacit-head{display:flex;align-items:baseline;gap:8px;margin-bottom:10px}.tacit-head-title{font-size:14px;font-weight:600;margin-right:auto}'
1550
+ + '.tacit-progress{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:8px 12px;margin-bottom:12px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5}.tacit-progress-title{font-weight:600;color:var(--dsw-alias-label-primary)}'
1551
+ + '.tacit-empty{color:var(--dsw-alias-label-secondary);text-align:center;padding:24px 8px;line-height:1.6}'
1552
+ + '.tacit-error{color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:8px 12px;margin-bottom:12px;overflow-wrap:anywhere;font-size:12px}'
1553
+ + '.tacit-btn{font:inherit;font-size:12px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:6px;cursor:pointer;padding:4px 10px}.tacit-btn:hover{border-color:var(--dsw-alias-label-primary)}.tacit-btn:disabled{opacity:.55;cursor:default}'
1554
+ + '.tacit-btn-primary{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground);border-color:transparent}.tacit-btn-primary:hover{border-color:transparent;filter:brightness(1.1)}'
1555
+ + '.tacit-btn-sm{padding:2px 8px;font-size:11px}'
1556
+ + '.tacit-row{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:10px 12px;margin-bottom:10px}'
1557
+ + '.tacit-row-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 12px}.tacit-row-heading{display:flex;align-items:baseline;gap:8px;font-weight:600}.tacit-row-turn{font-size:13px}.tacit-row-time{color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:400}.tacit-row-live{color:var(--dsw-alias-state-success-primary);font-size:11px;font-weight:600}'
1558
+ + '.tacit-row-chips{display:flex;flex-wrap:wrap;gap:4px;margin-left:auto}.tacit-chip{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary);border-radius:4px;padding:1px 7px;font-size:11px}.tacit-chip-warn{color:var(--dsw-alias-state-warn-primary)}'
1559
+ + '.tacit-row-prompt{display:flex;align-items:flex-start;gap:8px;margin-top:8px}.tacit-row-prompt-text{font:inherit;font-size:12px;color:var(--dsw-alias-label-primary);background:none;border:0;cursor:pointer;text-align:left;padding:0;white-space:pre-wrap;word-break:break-word;flex:1;line-height:1.55}'
1560
+ + '.tacit-report{border-top:1px dashed var(--dsw-alias-border-l1);margin-top:10px;padding-top:10px;display:flex;flex-direction:column;gap:6px}'
1561
+ + '.tacit-report-title{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary)}'
1562
+ + '.tacit-report-note{color:var(--dsw-alias-label-secondary);font-size:12px}'
1563
+ + '.tacit-report-body{color:var(--dsw-alias-label-primary);font-size:12px;line-height:1.6;word-break:break-word}.tacit-report-body>div{font-size:13px;line-height:1.6}'
1564
+ + '.tacit-report-improved{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-2);padding:8px 10px;display:flex;flex-direction:column;gap:6px}'
1565
+ + '.tacit-report-actions{display:flex;justify-content:flex-end}'
1566
+ + '.tacit-problem{border:1px solid var(--dsw-alias-border-l1);border-left:3px solid var(--dsw-alias-state-warn-primary);border-radius:6px;background:var(--dsw-alias-bg-layer-2);padding:6px 10px;display:flex;flex-direction:column;gap:3px}'
1567
+ + '.tacit-problem-head{display:flex;align-items:center;gap:8px}.tacit-problem-kind{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;font-weight:600}.tacit-problem-sev{font-size:10px;font-weight:600;border-radius:4px;padding:1px 6px}'
1568
+ + '.tacit-sev-high{background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 18%, transparent);color:var(--dsw-alias-state-error-primary)}'
1569
+ + '.tacit-sev-medium{background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 18%, transparent);color:var(--dsw-alias-state-warn-primary)}'
1570
+ + '.tacit-sev-low{background:color-mix(in srgb, var(--dsw-alias-label-secondary) 18%, transparent);color:var(--dsw-alias-label-secondary)}'
1571
+ + '.tacit-sev-info{background:color-mix(in srgb, var(--dsw-alias-brand-primary) 18%, transparent);color:var(--dsw-alias-brand-primary)}'
1572
+ + '.tacit-problem-what{font-size:12px;color:var(--dsw-alias-label-primary);line-height:1.5}.tacit-problem-why{font-size:11px;color:var(--dsw-alias-label-secondary);line-height:1.5}'
1573
+ + '.tacit-pre{margin:0;white-space:pre-wrap;word-break:break-word;font-family:inherit;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-primary)}'
1574
+ + '.tacit-settings{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:10px 12px;margin-bottom:12px;display:flex;flex-direction:column;gap:8px}'
1575
+ + '.tacit-settings-title{font-size:12px;font-weight:600}.tacit-settings-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.tacit-settings-label{font-size:12px;color:var(--dsw-alias-label-secondary);min-width:150px}'
1576
+ + '.tacit-settings-notice{font-size:11px;color:var(--dsw-alias-state-success-primary)}'
1577
+ + '.tacit-select,.tacit-input{font:inherit;font-size:12px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:6px;padding:3px 8px}.tacit-input{width:90px}'
1578
+ + '.tacit-improve-btn{font:inherit;font-size:13px;font-weight:500;line-height:20px;color:var(--dsw-alias-label-secondary);background:transparent;border:0;border-radius:24px;cursor:pointer;height:28px;padding:0 8px;white-space:nowrap;display:inline-flex;align-items:center;gap:4px}.tacit-improve-btn:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}'
1579
+ + '.tacit-check{width:14px;height:14px;accent-color:var(--dsw-alias-brand-primary);flex:none;margin:2px 4px 0 0}'
1580
+ + '.tacit-toolbar{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap}.tacit-toolbar-hint{flex:1;min-width:200px}.tacit-btn-quiet{color:var(--dsw-alias-label-secondary);background:transparent}.tacit-chip-muted{opacity:.7}.tacit-row-analyzed{border-left:3px solid var(--dsw-alias-brand-primary)}'
1581
+ + '.tacit-progress-head{display:flex;align-items:baseline;gap:8px;margin-bottom:6px}.tacit-progress-count{font-variant-numeric:tabular-nums;font-weight:600;color:var(--dsw-alias-label-primary)}'
1582
+ + '.tacit-progress-text{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.5}'
1583
+ + '.tacit-panel{display:flex;flex-direction:column;gap:10px}.tacit-panel-section{display:flex;flex-direction:column;gap:6px}.tacit-panel-hint{font-size:11px;color:var(--dsw-alias-label-secondary);line-height:1.5}'
1584
+ + '.tacit-coached-list{display:flex;flex-direction:column;gap:6px}.tacit-coached-row{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:6px 10px;display:flex;flex-direction:column;gap:3px}'
1585
+ + '.tacit-coached-meta{display:flex;align-items:baseline;gap:8px}.tacit-coached-turn{font-weight:600;font-size:12px}.tacit-coached-time{color:var(--dsw-alias-label-secondary);font-size:11px}'
1586
+ + '.tacit-coached-excerpt{font-size:12px;color:var(--dsw-alias-label-primary);white-space:pre-wrap;word-break:break-word;line-height:1.5}.tacit-coached-improved{font-size:11px;color:var(--dsw-alias-label-secondary);line-height:1.5;white-space:pre-wrap;word-break:break-word}'
1587
+ + ''
1588
+ + '.tacit-modal-backdrop{z-index:200;background:#00000073;display:flex;justify-content:center;align-items:center;position:fixed;inset:0}'
1589
+ + '.tacit-modal-card{box-sizing:border-box;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);width:min(760px,100vw - 48px);max-height:min(84vh,800px);box-shadow:var(--dsw-shadow-lv3,0 12px 32px #0006);color:var(--dsw-alias-label-primary);border-radius:12px;padding:16px 18px 18px;font-size:13px;display:flex;flex-direction:column;gap:10px;overflow-y:auto}'
1590
+ + '.tacit-modal-head{display:flex;align-items:baseline;gap:8px}.tacit-modal-title{font-size:14px;font-weight:600;margin-right:auto}.tacit-modal-close{color:var(--dsw-alias-label-secondary);cursor:pointer;background:none;border:0;border-radius:6px;padding:2px 8px;font-family:inherit;font-size:18px;line-height:1}.tacit-modal-close:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}'
1591
+ + '.tacit-modal-pending{color:var(--dsw-alias-label-secondary);padding:16px 0;text-align:center}'
1592
+ + '.tacit-modal-cols{display:grid;grid-template-columns:1fr 1fr;gap:10px}.tacit-modal-col{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-2);padding:8px 10px;min-width:0;max-height:300px;overflow-y:auto}'
1593
+ + '.tacit-modal-col-title{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary);margin-bottom:6px}'
1594
+ + '.tacit-modal-rationale-text{font-size:12px;color:var(--dsw-alias-label-primary);line-height:1.6}.tacit-modal-savings{color:var(--dsw-alias-state-success-primary);font-size:12px;font-weight:600}'
1595
+ + '.tacit-modal-actions{display:flex;gap:8px;justify-content:flex-end}'
1596
+ + '.tacit-feedback{display:flex;justify-content:center;align-items:center;margin:8px 2px 0;font-size:12px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:6px 10px;gap:8px;transition:opacity .35s ease}.tacit-feedback-fading{opacity:0}'
1597
+ + '.tacit-feedback-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.tacit-feedback-title{color:var(--dsw-alias-label-secondary)}'
1598
+ + '.tacit-feedback-vote{font:inherit;font-size:14px;line-height:1;cursor:pointer;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:3px 8px}.tacit-feedback-vote:hover{border-color:var(--dsw-alias-brand-primary)}.tacit-feedback-vote-active{border-color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 12%, transparent)}'
1599
+ + '.tacit-feedback-reason{display:flex;align-items:center;gap:6px;flex-wrap:wrap}.tacit-feedback-input{width:260px;max-width:60vw}'
1600
+ + '.tacit-feedback-noted{color:var(--dsw-alias-state-success-primary);font-weight:600}'
1601
+ + '.tacit-trend{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:6px}.tacit-chip-trigger,.tacit-coached-trigger{text-transform:uppercase;letter-spacing:.04em;font-size:10px}.tacit-coached-trigger{margin-left:auto}.tacit-row-enrichment{margin-top:8px;border-left:3px solid var(--dsw-alias-brand-primary);padding:4px 10px;background:var(--dsw-alias-bg-layer-2);border-radius:6px}'
1602
+ + '.tacit-chip-trial{color:var(--dsw-alias-brand-primary)}.tacit-chip-ok{color:var(--dsw-alias-state-success-primary)}'
1603
+ + '.tacit-directive{display:flex;align-items:center;gap:8px}.tacit-directive-text{flex:1;min-width:0}.tacit-directive-off .tacit-directive-text{opacity:.5;text-decoration:line-through}.tacit-directive-input{width:min(420px,100%);flex:1}.tacit-preview summary{cursor:pointer;font-size:11px;color:var(--dsw-alias-label-secondary)}.tacit-preview pre{margin-top:6px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:6px;padding:8px 10px}'
1604
+ + '.tacit-rules-list{display:flex;flex-direction:column;gap:6px}.tacit-rule{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-left:3px solid var(--dsw-alias-brand-primary);border-radius:6px;padding:6px 10px;font-size:12px;line-height:1.55;color:var(--dsw-alias-label-primary);white-space:pre-wrap;word-break:break-word}'
1605
+
1606
+ function injectCss() {
1607
+ const tagId = 'dsh-tacit/styles.css'
1608
+ if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null) {
1609
+ const tag = document.createElement('style')
1610
+ tag.dataset.plugin = NS
1611
+ tag.dataset.pluginCss = tagId
1612
+ tag.textContent = css
1613
+ document.head.appendChild(tag)
1614
+ }
1615
+ }
1616
+
1617
+ // ── Client plugin body ──────────────────────────────────────────────────
1618
+
1619
+ function apply(ctx) {
1620
+ ctx.effect(() => ctx.locale.register(NS, { zh: DICT_ZH, en: DICT_EN }), 'dsh-tacit: dictionaries')
1621
+ const t = ctx.locale.bind(NS)
1622
+ const kit = makeKit(t)
1623
+ injectCss()
1624
+
1625
+ const CoachTabView = CoachTab(kit)
1626
+ ctx.slots.inject('conversation.view', () => ctx.slots.register({
1627
+ name: 'conversation.view',
1628
+ id: 'tacit',
1629
+ order: 30,
1630
+ locale: NS,
1631
+ label: () => t('tab'),
1632
+ }, (props) => h(CoachTabView, props)))
1633
+
1634
+ const ImproveButtonView = ImproveButton(kit)
1635
+ ctx.slots.inject('conversation.input.left', () => ctx.slots.register({
1636
+ name: 'conversation.input.left',
1637
+ id: 'tacit-improve',
1638
+ order: 100,
1639
+ locale: NS,
1640
+ label: () => t('improve.btn'),
1641
+ }, (props) => h(ImproveButtonView, props)))
1642
+
1643
+ const PreviewOverlayView = PreviewOverlay(kit)
1644
+ ctx.slots.inject('conversation.input.overlay', () => ctx.slots.register({
1645
+ name: 'conversation.input.overlay',
1646
+ id: 'tacit-preview',
1647
+ order: 10,
1648
+ locale: NS,
1649
+ inject: (sessionId) => ({ hooks: { tacitStore: storeFor(sessionId) } }),
1650
+ }, (props) => h(PreviewOverlayView, props)))
1651
+
1652
+ // The post-apply 👍/👎 strip lives in the band UNDER the composer card
1653
+ // (composer.dock), only for the live stage (threshold reached).
1654
+ const FeedbackStripView = FeedbackStrip(kit)
1655
+ ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
1656
+ name: 'conversation.composer.dock',
1657
+ id: 'tacit-feedback',
1658
+ order: 10,
1659
+ locale: NS,
1660
+ label: () => t('feedback.title'),
1661
+ }, (props) => h(FeedbackStripView, props)))
1662
+
1663
+ // Own Settings page under "Cost": a nav section in the Settings panel,
1664
+ // on its own. Reuses the coach panel (progress, model, threshold, live,
1665
+ // clear reports, coached prompts). Order 32 places it right after
1666
+ // cost-meter's "Cost" section (order 30/31).
1667
+ const SettingsSectionView = SettingsSection(kit)
1668
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
1669
+ name: 'settings.section',
1670
+ id: 'tacit',
1671
+ order: 32,
1672
+ locale: NS,
1673
+ label: () => t('settings.sectionLabel'),
1674
+ }, (props) => h(SettingsSectionView, props)))
1675
+ }
1676
+
1677
+ return {
1678
+ name: NS,
1679
+ inject: ['slots', 'locale'],
1680
+ apply,
1681
+ // Test-only handles for the SSR suite (ignored by the module loader).
1682
+ __test: {
1683
+ css,
1684
+ applyImproved,
1685
+ closeFeedback,
1686
+ fadeFeedback,
1687
+ rootStore,
1688
+ },
1689
+ }
1690
+ },
1691
+ })