dsh-synapse 0.4.1 → 0.4.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/client.js CHANGED
@@ -1,241 +1,336 @@
1
- window.__ModuleLoader__.load({
2
- id: 'dsh-synapse',
3
- factory: () => {
4
- const module = { exports: {} }
5
- const currentSession = ctx => {
6
- const snapshot = ctx.sessions.list.getSnapshot()
7
- const id = snapshot.current
8
- if (id === undefined) return null
9
- const session = snapshot.byId[id]
10
- return session === undefined ? null : { id, title: session.displayTitle, cwd: session.cwd ?? null }
11
- }
12
- const sessionSnapshot = ctx => {
13
- const snapshot = ctx.sessions.list.getSnapshot()
14
- return snapshot.ids.map(id => {
15
- const session = snapshot.byId[id]
16
- return session === undefined ? null : { id, title: session.displayTitle, cwd: session.cwd ?? null, parentId: session.parentId ?? null, blank: session.blank }
17
- }).filter(Boolean)
18
- }
19
- const workspaceSnapshot = ctx => {
20
- const sessions = ctx.sessions.list.getSnapshot()
21
- const snapshot = ctx.workspaces.list.getSnapshot()
22
- const accounted = new Set(snapshot.items.flatMap(workspace => workspace.sessionIds))
23
- return [
24
- ...snapshot.items.map(workspace => ({ id: workspace.workspaceId, title: workspace.title, path: workspace.path, sessionIds: workspace.sessionIds })),
25
- { id: 'dsh-ungrouped', title: '未分组', path: null, sessionIds: sessions.ids.filter(id => !accounted.has(id)) },
26
- ]
27
- }
28
-
29
- module.exports.inject = ['sessions', 'workspaces']
30
- module.exports.apply = ctx => {
31
- const prompt = async (sessionId, text) => {
32
- const scope = ctx.sessions.scope(sessionId)
33
- const session = scope === undefined ? undefined : ctx.sessions.sessionOf(scope)
34
- if (session === undefined) throw new Error('关联的 DSH 会话已不可用')
35
- const result = await session.prompt([{ type: 'text', text }], 'queue')
36
- if (!result.ok) throw new Error(result.error?.message ?? 'DSH 未接受这条消息')
37
- }
38
- const style = document.createElement('style')
39
- style.textContent = '.dsh-synapse-switch{position:fixed;z-index:80;top:12px;left:50%;display:flex;gap:2px;transform:translateX(-50%);border:1px solid #d1d5db;border-radius:999px;background:rgba(255,255,255,.96);padding:3px;backdrop-filter:blur(10px)}.dsh-synapse-switch button{height:28px;border:0;border-radius:999px;background:transparent;padding:0 11px;color:#6b7280;font:600 12px Inter,system-ui,sans-serif;cursor:pointer;white-space:nowrap}.dsh-synapse-switch button:hover{background:#f3f4f6;color:#111827}.dsh-synapse-switch button.active{background:#111827;color:#fff}.dsh-synapse-switch button:focus-visible{outline:2px solid #111827;outline-offset:2px}.dsh-synapse-overlay{position:fixed;z-index:100;inset:0;background:#f5f7fa}.dsh-synapse-overlay.is-opening{visibility:hidden}.dsh-synapse-overlay[hidden]{display:none}.dsh-synapse-overlay iframe{display:block;width:100%;height:100%;border:0}'
40
- document.head.append(style)
41
- const host = document.createElement('div')
42
- host.className = 'dsh-synapse-host'
43
- host.innerHTML = '<div class="dsh-synapse-switch" role="group" aria-label="视图切换"><button type="button" data-view="dialog" class="active" aria-pressed="true">对话</button><button type="button" data-view="map" aria-pressed="false">会话地图</button></div><section class="dsh-synapse-overlay" hidden><iframe title="会话地图" src="/synapse/"></iframe></section>'
44
- document.body.append(host)
45
- const dialogButton = host.querySelector('[data-view="dialog"]')
46
- const mapButton = host.querySelector('[data-view="map"]')
47
- const overlay = host.querySelector('.dsh-synapse-overlay')
48
- const frame = host.querySelector('iframe')
49
-
50
- const setView = view => {
51
- const showingMap = view === 'map'
52
- dialogButton.classList.toggle('active', !showingMap)
53
- dialogButton.setAttribute('aria-pressed', String(!showingMap))
54
- mapButton.classList.toggle('active', showingMap)
55
- mapButton.setAttribute('aria-pressed', String(showingMap))
56
- }
57
- const close = () => {
58
- window.clearTimeout(mapOpenFallback)
59
- mapOpening = false
60
- overlay.classList.remove('is-opening')
61
- overlay.hidden = true
62
- setView('dialog')
63
- }
64
- const send = (type, payload) => { frame.contentWindow?.postMessage({ source: 'dsh-synapse', type, ...payload }, location.origin) }
65
- let syncQueued = false
66
- let knownSessionIds = new Set()
67
- const liveUnsubscribers = new Map()
68
- const syncLiveSessions = () => {
69
- const snapshot = ctx.sessions.list.getSnapshot()
70
- for (const id of snapshot.ids) {
71
- if (liveUnsubscribers.has(id)) continue
72
- const scope = ctx.sessions.scope(id)
73
- const session = scope === undefined ? undefined : ctx.sessions.sessionOf(scope)
74
- if (session === undefined) continue
75
- const publish = () => {
76
- if (overlay.hidden) return
77
- const state = session.getSnapshot()
78
- const text = state.partial?.blocks.filter(block => block.kind === 'text').map(block => block.text).join('\n') ?? ''
79
- send('synapse:live-reply', { sessionId: id, running: state.running, text })
80
- }
81
- liveUnsubscribers.set(id, session.subscribe(publish))
82
- publish()
83
- }
84
- for (const [id, unsubscribe] of liveUnsubscribers) if (!snapshot.ids.includes(id)) { unsubscribe(); liveUnsubscribers.delete(id) }
85
- }
86
- const syncSessions = () => {
87
- if (syncQueued) return
88
- syncQueued = true
89
- queueMicrotask(() => {
90
- syncQueued = false
91
- const sessions = sessionSnapshot(ctx)
92
- const sessionIds = new Set(sessions.map(session => session.id))
93
- const removedSessionIds = [...knownSessionIds].filter(id => !sessionIds.has(id))
94
- knownSessionIds = sessionIds
95
- void fetch('/synapse/api/sessions/sync', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessions, removedSessionIds }) }).catch(() => {})
96
- })
97
- }
98
- const syncTheme = () => {
99
- const dark = document.body?.hasAttribute?.('data-ds-dark-theme') === true
100
- send('synapse:theme', { dark })
101
- }
102
- const syncCurrentSession = () => {
103
- syncSessions()
104
- syncLiveSessions()
105
- syncTheme()
106
- if (!overlay.hidden) {
107
- send('synapse:workspaces', { workspaces: workspaceSnapshot(ctx) })
108
- send('synapse:current-session', { session: currentSession(ctx) })
109
- }
110
- }
111
- let mapOpenFallback = 0
112
- let mapOpening = false
113
- const showMapOverlay = () => {
114
- window.clearTimeout(mapOpenFallback)
115
- mapOpening = false
116
- overlay.hidden = false
117
- overlay.classList.remove('is-opening')
118
- syncCurrentSession()
119
- }
120
- const open = () => {
121
- window.clearTimeout(mapOpenFallback)
122
- mapOpening = true
123
- setView('map')
124
- // Keep the iframe laid out while hidden so its canvas can receive a
125
- // real scroll offset. display:none would clamp scrollTop back to zero.
126
- overlay.hidden = false
127
- overlay.classList.add('is-opening')
128
- window.requestAnimationFrame(() => {
129
- send('synapse:map-opened')
130
- syncCurrentSession()
131
- })
132
- mapOpenFallback = window.setTimeout(showMapOverlay, 300)
133
- }
134
- const onFrameLoad = () => {
135
- syncCurrentSession()
136
- if (mapOpening) send('synapse:map-opened')
137
- }
138
- const onMessage = event => {
139
- if (event.origin !== location.origin || event.data?.source !== 'dsh-synapse') return
140
- if (event.data.type === 'synapse:close') return close()
141
- if (event.data.type === 'synapse:map-ready') return showMapOverlay()
142
- if (event.data.type === 'synapse:request-current') {
143
- send('synapse:workspaces', { workspaces: workspaceSnapshot(ctx) })
144
- return send('synapse:current-session', { session: currentSession(ctx) })
145
- }
146
- if (event.data.type === 'synapse:open-session') {
147
- try { ctx.sessions.open(event.data.sessionId); close() } catch { send('synapse:bridge-error', { message: '关联的 DSH 会话已不可用' }) }
148
- // Best-effort anchor to the requested turn: chat nodes expose their
149
- // source event seq (anchorSeq) and render with data-chat-anchor-key,
150
- // so resolve seq -> node key -> scroll once the view materializes.
151
- const seq = event.data.seq
152
- if (Number.isInteger(seq)) {
153
- const tryScroll = attempt => {
154
- const scope = ctx.sessions.scope(event.data.sessionId)
155
- const session = scope === undefined ? undefined : ctx.sessions.sessionOf(scope)
156
- if (session === undefined) return
157
- const chat = session.getSnapshot()?.chat
158
- if (chat === undefined) return
159
- let key = undefined
160
- for (const node of chat.nodes.values()) {
161
- if (node.anchorSeq === seq) { key = node.key; break }
162
- }
163
- if (key !== undefined) {
164
- const row = document.querySelector(`[data-chat-anchor-key="${CSS.escape(key)}"]`)
165
- if (row instanceof HTMLElement) row.scrollIntoView({ block: 'start' })
166
- return
167
- }
168
- if (attempt < 3) window.setTimeout(() => tryScroll(attempt + 1), 500)
169
- }
170
- window.setTimeout(() => tryScroll(0), 300)
171
- }
172
- return
173
- }
174
- if (event.data.type === 'synapse:activate-session') {
175
- // Bidirectional current-session sync: switch DSH's current session
176
- // without closing the map; the sessions-list subscription re-sends
177
- // synapse:current-session so the map follows the new highlight.
178
- try { ctx.sessions.open(event.data.sessionId) } catch { send('synapse:bridge-error', { message: '关联的 DSH 会话已不可用' }) }
179
- return
180
- }
181
- if (event.data.type === 'synapse:fork-session') {
182
- const atSeq = Number.isInteger(event.data.atSeq) ? event.data.atSeq : undefined
183
- ctx.sessions.fork({ sessionId: event.data.sessionId, atSeq, increaseTitle: true }).then(id => {
184
- const snapshot = ctx.sessions.list.getSnapshot()
185
- send('synapse:forked-session', { requestId: event.data.requestId, session: { id, title: snapshot.byId[id]?.displayTitle ?? 'DSH 分支' } })
186
- }).catch(() => { send('synapse:bridge-error', { message: 'DSH 分支创建失败,请确认源会话已经完成当前轮次' }) })
187
- return
188
- }
189
- if (event.data.type === 'synapse:send-message') {
190
- const text = typeof event.data.text === 'string' ? event.data.text.trim() : ''
191
- if (text === '') return send('synapse:bridge-error', { requestId: event.data.requestId, message: '消息不能为空' })
192
- prompt(event.data.sessionId, text).then(() => {
193
- send('synapse:message-sent', { requestId: event.data.requestId, sessionId: event.data.sessionId })
194
- }).catch(error => {
195
- send('synapse:bridge-error', { requestId: event.data.requestId, message: error instanceof Error ? error.message : 'DSH 消息发送失败' })
196
- })
197
- return
198
- }
199
- if (event.data.type === 'synapse:create-session') {
200
- const workspaceId = typeof event.data.workspaceId === 'string' && event.data.workspaceId !== '' && event.data.workspaceId !== 'dsh-ungrouped' ? event.data.workspaceId : undefined
201
- const cwd = typeof event.data.cwd === 'string' && event.data.cwd !== '' ? event.data.cwd : undefined
202
- const create = workspaceId === undefined ? ctx.sessions.create(cwd === undefined ? {} : { cwd }) : ctx.sessions.create({ workspaceId })
203
- create.then(id => {
204
- const snapshot = ctx.sessions.list.getSnapshot()
205
- send('synapse:created-session', { requestId: event.data.requestId, session: { id, title: snapshot.byId[id]?.displayTitle ?? '新会话', cwd: snapshot.byId[id]?.cwd ?? cwd ?? null } })
206
- }).catch(() => { send('synapse:bridge-error', { requestId: event.data.requestId, message: 'DSH 会话创建失败,请先在 DSH 选择工作目录' }) })
207
- }
208
- }
209
- const onKeyDown = event => { if (event.key === 'Escape' && !overlay.hidden) close() }
210
- // Follow DSH's live theme switch: body[data-ds-dark-theme] is the web
211
- // client's dark-mode signal, mirrored into the map iframe via synapse:theme.
212
- const themeObserver = typeof MutationObserver === 'undefined'
213
- ? null
214
- : new MutationObserver(() => syncTheme())
215
- if (themeObserver !== null && document.body) {
216
- themeObserver.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] })
217
- }
218
- const unsubscribeSessions = ctx.sessions.list.subscribe(syncCurrentSession)
219
- const unsubscribeWorkspaces = ctx.workspaces.list.subscribe(syncCurrentSession)
220
- dialogButton.addEventListener('click', close)
221
- mapButton.addEventListener('click', open)
222
- frame.addEventListener('load', onFrameLoad)
223
- window.addEventListener('message', onMessage)
224
- window.addEventListener('keydown', onKeyDown)
225
- ctx.effect(() => () => {
226
- dialogButton.removeEventListener('click', close)
227
- mapButton.removeEventListener('click', open)
228
- frame.removeEventListener('load', onFrameLoad)
229
- window.removeEventListener('message', onMessage)
230
- window.removeEventListener('keydown', onKeyDown)
231
- themeObserver?.disconnect()
232
- unsubscribeSessions()
233
- unsubscribeWorkspaces()
234
- for (const unsubscribe of liveUnsubscribers.values()) unsubscribe()
235
- host.remove()
236
- style.remove()
237
- }, 'synapse: web workspace switch')
238
- }
239
- return module.exports
240
- },
241
- })
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-synapse',
3
+ factory: () => {
4
+ const module = { exports: {} }
5
+ // Consumer identity for owned Session references. The Client records
6
+ // positive reference counts per source; nothing else reads this string.
7
+ const SESSION_SOURCE = 'synapse'
8
+ // Shown when a Session is gone or no longer reachable from this browser.
9
+ const NO_SESSION = '关联的 DSH 会话已不可用'
10
+ const currentSession = ctx => {
11
+ const snapshot = ctx.sessions.list.getSnapshot()
12
+ // 0.1.7 removed `list.current`: the Main view's own reference count is the
13
+ // Client's current-Session signal. `ui-workspace` derives the highlighted
14
+ // Session row from the same fact, so both stay in step by construction.
15
+ const session = Object.values(snapshot.byId).find(row => (row.retainedBy?.mainView ?? 0) > 0)
16
+ return session === undefined ? null : { id: session.id, title: session.displayTitle, cwd: session.cwd ?? null }
17
+ }
18
+ const sessionSnapshot = ctx => {
19
+ const snapshot = ctx.sessions.list.getSnapshot()
20
+ return snapshot.ids.map(id => {
21
+ const session = snapshot.byId[id]
22
+ return session === undefined ? null : { id, title: session.displayTitle, cwd: session.cwd ?? null, parentId: session.parentId ?? null, blank: session.blank }
23
+ }).filter(Boolean)
24
+ }
25
+ const workspaceSnapshot = ctx => {
26
+ const sessions = ctx.sessions.list.getSnapshot()
27
+ const snapshot = ctx.workspaces.list.getSnapshot()
28
+ const accounted = new Set(snapshot.items.flatMap(workspace => workspace.sessionIds))
29
+ return [
30
+ ...snapshot.items.map(workspace => ({ id: workspace.workspaceId, title: workspace.title, path: workspace.path, sessionIds: workspace.sessionIds })),
31
+ { id: 'dsh-ungrouped', title: '未分组', path: null, sessionIds: sessions.ids.filter(id => !accounted.has(id)) },
32
+ ]
33
+ }
34
+
35
+ module.exports.inject = ['sessions', 'workspaces', 'uiWorkspace', 'uiConversation']
36
+ module.exports.apply = ctx => {
37
+ const prompt = async (sessionId, text) => {
38
+ // 0.1.7 owns Session lifetime explicitly: `scope()` only borrows an
39
+ // already-retained generation, so sending needs a reference of our own.
40
+ let reference
41
+ try {
42
+ reference = ctx.sessions.retain(sessionId, { source: SESSION_SOURCE })
43
+ await reference.ready
44
+ } catch {
45
+ reference?.release()
46
+ throw new Error(NO_SESSION)
47
+ }
48
+ try {
49
+ const result = await reference.binding.session.prompt([{ type: 'text', text }], 'queue')
50
+ if (!result.ok) throw new Error(result.error?.message ?? 'DSH 未接受这条消息')
51
+ } finally {
52
+ reference.release()
53
+ }
54
+ }
55
+ const style = document.createElement('style')
56
+ style.textContent = '.dsh-synapse-switch{position:fixed;z-index:80;top:12px;left:50%;display:flex;gap:2px;transform:translateX(-50%);border:1px solid #d1d5db;border-radius:999px;background:rgba(255,255,255,.96);padding:3px;backdrop-filter:blur(10px)}.dsh-synapse-switch button{height:28px;border:0;border-radius:999px;background:transparent;padding:0 11px;color:#6b7280;font:600 12px Inter,system-ui,sans-serif;cursor:pointer;white-space:nowrap}.dsh-synapse-switch button:hover{background:#f3f4f6;color:#111827}.dsh-synapse-switch button.active{background:#111827;color:#fff}.dsh-synapse-switch button:focus-visible{outline:2px solid #111827;outline-offset:2px}.dsh-synapse-overlay{position:fixed;z-index:100;inset:0;background:#f5f7fa}.dsh-synapse-overlay.is-opening{visibility:hidden}.dsh-synapse-overlay[hidden]{display:none}.dsh-synapse-overlay iframe{display:block;width:100%;height:100%;border:0}'
57
+ document.head.append(style)
58
+ const host = document.createElement('div')
59
+ host.className = 'dsh-synapse-host'
60
+ host.innerHTML = '<div class="dsh-synapse-switch" role="group" aria-label="视图切换"><button type="button" data-view="dialog" class="active" aria-pressed="true">对话</button><button type="button" data-view="map" aria-pressed="false">会话地图</button></div><section class="dsh-synapse-overlay" hidden><iframe title="会话地图" src="/synapse/"></iframe></section>'
61
+ document.body.append(host)
62
+ const dialogButton = host.querySelector('[data-view="dialog"]')
63
+ const mapButton = host.querySelector('[data-view="map"]')
64
+ const overlay = host.querySelector('.dsh-synapse-overlay')
65
+ const frame = host.querySelector('iframe')
66
+
67
+ const setView = view => {
68
+ const showingMap = view === 'map'
69
+ dialogButton.classList.toggle('active', !showingMap)
70
+ dialogButton.setAttribute('aria-pressed', String(!showingMap))
71
+ mapButton.classList.toggle('active', showingMap)
72
+ mapButton.setAttribute('aria-pressed', String(showingMap))
73
+ }
74
+ const close = () => {
75
+ window.clearTimeout(mapOpenFallback)
76
+ mapOpening = false
77
+ overlay.classList.remove('is-opening')
78
+ overlay.hidden = true
79
+ setView('dialog')
80
+ }
81
+ const send = (type, payload) => { frame.contentWindow?.postMessage({ source: 'dsh-synapse', type, ...payload }, location.origin) }
82
+ let syncQueued = false
83
+ let knownSessionIds = new Set()
84
+ // One live record per catalogued Session. The pre-0.1.7 Client minted a
85
+ // Session scope on demand for any listed id, so live state was readable
86
+ // for every card; 0.1.7 mints none, so a Chat view is readable only while
87
+ // something holds a reference. A card whose Session is not observable
88
+ // still reports its `running` flag from the list row, because only the
89
+ // streaming text needs the view.
90
+ const liveSessions = new Map()
91
+ // References this plugin owns, so it can read a running Session's Chat
92
+ // view without depending on another surface retaining it.
93
+ const ownReferences = new Map()
94
+
95
+ const publishLiveSession = id => {
96
+ if (overlay.hidden) return
97
+ const text = liveSessions.get(id)?.chat?.getSnapshot()?.legacy.partial?.blocks
98
+ .filter(block => block.kind === 'text').map(block => block.text).join('\n') ?? ''
99
+ const running = ctx.sessions.list.getSnapshot().byId[id]?.running === true
100
+ send('synapse:live-reply', { sessionId: id, running, text })
101
+ }
102
+
103
+ const detachLiveSession = record => {
104
+ for (const unsubscribe of record.unsubscribers) unsubscribe()
105
+ }
106
+
107
+ // A running Session is the only kind that produces live text, so it is the
108
+ // only kind worth opening a reference — and its history read — for.
109
+ const syncObservedSessions = () => {
110
+ const rows = ctx.sessions.list.getSnapshot()
111
+ for (const id of rows.ids) {
112
+ const held = ownReferences.has(id)
113
+ const wanted = rows.byId[id]?.running === true && ctx.sessions.binding(id) === undefined
114
+ if (wanted && !held) {
115
+ try {
116
+ const reference = ctx.sessions.retain(id, { source: SESSION_SOURCE })
117
+ void reference.ready.catch(() => {})
118
+ ownReferences.set(id, reference)
119
+ } catch { /* the Session is not retainable right now */ }
120
+ } else if (!wanted && held) {
121
+ ownReferences.get(id).release()
122
+ ownReferences.delete(id)
123
+ }
124
+ }
125
+ for (const [id, reference] of ownReferences) {
126
+ if (rows.ids.includes(id)) continue
127
+ reference.release()
128
+ ownReferences.delete(id)
129
+ }
130
+ }
131
+
132
+ const syncLiveSessions = () => {
133
+ syncObservedSessions()
134
+ const ids = ctx.sessions.list.getSnapshot().ids
135
+ for (const id of ids) {
136
+ const binding = ctx.sessions.binding(id)
137
+ let record = liveSessions.get(id)
138
+ // Rebuild when the observable source appears or disappears, and when a
139
+ // same-id generation replaced the borrowed binding.
140
+ if (record !== undefined && record.binding !== binding) {
141
+ detachLiveSession(record)
142
+ record = undefined
143
+ }
144
+ if (record === undefined) {
145
+ let chat
146
+ if (binding !== undefined) {
147
+ try { chat = ctx.uiConversation.binding(id).target('chat') } catch { chat = undefined }
148
+ }
149
+ liveSessions.set(id, {
150
+ binding,
151
+ chat,
152
+ // `target('chat')` is the only reader that activates the view
153
+ // target: its snapshot stays undefined until something subscribes.
154
+ unsubscribers: chat === undefined
155
+ ? []
156
+ : [chat.subscribe(() => publishLiveSession(id)), binding.session.subscribe(() => publishLiveSession(id))],
157
+ })
158
+ }
159
+ }
160
+ for (const [id, record] of liveSessions) {
161
+ if (ids.includes(id)) continue
162
+ detachLiveSession(record)
163
+ liveSessions.delete(id)
164
+ }
165
+ }
166
+ // Always runs after `syncLiveSessions`, so a newly attached Session gets
167
+ // its first report here rather than publishing twice on the same sync.
168
+ const publishLiveSessions = () => { for (const id of liveSessions.keys()) publishLiveSession(id) }
169
+ const syncSessions = () => {
170
+ if (syncQueued) return
171
+ syncQueued = true
172
+ queueMicrotask(() => {
173
+ syncQueued = false
174
+ const sessions = sessionSnapshot(ctx)
175
+ const sessionIds = new Set(sessions.map(session => session.id))
176
+ const removedSessionIds = [...knownSessionIds].filter(id => !sessionIds.has(id))
177
+ knownSessionIds = sessionIds
178
+ void fetch('/synapse/api/sessions/sync', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessions, removedSessionIds }) }).catch(() => {})
179
+ })
180
+ }
181
+ const syncTheme = () => {
182
+ const dark = document.body?.hasAttribute?.('data-ds-dark-theme') === true
183
+ send('synapse:theme', { dark })
184
+ }
185
+ const syncCurrentSession = () => {
186
+ syncSessions()
187
+ syncLiveSessions()
188
+ publishLiveSessions()
189
+ syncTheme()
190
+ if (!overlay.hidden) {
191
+ send('synapse:workspaces', { workspaces: workspaceSnapshot(ctx) })
192
+ send('synapse:current-session', { session: currentSession(ctx) })
193
+ }
194
+ }
195
+ let mapOpenFallback = 0
196
+ let mapOpening = false
197
+ const showMapOverlay = () => {
198
+ window.clearTimeout(mapOpenFallback)
199
+ mapOpening = false
200
+ overlay.hidden = false
201
+ overlay.classList.remove('is-opening')
202
+ syncCurrentSession()
203
+ }
204
+ const open = () => {
205
+ window.clearTimeout(mapOpenFallback)
206
+ mapOpening = true
207
+ setView('map')
208
+ // Keep the iframe laid out while hidden so its canvas can receive a
209
+ // real scroll offset. display:none would clamp scrollTop back to zero.
210
+ overlay.hidden = false
211
+ overlay.classList.add('is-opening')
212
+ window.requestAnimationFrame(() => {
213
+ send('synapse:map-opened')
214
+ syncCurrentSession()
215
+ })
216
+ mapOpenFallback = window.setTimeout(showMapOverlay, 300)
217
+ }
218
+ const onFrameLoad = () => {
219
+ syncCurrentSession()
220
+ if (mapOpening) send('synapse:map-opened')
221
+ }
222
+ const onMessage = event => {
223
+ if (event.origin !== location.origin || event.data?.source !== 'dsh-synapse') return
224
+ if (event.data.type === 'synapse:close') return close()
225
+ if (event.data.type === 'synapse:map-ready') return showMapOverlay()
226
+ if (event.data.type === 'synapse:request-current') {
227
+ send('synapse:workspaces', { workspaces: workspaceSnapshot(ctx) })
228
+ return send('synapse:current-session', { session: currentSession(ctx) })
229
+ }
230
+ if (event.data.type === 'synapse:open-session') {
231
+ // 0.1.7 moved navigation out of the Session Controller: selecting a
232
+ // Session and showing its Conversation is one Workspace UI action.
233
+ try { ctx.uiWorkspace.openSession(event.data.sessionId); close() } catch { send('synapse:bridge-error', { message: NO_SESSION }) }
234
+ // Best-effort anchor to the requested turn: chat nodes expose their
235
+ // source event seq (anchorSeq) and render with data-chat-anchor-key,
236
+ // so resolve seq -> node key -> scroll once the view materializes.
237
+ const seq = event.data.seq
238
+ if (Number.isInteger(seq)) {
239
+ const tryScroll = attempt => {
240
+ const record = liveSessions.get(event.data.sessionId)
241
+ if (record === undefined) {
242
+ if (attempt < 3) window.setTimeout(() => tryScroll(attempt + 1), 500)
243
+ return
244
+ }
245
+ let key = undefined
246
+ for (const node of record.chat.getSnapshot()?.nodes.values() ?? []) {
247
+ if (node.anchorSeq === seq) { key = node.key; break }
248
+ }
249
+ if (key !== undefined) {
250
+ const row = document.querySelector(`[data-chat-anchor-key="${CSS.escape(key)}"]`)
251
+ if (row instanceof HTMLElement) row.scrollIntoView({ block: 'start' })
252
+ return
253
+ }
254
+ // The requested turn may sit outside the loaded window; the jump
255
+ // loader pages history back through the seq, then the retry reads
256
+ // the rebuilt node set.
257
+ if (attempt === 0 && typeof record.session.loadThrough === 'function') {
258
+ void record.session.loadThrough(seq).catch(() => {})
259
+ }
260
+ if (attempt < 3) window.setTimeout(() => tryScroll(attempt + 1), 500)
261
+ }
262
+ window.setTimeout(() => tryScroll(0), 300)
263
+ }
264
+ return
265
+ }
266
+ if (event.data.type === 'synapse:activate-session') {
267
+ // Bidirectional current-session sync: switch DSH's current session
268
+ // without closing the map; the sessions-list subscription re-sends
269
+ // synapse:current-session so the map follows the new highlight.
270
+ try { ctx.uiWorkspace.openSession(event.data.sessionId) } catch { send('synapse:bridge-error', { message: NO_SESSION }) }
271
+ return
272
+ }
273
+ if (event.data.type === 'synapse:fork-session') {
274
+ const atSeq = Number.isInteger(event.data.atSeq) ? event.data.atSeq : undefined
275
+ ctx.sessions.fork({ sessionId: event.data.sessionId, atSeq, increaseTitle: true }).then(id => {
276
+ const snapshot = ctx.sessions.list.getSnapshot()
277
+ send('synapse:forked-session', { requestId: event.data.requestId, session: { id, title: snapshot.byId[id]?.displayTitle ?? 'DSH 分支' } })
278
+ }).catch(() => { send('synapse:bridge-error', { message: 'DSH 分支创建失败,请确认源会话已经完成当前轮次' }) })
279
+ return
280
+ }
281
+ if (event.data.type === 'synapse:send-message') {
282
+ const text = typeof event.data.text === 'string' ? event.data.text.trim() : ''
283
+ if (text === '') return send('synapse:bridge-error', { requestId: event.data.requestId, message: '消息不能为空' })
284
+ prompt(event.data.sessionId, text).then(() => {
285
+ send('synapse:message-sent', { requestId: event.data.requestId, sessionId: event.data.sessionId })
286
+ }).catch(error => {
287
+ send('synapse:bridge-error', { requestId: event.data.requestId, message: error instanceof Error ? error.message : 'DSH 消息发送失败' })
288
+ })
289
+ return
290
+ }
291
+ if (event.data.type === 'synapse:create-session') {
292
+ const workspaceId = typeof event.data.workspaceId === 'string' && event.data.workspaceId !== '' && event.data.workspaceId !== 'dsh-ungrouped' ? event.data.workspaceId : undefined
293
+ const cwd = typeof event.data.cwd === 'string' && event.data.cwd !== '' ? event.data.cwd : undefined
294
+ const create = workspaceId === undefined ? ctx.sessions.create(cwd === undefined ? {} : { cwd }) : ctx.sessions.create({ workspaceId })
295
+ create.then(id => {
296
+ const snapshot = ctx.sessions.list.getSnapshot()
297
+ send('synapse:created-session', { requestId: event.data.requestId, session: { id, title: snapshot.byId[id]?.displayTitle ?? '新会话', cwd: snapshot.byId[id]?.cwd ?? cwd ?? null } })
298
+ }).catch(() => { send('synapse:bridge-error', { requestId: event.data.requestId, message: 'DSH 会话创建失败,请先在 DSH 选择工作目录' }) })
299
+ }
300
+ }
301
+ const onKeyDown = event => { if (event.key === 'Escape' && !overlay.hidden) close() }
302
+ // Follow DSH's live theme switch: body[data-ds-dark-theme] is the web
303
+ // client's dark-mode signal, mirrored into the map iframe via synapse:theme.
304
+ const themeObserver = typeof MutationObserver === 'undefined'
305
+ ? null
306
+ : new MutationObserver(() => syncTheme())
307
+ if (themeObserver !== null && document.body) {
308
+ themeObserver.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] })
309
+ }
310
+ const unsubscribeSessions = ctx.sessions.list.subscribe(syncCurrentSession)
311
+ const unsubscribeWorkspaces = ctx.workspaces.list.subscribe(syncCurrentSession)
312
+ dialogButton.addEventListener('click', close)
313
+ mapButton.addEventListener('click', open)
314
+ frame.addEventListener('load', onFrameLoad)
315
+ window.addEventListener('message', onMessage)
316
+ window.addEventListener('keydown', onKeyDown)
317
+ ctx.effect(() => () => {
318
+ dialogButton.removeEventListener('click', close)
319
+ mapButton.removeEventListener('click', open)
320
+ frame.removeEventListener('load', onFrameLoad)
321
+ window.removeEventListener('message', onMessage)
322
+ window.removeEventListener('keydown', onKeyDown)
323
+ themeObserver?.disconnect()
324
+ unsubscribeSessions()
325
+ unsubscribeWorkspaces()
326
+ for (const record of liveSessions.values()) detachLiveSession(record)
327
+ liveSessions.clear()
328
+ for (const reference of ownReferences.values()) reference.release()
329
+ ownReferences.clear()
330
+ host.remove()
331
+ style.remove()
332
+ }, 'synapse: web workspace switch')
333
+ }
334
+ return module.exports
335
+ },
336
+ })