mixdog 0.9.45 → 0.9.47
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/package.json +5 -2
- package/scripts/agent-dispatch-abort-compose-test.mjs +31 -0
- package/scripts/agent-model-liveness-test.mjs +618 -0
- package/scripts/agent-trace-io-test.mjs +68 -4
- package/scripts/bench-run.mjs +30 -3
- package/scripts/compact-pressure-test.mjs +187 -4
- package/scripts/compact-prior-context-flatten-test.mjs +252 -0
- package/scripts/compact-trigger-migration-smoke.mjs +8 -6
- package/scripts/compacted-placeholder-scrub-test.mjs +20 -34
- package/scripts/context-mcp-metering-test.mjs +75 -0
- package/scripts/explore-prompt-policy-test.mjs +15 -16
- package/scripts/explore-timeout-cancel-test.mjs +345 -0
- package/scripts/headless-pristine-execution-test.mjs +614 -0
- package/scripts/internal-comms-smoke.mjs +15 -6
- package/scripts/live-worker-smoke.mjs +1 -1
- package/scripts/memory-core-input-test.mjs +137 -0
- package/scripts/memory-rule-contract-test.mjs +5 -5
- package/scripts/openai-oauth-refresh-race-test.mjs +120 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +26 -0
- package/scripts/parent-abort-link-test.mjs +22 -0
- package/scripts/provider-toolcall-test.mjs +22 -0
- package/scripts/reactive-compact-persist-smoke.mjs +8 -4
- package/scripts/session-bench.mjs +3 -70
- package/scripts/task-bench.mjs +0 -2
- package/scripts/terminal-bench-isolation-guards-test.mjs +102 -0
- package/scripts/tool-smoke.mjs +21 -21
- package/scripts/tool-tui-presentation-test.mjs +68 -0
- package/scripts/tui-transcript-jitter-harness-entry.jsx +91 -10
- package/src/app.mjs +28 -103
- package/src/cli.mjs +17 -13
- package/src/defaults/cycle3-review-prompt.md +10 -4
- package/src/defaults/memory-promote-prompt.md +4 -3
- package/src/headless-command.mjs +139 -0
- package/src/headless-role.mjs +121 -10
- package/src/help.mjs +4 -1
- package/src/rules/agent/00-common.md +3 -3
- package/src/rules/agent/00-core.md +8 -9
- package/src/rules/agent/20-skip-protocol.md +2 -3
- package/src/rules/agent/30-explorer.md +50 -56
- package/src/rules/agent/40-cycle1-agent.md +10 -12
- package/src/rules/agent/41-cycle2-agent.md +12 -9
- package/src/rules/agent/42-cycle3-agent.md +4 -6
- package/src/rules/lead/01-general.md +5 -6
- package/src/rules/lead/02-channels.md +1 -1
- package/src/rules/lead/lead-brief.md +14 -17
- package/src/rules/lead/lead-tool.md +3 -3
- package/src/rules/shared/01-tool.md +41 -43
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +46 -10
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +44 -31
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +18 -3
- package/src/runtime/agent/orchestrator/config.mjs +96 -30
- package/src/runtime/agent/orchestrator/context/collect.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +18 -5
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +123 -3
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +12 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +148 -30
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +5 -7
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +62 -14
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +141 -19
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -4
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +19 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +85 -17
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -8
- package/src/runtime/agent/orchestrator/providers/registry.mjs +47 -17
- package/src/runtime/agent/orchestrator/session/compact/summary.mjs +159 -20
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +83 -10
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +75 -7
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +4 -374
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +0 -75
- package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +0 -5
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +20 -3
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +32 -16
- package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +5 -2
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +86 -15
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +30 -27
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +1 -14
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +21 -27
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +1 -3
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +1 -51
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +26 -0
- package/src/runtime/memory/index.mjs +0 -1
- package/src/runtime/memory/lib/core-memory-store.mjs +44 -24
- package/src/runtime/memory/lib/http-router.mjs +0 -193
- package/src/runtime/memory/lib/memory-action-handlers.mjs +37 -11
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +13 -5
- package/src/runtime/memory/lib/memory-cycle2.mjs +8 -5
- package/src/runtime/memory/lib/memory-cycle3.mjs +41 -6
- package/src/runtime/memory/lib/query-handlers.mjs +49 -6
- package/src/runtime/memory/lib/recall-format.mjs +21 -6
- package/src/runtime/memory/tool-defs.mjs +5 -6
- package/src/runtime/shared/config.mjs +11 -34
- package/src/runtime/shared/pristine-execution-contract.json +75 -0
- package/src/runtime/shared/pristine-execution.mjs +356 -0
- package/src/runtime/shared/provider-api-key.mjs +43 -0
- package/src/runtime/shared/provider-auth-binding.mjs +21 -0
- package/src/session-runtime/context-status.mjs +61 -13
- package/src/session-runtime/mcp-glue.mjs +29 -2
- package/src/session-runtime/plugin-mcp.mjs +7 -0
- package/src/session-runtime/resource-api.mjs +38 -5
- package/src/session-runtime/runtime-core.mjs +5 -1
- package/src/session-runtime/session-turn-api.mjs +14 -2
- package/src/session-runtime/settings-api.mjs +5 -0
- package/src/session-runtime/tool-catalog.mjs +13 -2
- package/src/session-runtime/tool-defs.mjs +1 -3
- package/src/standalone/agent-task-status.mjs +50 -11
- package/src/standalone/agent-tool/tool-def.mjs +1 -1
- package/src/standalone/explore-tool.mjs +257 -49
- package/src/standalone/seeds.mjs +1 -0
- package/src/tui/App.jsx +23 -10
- package/src/tui/app/use-transcript-scroll.mjs +4 -3
- package/src/tui/app/use-transcript-window.mjs +12 -21
- package/src/tui/components/ContextPanel.jsx +19 -25
- package/src/tui/dist/index.mjs +77 -65
- package/src/tui/engine/agent-envelope.mjs +16 -5
- package/src/tui/engine/labels.mjs +1 -1
- package/src/workflows/default/WORKFLOW.md +21 -51
- package/src/workflows/solo/WORKFLOW.md +12 -17
- package/src/workflows/solo-review/WORKFLOW.md +0 -47
|
@@ -9,8 +9,6 @@ import { ensureProcessListenerHeadroom } from '../runtime/shared/process-listene
|
|
|
9
9
|
|
|
10
10
|
ensureProcessListenerHeadroom(64);
|
|
11
11
|
|
|
12
|
-
const EXPLORE_NO_RELOOKUP_CONTRACT = 'Every returned requested path:line freezes the LOCATION only; read/code_graph detail inspection is valid when content was not returned; never re-locate it, and search only unresolved facets.';
|
|
13
|
-
|
|
14
12
|
// Ported from the original mixdog tool-defs.mjs `explore` entry.
|
|
15
13
|
// `aiWrapped` is dropped: in the standalone build there is no aiWrapped
|
|
16
14
|
// dispatch hub — execution is wired directly in the runtime executor below
|
|
@@ -20,7 +18,7 @@ export const EXPLORE_TOOL = {
|
|
|
20
18
|
name: 'explore',
|
|
21
19
|
title: 'Explore',
|
|
22
20
|
annotations: { title: 'Explore', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
23
|
-
description:
|
|
21
|
+
description: 'Locator for broad/uncertain targets with no known path, repo or machine-wide (dot dirs included). Array = independent targets: query[] fans out facets (max 8), never rephrasings. Every returned path:line freezes the LOCATION only; read/code_graph detail inspection is valid when content was not returned; never re-locate it — search only unresolved facets.',
|
|
24
22
|
inputSchema: {
|
|
25
23
|
type: 'object',
|
|
26
24
|
properties: {
|
|
@@ -52,11 +50,15 @@ const EXPLORE_RESULT_CACHE_TTL_MS = (() => {
|
|
|
52
50
|
return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 5 * 60_000;
|
|
53
51
|
})();
|
|
54
52
|
// Hard ceiling for one explorer compute. The dispatch-level watchdog is the
|
|
55
|
-
// primary abort; this race is the last line of defence so
|
|
56
|
-
// never hang the awaiting tool call (and its cache key)
|
|
53
|
+
// primary abort; this race + its AbortController is the last line of defence so
|
|
54
|
+
// a wedged compute can never hang the awaiting tool call (and its cache key)
|
|
55
|
+
// forever. Default 60s (was 10min): a locator sub-session that has not produced
|
|
56
|
+
// anchors within a minute is wedged, and holding the tool call open longer only
|
|
57
|
+
// delays the caller and risks the compute outliving the turn. The
|
|
58
|
+
// MIXDOG_EXPLORE_HARD_TIMEOUT_MS override (including 0 = disabled) is preserved.
|
|
57
59
|
export const EXPLORE_COMPUTE_HARD_TIMEOUT_MS = (() => {
|
|
58
60
|
const raw = Number(process.env.MIXDOG_EXPLORE_HARD_TIMEOUT_MS);
|
|
59
|
-
return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) :
|
|
61
|
+
return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 60_000;
|
|
60
62
|
})();
|
|
61
63
|
const EXPLORE_RESULT_CACHE = new Map(); // key -> { ts, value?, promise? }
|
|
62
64
|
// Fan-out launch stagger: when >1 query, dispatch the first sub-session
|
|
@@ -72,17 +74,93 @@ const EXPLORE_FANOUT_STAGGER_MS = (() => {
|
|
|
72
74
|
return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 0;
|
|
73
75
|
})();
|
|
74
76
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
77
|
+
// Build a promise that rejects the MOMENT `signal` aborts (immediately if it is
|
|
78
|
+
// already aborted), plus a `cancel` to detach the listener once the caller has
|
|
79
|
+
// settled by another path. This is what makes a canceled/timed-out explore
|
|
80
|
+
// reject RIGHT AWAY even when the underlying compute is non-cooperative (ignores
|
|
81
|
+
// its AbortSignal) instead of hanging until the wall-clock timeout fires.
|
|
82
|
+
function abortRejectionPromise(signal) {
|
|
83
|
+
let cancel = () => {};
|
|
84
|
+
const promise = new Promise((_resolve, reject) => {
|
|
85
|
+
if (!(signal instanceof AbortSignal)) return; // never settles
|
|
86
|
+
const onAbort = () => {
|
|
87
|
+
const reason = signal.reason;
|
|
88
|
+
reject(reason instanceof Error ? reason : new Error(String(reason ?? 'explore aborted')));
|
|
89
|
+
};
|
|
90
|
+
if (signal.aborted) { onAbort(); return; }
|
|
91
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
92
|
+
cancel = () => { try { signal.removeEventListener('abort', onAbort); } catch { /* ignore */ } };
|
|
93
|
+
});
|
|
94
|
+
return { promise, cancel };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Arm the wall-clock hard timeout (default 60s; MIXDOG_EXPLORE_HARD_TIMEOUT_MS
|
|
98
|
+
// override incl. 0 = disabled): when it fires it ABORTS `controller` — which
|
|
99
|
+
// both tears down a cooperative compute (child dispatch + provider call) AND,
|
|
100
|
+
// via abortRejectionPromise(controller.signal), rejects the awaiting call.
|
|
101
|
+
// Returns a disposer that clears the timer.
|
|
102
|
+
function armExploreHardTimeout(controller, timeoutMs = EXPLORE_COMPUTE_HARD_TIMEOUT_MS) {
|
|
103
|
+
if (!(timeoutMs > 0)) return () => {};
|
|
104
|
+
const timer = setTimeout(() => {
|
|
105
|
+
try { controller.abort(new Error(`explorer timed out after ${timeoutMs}ms`)); } catch { /* ignore */ }
|
|
106
|
+
}, timeoutMs);
|
|
107
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
108
|
+
return () => clearTimeout(timer);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Cascade a parent AbortSignal into a compute controller (immediately if the
|
|
112
|
+
// parent is already aborted). Returns a detach fn for the listener, or null.
|
|
113
|
+
function linkParentAbort(parentSignal, controller) {
|
|
114
|
+
if (!(parentSignal instanceof AbortSignal)) return null;
|
|
115
|
+
const onParentAbort = () => {
|
|
116
|
+
try { controller.abort(parentSignal.reason); } catch { try { controller.abort(); } catch { /* ignore */ } }
|
|
117
|
+
};
|
|
118
|
+
if (parentSignal.aborted) { onParentAbort(); return null; }
|
|
119
|
+
parentSignal.addEventListener('abort', onParentAbort, { once: true });
|
|
120
|
+
return () => { try { parentSignal.removeEventListener('abort', onParentAbort); } catch { /* ignore */ } };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Interruptible stagger delay: resolves after `ms`, but REJECTS the instant
|
|
124
|
+
// `signal` aborts (or immediately if already aborted) so a canceled fan-out
|
|
125
|
+
// cancels the pending stagger BEFORE it dispatches the (now-pointless) child.
|
|
126
|
+
// Exported for focused stagger-cancellation regression tests.
|
|
127
|
+
export function exploreStaggerDelay(ms, signal) {
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
const rejectCanceled = () => {
|
|
130
|
+
const reason = signal?.reason;
|
|
131
|
+
reject(reason instanceof Error ? reason : new Error(String(reason ?? 'explore canceled')));
|
|
132
|
+
};
|
|
133
|
+
if (signal instanceof AbortSignal && signal.aborted) { rejectCanceled(); return; }
|
|
134
|
+
const timer = setTimeout(() => { cleanup(); resolve(); }, ms);
|
|
83
135
|
if (typeof timer.unref === 'function') timer.unref();
|
|
136
|
+
const onAbort = () => { cleanup(); rejectCanceled(); };
|
|
137
|
+
const cleanup = () => {
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
if (signal instanceof AbortSignal) { try { signal.removeEventListener('abort', onAbort); } catch { /* ignore */ } }
|
|
140
|
+
};
|
|
141
|
+
if (signal instanceof AbortSignal) signal.addEventListener('abort', onAbort, { once: true });
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Run one explorer compute under an AbortController that fires on EITHER the
|
|
146
|
+
// caller's cancellation (parentSignal) OR the wall-clock hard timeout, and RACE
|
|
147
|
+
// the compute against that abort so a canceled/timed-out call rejects
|
|
148
|
+
// IMMEDIATELY (it never waits on a non-cooperative compute). The compute
|
|
149
|
+
// receives the controller signal and threads it into the child dispatch so the
|
|
150
|
+
// abort tears down every child + its provider call at once. This is the
|
|
151
|
+
// single-caller path (cache disabled / non-shared); the shared-cache path uses
|
|
152
|
+
// startSharedCompute + subscribeToSharedCompute so one caller's cancellation
|
|
153
|
+
// never poisons the other subscribers. Exported for focused regression tests.
|
|
154
|
+
export function runExploreComputeWithAbort(compute, parentSignal = null, timeoutMs = EXPLORE_COMPUTE_HARD_TIMEOUT_MS) {
|
|
155
|
+
const controller = new AbortController();
|
|
156
|
+
const detachParent = linkParentAbort(parentSignal, controller);
|
|
157
|
+
const disarm = armExploreHardTimeout(controller, timeoutMs);
|
|
158
|
+
const { promise: aborted } = abortRejectionPromise(controller.signal);
|
|
159
|
+
const computePromise = Promise.resolve().then(() => compute(controller.signal));
|
|
160
|
+
return Promise.race([computePromise, aborted]).finally(() => {
|
|
161
|
+
disarm();
|
|
162
|
+
if (detachParent) detachParent();
|
|
84
163
|
});
|
|
85
|
-
return Promise.race([promise, timeout]).finally(() => { if (timer) clearTimeout(timer); });
|
|
86
164
|
}
|
|
87
165
|
|
|
88
166
|
function escapeXml(str) {
|
|
@@ -124,7 +202,13 @@ function resolveExploreCwd(input, callerCwd) {
|
|
|
124
202
|
|
|
125
203
|
function settledExplorerResult(result, cwd = null) {
|
|
126
204
|
if (result?.status !== 'fulfilled') {
|
|
127
|
-
|
|
205
|
+
const message = result?.reason?.message || String(result?.reason);
|
|
206
|
+
// The hard deadline has one caller-visible convention regardless of
|
|
207
|
+
// whether it expires during provider warmup or child dispatch.
|
|
208
|
+
if (/^explorer timed out after \d+ms$/.test(message)) {
|
|
209
|
+
return { ok: true, text: 'EXPLORATION_FAILED' };
|
|
210
|
+
}
|
|
211
|
+
return { ok: false, text: `[explorer error] ${message}` };
|
|
128
212
|
}
|
|
129
213
|
const text = cleanExplorerText(typeof result.value === 'string' ? result.value : responseText(result.value), cwd);
|
|
130
214
|
if (!text) return { ok: false, text: '[explorer error] empty response' };
|
|
@@ -220,12 +304,34 @@ export function resolveExploreRoute(config) {
|
|
|
220
304
|
return findConfigPreset(config, routeOrName);
|
|
221
305
|
}
|
|
222
306
|
|
|
223
|
-
async function ensureExploreProviderReady(config, route) {
|
|
307
|
+
export async function ensureExploreProviderReady(config, route, signal = null, init = initProviders) {
|
|
224
308
|
const provider = clean(route?.provider);
|
|
225
309
|
if (!provider) return;
|
|
226
310
|
const providers = { ...(config?.providers || {}) };
|
|
227
311
|
providers[provider] = { ...(providers[provider] || {}), enabled: true };
|
|
228
|
-
|
|
312
|
+
await init(providers, { signal });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Race provider warmup against the caller's cancellation so an ESC landing
|
|
316
|
+
// DURING provider init returns IMMEDIATELY (and no child dispatch follows)
|
|
317
|
+
// instead of finishing warmup and then spinning up subs it must tear down.
|
|
318
|
+
// Returns true when the caller canceled (already-aborted up front, or aborted
|
|
319
|
+
// before/at warmup completion), false when the provider is ready to dispatch. A
|
|
320
|
+
// genuine provider-init failure (not a cancel) still propagates. Exported for
|
|
321
|
+
// focused provider-init cancellation regression tests.
|
|
322
|
+
export async function awaitExploreProviderReadyOrCancel(readyPromise, parentSignal) {
|
|
323
|
+
if (!(parentSignal instanceof AbortSignal)) { await readyPromise; return false; }
|
|
324
|
+
if (parentSignal.aborted) return true;
|
|
325
|
+
const { promise: aborted, cancel } = abortRejectionPromise(parentSignal);
|
|
326
|
+
try {
|
|
327
|
+
await Promise.race([readyPromise, aborted]);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
if (parentSignal.aborted) return true; // canceled during warmup
|
|
330
|
+
throw err; // a real init failure still surfaces to the caller
|
|
331
|
+
} finally {
|
|
332
|
+
cancel();
|
|
333
|
+
}
|
|
334
|
+
return parentSignal.aborted;
|
|
229
335
|
}
|
|
230
336
|
|
|
231
337
|
function scheduleExploreCodeGraphPrewarm(cwd) {
|
|
@@ -260,43 +366,119 @@ function getCachedExploreResult(key) {
|
|
|
260
366
|
return null;
|
|
261
367
|
}
|
|
262
368
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
if (entry?.promise) {
|
|
269
|
-
// Pending in-flight dedup — but never trust a pending entry forever: a
|
|
270
|
-
// compute that outlived the hard timeout is wedged (its own race should
|
|
271
|
-
// have rejected); drop the poisoned key and recompute fresh.
|
|
272
|
-
if (!(EXPLORE_COMPUTE_HARD_TIMEOUT_MS > 0) || Date.now() - entry.ts <= EXPLORE_COMPUTE_HARD_TIMEOUT_MS) {
|
|
273
|
-
return await entry.promise;
|
|
274
|
-
}
|
|
275
|
-
EXPLORE_RESULT_CACHE.delete(key);
|
|
369
|
+
function trimExploreResultCache() {
|
|
370
|
+
while (EXPLORE_RESULT_CACHE.size > EXPLORE_RESULT_CACHE_MAX_ENTRIES) {
|
|
371
|
+
const oldest = EXPLORE_RESULT_CACHE.keys().next().value;
|
|
372
|
+
if (!oldest) break;
|
|
373
|
+
EXPLORE_RESULT_CACHE.delete(oldest);
|
|
276
374
|
}
|
|
277
|
-
|
|
278
|
-
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Start ONE shared, caller-agnostic compute for `key`. Its AbortController is
|
|
378
|
+
// driven ONLY by the wall-clock hard timeout (a shared deadline) and by the
|
|
379
|
+
// subscriber ref-count reaching zero (see subscribeToSharedCompute) — NEVER by a
|
|
380
|
+
// single subscriber's cancellation. So one caller canceling can neither abort
|
|
381
|
+
// the shared compute nor poison the OTHER subscribers. On success the pending
|
|
382
|
+
// entry is replaced by a value entry; on timeout/failure the entry is purged
|
|
383
|
+
// (identity-guarded) so a future call recomputes instead of awaiting a dead
|
|
384
|
+
// promise (which is exactly what surfaces later as an empty tool result).
|
|
385
|
+
function startSharedCompute(key, compute, timeoutMs = EXPLORE_COMPUTE_HARD_TIMEOUT_MS) {
|
|
386
|
+
const controller = new AbortController();
|
|
387
|
+
const entry = { ts: Date.now(), controller, subscribers: new Set(), promise: null };
|
|
388
|
+
const disarm = armExploreHardTimeout(controller, timeoutMs);
|
|
389
|
+
const { promise: aborted } = abortRejectionPromise(controller.signal);
|
|
390
|
+
const computePromise = Promise.resolve().then(() => compute(controller.signal));
|
|
391
|
+
entry.promise = Promise.race([computePromise, aborted])
|
|
279
392
|
.then((value) => {
|
|
393
|
+
// Identity-guard EVERY eventual write: only cache/purge when THIS entry is
|
|
394
|
+
// still the live one. A hard-timeout TTL eviction (runExploreCached) may
|
|
395
|
+
// have retired us and started a fresh compute; a late resolve from the
|
|
396
|
+
// retired compute must NEVER overwrite that newer entry (stale overwrite).
|
|
397
|
+
if (EXPLORE_RESULT_CACHE.get(key) !== entry) return value;
|
|
280
398
|
const text = typeof value === 'string' ? value : responseText(value);
|
|
281
399
|
const cleaned = cleanExplorerText(text);
|
|
282
400
|
if (cleaned && cleaned !== 'EXPLORATION_FAILED') {
|
|
283
401
|
EXPLORE_RESULT_CACHE.set(key, { ts: Date.now(), value: text });
|
|
284
|
-
|
|
285
|
-
const oldest = EXPLORE_RESULT_CACHE.keys().next().value;
|
|
286
|
-
if (!oldest) break;
|
|
287
|
-
EXPLORE_RESULT_CACHE.delete(oldest);
|
|
288
|
-
}
|
|
402
|
+
trimExploreResultCache();
|
|
289
403
|
} else {
|
|
290
404
|
EXPLORE_RESULT_CACHE.delete(key);
|
|
291
405
|
}
|
|
292
406
|
return value;
|
|
293
407
|
})
|
|
294
408
|
.catch((err) => {
|
|
295
|
-
|
|
409
|
+
// Canceled / timed-out / failed shared compute: purge the pending entry
|
|
410
|
+
// (only when it is still ours) so a later call recomputes fresh instead of
|
|
411
|
+
// awaiting a dead promise that would surface as "no tool output".
|
|
412
|
+
if (EXPLORE_RESULT_CACHE.get(key) === entry) EXPLORE_RESULT_CACHE.delete(key);
|
|
296
413
|
throw err;
|
|
297
|
-
})
|
|
298
|
-
|
|
299
|
-
|
|
414
|
+
})
|
|
415
|
+
.finally(() => { disarm(); });
|
|
416
|
+
EXPLORE_RESULT_CACHE.set(key, entry);
|
|
417
|
+
return entry;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Subscribe ONE caller (with its OWN signal) to a shared compute. The
|
|
421
|
+
// subscriber's await is raced against its own cancellation so a canceled caller
|
|
422
|
+
// is RELEASED IMMEDIATELY (never left waiting on the shared promise). When the
|
|
423
|
+
// LAST subscriber cancels, the shared compute is aborted and its entry purged;
|
|
424
|
+
// while any subscriber remains the shared compute keeps running for them, so one
|
|
425
|
+
// caller's cancellation never disturbs the unaffected subscribers.
|
|
426
|
+
function subscribeToSharedCompute(key, entry, subscriberSignal) {
|
|
427
|
+
const token = {};
|
|
428
|
+
entry.subscribers.add(token);
|
|
429
|
+
return new Promise((resolve, reject) => {
|
|
430
|
+
let done = false;
|
|
431
|
+
const detach = () => {
|
|
432
|
+
if (subscriberSignal instanceof AbortSignal) {
|
|
433
|
+
try { subscriberSignal.removeEventListener('abort', onAbort); } catch { /* ignore */ }
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
const settle = (fn, val) => {
|
|
437
|
+
if (done) return;
|
|
438
|
+
done = true;
|
|
439
|
+
entry.subscribers.delete(token);
|
|
440
|
+
detach();
|
|
441
|
+
fn(val);
|
|
442
|
+
};
|
|
443
|
+
function onAbort() {
|
|
444
|
+
const reason = subscriberSignal?.reason;
|
|
445
|
+
const err = reason instanceof Error ? reason : new Error(String(reason ?? 'explore canceled'));
|
|
446
|
+
settle(reject, err);
|
|
447
|
+
if (entry.subscribers.size === 0) {
|
|
448
|
+
// Last subscriber gone: tear the shared compute down and purge the entry
|
|
449
|
+
// so a future call recomputes instead of reusing an abandoned promise.
|
|
450
|
+
try { entry.controller.abort(err); } catch { /* ignore */ }
|
|
451
|
+
if (EXPLORE_RESULT_CACHE.get(key) === entry) EXPLORE_RESULT_CACHE.delete(key);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (subscriberSignal instanceof AbortSignal) {
|
|
455
|
+
if (subscriberSignal.aborted) { onAbort(); return; }
|
|
456
|
+
subscriberSignal.addEventListener('abort', onAbort, { once: true });
|
|
457
|
+
}
|
|
458
|
+
entry.promise.then((v) => settle(resolve, v), (e) => settle(reject, e));
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export async function runExploreCached(key, compute, parentSignal = null, timeoutMs = EXPLORE_COMPUTE_HARD_TIMEOUT_MS) {
|
|
463
|
+
const cached = getCachedExploreResult(key);
|
|
464
|
+
if (cached !== null) return cached;
|
|
465
|
+
if (!exploreResultCacheEnabled()) return await runExploreComputeWithAbort(compute, parentSignal);
|
|
466
|
+
let entry = EXPLORE_RESULT_CACHE.get(key);
|
|
467
|
+
if (entry?.promise
|
|
468
|
+
&& EXPLORE_COMPUTE_HARD_TIMEOUT_MS > 0
|
|
469
|
+
&& Date.now() - entry.ts > EXPLORE_COMPUTE_HARD_TIMEOUT_MS) {
|
|
470
|
+
// A pending entry that outlived the hard timeout is wedged — ABORT its
|
|
471
|
+
// compute (tearing down any in-flight child dispatch + provider call) and
|
|
472
|
+
// drop it, then recompute rather than subscribe to a promise that may never
|
|
473
|
+
// settle (which later surfaces as an empty "no tool output" result). The
|
|
474
|
+
// abort + identity-guarded writes in startSharedCompute guarantee the
|
|
475
|
+
// retired compute can never overwrite the fresh entry with a stale value.
|
|
476
|
+
if (EXPLORE_RESULT_CACHE.get(key) === entry) EXPLORE_RESULT_CACHE.delete(key);
|
|
477
|
+
try { entry.controller?.abort(new Error('explore pending entry evicted after hard timeout')); } catch { /* ignore */ }
|
|
478
|
+
entry = null;
|
|
479
|
+
}
|
|
480
|
+
if (!entry?.promise) entry = startSharedCompute(key, compute, timeoutMs);
|
|
481
|
+
return await subscribeToSharedCompute(key, entry, parentSignal);
|
|
300
482
|
}
|
|
301
483
|
|
|
302
484
|
function responseText(response) {
|
|
@@ -404,6 +586,13 @@ async function runExploreSync(args = {}, ctx = {}) {
|
|
|
404
586
|
const queries = normalizeExploreQueries(args.query);
|
|
405
587
|
if (queries.length === 0) return fail('query is required (one or more non-empty strings)');
|
|
406
588
|
|
|
589
|
+
// Already-canceled call: short-circuit BEFORE any provider warmup / dispatch
|
|
590
|
+
// so a canceled explore never spins up child sub-sessions it must immediately
|
|
591
|
+
// tear down (and never continues into a dead "no tool output" state).
|
|
592
|
+
if (ctx.signal instanceof AbortSignal && ctx.signal.aborted) {
|
|
593
|
+
return fail('explore canceled before dispatch');
|
|
594
|
+
}
|
|
595
|
+
|
|
407
596
|
let working = queries;
|
|
408
597
|
let capNotice = '';
|
|
409
598
|
if (working.length > MAX_FANOUT_QUERIES) {
|
|
@@ -416,7 +605,8 @@ async function runExploreSync(args = {}, ctx = {}) {
|
|
|
416
605
|
scheduleExploreFindPrewarm(resolvedCwd);
|
|
417
606
|
const config = loadConfig();
|
|
418
607
|
const route = resolveExploreRoute(config);
|
|
419
|
-
|
|
608
|
+
const parentSignal = ctx.signal instanceof AbortSignal ? ctx.signal : null;
|
|
609
|
+
if (parentSignal?.aborted) return fail('explore canceled before dispatch');
|
|
420
610
|
// Turn budget is enforced BOTH ways: the prompt contract (rules/agent/
|
|
421
611
|
// 30-explorer.md, "hard max 3 tool turns, expected 1") steers the model,
|
|
422
612
|
// and maxLoopIterations mechanically backstops it — live traces showed
|
|
@@ -439,14 +629,25 @@ async function runExploreSync(args = {}, ctx = {}) {
|
|
|
439
629
|
// provider prompt-cache write. Index 0 fires immediately; a cached-result
|
|
440
630
|
// query resolves without delay regardless of index.
|
|
441
631
|
const stagger = working.length > 1 ? EXPLORE_FANOUT_STAGGER_MS : 0;
|
|
632
|
+
// Caller cancellation (ESC / owner-session abort) cascades into every child
|
|
633
|
+
// dispatch: the per-compute AbortController links this signal (and the hard
|
|
634
|
+
// timeout) so aborting the explore tool call tears down all fan-out subs and
|
|
635
|
+
// their provider calls at once.
|
|
442
636
|
const settled = await Promise.allSettled(working.map((q, i) => {
|
|
443
637
|
const key = exploreResultCacheKey({ cwd: resolvedCwd, route, query: q });
|
|
444
|
-
return runExploreCached(key, () => {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
638
|
+
return runExploreCached(key, async (computeSignal) => {
|
|
639
|
+
// Provider initialization is part of the timed compute: a wedged init
|
|
640
|
+
// must consume the same 60s wall-clock budget as dispatch, never block
|
|
641
|
+
// runExplore before its hard-timeout AbortController has been armed.
|
|
642
|
+
await ensureExploreProviderReady(config, route, computeSignal);
|
|
643
|
+
// Interruptible stagger: if the compute signal aborts during the delay,
|
|
644
|
+
// exploreStaggerDelay rejects and the (now-pointless) child dispatch is
|
|
645
|
+
// never launched.
|
|
646
|
+
const delay = (stagger > 0 && i > 0) ? exploreStaggerDelay(stagger, computeSignal) : null;
|
|
647
|
+
return await (delay
|
|
648
|
+
? delay.then(() => llm({ prompt: buildExplorerPrompt(q), parentSignal: computeSignal }))
|
|
649
|
+
: llm({ prompt: buildExplorerPrompt(q), parentSignal: computeSignal }));
|
|
650
|
+
}, parentSignal);
|
|
450
651
|
}));
|
|
451
652
|
|
|
452
653
|
const fatal = fatalExploreError(settled);
|
|
@@ -457,3 +658,10 @@ async function runExploreSync(args = {}, ctx = {}) {
|
|
|
457
658
|
const out = capNotice + merged;
|
|
458
659
|
return allFailed ? fail(out) : ok(out);
|
|
459
660
|
}
|
|
661
|
+
|
|
662
|
+
// Test-only hook: inspect the explore result cache so cancellation/timeout
|
|
663
|
+
// regression tests can assert poisoned entries are purged. Not part of the
|
|
664
|
+
// runtime surface.
|
|
665
|
+
export function __exploreResultCacheForTest() {
|
|
666
|
+
return EXPLORE_RESULT_CACHE;
|
|
667
|
+
}
|
package/src/standalone/seeds.mjs
CHANGED
|
@@ -22,6 +22,7 @@ export function ensureStandaloneEnvironment({ rootDir, dataDir }) {
|
|
|
22
22
|
// user data skills dir, but only when the target dir does not already exist —
|
|
23
23
|
// never overwrite user-owned skill dirs.
|
|
24
24
|
export function seedBundledSkills({ rootDir, dataDir }) {
|
|
25
|
+
if (/^(?:1|true|on|yes)$/i.test(String(process.env.MIXDOG_DISABLE_SKILLS || ''))) return;
|
|
25
26
|
const bundledDir = join(rootDir, 'defaults', 'skills');
|
|
26
27
|
if (!existsSync(bundledDir)) return;
|
|
27
28
|
const targetRoot = join(dataDir, 'skills');
|
package/src/tui/App.jsx
CHANGED
|
@@ -1211,7 +1211,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
1211
1211
|
return;
|
|
1212
1212
|
}
|
|
1213
1213
|
if (count === previousCount || dragRef.current.active) return;
|
|
1214
|
-
if (scrollTargetRef.current
|
|
1214
|
+
if (scrollTargetRef.current === 0 || followingRef.current) followingRef.current = true;
|
|
1215
1215
|
}, [state.items.length, resetTranscriptScroll]);
|
|
1216
1216
|
|
|
1217
1217
|
// `exiting` removes the inline caret (PromptInput draws none when disabled) and
|
|
@@ -1496,7 +1496,13 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
1496
1496
|
};
|
|
1497
1497
|
|
|
1498
1498
|
const openContextPicker = () => {
|
|
1499
|
-
const tools = store.toolsStatus?.() || {
|
|
1499
|
+
const tools = store.toolsStatus?.() || {
|
|
1500
|
+
activeCount: 0,
|
|
1501
|
+
count: 0,
|
|
1502
|
+
mcpToolCount: 0,
|
|
1503
|
+
activeMcpToolCount: 0,
|
|
1504
|
+
activeTools: [],
|
|
1505
|
+
};
|
|
1500
1506
|
const mcp = store.mcpStatus?.() || { connectedCount: 0, configuredCount: 0, failedCount: 0 };
|
|
1501
1507
|
const skills = store.skillsStatus?.() || { count: 0 };
|
|
1502
1508
|
const plugins = store.pluginsStatus?.() || { count: 0 };
|
|
@@ -1504,6 +1510,13 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
1504
1510
|
const usage = context.usage || {};
|
|
1505
1511
|
const messages = context.messages || {};
|
|
1506
1512
|
const request = context.request || {};
|
|
1513
|
+
const schemaBreakdown = request.toolSchemaBreakdown || {};
|
|
1514
|
+
const schemaTokensFor = (buckets) => buckets.reduce(
|
|
1515
|
+
(sum, bucket) => sum + Number(schemaBreakdown?.[bucket]?.tokens || 0),
|
|
1516
|
+
0,
|
|
1517
|
+
);
|
|
1518
|
+
const builtInToolSchemaTokens = schemaTokensFor(['code', 'web', 'mutation', 'channels', 'setup', 'other']);
|
|
1519
|
+
const mcpToolSchemaTokens = schemaTokensFor(['mcp']);
|
|
1507
1520
|
const compaction = context.compaction || {};
|
|
1508
1521
|
const windowTokens = Number(context.contextWindow || state.contextWindow || context.rawContextWindow || state.rawContextWindow || 0);
|
|
1509
1522
|
const rawWindowTokens = Number(context.rawContextWindow || state.rawContextWindow || windowTokens || 0);
|
|
@@ -1593,7 +1606,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
1593
1606
|
{
|
|
1594
1607
|
value: 'tools',
|
|
1595
1608
|
label: 'Tools',
|
|
1596
|
-
description: `${fmt(
|
|
1609
|
+
description: `${fmt(builtInToolSchemaTokens)} schema tokens (${pct(builtInToolSchemaTokens)}) · ${tools.activeCount || 0}/${tools.count || 0} active`,
|
|
1597
1610
|
_action: 'tools',
|
|
1598
1611
|
},
|
|
1599
1612
|
{
|
|
@@ -1667,7 +1680,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
1667
1680
|
semantic: messages.semantic,
|
|
1668
1681
|
},
|
|
1669
1682
|
tools: {
|
|
1670
|
-
schemaTokens:
|
|
1683
|
+
schemaTokens: builtInToolSchemaTokens,
|
|
1671
1684
|
active: tools.activeCount,
|
|
1672
1685
|
count: tools.count,
|
|
1673
1686
|
},
|
|
@@ -1699,6 +1712,9 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
1699
1712
|
connected: mcp.connectedCount,
|
|
1700
1713
|
configured: mcp.configuredCount,
|
|
1701
1714
|
failed: mcp.failedCount,
|
|
1715
|
+
tools: tools.mcpToolCount,
|
|
1716
|
+
activeTools: tools.activeMcpToolCount,
|
|
1717
|
+
schemaTokens: mcpToolSchemaTokens,
|
|
1702
1718
|
},
|
|
1703
1719
|
},
|
|
1704
1720
|
rows: contextRows,
|
|
@@ -2871,12 +2887,9 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
2871
2887
|
1,
|
|
2872
2888
|
viewportHeight - transcriptGuardRows - panelCloseMaskRows - welcomePromptHintRows - overlayHintBandRows,
|
|
2873
2889
|
);
|
|
2874
|
-
//
|
|
2875
|
-
//
|
|
2876
|
-
//
|
|
2877
|
-
// pre-guard offset of 1 survives while no reading anchor is active, still treat
|
|
2878
|
-
// the viewport as pinned to the live tail so streaming/tool output continues
|
|
2879
|
-
// to auto-follow instead of freezing one row above bottom.
|
|
2890
|
+
// Keep the keyboard-selection edge step anchored to the base guard. This is
|
|
2891
|
+
// not a follow threshold: any positive wheel target is a reading position;
|
|
2892
|
+
// only the true bottom may auto-follow a live tail.
|
|
2880
2893
|
const transcriptBottomSlackRows = Math.max(0, baseGuardRows);
|
|
2881
2894
|
transcriptBottomSlackRowsRef.current = transcriptBottomSlackRows;
|
|
2882
2895
|
transcriptViewportRef.current = {
|
|
@@ -451,10 +451,11 @@ export function useTranscriptScroll({
|
|
|
451
451
|
// A manual scroll moves the reading position. Capture the new reading anchor
|
|
452
452
|
// SYNCHRONOUSLY from the latest published geometry so the very next render
|
|
453
453
|
// already locks to it — no one-frame "dirty" window where concurrent
|
|
454
|
-
// streaming growth could lurch the view.
|
|
455
|
-
// so the bottom-follow path owns the viewport again
|
|
454
|
+
// streaming growth could lurch the view. Only at the true bottom drop the
|
|
455
|
+
// anchor so the bottom-follow path owns the viewport again: a positive
|
|
456
|
+
// wheel offset is an explicit reading position, even inside the old slack.
|
|
456
457
|
if (appliedDelta !== 0) {
|
|
457
|
-
if (target
|
|
458
|
+
if (target === 0) {
|
|
458
459
|
transcriptAnchorRef.current = null;
|
|
459
460
|
transcriptAnchorDirtyRef.current = false;
|
|
460
461
|
} else {
|
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
* memos, the same-frame anchor lock + capture, the per-commit Yoga height
|
|
7
7
|
* harvest, bottom-follow/anchor post-commit sync, selection layout shift and
|
|
8
8
|
* the scroll clamp. Scroll/anchor/drag refs stay App-owned (injected); this
|
|
9
|
-
* hook owns the measurement maps, measuredRowsVersion and
|
|
10
|
-
* preserved-delta bookkeeping refs.
|
|
9
|
+
* hook owns the measurement maps, measuredRowsVersion and totalRows bookkeeping.
|
|
11
10
|
*/
|
|
12
11
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
13
12
|
import {
|
|
@@ -61,7 +60,6 @@ export function useTranscriptWindow({
|
|
|
61
60
|
setMeasuredRowsVersion,
|
|
62
61
|
}) {
|
|
63
62
|
const transcriptTotalRowsRef = useRef(0);
|
|
64
|
-
const preservedScrollDeltaRef = useRef(0);
|
|
65
63
|
// Pessimistic "committed" max-scroll for the IMMEDIATE wheel/keyboard clamp.
|
|
66
64
|
// transcriptWindow.maxScrollRows is derived from the row index, which uses
|
|
67
65
|
// ESTIMATED heights for rows in the mounted slice. On the frame a scroll-up
|
|
@@ -138,7 +136,7 @@ export function useTranscriptWindow({
|
|
|
138
136
|
: null;
|
|
139
137
|
const scrolledUpRowsForPin = Math.max(0, Number(scrollTargetRef.current) || 0);
|
|
140
138
|
const transcriptPinnedForStreaming = followingRef.current
|
|
141
|
-
|| scrolledUpRowsForPin
|
|
139
|
+
|| scrolledUpRowsForPin === 0;
|
|
142
140
|
// Publish the pinned state to the streaming-tail estimator BEFORE resolving
|
|
143
141
|
// tailSig / the row-index memo this render, so max(measuredFloor, liveEstimate)
|
|
144
142
|
// applies while bottom-pinned (right geometry on first commit, no judder) and
|
|
@@ -156,7 +154,7 @@ export function useTranscriptWindow({
|
|
|
156
154
|
);
|
|
157
155
|
const transcriptStructureSig = `${revision}#${tailSig}`;
|
|
158
156
|
const transcriptStreamingActive = !!streamingTailItem;
|
|
159
|
-
const scrolledUpForStreamingMeasure = scrolledUpRowsForPin >
|
|
157
|
+
const scrolledUpForStreamingMeasure = scrolledUpRowsForPin > 0;
|
|
160
158
|
const prevEstimateGeometry = transcriptGeomRef.current?.suppressMeasuredRowHeights === true;
|
|
161
159
|
const hasStreamingReadingAnchor = !!transcriptAnchorRef.current
|
|
162
160
|
|| transcriptAnchorDirtyRef.current;
|
|
@@ -197,10 +195,10 @@ export function useTranscriptWindow({
|
|
|
197
195
|
// (bottom-follow / pinned) or it cannot be aligned (anchor item gone).
|
|
198
196
|
const hasReadingAnchor = !!transcriptAnchorRef.current && !transcriptAnchorDirtyRef.current;
|
|
199
197
|
const scrolledUpRows = Math.max(0, Number(scrollTargetRef.current) || 0);
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
const scrolledUp = scrolledUpRows >
|
|
198
|
+
// Any positive offset is a user reading position. Only the true bottom is
|
|
199
|
+
// pinned, so a wheel notch inside the former slack band cannot re-enable
|
|
200
|
+
// follow while a stream is appending rows.
|
|
201
|
+
const scrolledUp = scrolledUpRows > 0;
|
|
204
202
|
// A genuine reading anchor wins even if followingRef is stale-true while the
|
|
205
203
|
// user is scrolled up. The follow-arm SHOULD have been cleared when the anchor
|
|
206
204
|
// was captured, but if it lingers true we must not fall through to the stale-
|
|
@@ -470,7 +468,7 @@ export function useTranscriptWindow({
|
|
|
470
468
|
overlayHintAttachItemIndex = i;
|
|
471
469
|
break;
|
|
472
470
|
}
|
|
473
|
-
const transcriptTailPinned = Math.max(0, Number(transcriptWindow.effectiveScrollOffset) || 0)
|
|
471
|
+
const transcriptTailPinned = Math.max(0, Number(transcriptWindow.effectiveScrollOffset) || 0) === 0;
|
|
474
472
|
const overlayHintOnLastItem = overlayHintRequested
|
|
475
473
|
&& floatingPanelRows <= 0
|
|
476
474
|
&& transcriptWindow.bottomSpacerRows === 0
|
|
@@ -635,7 +633,7 @@ export function useTranscriptWindow({
|
|
|
635
633
|
const currentPosition = Math.max(0, Number(scrollPositionRef.current) || 0);
|
|
636
634
|
const currentOffset = Math.max(0, Number(scrollOffset) || 0);
|
|
637
635
|
const maxRows = Math.max(0, Number(transcriptWindow.maxScrollRows) || 0);
|
|
638
|
-
const nearBottom = followingRef.current || currentTarget
|
|
636
|
+
const nearBottom = followingRef.current || currentTarget === 0;
|
|
639
637
|
const pinnedToBottom = nearBottom;
|
|
640
638
|
// A genuine reading anchor must win over ordinary stream growth, but an
|
|
641
639
|
// explicit follow arm (prompt submit / pinned bottom) must win over stale
|
|
@@ -731,22 +729,20 @@ export function useTranscriptWindow({
|
|
|
731
729
|
stopSmoothScroll();
|
|
732
730
|
scrollTargetRef.current = desired;
|
|
733
731
|
scrollPositionRef.current = Math.max(0, Math.min(maxRows, currentPosition + appliedDelta));
|
|
734
|
-
preservedScrollDeltaRef.current += appliedDelta;
|
|
735
732
|
setScrollOffset(Math.max(0, Math.round(desired)));
|
|
736
|
-
}, [transcriptWindow.totalRows, transcriptWindow.maxScrollRows, transcriptRowIndex, transcriptContentHeight,
|
|
733
|
+
}, [transcriptWindow.totalRows, transcriptWindow.maxScrollRows, transcriptRowIndex, transcriptContentHeight, scrollOffset, stopSmoothScroll]);
|
|
737
734
|
useLayoutEffect(() => {
|
|
738
|
-
if (transcriptBottomSlackRows <= 0) return;
|
|
739
735
|
if (transcriptAnchorRef.current || transcriptAnchorDirtyRef.current || followingRef.current) return;
|
|
740
736
|
const currentTarget = Math.max(0, Number(scrollTargetRef.current) || 0);
|
|
741
737
|
const currentPosition = Math.max(0, Number(scrollPositionRef.current) || 0);
|
|
742
738
|
const currentOffset = Math.max(0, Number(scrollOffset) || 0);
|
|
739
|
+
if (currentTarget !== 0) return;
|
|
743
740
|
if (Math.max(currentTarget, currentPosition, currentOffset) === 0) return;
|
|
744
|
-
if (Math.max(currentTarget, currentPosition, currentOffset) > transcriptBottomSlackRows) return;
|
|
745
741
|
stopSmoothScroll();
|
|
746
742
|
scrollTargetRef.current = 0;
|
|
747
743
|
scrollPositionRef.current = 0;
|
|
748
744
|
setScrollOffset(0);
|
|
749
|
-
}, [
|
|
745
|
+
}, [scrollOffset, stopSmoothScroll]);
|
|
750
746
|
useLayoutEffect(() => {
|
|
751
747
|
const top = Math.max(0, Number(transcriptViewportRef.current?.top) || 0);
|
|
752
748
|
const next = {
|
|
@@ -755,11 +751,6 @@ export function useTranscriptWindow({
|
|
|
755
751
|
totalRows: Math.max(0, Number(transcriptWindow.totalRows) || 0),
|
|
756
752
|
scrollOffset: Math.max(0, Number(transcriptWindow.effectiveScrollOffset) || 0),
|
|
757
753
|
};
|
|
758
|
-
const preservedDelta = Number(preservedScrollDeltaRef.current) || 0;
|
|
759
|
-
if (preservedDelta !== 0) {
|
|
760
|
-
next.scrollOffset = Math.max(0, next.scrollOffset + preservedDelta);
|
|
761
|
-
preservedScrollDeltaRef.current = 0;
|
|
762
|
-
}
|
|
763
754
|
const previous = selectionLayoutRef.current;
|
|
764
755
|
selectionLayoutRef.current = next;
|
|
765
756
|
if (!previous || !dragRef.current.rect || dragRef.current.active) return;
|