dsh-sidenote 0.3.1 → 0.3.2

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,91 @@
1
+ /**
2
+ * 完成通知(WI-03,L3 常驻监听层):侧聊子会话 running true→false 翻转时
3
+ * toast 提醒——用户焦点在主对话时侧聊长任务跑完能被看见(Zed
4
+ * notify_when_agent_waiting 先例)。
5
+ *
6
+ * 事件源是 session.subscribe 快照推送(非轮询);宿主 notifier 入账不依赖
7
+ * 订阅者——面板不可见(SideChatPanel 的 uSES 断订阅)期间事件不丢。
8
+ * 本层是常驻 ctx.effect:Tab 开闭由 betterSidebar.subscribeState 重扫驱动。
9
+ */
10
+ import type { Context } from '../host/contracts.ts'
11
+ import { collectSideTabs, parseSideChatMeta } from './model.ts'
12
+ import { openSessionWindow } from './lifecycle.ts'
13
+ import { showToast } from './toast.tsx'
14
+ import { t } from '../locales.ts'
15
+
16
+ interface Watcher {
17
+ unsub: () => void
18
+ wasRunning: boolean
19
+ /** 建 watcher 的时间——挂接瞬间的 running 翻转(残影)不报。 */
20
+ since: number
21
+ }
22
+
23
+ /** 挂接保护窗:watcher 建立后该窗口内的翻转不视为「一个真实 turn 跑完」。 */
24
+ const ATTACH_GRACE_MS = 800
25
+
26
+ /**
27
+ * 翻转侦测核心(抽出来纯可测):running true→false 且距挂接超过保护窗
28
+ * 时返回 true。调用方持状态。
29
+ */
30
+ export function shouldNotify(wasRunning: boolean, running: boolean, attachedAt: number, now: number): boolean {
31
+ return wasRunning && !running && now - attachedAt > ATTACH_GRACE_MS
32
+ }
33
+
34
+ export function registerCompletionNotify(ctx: Context): void {
35
+ ctx.effect(() => {
36
+ const watchers = new Map<string, Watcher>()
37
+
38
+ const rescan = (): void => {
39
+ let tabs: ReturnType<typeof collectSideTabs> = []
40
+ try {
41
+ tabs = collectSideTabs(ctx.betterSidebar.getSnapshot())
42
+ } catch {
43
+ return
44
+ }
45
+ const alive = new Set<string>()
46
+ for (const tab of tabs) {
47
+ const meta = parseSideChatMeta(tab.meta)
48
+ const childId = meta.childId
49
+ if (childId === undefined) continue
50
+ alive.add(childId)
51
+ if (watchers.has(childId)) continue
52
+ // 新侧聊:binding 可能 throw(会话还没就绪)——本轮跳过,下轮重扫补。
53
+ try {
54
+ const session = ctx.sessions.binding(childId)?.session
55
+ if (session === undefined) continue
56
+ openSessionWindow(session)
57
+ const title = tab.title
58
+ const watcher: Watcher = { unsub: () => {}, wasRunning: false, since: Date.now() }
59
+ watcher.unsub = session.subscribe(() => {
60
+ const snap = session.getSnapshot() as { running?: unknown } | null
61
+ const running = snap?.running === true
62
+ if (shouldNotify(watcher.wasRunning, running, watcher.since, Date.now())) {
63
+ showToast(t('sideChatDone', { title }))
64
+ }
65
+ watcher.wasRunning = running
66
+ })
67
+ // 挂接即取一次初值(正在跑的会话稍后跑完时报)。
68
+ watcher.wasRunning = (session.getSnapshot() as { running?: unknown } | null)?.running === true
69
+ watchers.set(childId, watcher)
70
+ } catch {
71
+ // 下轮重扫再试。
72
+ }
73
+ }
74
+ // 清理已关闭 Tab 的 watcher(× 即焚的会话不再报)。
75
+ for (const [id, w] of watchers) {
76
+ if (!alive.has(id)) {
77
+ w.unsub()
78
+ watchers.delete(id)
79
+ }
80
+ }
81
+ }
82
+
83
+ const offState = ctx.betterSidebar.subscribeState(rescan)
84
+ rescan()
85
+ return () => {
86
+ offState()
87
+ for (const w of watchers.values()) w.unsub()
88
+ watchers.clear()
89
+ }
90
+ }, 'dsh-sidenote: completion notify')
91
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * R8 侧边审批/提问答复(L0/L1:双版本数据源探测链 + 视图模型归一)。
3
+ *
4
+ * 侦察依据(.vibe Delivery_04/Workitem_02 design.md):
5
+ * - pending 不在 projections.faceOf 体系——0.1.1 在 Session 快照顶层
6
+ * `pending: PendingWait[]`(mux 帧铸造,重连重放 rpcId 不变);0.1.2 移入
7
+ * `ctx.get('uiSession').pendingInteractions`(Map<SessionId, Pending>,
8
+ * 每会话至多一项,precedence 仲裁)。
9
+ * - 写路径都是公开对象的公开方法:0.1.1 `wait.respond({ok,value})`(审批
10
+ * value={sessionId, approvalId, outcome};提问整批 {sessionId, answer},
11
+ * 取消走 {ok:false,error:{code:'cancelled',…}});0.1.2 域对象
12
+ * `answer(outcome|answer)` / `cancel()`。
13
+ * - 竞答安全:先答先得,settle 幂等——后答方 throw,UI catch 吞掉并回退
14
+ * 按钮态(宿主同款 `.catch(() => setAnswered(false))`)。
15
+ *
16
+ * off-face 纪律:两面都 feature-check;全缺席返回 undefined(面板回退
17
+ * pendingBar 跳转行为,不崩)。
18
+ */
19
+
20
+ // ── 视图模型(视图层唯一消费形状;双版本在此归一)──
21
+
22
+ export interface PendingApprovalView {
23
+ kind: 'approval'
24
+ key: string
25
+ toolName: string
26
+ reason?: string | undefined
27
+ answer: (outcome: 'allowed-once' | 'rejected') => Promise<void>
28
+ }
29
+
30
+ export interface PendingQuestionOption {
31
+ label: string
32
+ description?: string | undefined
33
+ }
34
+
35
+ export interface PendingQuestionView {
36
+ kind: 'question'
37
+ key: string
38
+ /** plan-review 按通用提问流降级(提交值逐字回传 asker 原 label)。 */
39
+ planReview: boolean
40
+ questions: readonly {
41
+ id: string
42
+ question: string
43
+ header?: string | undefined
44
+ detail?: string | undefined
45
+ options: readonly PendingQuestionOption[]
46
+ multiSelect: boolean
47
+ }[]
48
+ /** 整批提交(不可拆单题):answers = [{id, selected: label[], custom?}]。 */
49
+ answer: (answers: readonly { id: string; selected: readonly string[]; custom?: string }[]) => Promise<void>
50
+ cancel: () => Promise<void>
51
+ }
52
+
53
+ export type PendingItem = PendingApprovalView | PendingQuestionView
54
+
55
+ export interface PendingStore {
56
+ readonly available: boolean
57
+ subscribe(fn: () => void): () => void
58
+ /** 缓存快照:底层引用不变则返回同一数组(uSES 纪律)。 */
59
+ getSnapshot(): readonly PendingItem[]
60
+ }
61
+
62
+ interface CtxLike {
63
+ get?: (key: string) => unknown
64
+ }
65
+
66
+ interface SessionLike {
67
+ subscribe(fn: () => void): () => void
68
+ getSnapshot(): unknown
69
+ }
70
+
71
+ // ── 0.1.2 适配(uiSession.pendingInteractions 域对象)──
72
+
73
+ function adapt012(pending: unknown): readonly PendingItem[] {
74
+ if (typeof pending !== 'object' || pending === null) return []
75
+ const o = pending as Record<string, unknown>
76
+ if (o.kind === 'approval' && typeof o.answer === 'function') {
77
+ return [{
78
+ kind: 'approval',
79
+ key: typeof o.key === 'string' ? o.key : 'approval',
80
+ toolName: typeof o.toolName === 'string' ? o.toolName : '',
81
+ ...(typeof o.reason === 'string' ? { reason: o.reason } : {}),
82
+ answer: (outcome) => (o.answer as (x: string) => Promise<void>).call(o, outcome),
83
+ }]
84
+ }
85
+ if ((o.kind === 'question' || o.kind === 'plan-review') && typeof o.answer === 'function' && typeof o.cancel === 'function') {
86
+ const questions = normalizeQuestions(o.questions ?? (o.payload as { questions?: unknown } | undefined)?.questions)
87
+ if (questions === undefined) return []
88
+ return [{
89
+ kind: 'question',
90
+ key: typeof o.key === 'string' ? o.key : 'question',
91
+ planReview: o.kind === 'plan-review',
92
+ questions,
93
+ answer: (answers) => (o.answer as (a: unknown) => Promise<void>).call(o, { answers }),
94
+ cancel: () => (o.cancel as () => Promise<void>).call(o),
95
+ }]
96
+ }
97
+ return []
98
+ }
99
+
100
+ // ── 0.1.1 适配(Session 快照 pending: PendingWait[])──
101
+
102
+ interface PendingWaitLike {
103
+ kind?: unknown
104
+ key?: unknown
105
+ sessionId?: unknown
106
+ payload?: unknown
107
+ respond?: unknown
108
+ }
109
+
110
+ function adapt011(pending: readonly unknown[]): readonly PendingItem[] {
111
+ const items: PendingItem[] = []
112
+ for (const raw of pending) {
113
+ const w = raw as PendingWaitLike
114
+ if (typeof w.respond !== 'function') continue
115
+ const respond = w.respond as (r: unknown) => Promise<{ accepted?: boolean }>
116
+ const sessionId = typeof w.sessionId === 'string' ? w.sessionId : undefined
117
+ if (sessionId === undefined) continue
118
+ const payload = (w.payload ?? {}) as Record<string, unknown>
119
+ const key = typeof w.key === 'string' ? w.key : `pending:${items.length}`
120
+ if (w.kind === 'approval') {
121
+ const approvalId = payload.approvalId
122
+ if (typeof approvalId !== 'string') continue
123
+ items.push({
124
+ kind: 'approval',
125
+ key,
126
+ toolName: typeof payload.toolName === 'string' ? payload.toolName : '',
127
+ ...(typeof payload.reason === 'string' ? { reason: payload.reason } : {}),
128
+ answer: async (outcome) => {
129
+ const receipt = await respond({ ok: true, value: { sessionId, approvalId, outcome } })
130
+ if (receipt.accepted === false) throw new Error('approval response rejected (not pending)')
131
+ },
132
+ })
133
+ } else if (w.kind === 'question') {
134
+ const questions = normalizeQuestions(payload.questions)
135
+ if (questions === undefined) continue
136
+ items.push({
137
+ kind: 'question',
138
+ key,
139
+ planReview: false,
140
+ questions,
141
+ answer: async (answers) => {
142
+ const receipt = await respond({ ok: true, value: { sessionId, answer: { answers } } })
143
+ if (receipt.accepted === false) throw new Error('question response rejected (not pending)')
144
+ },
145
+ cancel: async () => {
146
+ await respond({ ok: false, error: { code: 'cancelled', message: 'the user closed this question request', details: {} } })
147
+ },
148
+ })
149
+ }
150
+ }
151
+ return items
152
+ }
153
+
154
+ /** questions 字段归一(选项 {label,description?} 与纯字符串两形都收)。 */
155
+ function normalizeQuestions(raw: unknown): PendingQuestionView['questions'] | undefined {
156
+ if (!Array.isArray(raw)) return undefined
157
+ const out: PendingQuestionView['questions'][number][] = []
158
+ for (const rawQ of raw) {
159
+ if (typeof rawQ !== 'object' || rawQ === null) return undefined
160
+ const q = rawQ as Record<string, unknown>
161
+ if (typeof q.question !== 'string') return undefined
162
+ const options: PendingQuestionOption[] = []
163
+ if (Array.isArray(q.options)) {
164
+ for (const rawO of q.options) {
165
+ if (typeof rawO === 'string') options.push({ label: rawO })
166
+ else if (typeof rawO === 'object' && rawO !== null && typeof (rawO as { label?: unknown }).label === 'string') {
167
+ const oo = rawO as { label: string; description?: unknown }
168
+ options.push({ label: oo.label, ...(typeof oo.description === 'string' ? { description: oo.description } : {}) })
169
+ } else return undefined
170
+ }
171
+ }
172
+ out.push({
173
+ id: typeof q.id === 'string' ? q.id : `q${out.length}`,
174
+ question: q.question,
175
+ ...(typeof q.header === 'string' ? { header: q.header } : {}),
176
+ ...(typeof q.detail === 'string' ? { detail: q.detail } : {}),
177
+ options,
178
+ multiSelect: q.multiSelect === true,
179
+ })
180
+ }
181
+ return out
182
+ }
183
+
184
+ /**
185
+ * 双版本探测链(chatSourceOf 同款纪律):0.1.2 uiSession.pendingInteractions
186
+ * (getSnapshot() 是 Map 才认)优先;0.1.1 Session 快照 pending 数组回退;
187
+ * 双缺席 undefined(面板回退 pendingBar 跳转)。
188
+ */
189
+ export function createPendingStore(ctx: CtxLike, session: SessionLike | undefined, childId: string): PendingStore | undefined {
190
+ // 面 1(0.1.2+):uiSession 服务的 pendingInteractions observable。
191
+ try {
192
+ const ui = typeof ctx.get === 'function' ? ctx.get('uiSession') : undefined
193
+ const pim = (ui as { pendingInteractions?: { subscribe?: unknown; getSnapshot?: unknown } } | undefined)?.pendingInteractions
194
+ if (pim !== undefined && typeof pim.subscribe === 'function' && typeof pim.getSnapshot === 'function') {
195
+ const obs = pim as { subscribe(fn: () => void): () => void; getSnapshot(): unknown }
196
+ if (obs.getSnapshot() instanceof Map) {
197
+ let cachedMap: unknown = null
198
+ let cachedItems: readonly PendingItem[] = []
199
+ return {
200
+ available: true,
201
+ subscribe: (fn) => obs.subscribe(fn),
202
+ getSnapshot: () => {
203
+ const map = obs.getSnapshot()
204
+ if (map === cachedMap) return cachedItems
205
+ cachedMap = map
206
+ cachedItems = map instanceof Map ? adapt012(map.get(childId)) : []
207
+ return cachedItems
208
+ },
209
+ }
210
+ }
211
+ }
212
+ } catch {
213
+ // 落面 2。
214
+ }
215
+ // 面 2(0.1.1):Session 快照顶层 pending 数组。
216
+ if (session === undefined) return undefined
217
+ try {
218
+ const snap = session.getSnapshot()
219
+ if (Array.isArray((snap as { pending?: unknown } | null)?.pending)) {
220
+ let cachedArr: unknown = null
221
+ let cachedItems: readonly PendingItem[] = []
222
+ return {
223
+ available: true,
224
+ subscribe: (fn) => session.subscribe(fn),
225
+ getSnapshot: () => {
226
+ const arr = (session.getSnapshot() as { pending?: unknown } | null)?.pending
227
+ if (arr === cachedArr) return cachedItems
228
+ cachedArr = arr
229
+ cachedItems = Array.isArray(arr) ? adapt011(arr) : []
230
+ return cachedItems
231
+ },
232
+ }
233
+ }
234
+ } catch {
235
+ // 双缺席。
236
+ }
237
+ return undefined
238
+ }
@@ -626,7 +626,148 @@
626
626
  background: var(--dsw-alias-bg-layer-2);
627
627
  }
