dsh-sidenote 0.3.0 → 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.
- package/README.md +2 -2
- package/lib/client.js +1015 -203
- package/lib/types/client/chat/ToolCard.d.ts +1 -0
- package/lib/types/client/chat/cards.d.ts +25 -0
- package/lib/types/client/locales.d.ts +15 -0
- package/lib/types/client/sidechat/PendingCard.d.ts +4 -0
- package/lib/types/client/sidechat/notify.d.ts +16 -0
- package/lib/types/client/sidechat/pending.d.ts +71 -0
- package/lib/types/client/sidechat/toast.d.ts +5 -0
- package/package.json +1 -1
- package/src/client/chat/FoldCard.tsx +2 -1
- package/src/client/chat/ReasoningRow.tsx +13 -1
- package/src/client/chat/ToolCard.tsx +20 -4
- package/src/client/chat/cards.ts +285 -11
- package/src/client/chat/transcript.ts +1 -0
- package/src/client/locales.ts +32 -2
- package/src/client/sidechat/ComposerBar.tsx +100 -95
- package/src/client/sidechat/ModelMenu.tsx +3 -2
- package/src/client/sidechat/PendingCard.tsx +145 -0
- package/src/client/sidechat/PermissionChip.tsx +4 -2
- package/src/client/sidechat/SideChatPanel.tsx +32 -2
- package/src/client/sidechat/index.tsx +6 -0
- package/src/client/sidechat/notify.ts +91 -0
- package/src/client/sidechat/pending.ts +238 -0
- package/src/client/sidechat/rows.tsx +1 -0
- package/src/client/sidechat/sidechat.module.css +259 -33
- package/src/client/sidechat/toast.tsx +60 -0
|
@@ -49,110 +49,115 @@ export function ComposerBar(props: {
|
|
|
49
49
|
{menuOpen && menuState !== null && triggerCtl !== null && (
|
|
50
50
|
<SlashMenuView state={menuState} onPick={(source, index) => { triggerCtl.pick(source, index) }} />
|
|
51
51
|
)}
|
|
52
|
-
{
|
|
53
|
-
|
|
54
|
-
{
|
|
55
|
-
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
<textarea
|
|
68
|
-
ref={inputRef}
|
|
69
|
-
className={css.input}
|
|
70
|
-
placeholder={t('inputPlaceholder')}
|
|
71
|
-
value={composer.draft}
|
|
72
|
-
onChange={(event) => {
|
|
73
|
-
composer.setDraft(event.target.value)
|
|
74
|
-
// 触发侦听(CAS 语义:带最新 draftRev,过期静默 no-op 由机器兜)。
|
|
75
|
-
triggerCtl?.track(
|
|
76
|
-
event.target.value,
|
|
77
|
-
event.target.selectionStart ?? event.target.value.length,
|
|
78
|
-
guardOf(composer.phase),
|
|
79
|
-
composer.draftRev ?? 0,
|
|
80
|
-
)
|
|
81
|
-
}}
|
|
82
|
-
onKeyDown={(event) => {
|
|
83
|
-
// 菜单开着先仲裁(上下/Enter 选中/Esc 关闭归控制器)。
|
|
84
|
-
if (menuOpen && triggerCtl !== null) {
|
|
85
|
-
const arbKey = arbitrateKeyOf(event.key)
|
|
86
|
-
if (arbKey !== null) {
|
|
87
|
-
const outcome = triggerCtl.arbitrate(arbKey, event.nativeEvent.isComposing)
|
|
88
|
-
if (outcome !== 'pass') {
|
|
89
|
-
event.preventDefault()
|
|
90
|
-
return
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
// IME 保护:组合中(候选窗未提交)的 Enter 属于输入法。
|
|
95
|
-
if (event.key !== 'Enter' || event.shiftKey) return
|
|
96
|
-
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return
|
|
97
|
-
event.preventDefault()
|
|
98
|
-
// busy-Enter:Cmd/Ctrl+Enter = steer(插队打断);裸 Enter 由机器/
|
|
99
|
-
// 设置裁决(默认 queue——与主对话同语义)。
|
|
100
|
-
composer.submit(event.metaKey || event.ctrlKey ? 'steer' : undefined)
|
|
101
|
-
}}
|
|
102
|
-
/>
|
|
103
|
-
<div className={css.composerFoot}>
|
|
104
|
-
{composer.canAttach && (
|
|
105
|
-
<button
|
|
106
|
-
type="button"
|
|
107
|
-
className={css.attachButton}
|
|
108
|
-
title={t('attachTitle')}
|
|
109
|
-
aria-label={t('attachTitle')}
|
|
110
|
-
onClick={() => { fileInputRef.current?.click() }}
|
|
111
|
-
>
|
|
112
|
-
<IconPaperclipOutline16 size={14} />
|
|
113
|
-
</button>
|
|
52
|
+
<div className={css.composerCard}>
|
|
53
|
+
{composer.imagePreviews.length > 0 && (
|
|
54
|
+
<div className={css.attachRail}>
|
|
55
|
+
{composer.imagePreviews.map(img => (
|
|
56
|
+
<span key={img.id} className={css.attachThumb}>
|
|
57
|
+
<img src={img.url} alt="" className={css.attachImg} />
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
className={css.attachRemove}
|
|
61
|
+
aria-label={t('attachRemove')}
|
|
62
|
+
onClick={() => { composer.removeImage(img.id) }}
|
|
63
|
+
>×</button>
|
|
64
|
+
</span>
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
114
67
|
)}
|
|
115
|
-
<
|
|
116
|
-
ref={
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
hidden
|
|
68
|
+
<textarea
|
|
69
|
+
ref={inputRef}
|
|
70
|
+
className={css.input}
|
|
71
|
+
placeholder={t('inputPlaceholder')}
|
|
72
|
+
value={composer.draft}
|
|
121
73
|
onChange={(event) => {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
74
|
+
composer.setDraft(event.target.value)
|
|
75
|
+
// 触发侦听(CAS 语义:带最新 draftRev,过期静默 no-op 由机器兜)。
|
|
76
|
+
triggerCtl?.track(
|
|
77
|
+
event.target.value,
|
|
78
|
+
event.target.selectionStart ?? event.target.value.length,
|
|
79
|
+
guardOf(composer.phase),
|
|
80
|
+
composer.draftRev ?? 0,
|
|
81
|
+
)
|
|
82
|
+
}}
|
|
83
|
+
onKeyDown={(event) => {
|
|
84
|
+
// 菜单开着先仲裁(上下/Enter 选中/Esc 关闭归控制器)。
|
|
85
|
+
if (menuOpen && triggerCtl !== null) {
|
|
86
|
+
const arbKey = arbitrateKeyOf(event.key)
|
|
87
|
+
if (arbKey !== null) {
|
|
88
|
+
const outcome = triggerCtl.arbitrate(arbKey, event.nativeEvent.isComposing)
|
|
89
|
+
if (outcome !== 'pass') {
|
|
90
|
+
event.preventDefault()
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// IME 保护:组合中(候选窗未提交)的 Enter 属于输入法。
|
|
96
|
+
if (event.key !== 'Enter' || event.shiftKey) return
|
|
97
|
+
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return
|
|
98
|
+
event.preventDefault()
|
|
99
|
+
// busy-Enter:Cmd/Ctrl+Enter = steer(插队打断);裸 Enter 由机器/
|
|
100
|
+
// 设置裁决(默认 queue——与主对话同语义)。
|
|
101
|
+
composer.submit(event.metaKey || event.ctrlKey ? 'steer' : undefined)
|
|
125
102
|
}}
|
|
126
103
|
/>
|
|
127
|
-
{
|
|
128
|
-
|
|
129
|
-
<span className={css.composerChips}>
|
|
130
|
-
<PermissionChip session={session} />
|
|
131
|
-
<ModelMenu ctx={props.ctx} sessionId={childIdForMenu} modelName={props.modelName} onSwitched={props.onModelSwitched} />
|
|
132
|
-
</span>
|
|
133
|
-
)
|
|
134
|
-
: <span className={css.modelLabel}>{t('modelLabel', { name: props.modelName ?? t('modelFollowsMain') })}</span>}
|
|
135
|
-
{running
|
|
136
|
-
? (
|
|
104
|
+
<div className={css.composerFoot}>
|
|
105
|
+
{composer.canAttach && (
|
|
137
106
|
<button
|
|
138
107
|
type="button"
|
|
139
|
-
className={css.
|
|
140
|
-
title={t('
|
|
141
|
-
|
|
108
|
+
className={css.attachButton}
|
|
109
|
+
title={t('attachTitle')}
|
|
110
|
+
aria-label={t('attachTitle')}
|
|
111
|
+
onClick={() => { fileInputRef.current?.click() }}
|
|
142
112
|
>
|
|
143
|
-
<
|
|
144
|
-
</button>
|
|
145
|
-
)
|
|
146
|
-
: (
|
|
147
|
-
<button
|
|
148
|
-
type="button"
|
|
149
|
-
className={css.sendButton}
|
|
150
|
-
disabled={composer.draft.trim() === ''}
|
|
151
|
-
onClick={() => { composer.submit() }}
|
|
152
|
-
>
|
|
153
|
-
<IconSendOutline16 size={14} /> {t('send')}
|
|
113
|
+
<IconPaperclipOutline16 size={14} />
|
|
154
114
|
</button>
|
|
155
115
|
)}
|
|
116
|
+
<input
|
|
117
|
+
ref={fileInputRef}
|
|
118
|
+
type="file"
|
|
119
|
+
accept="image/*"
|
|
120
|
+
multiple
|
|
121
|
+
hidden
|
|
122
|
+
onChange={(event) => {
|
|
123
|
+
const files = [...(event.target.files ?? [])]
|
|
124
|
+
if (files.length > 0) composer.attachImages(files)
|
|
125
|
+
event.target.value = ''
|
|
126
|
+
}}
|
|
127
|
+
/>
|
|
128
|
+
{childIdForMenu !== undefined
|
|
129
|
+
? (
|
|
130
|
+
<span className={css.composerChips}>
|
|
131
|
+
<PermissionChip session={session} />
|
|
132
|
+
<ModelMenu ctx={props.ctx} sessionId={childIdForMenu} modelName={props.modelName} onSwitched={props.onModelSwitched} />
|
|
133
|
+
</span>
|
|
134
|
+
)
|
|
135
|
+
: <span className={css.modelLabel}>{t('modelLabel', { name: props.modelName ?? t('modelFollowsMain') })}</span>}
|
|
136
|
+
{running
|
|
137
|
+
? (
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
className={css.stopButton}
|
|
141
|
+
title={t('stopReplyTitle')}
|
|
142
|
+
aria-label={t('stopReplyTitle')}
|
|
143
|
+
onClick={() => { session?.cancel().catch(() => {}) }}
|
|
144
|
+
>
|
|
145
|
+
<IconStopFill16 size={12} />
|
|
146
|
+
</button>
|
|
147
|
+
)
|
|
148
|
+
: (
|
|
149
|
+
<button
|
|
150
|
+
type="button"
|
|
151
|
+
className={css.sendButton}
|
|
152
|
+
title={t('send')}
|
|
153
|
+
aria-label={t('send')}
|
|
154
|
+
disabled={composer.draft.trim() === ''}
|
|
155
|
+
onClick={() => { composer.submit() }}
|
|
156
|
+
>
|
|
157
|
+
<IconSendOutline16 size={14} />
|
|
158
|
+
</button>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
156
161
|
</div>
|
|
157
162
|
{composer.sendError !== null && <div className={css.errorRow}>{composer.sendError}</div>}
|
|
158
163
|
</div>
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* (selectModel 返回的 selected 为准)。
|
|
8
8
|
*/
|
|
9
9
|
import { useState } from 'react'
|
|
10
|
-
import { IconCheckOutline16, Menu, type MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
10
|
+
import { IconCheckOutline16, IconChevronDownOutline14, Menu, type MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
11
11
|
import type { Context, SessionModelsResult } from '../host/contracts.ts'
|
|
12
12
|
import { listModels, switchModel } from './lifecycle.ts'
|
|
13
13
|
import { t } from '../locales.ts'
|
|
@@ -62,7 +62,8 @@ export function ModelMenu(props: {
|
|
|
62
62
|
title={t('modelSwitchTitle')}
|
|
63
63
|
onClick={() => { open ? setOpen(false) : openMenu() }}
|
|
64
64
|
>
|
|
65
|
-
{t('modelLabel', { name: props.modelName ?? t('modelFollowsMain') })}
|
|
65
|
+
<span>{t('modelLabel', { name: props.modelName ?? t('modelFollowsMain') })}</span>
|
|
66
|
+
<IconChevronDownOutline14 size={12} />
|
|
66
67
|
</button>
|
|
67
68
|
}
|
|
68
69
|
items={items}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R8 审批/提问答复卡(L2 视图):钉在 composer 上方(原 pendingBar 位置)。
|
|
3
|
+
* 自绘 + primitives 图标(材质同源,不求像素级复刻宿主审批卡)。
|
|
4
|
+
*
|
|
5
|
+
* 竞答纪律:先答先得——点击后置 answered 本地态;answer 失败(已被主视图
|
|
6
|
+
* 抢答/settle)catch 吞错并回退按钮态(宿主 `.catch(() => setAnswered(false))`
|
|
7
|
+
* 同款);卡片消失由快照驱动(pending 项消失即卸载),UI 不自造状态机。
|
|
8
|
+
*/
|
|
9
|
+
import { useState } from 'react'
|
|
10
|
+
import { IconWarningOutline16, IconQuestionOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
11
|
+
import type { PendingItem, PendingQuestionView } from './pending.ts'
|
|
12
|
+
import { t } from '../locales.ts'
|
|
13
|
+
import { useLocaleTick } from '../locale-tick.ts'
|
|
14
|
+
import css from './sidechat.module.css'
|
|
15
|
+
|
|
16
|
+
export function PendingCardList({ items }: { items: readonly PendingItem[] }) {
|
|
17
|
+
return (
|
|
18
|
+
<>
|
|
19
|
+
{items.map(item => item.kind === 'approval'
|
|
20
|
+
? <ApprovalCard key={item.key} item={item} />
|
|
21
|
+
: <QuestionCard key={item.key} item={item} />)}
|
|
22
|
+
</>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 审批卡:reason + 拒绝 / 允许一次。 */
|
|
27
|
+
function ApprovalCard({ item }: { item: Extract<PendingItem, { kind: 'approval' }> }) {
|
|
28
|
+
useLocaleTick()
|
|
29
|
+
const [answered, setAnswered] = useState(false)
|
|
30
|
+
const answer = (outcome: 'allowed-once' | 'rejected'): void => {
|
|
31
|
+
setAnswered(true)
|
|
32
|
+
item.answer(outcome).catch(() => { setAnswered(false) })
|
|
33
|
+
}
|
|
34
|
+
return (
|
|
35
|
+
<div className={css.pendingCard} data-kind="approval">
|
|
36
|
+
<div className={css.pendingHead}>
|
|
37
|
+
<IconWarningOutline16 size={14} />
|
|
38
|
+
<span className={css.pendingTitle}>{t('pendingApprovalTitle')}</span>
|
|
39
|
+
</div>
|
|
40
|
+
<div className={css.pendingDesc}>{t('pendingApprovalDesc', { name: item.toolName })}</div>
|
|
41
|
+
{item.reason !== undefined && item.reason !== '' && <div className={css.pendingReason}>{item.reason}</div>}
|
|
42
|
+
<div className={css.pendingActions}>
|
|
43
|
+
<button type="button" className={css.pendingBtnGhost} disabled={answered} onClick={() => { answer('rejected') }}>
|
|
44
|
+
{t('pendingReject')}
|
|
45
|
+
</button>
|
|
46
|
+
<button type="button" className={css.pendingBtnPrimary} disabled={answered} onClick={() => { answer('allowed-once') }}>
|
|
47
|
+
{t('pendingAllowOnce')}
|
|
48
|
+
</button>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 提问卡:选项(单选即点即答语义并入整批提交)+ 自定义 + 整批提交/取消。 */
|
|
55
|
+
function QuestionCard({ item }: { item: PendingQuestionView }) {
|
|
56
|
+
useLocaleTick()
|
|
57
|
+
// 每题的选择集合(多选为 Set;单选为单元素 Set)+ 自定义文本。
|
|
58
|
+
const [selections, setSelections] = useState<ReadonlyMap<string, ReadonlySet<string>>>(new Map())
|
|
59
|
+
const [customs, setCustoms] = useState<ReadonlyMap<string, string>>(new Map())
|
|
60
|
+
const [answered, setAnswered] = useState(false)
|
|
61
|
+
|
|
62
|
+
const toggle = (qid: string, label: string, multi: boolean): void => {
|
|
63
|
+
const next = new Map(selections)
|
|
64
|
+
const cur = new Set(next.get(qid) ?? [])
|
|
65
|
+
if (multi) {
|
|
66
|
+
if (cur.has(label)) cur.delete(label)
|
|
67
|
+
else cur.add(label)
|
|
68
|
+
} else {
|
|
69
|
+
cur.clear()
|
|
70
|
+
cur.add(label)
|
|
71
|
+
}
|
|
72
|
+
next.set(qid, cur)
|
|
73
|
+
setSelections(next)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 提交门槛:每题至少有选中项或自定义文本(整批语义,不留空题)。
|
|
77
|
+
const ready = item.questions.every(q =>
|
|
78
|
+
(selections.get(q.id)?.size ?? 0) > 0 || (customs.get(q.id)?.trim() ?? '') !== '',
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
const submit = (): void => {
|
|
82
|
+
setAnswered(true)
|
|
83
|
+
item.answer(item.questions.map(q => ({
|
|
84
|
+
id: q.id,
|
|
85
|
+
selected: [...(selections.get(q.id) ?? [])],
|
|
86
|
+
...((customs.get(q.id)?.trim() ?? '') !== '' ? { custom: customs.get(q.id)!.trim() } : {}),
|
|
87
|
+
}))).catch(() => { setAnswered(false) })
|
|
88
|
+
}
|
|
89
|
+
const cancel = (): void => {
|
|
90
|
+
setAnswered(true)
|
|
91
|
+
item.cancel().catch(() => { setAnswered(false) })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div className={css.pendingCard} data-kind="question">
|
|
96
|
+
<div className={css.pendingHead}>
|
|
97
|
+
<IconQuestionOutline14 size={14} />
|
|
98
|
+
<span className={css.pendingTitle}>
|
|
99
|
+
{item.planReview ? t('pendingPlanReview') : t('pendingQuestionTitle')}
|
|
100
|
+
</span>
|
|
101
|
+
</div>
|
|
102
|
+
{item.questions.map(q => (
|
|
103
|
+
<div key={q.id} className={css.pendingQuestion}>
|
|
104
|
+
{q.header !== undefined && q.header !== '' && <div className={css.pendingQHeader}>{q.header}</div>}
|
|
105
|
+
<div className={css.pendingQText}>{q.question}</div>
|
|
106
|
+
{q.detail !== undefined && q.detail !== '' && <div className={css.pendingReason}>{q.detail}</div>}
|
|
107
|
+
{q.options.map(o => {
|
|
108
|
+
const active = selections.get(q.id)?.has(o.label) === true
|
|
109
|
+
return (
|
|
110
|
+
<button
|
|
111
|
+
key={o.label}
|
|
112
|
+
type="button"
|
|
113
|
+
className={active ? css.pendingOptionActive : css.pendingOption}
|
|
114
|
+
disabled={answered}
|
|
115
|
+
onClick={() => { toggle(q.id, o.label, q.multiSelect) }}
|
|
116
|
+
>
|
|
117
|
+
{o.label}
|
|
118
|
+
{o.description !== undefined && o.description !== '' && <span className={css.pendingOptionDesc}>{o.description}</span>}
|
|
119
|
+
</button>
|
|
120
|
+
)
|
|
121
|
+
})}
|
|
122
|
+
<input
|
|
123
|
+
className={css.pendingCustom}
|
|
124
|
+
placeholder={t('pendingCustomPlaceholder')}
|
|
125
|
+
disabled={answered}
|
|
126
|
+
value={customs.get(q.id) ?? ''}
|
|
127
|
+
onChange={(event) => {
|
|
128
|
+
const next = new Map(customs)
|
|
129
|
+
next.set(q.id, event.target.value)
|
|
130
|
+
setCustoms(next)
|
|
131
|
+
}}
|
|
132
|
+
/>
|
|
133
|
+
</div>
|
|
134
|
+
))}
|
|
135
|
+
<div className={css.pendingActions}>
|
|
136
|
+
<button type="button" className={css.pendingBtnGhost} disabled={answered} onClick={cancel}>
|
|
137
|
+
{t('pendingQuestionCancel')}
|
|
138
|
+
</button>
|
|
139
|
+
<button type="button" className={css.pendingBtnPrimary} disabled={answered || !ready} onClick={submit}>
|
|
140
|
+
{t('pendingQuestionSubmit')}
|
|
141
|
+
</button>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* (默认表 workspace-write/danger-full-access,dsh-permission-presets 实证)。
|
|
8
8
|
*/
|
|
9
9
|
import { useMemo, useState, useSyncExternalStore } from 'react'
|
|
10
|
-
import { Menu, type MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
10
|
+
import { IconChevronDownOutline14, IconInspectOutline12, Menu, type MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
11
11
|
import type { SessionFace } from '../host/contracts.ts'
|
|
12
12
|
import { t } from '../locales.ts'
|
|
13
13
|
import { useLocaleTick } from '../locale-tick.ts'
|
|
@@ -69,7 +69,9 @@ export function PermissionChip(props: { session: SessionFace | undefined }) {
|
|
|
69
69
|
title={t('permChipTitle')}
|
|
70
70
|
onClick={() => { setOpen(!open) }}
|
|
71
71
|
>
|
|
72
|
-
{
|
|
72
|
+
<IconInspectOutline12 size={12} />
|
|
73
|
+
<span>{presetLabel(effective)}</span>
|
|
74
|
+
<IconChevronDownOutline14 size={12} />
|
|
73
75
|
</button>
|
|
74
76
|
}
|
|
75
77
|
items={items}
|
|
@@ -26,11 +26,16 @@ import { ReasoningRow } from '../chat/ReasoningRow.tsx'
|
|
|
26
26
|
import { FoldCard } from '../chat/FoldCard.tsx'
|
|
27
27
|
import { createFoldStore } from '../chat/viewState.ts'
|
|
28
28
|
import type { ReflowStore } from '../reflow.ts'
|
|
29
|
+
import { showToast } from './toast.tsx'
|
|
30
|
+
import { createPendingStore } from './pending.ts'
|
|
31
|
+
import { PendingCardList } from './PendingCard.tsx'
|
|
29
32
|
import { t } from '../locales.ts'
|
|
30
33
|
import { useLocaleTick } from '../locale-tick.ts'
|
|
31
34
|
import css from './sidechat.module.css'
|
|
32
35
|
|
|
33
36
|
const NOOP_UNSUBSCRIBE = (): void => {}
|
|
37
|
+
/** uSES 空快照常量(禁产新引用——`?? []` 字面量每次新数组会无限重渲)。 */
|
|
38
|
+
const NO_PENDING: readonly import('./pending.ts').PendingItem[] = []
|
|
34
39
|
|
|
35
40
|
/** D2 写型工具名(无 diff 卡时的名字兜底)。 */
|
|
36
41
|
const WRITE_TOOLS = new Set(['edit', 'write', 'str-replace-editor', 'str_replace_editor'])
|
|
@@ -132,6 +137,23 @@ export function SideChatPanel(props: TabComponentProps & { reflow: ReflowStore }
|
|
|
132
137
|
),
|
|
133
138
|
() => (parentSession === undefined ? null : parentSession.getSnapshot()),
|
|
134
139
|
)
|
|
140
|
+
// R8:审批/提问数据源(双版本探测链——0.1.2 uiSession.pendingInteractions
|
|
141
|
+
// 优先,0.1.1 Session 快照 pending 回退;面缺席 undefined → 回退纯跳转
|
|
142
|
+
// pendingBar)。hooks 纪律:必须在相位早退之前(#310 教训)。
|
|
143
|
+
const pendingStore = useMemo(
|
|
144
|
+
() => (childId === undefined || session === undefined ? undefined : createPendingStore(ctx, session, childId)),
|
|
145
|
+
[ctx, session, childId],
|
|
146
|
+
)
|
|
147
|
+
const pendingItems = useSyncExternalStore(
|
|
148
|
+
useCallback(
|
|
149
|
+
(notify: () => void) => (visible && pendingStore !== undefined ? pendingStore.subscribe(notify) : NOOP_UNSUBSCRIBE),
|
|
150
|
+
[visible, pendingStore],
|
|
151
|
+
),
|
|
152
|
+
// NO_PENDING 常量:uSES getSnapshot 禁产新引用(`?? []` 每次新数组 →
|
|
153
|
+
// 无限重渲——实证教训)。
|
|
154
|
+
() => pendingStore?.getSnapshot() ?? NO_PENDING,
|
|
155
|
+
)
|
|
156
|
+
|
|
135
157
|
// D2 提示的消除态(hooks 纪律:必须在相位早退之前——#310 教训)。
|
|
136
158
|
const [writeNoticeDismissed, setWriteNoticeDismissed] = useState(
|
|
137
159
|
() => typeof localStorage !== 'undefined' && localStorage.getItem(WRITE_NOTICE_KEY) === '1',
|
|
@@ -166,7 +188,9 @@ export function SideChatPanel(props: TabComponentProps & { reflow: ReflowStore }
|
|
|
166
188
|
}, [pendingDraft, phase, ctx, tab.id])
|
|
167
189
|
|
|
168
190
|
// D3a 保存为正式会话:fork 子会话为独立主会话(无 unarchive API,
|
|
169
|
-
// fork 即转正——内置侧边对话同款路径)→ 主视图打开 → 关本 Tab
|
|
191
|
+
// fork 即转正——内置侧边对话同款路径)→ 主视图打开 → 关本 Tab →
|
|
192
|
+
// 全局 toast 确认去向(P4:Tab 关闭后面板树卸载,toast 挂在 body 级
|
|
193
|
+
// 宿主上,寿命不受波及)。
|
|
170
194
|
const promote = useCallback(() => {
|
|
171
195
|
if (childId === undefined) return
|
|
172
196
|
void (async () => {
|
|
@@ -174,6 +198,7 @@ export function SideChatPanel(props: TabComponentProps & { reflow: ReflowStore }
|
|
|
174
198
|
const promoted = await ctx.sessions.fork({ sessionId: childId, increaseTitle: true })
|
|
175
199
|
ctx.sessions.open(promoted)
|
|
176
200
|
ctx.betterSidebar.closeTab(tab.id, { sessionId: scope.sessionId })
|
|
201
|
+
showToast(t('promoteDone'))
|
|
177
202
|
} catch (error) {
|
|
178
203
|
console.warn('[dsh-sidenote] 保存为正式会话失败:', error)
|
|
179
204
|
}
|
|
@@ -317,7 +342,12 @@ export function SideChatPanel(props: TabComponentProps & { reflow: ReflowStore }
|
|
|
317
342
|
>×</button>
|
|
318
343
|
</div>
|
|
319
344
|
)}
|
|
320
|
-
{
|
|
345
|
+
{/* R8:数据源在场 → 面板内审批/提问答复卡;面缺席(老宿主)→ 回退
|
|
346
|
+
纯跳转 pendingBar(原行为不变)。 */}
|
|
347
|
+
{pendingStore !== undefined && pendingItems.length > 0 && (
|
|
348
|
+
<PendingCardList items={pendingItems} />
|
|
349
|
+
)}
|
|
350
|
+
{pendingStore === undefined && pendingCount > 0 && childId !== undefined && (
|
|
321
351
|
<button
|
|
322
352
|
type="button"
|
|
323
353
|
className={css.pendingBar}
|
|
@@ -28,6 +28,8 @@ import { t } from '../locales.ts'
|
|
|
28
28
|
import { registerHeaderEntry } from './header.tsx'
|
|
29
29
|
import { registerSideCommand } from './slash.ts'
|
|
30
30
|
import { registerSideChatReferenceSource } from './referenceSource.ts'
|
|
31
|
+
import { registerToastHost } from './toast.tsx'
|
|
32
|
+
import { registerCompletionNotify } from './notify.ts'
|
|
31
33
|
|
|
32
34
|
export function registerSideChat(ctx: Context, reflow: ReflowStore): void {
|
|
33
35
|
ctx.effect(
|
|
@@ -76,4 +78,8 @@ export function registerSideChat(ctx: Context, reflow: ReflowStore): void {
|
|
|
76
78
|
registerSideChatReferenceSource(ctx)
|
|
77
79
|
// 顶栏「侧边」入口(发现性):会话头部右上角常驻按钮。
|
|
78
80
|
registerHeaderEntry(ctx)
|
|
81
|
+
// 全局瞬态通知宿主(P4:转正确认等跨 Tab 寿命的反馈)。
|
|
82
|
+
registerToastHost(ctx)
|
|
83
|
+
// 完成通知(WI-03):侧聊 running 翻转 → toast。
|
|
84
|
+
registerCompletionNotify(ctx)
|
|
79
85
|
}
|
|
@@ -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
|
+
}
|