@yeaft/webchat-agent 1.0.43 → 1.0.45
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/conversation.js +10 -0
- package/package.json +1 -1
- package/providers/acp-client.js +25 -4
- package/providers/copilot.js +83 -15
- package/yeaft/web-bridge.js +43 -31
package/conversation.js
CHANGED
|
@@ -369,6 +369,11 @@ export async function createConversation(msg) {
|
|
|
369
369
|
});
|
|
370
370
|
} else {
|
|
371
371
|
// Non-Claude providers own their own state construction.
|
|
372
|
+
// deferBoot: don't block conversation_created on the provider's slow boot
|
|
373
|
+
// (Copilot's ACP handshake spawns a CLI and can take tens of seconds). The
|
|
374
|
+
// sidebar row must appear immediately; boot runs in the background and the
|
|
375
|
+
// first sendInput joins the same in-flight boot. Mirrors the Claude path,
|
|
376
|
+
// which prestarts in the background below.
|
|
372
377
|
const driver = getProvider(provider);
|
|
373
378
|
const state = await driver.start({
|
|
374
379
|
conversationId,
|
|
@@ -377,6 +382,7 @@ export async function createConversation(msg) {
|
|
|
377
382
|
userId,
|
|
378
383
|
username,
|
|
379
384
|
providerOptions: msg.providerOptions || {},
|
|
385
|
+
deferBoot: true,
|
|
380
386
|
});
|
|
381
387
|
state.disallowedTools = disallowedTools || null;
|
|
382
388
|
}
|
|
@@ -495,6 +501,9 @@ export async function resumeConversation(msg) {
|
|
|
495
501
|
// Non-Claude providers: re-init state via driver so sessionId/providerName are set correctly.
|
|
496
502
|
if (provider !== 'claude-code') {
|
|
497
503
|
const driver = getProvider(provider);
|
|
504
|
+
// deferBoot: same reasoning as createConversation — emit conversation_resumed
|
|
505
|
+
// immediately so the row shows, and boot the provider session in the
|
|
506
|
+
// background instead of blocking restore on the ACP handshake.
|
|
498
507
|
const state = await driver.start({
|
|
499
508
|
conversationId,
|
|
500
509
|
workDir: effectiveWorkDir,
|
|
@@ -502,6 +511,7 @@ export async function resumeConversation(msg) {
|
|
|
502
511
|
userId,
|
|
503
512
|
username,
|
|
504
513
|
providerOptions: msg.providerOptions || priorProviderOptions || {},
|
|
514
|
+
deferBoot: true,
|
|
505
515
|
});
|
|
506
516
|
state.disallowedTools = disallowedTools || null;
|
|
507
517
|
}
|
package/package.json
CHANGED
package/providers/acp-client.js
CHANGED
|
@@ -38,17 +38,36 @@ export class AcpClient {
|
|
|
38
38
|
stdout.on('close', () => this._handleClose());
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Send a JSONRPC request and await its response.
|
|
43
|
+
* @param {string} method
|
|
44
|
+
* @param {any} params
|
|
45
|
+
* @param {object} [opts]
|
|
46
|
+
* @param {number} [opts.timeoutMs] reject if no response arrives within this
|
|
47
|
+
* window. Omit for no timeout (e.g. session/prompt, which can legitimately
|
|
48
|
+
* run for minutes). Use it on the boot handshake so a wedged CLI surfaces an
|
|
49
|
+
* error instead of hanging the session forever.
|
|
50
|
+
*/
|
|
51
|
+
request(method, params, opts = {}) {
|
|
43
52
|
if (this._closed) return Promise.reject(new Error('acp client closed'));
|
|
44
53
|
const id = this._nextId++;
|
|
45
54
|
const payload = { jsonrpc: '2.0', id, method, params };
|
|
46
55
|
return new Promise((resolve, reject) => {
|
|
47
|
-
|
|
56
|
+
let timer = null;
|
|
57
|
+
if (opts.timeoutMs > 0) {
|
|
58
|
+
timer = setTimeout(() => {
|
|
59
|
+
if (!this._pending.has(id)) return;
|
|
60
|
+
this._pending.delete(id);
|
|
61
|
+
reject(new Error(`acp request '${method}' timed out after ${opts.timeoutMs}ms`));
|
|
62
|
+
}, opts.timeoutMs);
|
|
63
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
64
|
+
}
|
|
65
|
+
this._pending.set(id, { resolve, reject, timer });
|
|
48
66
|
try {
|
|
49
67
|
this._stdin.write(JSON.stringify(payload) + '\n');
|
|
50
68
|
} catch (err) {
|
|
51
69
|
this._pending.delete(id);
|
|
70
|
+
if (timer) clearTimeout(timer);
|
|
52
71
|
reject(err);
|
|
53
72
|
}
|
|
54
73
|
});
|
|
@@ -67,7 +86,8 @@ export class AcpClient {
|
|
|
67
86
|
if (this._closed) return;
|
|
68
87
|
this._closed = true;
|
|
69
88
|
const err = new Error(reason || 'acp client closed');
|
|
70
|
-
for (const { reject } of this._pending.values()) {
|
|
89
|
+
for (const { reject, timer } of this._pending.values()) {
|
|
90
|
+
if (timer) clearTimeout(timer);
|
|
71
91
|
try { reject(err); } catch { /* noop */ }
|
|
72
92
|
}
|
|
73
93
|
this._pending.clear();
|
|
@@ -97,6 +117,7 @@ export class AcpClient {
|
|
|
97
117
|
const slot = this._pending.get(msg.id);
|
|
98
118
|
if (!slot) return; // stale
|
|
99
119
|
this._pending.delete(msg.id);
|
|
120
|
+
if (slot.timer) clearTimeout(slot.timer);
|
|
100
121
|
if (msg.error) slot.reject(Object.assign(new Error(msg.error.message || 'acp error'), { code: msg.error.code, data: msg.error.data }));
|
|
101
122
|
else slot.resolve(msg.result);
|
|
102
123
|
return;
|
package/providers/copilot.js
CHANGED
|
@@ -25,6 +25,11 @@ const COPILOT_BIN = process.env.COPILOT_BIN || 'copilot';
|
|
|
25
25
|
// YOLO is now opt-in only; per-conv allowAllTools is the normal channel.
|
|
26
26
|
const YOLO = process.env.COPILOT_YOLO === '1';
|
|
27
27
|
const ACP_PROTOCOL_VERSION = 1;
|
|
28
|
+
// Boot handshake (initialize / session/new / session/load) timeout. A wedged or
|
|
29
|
+
// mis-installed CLI would otherwise leave the request pending forever — and with
|
|
30
|
+
// deferred boot that means a session that never finishes connecting. session/prompt
|
|
31
|
+
// is deliberately NOT bounded: a turn can legitimately run for minutes.
|
|
32
|
+
const BOOT_REQUEST_TIMEOUT_MS = 120_000;
|
|
28
33
|
|
|
29
34
|
export function resolveCopilotLaunchOptions({ cwd, env = process.env, platform = osPlatform(), bin = COPILOT_BIN } = {}) {
|
|
30
35
|
const isWindows = platform === 'win32';
|
|
@@ -78,27 +83,80 @@ export async function start(opts) {
|
|
|
78
83
|
allowAllTools,
|
|
79
84
|
capabilities,
|
|
80
85
|
initialized: false,
|
|
86
|
+
_bootPromise: null, // in-flight _bootAcp() promise; coalesces concurrent boots
|
|
81
87
|
pendingPermissions: new Map(), // requestId → { resolve, reject } for ask-user round-trip
|
|
82
88
|
usage: { inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheCreation: 0, totalCostUsd: 0 },
|
|
83
89
|
};
|
|
84
90
|
ctx.conversations.set(conversationId, state);
|
|
85
91
|
|
|
86
|
-
|
|
87
|
-
|
|
92
|
+
const resumeSessionId = opts.resumeSessionId || null;
|
|
93
|
+
|
|
94
|
+
// deferBoot: return immediately without awaiting the ACP handshake. The slow
|
|
95
|
+
// part — spawn `copilot --acp` → initialize → session/new — would otherwise
|
|
96
|
+
// block the caller (session create/resume) from emitting conversation_created,
|
|
97
|
+
// so the sidebar row only appears after the whole handshake (~tens of seconds
|
|
98
|
+
// on a cold CLI). With deferBoot the row shows instantly and the boot runs in
|
|
99
|
+
// the background; the first sendInput joins the same in-flight boot via
|
|
100
|
+
// _ensureBooted instead of spawning a second child.
|
|
101
|
+
if (opts.deferBoot) {
|
|
102
|
+
_ensureBooted(state, resumeSessionId).catch((err) => {
|
|
103
|
+
// _emitBootError writes to the WebSocket; if that itself throws (server
|
|
104
|
+
// gone) there's nothing more to do — don't turn it into an unhandled rejection.
|
|
105
|
+
try { _emitBootError(state, err); } catch { /* noop */ }
|
|
106
|
+
});
|
|
107
|
+
return state;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Foreground boot (self-heal paths that must have a live session before
|
|
111
|
+
// dispatching the very next action). Best-effort; failures emit a result
|
|
112
|
+
// envelope and leave state in place so the next sendInput retries.
|
|
88
113
|
try {
|
|
89
|
-
await
|
|
114
|
+
await _ensureBooted(state, resumeSessionId);
|
|
90
115
|
} catch (err) {
|
|
91
|
-
|
|
92
|
-
type: 'result',
|
|
93
|
-
subtype: 'error',
|
|
94
|
-
session_id: state.sessionId,
|
|
95
|
-
is_error: true,
|
|
96
|
-
error: `copilot ACP init failed: ${err?.message || err}. Run \`copilot login\` and ensure CLI >= 1.0.59.`,
|
|
97
|
-
});
|
|
116
|
+
_emitBootError(state, err);
|
|
98
117
|
}
|
|
99
118
|
return state;
|
|
100
119
|
}
|
|
101
120
|
|
|
121
|
+
function _emitBootError(state, err) {
|
|
122
|
+
sendOutput(state.conversationId, {
|
|
123
|
+
type: 'result',
|
|
124
|
+
subtype: 'error',
|
|
125
|
+
session_id: state.sessionId,
|
|
126
|
+
is_error: true,
|
|
127
|
+
error: `copilot ACP init failed: ${err?.message || err}. Run \`copilot login\` and ensure CLI >= 1.0.59.`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Ensure the ACP child + session are up, coalescing concurrent callers onto a
|
|
133
|
+
* single in-flight boot. A backgrounded start() (deferBoot) and an early
|
|
134
|
+
* sendInput therefore share ONE boot instead of spawning two children. Throws
|
|
135
|
+
* if the boot fails; the in-flight promise is cleared either way so the next
|
|
136
|
+
* call retries from scratch.
|
|
137
|
+
*/
|
|
138
|
+
async function _ensureBooted(state, resumeSessionId) {
|
|
139
|
+
// Readiness requires a sessionId too, not just `initialized`. `initialized`
|
|
140
|
+
// flips true right after the `initialize` response but BEFORE session/new
|
|
141
|
+
// establishes state.sessionId — a message arriving in that window would
|
|
142
|
+
// otherwise prompt with sessionId:null and lose the turn. Gating on sessionId
|
|
143
|
+
// keeps callers coalesced onto the in-flight boot until the session exists.
|
|
144
|
+
if (state.initialized && state.acpClient && state.sessionId) return;
|
|
145
|
+
if (state._bootPromise) return state._bootPromise;
|
|
146
|
+
const p = (async () => {
|
|
147
|
+
try {
|
|
148
|
+
await _bootAcp(state, resumeSessionId);
|
|
149
|
+
} finally {
|
|
150
|
+
// Only clear if we're still the current boot — a crash-close (which nulls
|
|
151
|
+
// _bootPromise) followed by a fresh boot must not have its promise wiped
|
|
152
|
+
// by this stale finally.
|
|
153
|
+
if (state._bootPromise === p) state._bootPromise = null;
|
|
154
|
+
}
|
|
155
|
+
})();
|
|
156
|
+
state._bootPromise = p;
|
|
157
|
+
return p;
|
|
158
|
+
}
|
|
159
|
+
|
|
102
160
|
async function _bootAcp(state, resumeSessionId) {
|
|
103
161
|
const args = ['--acp'];
|
|
104
162
|
if (Array.isArray(state.providerOptions?.addDirs)) {
|
|
@@ -125,6 +183,10 @@ async function _bootAcp(state, resumeSessionId) {
|
|
|
125
183
|
if (client) client.close(message);
|
|
126
184
|
});
|
|
127
185
|
child.on('close', (code) => {
|
|
186
|
+
// Ignore a late close from a child that's already been replaced (e.g. a
|
|
187
|
+
// reboot spawned a new one) or torn down — otherwise this would clobber the
|
|
188
|
+
// live boot's acpClient/_bootPromise and orphan the new child.
|
|
189
|
+
if (state.copilotChild !== child) return;
|
|
128
190
|
if (state.turnActive) {
|
|
129
191
|
const tail = stderrBuf.trim().slice(-2000);
|
|
130
192
|
_sendTurnError(state, tail || `copilot exited mid-turn (code ${code})`);
|
|
@@ -139,6 +201,7 @@ async function _bootAcp(state, resumeSessionId) {
|
|
|
139
201
|
state.copilotChild = null;
|
|
140
202
|
state.acpClient = null;
|
|
141
203
|
state.initialized = false;
|
|
204
|
+
state._bootPromise = null;
|
|
142
205
|
});
|
|
143
206
|
|
|
144
207
|
client = new AcpClient({
|
|
@@ -160,7 +223,7 @@ async function _bootAcp(state, resumeSessionId) {
|
|
|
160
223
|
const initResp = await client.request('initialize', {
|
|
161
224
|
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
162
225
|
clientCapabilities: {},
|
|
163
|
-
});
|
|
226
|
+
}, { timeoutMs: BOOT_REQUEST_TIMEOUT_MS });
|
|
164
227
|
state.acpCapabilities = initResp?.agentCapabilities || {};
|
|
165
228
|
state.initialized = true;
|
|
166
229
|
|
|
@@ -170,7 +233,7 @@ async function _bootAcp(state, resumeSessionId) {
|
|
|
170
233
|
sessionId: resumeSessionId,
|
|
171
234
|
cwd: state.workDir,
|
|
172
235
|
mcpServers: [],
|
|
173
|
-
});
|
|
236
|
+
}, { timeoutMs: BOOT_REQUEST_TIMEOUT_MS });
|
|
174
237
|
state.sessionId = resumeSessionId;
|
|
175
238
|
state.claudeSessionId = resumeSessionId;
|
|
176
239
|
if (Array.isArray(r?.modes?.availableModes)) state.acpModes = r.modes.availableModes;
|
|
@@ -189,7 +252,7 @@ async function _bootAcp(state, resumeSessionId) {
|
|
|
189
252
|
const r = await client.request('session/new', {
|
|
190
253
|
cwd: state.workDir,
|
|
191
254
|
mcpServers: [],
|
|
192
|
-
});
|
|
255
|
+
}, { timeoutMs: BOOT_REQUEST_TIMEOUT_MS });
|
|
193
256
|
state.sessionId = r?.sessionId || randomUUID();
|
|
194
257
|
state.claudeSessionId = state.sessionId;
|
|
195
258
|
if (Array.isArray(r?.modes?.availableModes)) state.acpModes = r.modes.availableModes;
|
|
@@ -217,9 +280,13 @@ export async function sendInput(state, prompt, opts = {}) {
|
|
|
217
280
|
if (!conversationId) throw new Error('copilot: conversationId required');
|
|
218
281
|
|
|
219
282
|
// Ensure ACP child + session are up; reboot if a prior crash dropped them.
|
|
220
|
-
|
|
283
|
+
// _ensureBooted coalesces onto a backgrounded deferBoot still in flight, so a
|
|
284
|
+
// fast first message doesn't spawn a second child. Gate on sessionId too: a
|
|
285
|
+
// message can land after `initialize` but before session/new, when initialized
|
|
286
|
+
// is already true but sessionId is still null.
|
|
287
|
+
if (!state.initialized || !state.acpClient || !state.sessionId) {
|
|
221
288
|
try {
|
|
222
|
-
await
|
|
289
|
+
await _ensureBooted(state, state.sessionId || null);
|
|
223
290
|
} catch (err) {
|
|
224
291
|
_sendTurnError(state, `copilot ACP reinit failed: ${err?.message || err}`);
|
|
225
292
|
_completeTurn(state, conversationId);
|
|
@@ -323,6 +390,7 @@ export function dispose(state, reason = 'disposed') {
|
|
|
323
390
|
state.copilotChild = null;
|
|
324
391
|
}
|
|
325
392
|
state.initialized = false;
|
|
393
|
+
state._bootPromise = null;
|
|
326
394
|
state.turnActive = false;
|
|
327
395
|
}
|
|
328
396
|
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -164,6 +164,41 @@ async function sendDreamSnapshotForSession(sessionId, extra = {}) {
|
|
|
164
164
|
return snapshot;
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
function scheduleYeaftLoadHistoryMetadataReplay(sessionId) {
|
|
168
|
+
const replaySession = session;
|
|
169
|
+
const replayConversationId = yeaftConversationId;
|
|
170
|
+
setTimeout(() => {
|
|
171
|
+
try {
|
|
172
|
+
if (!replaySession) return;
|
|
173
|
+
refreshLiveSessionConfig();
|
|
174
|
+
hydrateYeaftStatusFromSession(replaySession, { reason: 'history_load', emitEvent: true });
|
|
175
|
+
sendSessionEvent({
|
|
176
|
+
type: 'session_ready',
|
|
177
|
+
conversationId: replayConversationId,
|
|
178
|
+
model: replaySession.config.primaryModel || replaySession.config.model,
|
|
179
|
+
modelEffort: replaySession.config.modelEffort || null,
|
|
180
|
+
availableModels: replaySession.config.availableModels || [],
|
|
181
|
+
skills: replaySession.status.skills,
|
|
182
|
+
mcpServers: replaySession.status.mcpServers,
|
|
183
|
+
tools: replaySession.status.tools,
|
|
184
|
+
yeaftDir: ctx.CONFIG?.yeaftDir || null,
|
|
185
|
+
tasks: replaySession.taskManager ? replaySession.taskManager.listActiveTasks() : [],
|
|
186
|
+
});
|
|
187
|
+
sendSessionSnapshotBroadcast();
|
|
188
|
+
if (sessionId && session === replaySession) {
|
|
189
|
+
sendDreamSnapshotForSession(sessionId, { trigger: 'load_history' }).catch(() => null);
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
getVpStatusBroker().broadcastSnapshot();
|
|
193
|
+
} catch (err) {
|
|
194
|
+
console.warn('[Yeaft] vp-status snapshot broadcast (replay) failed:', err?.message || err);
|
|
195
|
+
}
|
|
196
|
+
} catch (err) {
|
|
197
|
+
console.warn('[Yeaft] load-history metadata replay failed:', err?.message || err);
|
|
198
|
+
}
|
|
199
|
+
}, 0);
|
|
200
|
+
}
|
|
201
|
+
|
|
167
202
|
|
|
168
203
|
/**
|
|
169
204
|
* Single in-flight AbortController for legacy 1:1 chat. A new 1:1 user message
|
|
@@ -4819,6 +4854,7 @@ export function handleYeaftModelSwitch(msg) {
|
|
|
4819
4854
|
*/
|
|
4820
4855
|
export async function handleYeaftLoadHistory(msg) {
|
|
4821
4856
|
const sessionId = (msg && typeof msg.sessionId === 'string' && msg.sessionId) || null;
|
|
4857
|
+
const metadataOnly = msg && Number.isFinite(msg.limit) && msg.limit <= 0;
|
|
4822
4858
|
// `lim` is now expressed in TURNS, not raw messages. `loadRecent` and
|
|
4823
4859
|
// `loadRecentBySession` use turn-based slicing so the cut never lands
|
|
4824
4860
|
// mid-tool-arc. Pass `undefined` to use the persistence-layer default
|
|
@@ -4828,6 +4864,7 @@ export async function handleYeaftLoadHistory(msg) {
|
|
|
4828
4864
|
let historyAlreadyReplayed = false;
|
|
4829
4865
|
|
|
4830
4866
|
const replayHistoryFromStore = () => {
|
|
4867
|
+
if (metadataOnly) return;
|
|
4831
4868
|
// Delta path: caller knows the latest seq (or message id) it has cached
|
|
4832
4869
|
// and wants only the messages that arrived after that cursor. Returns
|
|
4833
4870
|
// mode:'delta' so the frontend can append+dedupe instead of replacing
|
|
@@ -4959,7 +4996,7 @@ export async function handleYeaftLoadHistory(msg) {
|
|
|
4959
4996
|
afterSeq,
|
|
4960
4997
|
});
|
|
4961
4998
|
sendSessionEvent({ type: 'history_loaded', mode: 'delta', count: projectedMessages.length, sessionId, latestSeq: delta.latestSeq, afterSeq });
|
|
4962
|
-
} else {
|
|
4999
|
+
} else if (!metadataOnly) {
|
|
4963
5000
|
emitVisibleHistoryReplay({ store: coldStore, sessionId, limit, mode: 'recent' });
|
|
4964
5001
|
}
|
|
4965
5002
|
historyAlreadyReplayed = true;
|
|
@@ -4972,9 +5009,6 @@ export async function handleYeaftLoadHistory(msg) {
|
|
|
4972
5009
|
});
|
|
4973
5010
|
installYeaftRuntimeBridge(session);
|
|
4974
5011
|
|
|
4975
|
-
refreshLiveSessionConfig();
|
|
4976
|
-
hydrateYeaftStatusFromSession(session, { reason: 'history_load', emitEvent: true });
|
|
4977
|
-
|
|
4978
5012
|
// Per-group history hydrates lazily via getOrCreateSessionHistory.
|
|
4979
5013
|
// When the load-history call carries a sessionId, force-refresh THAT
|
|
4980
5014
|
// group's tape so the next user message sees on-disk state. When
|
|
@@ -4984,8 +5018,6 @@ export async function handleYeaftLoadHistory(msg) {
|
|
|
4984
5018
|
} else {
|
|
4985
5019
|
replayHistoryFromStore();
|
|
4986
5020
|
historyAlreadyReplayed = true;
|
|
4987
|
-
refreshLiveSessionConfig();
|
|
4988
|
-
hydrateYeaftStatusFromSession(session, { reason: 'history_load', emitEvent: true });
|
|
4989
5021
|
}
|
|
4990
5022
|
|
|
4991
5023
|
if (sessionId) {
|
|
@@ -4995,31 +5027,11 @@ export async function handleYeaftLoadHistory(msg) {
|
|
|
4995
5027
|
setGroupHistory(sessionId, hydrateGroupHistory(sessionId));
|
|
4996
5028
|
}
|
|
4997
5029
|
|
|
4998
|
-
// Always replay session_ready so refresh / reconnect rebuilds UI state
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
modelEffort: session.config.modelEffort || null,
|
|
5004
|
-
availableModels: session.config.availableModels || [],
|
|
5005
|
-
skills: session.status.skills,
|
|
5006
|
-
mcpServers: session.status.mcpServers,
|
|
5007
|
-
tools: session.status.tools,
|
|
5008
|
-
yeaftDir: ctx.CONFIG?.yeaftDir || null,
|
|
5009
|
-
tasks: session.taskManager ? session.taskManager.listActiveTasks() : [],
|
|
5010
|
-
});
|
|
5011
|
-
sendSessionSnapshotBroadcast();
|
|
5012
|
-
if (sessionId) {
|
|
5013
|
-
await sendDreamSnapshotForSession(sessionId, { trigger: 'load_history' }).catch(() => null);
|
|
5014
|
-
}
|
|
5015
|
-
// vp-status: replay the authoritative table on reconnect so a refreshed
|
|
5016
|
-
// frontend doesn't have to wait for the next transition to learn each
|
|
5017
|
-
// VP's current state.
|
|
5018
|
-
try {
|
|
5019
|
-
getVpStatusBroker().broadcastSnapshot();
|
|
5020
|
-
} catch (err) {
|
|
5021
|
-
console.warn('[Yeaft] vp-status snapshot broadcast (replay) failed:', err?.message || err);
|
|
5022
|
-
}
|
|
5030
|
+
// Always replay session_ready so refresh / reconnect rebuilds UI state, but
|
|
5031
|
+
// never make the history response wait for bulky metadata snapshots. The
|
|
5032
|
+
// first visible chunk has already been sent above; defer metadata to the next
|
|
5033
|
+
// tick so the browser can paint messages before VP/session/dream snapshots.
|
|
5034
|
+
scheduleYeaftLoadHistoryMetadataReplay(sessionId);
|
|
5023
5035
|
|
|
5024
5036
|
if (historyAlreadyReplayed) return;
|
|
5025
5037
|
|