628
628
 
629
- /* R8 审批提示条(钉在 composer 上方)。 */
629
+ /* R8 审批/提问答复卡(钉在 composer 上方;警示色左边条 + 圆角卡——
630
+ 材质与胶囊 composer 同族)。 */
631
+ .pendingCard {
632
+ margin: 0 10px 6px;
633
+ padding: 10px 12px;
634
+ border: 1px solid var(--dsw-alias-border-l2);
635
+ border-left: 3px solid var(--dsw-alias-state-warning-primary, #e8a13a);
636
+ border-radius: 10px;
637
+ background: var(--dsw-alias-bg-layer-2);
638
+ display: flex;
639
+ flex-direction: column;
640
+ gap: 6px;
641
+ font-size: 12px;
642
+ }
643
+
644
+ .pendingHead {
645
+ display: flex;
646
+ align-items: center;
647
+ gap: 6px;
648
+ color: var(--dsw-alias-state-warning-primary, #e8a13a);
649
+ }
650
+
651
+ .pendingTitle {
652
+ font-weight: 600;
653
+ color: var(--dsw-alias-label-primary);
654
+ }
655
+
656
+ .pendingDesc {
657
+ color: var(--dsw-alias-label-secondary);
658
+ }
659
+
660
+ .pendingReason {
661
+ color: var(--dsw-alias-label-tertiary);
662
+ white-space: pre-wrap;
663
+ word-break: break-word;
664
+ }
665
+
666
+ .pendingQuestion {
667
+ display: flex;
668
+ flex-direction: column;
669
+ gap: 4px;
670
+ padding: 4px 0;
671
+ }
672
+
673
+ .pendingQHeader {
674
+ font-weight: 600;
675
+ color: var(--dsw-alias-label-primary);
676
+ }
677
+
678
+ .pendingQText {
679
+ color: var(--dsw-alias-label-secondary);
680
+ }
681
+
682
+ .pendingOption {
683
+ display: flex;
684
+ flex-direction: column;
685
+ align-items: flex-start;
686
+ gap: 2px;
687
+ text-align: left;
688
+ border: 1px solid var(--dsw-alias-border-l2);
689
+ border-radius: 6px;
690
+ background: none;
691
+ color: var(--dsw-alias-label-secondary);
692
+ padding: 5px 8px;
693
+ font-size: 12px;
694
+ cursor: pointer;
695
+ }
696
+
697
+ .pendingOption:hover {
698
+ background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05));
699
+ }
700
+
701
+ /* 选中态:与 .pendingOption 同结构(CSS 层 composes 依赖构建管道,不冒进)。 */
702
+ .pendingOptionActive {
703
+ display: flex;
704
+ flex-direction: column;
705
+ align-items: flex-start;
706
+ gap: 2px;
707
+ text-align: left;
708
+ border: 1px solid var(--dsw-alias-state-business-primary);
709
+ border-radius: 6px;
710
+ background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05));
711
+ color: var(--dsw-alias-label-primary);
712
+ padding: 5px 8px;
713
+ font-size: 12px;
714
+ cursor: pointer;
715
+ }
716
+
717
+ .pendingOptionDesc {
718
+ color: var(--dsw-alias-label-dimmed);
719
+ font-size: 11px;
720
+ }
721
+
722
+ .pendingCustom {
723
+ border: 1px solid var(--dsw-alias-border-l2);
724
+ border-radius: 6px;
725
+ background: transparent;
726
+ color: var(--dsw-alias-label-primary);
727
+ padding: 5px 8px;
728
+ font-size: 12px;
729
+ font: inherit;
730
+ }
731
+
732
+ .pendingCustom:focus {
733
+ outline: none;
734
+ border-color: var(--dsw-alias-state-business-primary);
735
+ }
736
+
737
+ .pendingActions {
738
+ display: flex;
739
+ justify-content: flex-end;
740
+ gap: 8px;
741
+ margin-top: 2px;
742
+ }
743
+
744
+ .pendingBtnGhost {
745
+ border: 1px solid var(--dsw-alias-border-l2);
746
+ border-radius: 6px;
747
+ background: none;
748
+ color: var(--dsw-alias-label-secondary);
749
+ padding: 4px 12px;
750
+ font-size: 12px;
751
+ cursor: pointer;
752
+ }
753
+
754
+ .pendingBtnPrimary {
755
+ border: none;
756
+ border-radius: 6px;
757
+ background: var(--dsw-alias-button-info-fill);
758
+ color: #fff;
759
+ padding: 4px 12px;
760
+ font-size: 12px;
761
+ cursor: pointer;
762
+ }
763
+
764
+ .pendingBtnGhost:disabled,
765
+ .pendingBtnPrimary:disabled {
766
+ opacity: 0.5;
767
+ cursor: default;
768
+ }
769
+
770
+ /* R8 审批提示条(钉在 composer 上方)——面缺席时的回退跳转条。 */
630
771
  .pendingBar {
631
772
  display: block;
632
773
  width: calc(100% - 16px);