mixdog 0.9.87 → 0.9.89
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 +13 -7
- package/scripts/tool-failures.mjs +60 -24
- package/src/agents/heavy-worker/AGENT.md +3 -4
- package/src/agents/worker/AGENT.md +1 -2
- package/src/cli.mjs +8 -0
- package/src/defaults/cycle3-review-prompt.md +4 -4
- package/src/rules/agent/00-core.md +3 -0
- package/src/rules/agent/42-cycle3-agent.md +5 -6
- package/src/rules/lead/01-general.md +2 -0
- package/src/rules/shared/01-tool.md +29 -35
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +3 -51
- package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +59 -0
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -2
- package/src/runtime/agent/orchestrator/agent-runtime/title-completion.mjs +67 -0
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +57 -4
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +2 -3
- package/src/runtime/agent/orchestrator/config.mjs +29 -11
- package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
- package/src/runtime/agent/orchestrator/internal-agents.mjs +1 -1
- package/src/runtime/agent/orchestrator/mcp/client.mjs +0 -24
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +3 -3
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +25 -17
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +321 -22
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -22
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +28 -23
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +10 -2
- package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +0 -1
- package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +0 -1
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +2 -5
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +27 -0
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +23 -0
- package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +329 -0
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +23 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +107 -20
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +4 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +124 -12
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +19 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +110 -75
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +96 -113
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +160 -11
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +18 -1
- package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +147 -117
- package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +88 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +50 -8
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +47 -6
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +485 -24
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +100 -2
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +80 -14
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +142 -2
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +128 -32
- package/src/runtime/agent/orchestrator/session/manager.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +77 -4
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +70 -39
- package/src/runtime/agent/orchestrator/session/store/fs-probe.mjs +91 -0
- package/src/runtime/agent/orchestrator/session/store/listing.mjs +141 -64
- package/src/runtime/agent/orchestrator/session/store/live-state.mjs +253 -0
- package/src/runtime/agent/orchestrator/session/store/load-cache.mjs +211 -28
- package/src/runtime/agent/orchestrator/session/store/save-fault.mjs +313 -0
- package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +622 -53
- package/src/runtime/agent/orchestrator/session/store/serialize.mjs +43 -10
- package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +53 -8
- package/src/runtime/agent/orchestrator/session/store/summary-rebuild-worker.mjs +20 -0
- package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +113 -9
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +476 -52
- package/src/runtime/agent/orchestrator/session/store.mjs +721 -196
- package/src/runtime/agent/orchestrator/session/token-native.mjs +2 -4
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -0
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +4 -6
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +146 -90
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +6 -0
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +93 -17
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +7 -7
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +17 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +85 -5
- package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +52 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +54 -51
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -15
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +132 -8
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +86 -3
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +210 -38
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +23 -1
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +87 -19
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -8
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +12 -2
- package/src/runtime/media/lanes.mjs +2 -2
- package/src/runtime/media/renditions.mjs +101 -17
- package/src/runtime/media/renditions.test.mjs +54 -0
- package/src/runtime/media/store.mjs +111 -43
- package/src/runtime/media/store.test.mjs +53 -11
- package/src/runtime/memory/index.mjs +26 -50
- package/src/runtime/memory/lib/core-memory-candidates.mjs +2 -7
- package/src/runtime/memory/lib/core-memory-store.mjs +2 -9
- package/src/runtime/memory/lib/cycle-scheduler.mjs +10 -0
- package/src/runtime/memory/lib/embedding-provider.mjs +3 -3
- package/src/runtime/memory/lib/embedding-worker.mjs +60 -15
- package/src/runtime/memory/lib/http-router.mjs +4 -0
- package/src/runtime/memory/lib/ko-morph.mjs +49 -6
- package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -18
- package/src/runtime/memory/lib/memory-cycle2.mjs +3 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +1 -3
- package/src/runtime/memory/lib/memory-recall-store.mjs +24 -0
- package/src/runtime/memory/lib/query-handlers.mjs +26 -28
- package/src/runtime/memory/tool-defs.mjs +3 -3
- package/src/runtime/shared/atomic-file.mjs +5 -1
- package/src/runtime/shared/child-guardian.mjs +73 -2
- package/src/runtime/shared/provider-api-key.mjs +22 -5
- package/src/runtime/shared/tool-execution-contract.mjs +53 -0
- package/src/session-runtime/config-helpers.mjs +6 -5
- package/src/session-runtime/lifecycle-api.mjs +16 -4
- package/src/session-runtime/media-api.mjs +31 -16
- package/src/session-runtime/prewarm.mjs +2 -24
- package/src/session-runtime/runtime-core.mjs +33 -15
- package/src/session-runtime/runtime-tunables.mjs +0 -3
- package/src/session-runtime/session-lifecycle.mjs +0 -6
- package/src/session-runtime/session-title.mjs +228 -0
- package/src/session-runtime/session-turn-api.mjs +11 -7
- package/src/session-runtime/workflow-agents-api.mjs +16 -0
- package/src/session-runtime/workflow.mjs +7 -5
- package/src/standalone/agent-tool/job-views.mjs +90 -36
- package/src/standalone/agent-tool/spawn-flow.mjs +25 -216
- package/src/standalone/agent-tool/worker-index.mjs +8 -0
- package/src/standalone/agent-tool/worker-rows.mjs +2 -1
- package/src/standalone/agent-tool.mjs +17 -17
- package/src/standalone/agent-watchdog-registry.mjs +19 -2
- package/src/standalone/channel-daemon.mjs +8 -0
- package/src/standalone/explore-tool.mjs +1 -1
- package/src/standalone/memory-runtime-proxy.mjs +4 -7
- package/src/standalone/projects.mjs +4 -1
- package/src/standalone/usage-dashboard.mjs +25 -3
- package/src/tui/App.jsx +5 -3
- package/src/tui/app/resume-picker.mjs +7 -2
- package/src/tui/app/route-pickers.mjs +11 -117
- package/src/tui/app/slash-dispatch.mjs +13 -3
- package/src/tui/app/transcript-row-estimate.mjs +1 -1
- package/src/tui/app/transcript-window.mjs +17 -93
- package/src/tui/app/use-transcript-scroll.mjs +32 -2
- package/src/tui/app/use-transcript-window.mjs +64 -114
- package/src/tui/components/Markdown.jsx +19 -2
- package/src/tui/components/Message.jsx +8 -3
- package/src/tui/components/ToolExecution.jsx +7 -3
- package/src/tui/dist/index.mjs +243 -223
- package/src/tui/engine/live-share.mjs +15 -5
- package/src/tui/engine/render-timing.mjs +2 -2
- package/src/tui/engine/session-api-ext.mjs +74 -4
- package/src/tui/engine/session-flow.mjs +3 -1
- package/src/tui/engine.mjs +2 -2
- package/src/tui/hooks/useSharedTick.mjs +0 -2
- package/src/tui/index.jsx +5 -5
- package/src/ui/statusline-agents.mjs +44 -11
- package/src/vendor/statusline/bin/statusline-route.mjs +26 -3
- package/src/workflows/default/WORKFLOW.md +3 -3
- package/src/workflows/solo/WORKFLOW.md +5 -3
- package/src/runtime/media/index.mjs +0 -18
- package/src/runtime/memory/lib/embedding-warmup.mjs +0 -68
- package/src/standalone/agent-shard/shard-child.mjs +0 -300
- package/src/standalone/agent-shard/shard-pool.mjs +0 -443
|
@@ -15,6 +15,14 @@
|
|
|
15
15
|
process.env.MIXDOG_CHANNEL_DAEMON = '1';
|
|
16
16
|
process.env.MIXDOG_WORKER_MODE = process.env.MIXDOG_WORKER_MODE || '1';
|
|
17
17
|
|
|
18
|
+
// V8 compile cache: the daemon is a standalone child entry (not via cli.mjs);
|
|
19
|
+
// caching compiled bytecode across restarts removes the channels+memory
|
|
20
|
+
// runtime parse cost from every daemon boot. Best-effort.
|
|
21
|
+
try {
|
|
22
|
+
const { enableCompileCache } = await import('node:module');
|
|
23
|
+
enableCompileCache?.();
|
|
24
|
+
} catch { /* launch-speed optimization only */ }
|
|
25
|
+
|
|
18
26
|
import os from 'node:os';
|
|
19
27
|
import path from 'node:path';
|
|
20
28
|
import { mkdirSync, appendFileSync } from 'node:fs';
|
|
@@ -18,7 +18,7 @@ export const EXPLORE_TOOL = {
|
|
|
18
18
|
name: 'explore',
|
|
19
19
|
title: 'Explore',
|
|
20
20
|
annotations: { title: 'Explore', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
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.
|
|
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.',
|
|
22
22
|
inputSchema: {
|
|
23
23
|
type: 'object',
|
|
24
24
|
properties: {
|
|
@@ -446,13 +446,10 @@ export function createStandaloneMemoryRuntime({
|
|
|
446
446
|
MIXDOG_OWNER_LEAD_PID: String(process.pid),
|
|
447
447
|
MIXDOG_MEMORY_SECONDARY: '0',
|
|
448
448
|
MIXDOG_PG_ATTACH_ONLY: '0',
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
// every recall runs lexical-only until the first fallback flush
|
|
454
|
-
// lazily loads the model (observed 1.5h '(no results)' windows).
|
|
455
|
-
MIXDOG_EMBED_WARMUP: '1',
|
|
449
|
+
// The daemon owns the model, but ownership does not imply eager
|
|
450
|
+
// residency. A recall starts warmup on demand and immediately uses
|
|
451
|
+
// lexical search while it loads, keeping desktop/TUI boot lightweight.
|
|
452
|
+
MIXDOG_EMBED_WARMUP: process.env.MIXDOG_EMBED_WARMUP ?? '0',
|
|
456
453
|
MIXDOG_MEMORY_DISABLE_CYCLES: process.env.MIXDOG_MEMORY_DISABLE_CYCLES ?? '0',
|
|
457
454
|
MIXDOG_MEMORY_DISABLE_LLM_WORKER: process.env.MIXDOG_MEMORY_DISABLE_LLM_WORKER ?? '0',
|
|
458
455
|
MIXDOG_QUIET_SESSION_LOG: process.env.MIXDOG_QUIET_SESSION_LOG ?? '1',
|
|
@@ -12,7 +12,9 @@ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSyn
|
|
|
12
12
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
13
13
|
|
|
14
14
|
const MIXDOG_HOME = process.env.MIXDOG_HOME || join(homedir(), '.mixdog');
|
|
15
|
-
const PROJECTS_FILE =
|
|
15
|
+
const PROJECTS_FILE = process.env.MIXDOG_PROJECTS_FILE
|
|
16
|
+
? resolve(process.env.MIXDOG_PROJECTS_FILE)
|
|
17
|
+
: join(MIXDOG_HOME, 'projects.json');
|
|
16
18
|
|
|
17
19
|
/** Resolve a raw path to an absolute path against the user's cwd. */
|
|
18
20
|
function toAbsolute(rawPath) {
|
|
@@ -76,6 +78,7 @@ function writeStore(store) {
|
|
|
76
78
|
*/
|
|
77
79
|
function ensureProjectIdMarker(absPath, name) {
|
|
78
80
|
try {
|
|
81
|
+
if (process.env.MIXDOG_DISABLE_PROJECT_MARKERS === '1') return;
|
|
79
82
|
if (!absPath) return;
|
|
80
83
|
// Only when the project folder actually exists as a directory on disk.
|
|
81
84
|
try {
|
|
@@ -88,9 +88,25 @@ function normaliseResetCredits(value) {
|
|
|
88
88
|
const offerRevision = clean(value.offerRevision ?? value.offer_revision);
|
|
89
89
|
if (availableCount === null || !offerRevision) return null;
|
|
90
90
|
const nextExpiresAt = num(value.nextExpiresAt ?? value.next_expires_at, null);
|
|
91
|
+
const rawAvailableCredits = value.availableCredits ?? value.available_credits;
|
|
92
|
+
const availableCredits = (Array.isArray(rawAvailableCredits) ? rawAvailableCredits : [])
|
|
93
|
+
.map((credit) => {
|
|
94
|
+
if (!credit || typeof credit !== 'object' || Array.isArray(credit)) return null;
|
|
95
|
+
const expiresAt = num(credit.expiresAt ?? credit.expires_at, null);
|
|
96
|
+
const grantedAt = num(credit.grantedAt ?? credit.granted_at, null);
|
|
97
|
+
return {
|
|
98
|
+
...(expiresAt && expiresAt > 0 ? { expiresAt } : {}),
|
|
99
|
+
...(grantedAt && grantedAt > 0 ? { grantedAt } : {}),
|
|
100
|
+
};
|
|
101
|
+
})
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
.sort((left, right) =>
|
|
104
|
+
(left.expiresAt ?? Number.POSITIVE_INFINITY)
|
|
105
|
+
- (right.expiresAt ?? Number.POSITIVE_INFINITY));
|
|
91
106
|
return {
|
|
92
107
|
availableCount: Math.max(0, Math.floor(availableCount)),
|
|
93
108
|
offerRevision,
|
|
109
|
+
availableCredits,
|
|
94
110
|
...(nextExpiresAt && nextExpiresAt > 0 ? { nextExpiresAt } : {}),
|
|
95
111
|
};
|
|
96
112
|
}
|
|
@@ -234,22 +250,28 @@ function rowTone(row) {
|
|
|
234
250
|
async function oauthSnapshot(providerId, { refresh, getProvider, log }) {
|
|
235
251
|
const cached = readCachedOAuthUsageSnapshot({ provider: providerId, model: '' });
|
|
236
252
|
if (!refresh && cached) return cached;
|
|
253
|
+
// A rate-limited or failed forced refresh (Anthropic /oauth/usage 429s
|
|
254
|
+
// aggressively) must not blank a known quota row: fall back to the last
|
|
255
|
+
// contentful snapshot — a stale meter with its updatedAt beats an empty row.
|
|
256
|
+
const staleFallback = () => cached
|
|
257
|
+
|| readCachedOAuthUsageSnapshot({ provider: providerId, model: '' }, { allowStale: true });
|
|
237
258
|
if (typeof getProvider === 'function') {
|
|
238
259
|
try {
|
|
239
260
|
const providerObj = getProvider(providerId);
|
|
240
261
|
if (providerObj) {
|
|
241
|
-
|
|
262
|
+
const fresh = await fetchOAuthUsageSnapshot(
|
|
242
263
|
{ provider: providerId, model: '' },
|
|
243
264
|
providerObj,
|
|
244
265
|
log,
|
|
245
266
|
{ force: refresh === true },
|
|
246
267
|
);
|
|
268
|
+
return fresh || staleFallback();
|
|
247
269
|
}
|
|
248
270
|
} catch {
|
|
249
|
-
return
|
|
271
|
+
return staleFallback();
|
|
250
272
|
}
|
|
251
273
|
}
|
|
252
|
-
return
|
|
274
|
+
return staleFallback();
|
|
253
275
|
}
|
|
254
276
|
|
|
255
277
|
async function apiSnapshot(providerId, { refresh } = {}) {
|
package/src/tui/App.jsx
CHANGED
|
@@ -764,8 +764,10 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
764
764
|
});
|
|
765
765
|
|
|
766
766
|
|
|
767
|
-
// Item-count changes
|
|
768
|
-
//
|
|
767
|
+
// Item-count changes never infer follow permission from scrollTarget=0. A
|
|
768
|
+
// first read-back input can be waiting one frame for committed row geometry;
|
|
769
|
+
// only prompt submit, session reset, or an explicit return to bottom arms
|
|
770
|
+
// follow. Pure streaming height growth is handled in the row-delta effect.
|
|
769
771
|
useLayoutEffect(() => {
|
|
770
772
|
const count = state.items.length;
|
|
771
773
|
const previousCount = lastItemsCountRef.current;
|
|
@@ -775,7 +777,6 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
775
777
|
return;
|
|
776
778
|
}
|
|
777
779
|
if (count === previousCount || dragRef.current.active) return;
|
|
778
|
-
if (scrollTargetRef.current === 0 || followingRef.current) followingRef.current = true;
|
|
779
780
|
}, [state.items.length, resetTranscriptScroll]);
|
|
780
781
|
|
|
781
782
|
// Exit + queued-restore + prompt history: app/use-prompt-queue-history.mjs.
|
|
@@ -1139,6 +1140,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
|
|
|
1139
1140
|
structureRevision: state.transcriptViewItems
|
|
1140
1141
|
? state.transcriptViewRevision
|
|
1141
1142
|
: state.structureRevision,
|
|
1143
|
+
sessionKey: state.sessionId || '',
|
|
1142
1144
|
// Historical pages are contiguous settled windows. Keep the independently
|
|
1143
1145
|
// growing live tail hidden until paging forward reaches the live window.
|
|
1144
1146
|
streamingTail: state.transcriptViewItems ? null : state.streamingTail,
|
|
@@ -31,11 +31,12 @@ export function createResumePicker({
|
|
|
31
31
|
}
|
|
32
32
|
const items = sessions.map((s) => {
|
|
33
33
|
const preview = String(s.preview || '').replace(/\n/g, ' ').trim();
|
|
34
|
+
const title = String(s.title || '').replace(/\s+/g, ' ').trim();
|
|
34
35
|
const count = formatSessionMessageCount(s.messageCount);
|
|
35
36
|
return {
|
|
36
37
|
value: s.id,
|
|
37
38
|
label: `${formatSessionUpdatedAt(s.updatedAt)} ${count}`,
|
|
38
|
-
description: preview || '(no message)',
|
|
39
|
+
description: title || preview || '(no message)',
|
|
39
40
|
};
|
|
40
41
|
});
|
|
41
42
|
setPicker({
|
|
@@ -45,8 +46,12 @@ export function createResumePicker({
|
|
|
45
46
|
labelWidth: 21,
|
|
46
47
|
onSelect: (value) => {
|
|
47
48
|
setPicker(null);
|
|
49
|
+
const selected = sessions.find((session) => session.id === value);
|
|
50
|
+
const resumedName = String(selected?.title || selected?.preview || value)
|
|
51
|
+
.replace(/\s+/g, ' ')
|
|
52
|
+
.trim();
|
|
48
53
|
void store.resume(value)
|
|
49
|
-
.then(ok => store.pushNotice(ok ? `Resumed ${value}` : 'Couldn’t resume chat.', ok ? 'info' : 'warn'))
|
|
54
|
+
.then(ok => store.pushNotice(ok ? `Resumed ${resumedName || value}` : 'Couldn’t resume chat.', ok ? 'info' : 'warn'))
|
|
50
55
|
.catch((e) => store.pushNotice(`Couldn’t resume chat: ${e?.message || e}`, 'error'));
|
|
51
56
|
},
|
|
52
57
|
onCancel: () => {
|
|
@@ -157,134 +157,28 @@ export function createRoutePickers({
|
|
|
157
157
|
closeUsagePanel();
|
|
158
158
|
setPicker({
|
|
159
159
|
title: 'Workflow',
|
|
160
|
-
description: '
|
|
161
|
-
help: returnTo ? '↑/↓ Select · Enter
|
|
160
|
+
description: 'Select active workflow.',
|
|
161
|
+
help: returnTo ? '↑/↓ Select · Enter Choose · Esc Settings' : '↑/↓ Select · Enter Choose · Esc Back',
|
|
162
162
|
labelWidth: 18,
|
|
163
|
-
initialIndex: Math.max(0, items.findIndex((item) => item.value === (options.initialWorkflowId || ''))),
|
|
164
163
|
items,
|
|
165
164
|
onSelect: (_value, item) => {
|
|
166
165
|
const workflow = item?._workflow;
|
|
167
166
|
if (!workflow) return;
|
|
168
|
-
// Depth mirrors the system model: a workflow pack owns the agent set
|
|
169
|
-
// it delegates to, so Enter opens the pack (activate + its agents)
|
|
170
|
-
// instead of switching immediately.
|
|
171
|
-
openWorkflowDetailPicker(workflow.id, { returnTo });
|
|
172
|
-
},
|
|
173
|
-
onCancel: () => {
|
|
174
167
|
setPicker(null);
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
});
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
// Workflow detail: activation row plus the agents this pack delegates to
|
|
181
|
-
// (route editing inline). Packs without an `agents:` key delegate to all
|
|
182
|
-
// fixed agents; a configured-but-empty list means Lead works alone.
|
|
183
|
-
const openWorkflowDetailPicker = (workflowId, options = {}) => {
|
|
184
|
-
const returnTo = typeof options.returnTo === 'function' ? options.returnTo : null;
|
|
185
|
-
let workflows = [];
|
|
186
|
-
try {
|
|
187
|
-
workflows = store.listWorkflows?.() || [];
|
|
188
|
-
} catch (e) {
|
|
189
|
-
store.pushNotice(`could not list workflows: ${e?.message || e}`, 'error');
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
const workflow = workflows.find((item) => item.id === workflowId);
|
|
193
|
-
if (!workflow) {
|
|
194
|
-
store.pushNotice(`workflow "${workflowId}" not found`, 'warn');
|
|
195
|
-
openWorkflowPicker({ returnTo });
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
let agents = [];
|
|
199
|
-
try {
|
|
200
|
-
agents = store.listAgents?.() || [];
|
|
201
|
-
} catch (e) {
|
|
202
|
-
store.pushNotice(`could not list agents: ${e?.message || e}`, 'error');
|
|
203
|
-
agents = [];
|
|
204
|
-
}
|
|
205
|
-
const packAgents = workflow.agentsConfigured === true
|
|
206
|
-
? (workflow.agents || [])
|
|
207
|
-
.map((id) => agents.find((agent) => agent.id === id) || { id, label: id, route: null, description: '' })
|
|
208
|
-
: agents.filter((agent) => agent.custom !== true);
|
|
209
|
-
const routeOverrides = options.routeOverrides && typeof options.routeOverrides === 'object' ? options.routeOverrides : {};
|
|
210
|
-
const items = [
|
|
211
|
-
{
|
|
212
|
-
value: '__activate',
|
|
213
|
-
label: workflow.active ? 'Active' : 'Activate',
|
|
214
|
-
marker: workflow.active ? '✓' : '',
|
|
215
|
-
markerColor: theme.success,
|
|
216
|
-
description: workflow.active
|
|
217
|
-
? 'This workflow is active.'
|
|
218
|
-
: 'Make this the active workflow.',
|
|
219
|
-
},
|
|
220
|
-
...(packAgents.length
|
|
221
|
-
? packAgents.map((agent) => ({
|
|
222
|
-
value: agent.id,
|
|
223
|
-
label: agent.label,
|
|
224
|
-
metaParts: agentModelParts(routeOverrides[agent.id] || agent.route),
|
|
225
|
-
description: agent.description || agent.definition?.description || '',
|
|
226
|
-
_agent: agent,
|
|
227
|
-
}))
|
|
228
|
-
: [{
|
|
229
|
-
value: '__lead-only',
|
|
230
|
-
label: 'Lead only',
|
|
231
|
-
description: 'This workflow delegates to no agents.',
|
|
232
|
-
}]),
|
|
233
|
-
];
|
|
234
|
-
setPicker({
|
|
235
|
-
title: workflow.name,
|
|
236
|
-
description: workflow.description || 'Workflow activation and agents.',
|
|
237
|
-
help: '↑/↓ Select · Enter Choose · Esc Back',
|
|
238
|
-
indexMode: 'always',
|
|
239
|
-
labelWidth: 18,
|
|
240
|
-
metaWidth: 33,
|
|
241
|
-
initialIndex: Math.max(0, items.findIndex((item) => item.value === (options.initialValue || '__activate'))),
|
|
242
|
-
items,
|
|
243
|
-
onSelect: (_value, item) => {
|
|
244
|
-
if (item?.value === '__activate') {
|
|
245
|
-
if (workflow.active) {
|
|
246
|
-
store.pushNotice(`${workflow.name} is already the active workflow`, 'info');
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
setPicker(null);
|
|
250
|
-
void store.setWorkflow?.(workflow.id)
|
|
251
|
-
.then((result) => {
|
|
252
|
-
if (!result) {
|
|
253
|
-
store.pushNotice('Workflow switch is already running.', 'warn');
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
store.pushNotice(workflowSwitchNotice(result), 'info');
|
|
257
|
-
openWorkflowPicker({ returnTo, initialWorkflowId: workflow.id });
|
|
258
|
-
})
|
|
259
|
-
.catch((e) => store.pushNotice(`Couldn’t switch workflow: ${e?.message || e}`, 'error'));
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
const agent = item?._agent;
|
|
263
|
-
if (!agent) return;
|
|
264
|
-
void openModelPicker({
|
|
265
|
-
title: `${agent.label} Model`,
|
|
266
|
-
providerDescription: 'Choose a provider for this agent.',
|
|
267
|
-
currentRoute: agent.route || null,
|
|
268
|
-
returnTo: () => openWorkflowDetailPicker(workflow.id, { returnTo, initialValue: agent.id }),
|
|
269
|
-
onImmediateSelect: (routeInput) => {
|
|
270
|
-
openWorkflowDetailPicker(workflow.id, {
|
|
271
|
-
returnTo,
|
|
272
|
-
routeOverrides: { [agent.id]: routeInput },
|
|
273
|
-
initialValue: agent.id,
|
|
274
|
-
});
|
|
275
|
-
},
|
|
276
|
-
onSelectRoute: async (routeInput) => {
|
|
277
|
-
const result = await store.setAgentRoute?.(agent.id, routeInput);
|
|
168
|
+
void store.setWorkflow?.(workflow.id)
|
|
169
|
+
.then((result) => {
|
|
278
170
|
if (!result) {
|
|
279
|
-
store.pushNotice('
|
|
171
|
+
store.pushNotice('Workflow switch is already running.', 'warn');
|
|
280
172
|
return;
|
|
281
173
|
}
|
|
282
|
-
store.pushNotice(
|
|
283
|
-
|
|
284
|
-
|
|
174
|
+
store.pushNotice(workflowSwitchNotice(result), 'info');
|
|
175
|
+
if (returnTo) returnTo();
|
|
176
|
+
})
|
|
177
|
+
.catch((e) => store.pushNotice(`Couldn’t switch workflow: ${e?.message || e}`, 'error'));
|
|
285
178
|
},
|
|
286
179
|
onCancel: () => {
|
|
287
|
-
|
|
180
|
+
setPicker(null);
|
|
181
|
+
if (returnTo) returnTo();
|
|
288
182
|
},
|
|
289
183
|
});
|
|
290
184
|
};
|
|
@@ -63,12 +63,22 @@ export function createSlashDispatch({
|
|
|
63
63
|
if (cmd !== 'usage') closeUsagePanel();
|
|
64
64
|
switch (cmd) {
|
|
65
65
|
case 'clear':
|
|
66
|
-
if (state.busy) {
|
|
67
|
-
store.pushNotice(
|
|
66
|
+
if (state.busy || state.commandBusy) {
|
|
67
|
+
store.pushNotice(`wait for the current session command to finish before /${rawName === 'new' ? 'new' : 'clear'}`, 'warn');
|
|
68
68
|
return false;
|
|
69
69
|
}
|
|
70
70
|
if (rawName === 'new') {
|
|
71
|
-
void store.newSession()
|
|
71
|
+
void store.newSession()
|
|
72
|
+
.then((created) => {
|
|
73
|
+
if (created === false) {
|
|
74
|
+
store.pushNotice('new session is already running', 'warn');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// Incremental Ink rendering can otherwise retain physical rows
|
|
78
|
+
// from the taller outgoing transcript on Windows Terminal.
|
|
79
|
+
store.forceRenderRepaint?.();
|
|
80
|
+
})
|
|
81
|
+
.catch((e) => store.pushNotice(`new session failed: ${e?.message || e}`, 'error'));
|
|
72
82
|
} else {
|
|
73
83
|
void store.clear().then(() => {}).catch((e) => store.pushNotice(`clear failed: ${e?.message || e}`, 'error'));
|
|
74
84
|
}
|
|
@@ -272,7 +272,7 @@ export function estimateTranscriptItemRows(item, columns, toolOutputExpanded, at
|
|
|
272
272
|
// marginTop={1} (AssistantMessage <Box>) + rendered body height.
|
|
273
273
|
return 1 + (item.streaming
|
|
274
274
|
? measureStreamingMarkdownRenderedRows(item.text, columns, item.id)
|
|
275
|
-
: measureMarkdownRenderedRows(item.text, columns));
|
|
275
|
+
: measureMarkdownRenderedRows(item.text, columns, { trimPartialFences: true }));
|
|
276
276
|
case 'tool': {
|
|
277
277
|
// Consecutive tool cards render attached (marginTop 0) — see
|
|
278
278
|
// TranscriptItem's attached={prevKind === 'tool'}.
|
|
@@ -354,12 +354,12 @@ export function carryTranscriptMeasuredRowsCache(prevItem, nextItem) {
|
|
|
354
354
|
export function measuredTranscriptRows(item, columns, toolOutputExpanded) {
|
|
355
355
|
if (!TRANSCRIPT_MEASURED_ROWS || !item) return null;
|
|
356
356
|
if (shouldSuppressFullyFailedToolItemCached(item)) return 0;
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
if (item.kind === 'assistant'
|
|
357
|
+
// Assistant Markdown has an exact deterministic renderer measurement for
|
|
358
|
+
// both streaming and settled states. Never mix Yoga's mounted-window height
|
|
359
|
+
// back into that model: a fenced script whose measured height alternated as
|
|
360
|
+
// the window corrected itself could otherwise trigger up to ten idle
|
|
361
|
+
// measure→reindex commits with no text change.
|
|
362
|
+
if (item.kind === 'assistant') return null;
|
|
363
363
|
const entry = transcriptMeasuredRowsCache.get(item);
|
|
364
364
|
if (!entry) return null;
|
|
365
365
|
if (entry.rows <= 0) return null;
|
|
@@ -371,17 +371,6 @@ export function measuredTranscriptRows(item, columns, toolOutputExpanded) {
|
|
|
371
371
|
|
|
372
372
|
const STREAMING_ROW_QUANTUM = 1;
|
|
373
373
|
|
|
374
|
-
// Bottom-pinned flag for the streaming-tail row estimate. Set once per hook
|
|
375
|
-
// render (use-transcript-window.mjs) BEFORE any tail resolution so every path
|
|
376
|
-
// that resolves the streaming tail height (tailSig, buildTranscriptRowIndex,
|
|
377
|
-
// incremental tail, transcriptStructureSignature) reads the same value and
|
|
378
|
-
// cannot diverge. Bottom-pinned → fold the live estimate in (geometry right on
|
|
379
|
-
// the first commit); away from bottom → d19cad1e defer-growth.
|
|
380
|
-
let streamingBottomPinned = false;
|
|
381
|
-
export function setStreamingBottomPinned(pinned) {
|
|
382
|
-
streamingBottomPinned = !!pinned;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
374
|
function assistantTextForStreamingRowEstimate(text) {
|
|
386
375
|
return streamingLayoutText(text);
|
|
387
376
|
}
|
|
@@ -438,49 +427,6 @@ export function streamingEstimateRows(item, columns, toolOutputExpanded) {
|
|
|
438
427
|
return rows;
|
|
439
428
|
}
|
|
440
429
|
|
|
441
|
-
// ── Same-frame streaming-tail growth compensation (scrolled-up only) ────────
|
|
442
|
-
// Away from bottom, estimateTranscriptItemRowsCached freezes the trailing
|
|
443
|
-
// streaming item at its last Yoga-confirmed height (defer-growth), so the row
|
|
444
|
-
// index / totalRows this render still counts the OLD height even though the
|
|
445
|
-
// mounted Box lays out at the live-grown height NOW. That one-frame mismatch is
|
|
446
|
-
// the newline judder: the physical content grew but the scroll offset did not,
|
|
447
|
-
// so the visible window shifts up until measuredRowsVersion bumps next frame.
|
|
448
|
-
// Report how many rows the mounted tail has grown beyond the height the row
|
|
449
|
-
// index used so the caller can fold it into the scroll offset for the SAME
|
|
450
|
-
// render. Returns { tailRows, delta }:
|
|
451
|
-
// tailRows — the height the row index used for the tail (for the in-slice gate)
|
|
452
|
-
// delta — live-estimate rows grown beyond that (0 when nothing to correct)
|
|
453
|
-
// Returns null when the tail is not a deferred streaming item (no confirmed
|
|
454
|
-
// measurement yet → the index already used the live estimate, so no mismatch).
|
|
455
|
-
export function streamingTailMountedGrowth(items, columns, toolOutputExpanded) {
|
|
456
|
-
const list = Array.isArray(items) ? items : [];
|
|
457
|
-
const tail = list.length > 0 ? list[list.length - 1] : null;
|
|
458
|
-
if (!tail || tail.kind !== 'assistant' || !tail.streaming) return null;
|
|
459
|
-
const toolExpanded = toolOutputExpanded ? 1 : 0;
|
|
460
|
-
const idEntry = streamingMeasuredRowsById.get(tail.id);
|
|
461
|
-
if (!idEntry || !(idEntry.rows > 0) || idEntry.columns !== columns || idEntry.toolExpanded !== toolExpanded) {
|
|
462
|
-
return null;
|
|
463
|
-
}
|
|
464
|
-
const live = streamingEstimateRows(tail, columns, toolOutputExpanded);
|
|
465
|
-
// Bottom-pinned already folds the live estimate into the row index, so there
|
|
466
|
-
// is nothing to compensate (delta 0); the caller also only invokes this while
|
|
467
|
-
// scrolled up, so this is a belt-and-suspenders guard.
|
|
468
|
-
if (streamingBottomPinned) return { tailRows: Math.max(idEntry.rows, live), delta: 0 };
|
|
469
|
-
// Away from bottom the row index used the real Yoga height (idEntry.rows). The
|
|
470
|
-
// physical mismatch to compensate is real_now − real_measured, but `live` (an
|
|
471
|
-
// estimate) vs idEntry.rows (measured) also carries the estimator's steady
|
|
472
|
-
// bias, which would keep delta positive AFTER measuredRowsVersion has already
|
|
473
|
-
// absorbed the real height — permanent over-compensation. Measure growth in
|
|
474
|
-
// the SAME (estimate) metric instead: delta = live_now − estimateAtMeasure,
|
|
475
|
-
// where estimateAtMeasure is the estimate for the exact text that was
|
|
476
|
-
// measured. The estimator bias cancels, so an unchanged tail gives delta 0
|
|
477
|
-
// (nothing left for the index to consume) and only genuine text growth since
|
|
478
|
-
// the last measurement compensates.
|
|
479
|
-
const baseline = idEntry.estimateRows > 0 ? idEntry.estimateRows : idEntry.rows;
|
|
480
|
-
const delta = Math.max(0, live - baseline);
|
|
481
|
-
return { tailRows: idEntry.rows, delta };
|
|
482
|
-
}
|
|
483
|
-
|
|
484
430
|
// Identity-keyed memo, same contract as transcriptVariantKeyCache: transcript
|
|
485
431
|
// items are replaced (never mutated) when their content changes, so the
|
|
486
432
|
// suppress decision — which re-parses tool args via isFullyFailedToolBatch —
|
|
@@ -499,41 +445,14 @@ export function estimateTranscriptItemRowsCached(item, columns, toolOutputExpand
|
|
|
499
445
|
if (!item) return Math.max(1, Math.ceil(estimateTranscriptItemRows(item, columns, toolOutputExpanded)));
|
|
500
446
|
if (shouldSuppressFullyFailedToolItemCached(item)) return 0;
|
|
501
447
|
if (item.kind === 'assistant' && item.streaming) {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
// — max(measuredFloor, liveEstimate) — so totalRows/scrollOffset are
|
|
509
|
-
// right this frame instead of committing the stale measured floor and
|
|
510
|
-
// growing a frame later when the harvest bumps measuredRowsVersion
|
|
511
|
-
// (the judder). The measured floor still guards against an estimate
|
|
512
|
-
// that undercounts the real wrap. Away from bottom the defer-growth
|
|
513
|
-
// path below is kept (its anchor-stability / perf intent).
|
|
514
|
-
return Math.max(idEntry.rows, streamingEstimateRows(item, columns, toolOutputExpanded));
|
|
515
|
-
}
|
|
516
|
-
// Defer-growth: while a confirmed (post-commit Yoga) measurement exists
|
|
517
|
-
// for this streaming id, THIS render keeps that value instead of
|
|
518
|
-
// folding in the freshly-grown estimate. Combining
|
|
519
|
-
// max(measuredFloor, liveEstimate) let per-flush row growth reach
|
|
520
|
-
// totalRows/scrollOffset a full frame BEFORE Yoga confirmed the real
|
|
521
|
-
// wrap — the estimate could over/undercount vs the real layout, and
|
|
522
|
-
// the next commit's harvest then corrected it, bouncing an anchored or
|
|
523
|
-
// bottom-pinned offset by the mismatch. Freezing at the last confirmed
|
|
524
|
-
// height means growth only reaches the row index on the SAME frame the
|
|
525
|
-
// harvest (use-transcript-window.mjs) writes the new measured value and
|
|
526
|
-
// bumps measuredRowsVersion — the "measured frame" consumes it, not the
|
|
527
|
-
// estimate frame.
|
|
528
|
-
return idEntry.rows;
|
|
529
|
-
}
|
|
530
|
-
if (idEntry) {
|
|
448
|
+
// One geometry authority for the active stream: the deterministic renderer
|
|
449
|
+
// model. Never feed a later Yoga measurement back into the row index; that
|
|
450
|
+
// estimate→measure→correct cycle produced the newline dip/snap. The
|
|
451
|
+
// high-water estimate is monotonic for this stream id, so code fences and
|
|
452
|
+
// stable/unstable chunk promotion cannot move the viewport backward.
|
|
453
|
+
if (streamingMeasuredRowsById.has(item.id)) {
|
|
531
454
|
streamingMeasuredRowsById.delete(item.id);
|
|
532
|
-
streamingEstimateHighWaterById.delete(item.id);
|
|
533
|
-
streamingTailEstimateById.delete(item.id);
|
|
534
455
|
}
|
|
535
|
-
// No confirmed measurement yet for this id (item just started streaming):
|
|
536
|
-
// nothing to defer against, so the first frame falls back to the estimate.
|
|
537
456
|
return streamingEstimateRows(item, columns, toolOutputExpanded);
|
|
538
457
|
}
|
|
539
458
|
if (item.kind === 'assistant') {
|
|
@@ -715,6 +634,11 @@ function appendTranscriptRow(prefix, tailValue) {
|
|
|
715
634
|
atIndex: (index) => index === prefix.length
|
|
716
635
|
? tailValue
|
|
717
636
|
: (Number(prefix[index]) || 0),
|
|
637
|
+
at: (index) => {
|
|
638
|
+
const resolved = index < 0 ? prefix.length + 1 + index : index;
|
|
639
|
+
if (resolved < 0 || resolved > prefix.length) return undefined;
|
|
640
|
+
return resolved === prefix.length ? tailValue : prefix[resolved];
|
|
641
|
+
},
|
|
718
642
|
slice: (start = 0, end = prefix.length + 1) => {
|
|
719
643
|
const values = [];
|
|
720
644
|
const lo = Math.max(0, start < 0 ? prefix.length + 1 + start : start);
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* its internal timers (animation interval, selection paint throttle, text
|
|
9
9
|
* capture defer, scroll coalescer).
|
|
10
10
|
*/
|
|
11
|
-
import { useCallback, useEffect, useRef } from 'react';
|
|
11
|
+
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
|
|
12
12
|
import { theme } from '../theme.mjs';
|
|
13
13
|
import {
|
|
14
14
|
SELECTION_PAINT_INTERVAL_MS,
|
|
@@ -41,6 +41,7 @@ export function useTranscriptScroll({
|
|
|
41
41
|
selectionTextRef,
|
|
42
42
|
}) {
|
|
43
43
|
const scrollAnimationRef = useRef(null);
|
|
44
|
+
const pendingReadbackRowsRef = useRef(0);
|
|
44
45
|
const selectionPaintRef = useRef({ t: 0, rect: null, pending: null, timer: null });
|
|
45
46
|
// Coalescer for edge-drag auto-scroll + wheel scroll deltas (see
|
|
46
47
|
// SCROLL_COALESCE_MS). Both call sites accumulate into pendingRows.
|
|
@@ -203,6 +204,7 @@ export function useTranscriptScroll({
|
|
|
203
204
|
const resetTranscriptScroll = useCallback(() => {
|
|
204
205
|
cancelTranscriptFollow();
|
|
205
206
|
stopSmoothScroll();
|
|
207
|
+
pendingReadbackRowsRef.current = 0;
|
|
206
208
|
scrollPositionRef.current = 0;
|
|
207
209
|
scrollTargetRef.current = 0;
|
|
208
210
|
transcriptAnchorRef.current = null;
|
|
@@ -218,6 +220,7 @@ export function useTranscriptScroll({
|
|
|
218
220
|
// perform the single bottom-follow when the transcript actually grows.
|
|
219
221
|
transcriptAnchorRef.current = null;
|
|
220
222
|
transcriptAnchorDirtyRef.current = false;
|
|
223
|
+
pendingReadbackRowsRef.current = 0;
|
|
221
224
|
followingRef.current = true;
|
|
222
225
|
stopSmoothScroll();
|
|
223
226
|
}, [stopSmoothScroll]);
|
|
@@ -441,6 +444,12 @@ export function useTranscriptScroll({
|
|
|
441
444
|
|
|
442
445
|
const scrollTranscriptRows = useCallback((deltaRows, options = {}) => {
|
|
443
446
|
const maxTarget = Math.max(0, Number(maxScrollRowsRef.current) || 0);
|
|
447
|
+
const publishedGeometry = transcriptGeomRef.current || {};
|
|
448
|
+
const geometryMax = Math.max(
|
|
449
|
+
0,
|
|
450
|
+
(Number(publishedGeometry.totalRows) || 0)
|
|
451
|
+
- Math.max(1, Number(publishedGeometry.viewRows) || 1),
|
|
452
|
+
);
|
|
444
453
|
const historyState = store.getState?.() || {};
|
|
445
454
|
const captureRestoreAnchor = () => {
|
|
446
455
|
const geom = transcriptGeomRef.current || {};
|
|
@@ -496,6 +505,20 @@ export function useTranscriptScroll({
|
|
|
496
505
|
const BOTTOM_SNAP_ROWS = 3;
|
|
497
506
|
if (deltaRows < 0 && target > 0 && target <= BOTTOM_SNAP_ROWS) target = 0;
|
|
498
507
|
const appliedDelta = target - scrollTargetRef.current;
|
|
508
|
+
const blockedReadbackIntent = deltaRows > 0
|
|
509
|
+
&& appliedDelta === 0
|
|
510
|
+
&& maxTarget === 0
|
|
511
|
+
&& geometryMax > 0;
|
|
512
|
+
if (blockedReadbackIntent) {
|
|
513
|
+
// A newly mounted row can hold the committed max at zero for one frame.
|
|
514
|
+
// Preserve the first upward wheel/keyboard intent instead of letting the
|
|
515
|
+
// next item/measurement commit infer bottom-follow from target=0 and yank
|
|
516
|
+
// the viewport back. The layout effect below applies it before paint as
|
|
517
|
+
// soon as the committed range becomes available.
|
|
518
|
+
pendingReadbackRowsRef.current += deltaRows;
|
|
519
|
+
} else if (deltaRows < 0 || appliedDelta !== 0) {
|
|
520
|
+
pendingReadbackRowsRef.current = 0;
|
|
521
|
+
}
|
|
499
522
|
// Before the scroll moves selected rows out of view, snapshot the rows
|
|
500
523
|
// currently under the selection into the stitch buffer keyed by the
|
|
501
524
|
// PRE-scroll offset (ref selection.ts captureScrolledRows). Runs for BOTH
|
|
@@ -509,7 +532,7 @@ export function useTranscriptScroll({
|
|
|
509
532
|
}
|
|
510
533
|
// Any manual wheel/keyboard scroll takes precedence over an in-flight
|
|
511
534
|
// transcript follow: drop the glide so the user's intent wins.
|
|
512
|
-
if (appliedDelta !== 0) cancelTranscriptFollow();
|
|
535
|
+
if (appliedDelta !== 0 || blockedReadbackIntent) cancelTranscriptFollow();
|
|
513
536
|
scrollTargetRef.current = target;
|
|
514
537
|
// A manual scroll moves the reading position. Capture the new reading anchor
|
|
515
538
|
// SYNCHRONOUSLY from the latest published geometry so the very next render
|
|
@@ -593,6 +616,13 @@ export function useTranscriptScroll({
|
|
|
593
616
|
setScrollOffset(Math.round(target));
|
|
594
617
|
}, [startSmoothScroll, stopSmoothScroll, paintSelectionRect, selectionPointAtCurrentScroll, withSelectionClip, cancelTranscriptFollow, buildSpanRect, harvestStitchRowsNow, cancelPendingSelectionPaint, flushPendingSelectionPaint]);
|
|
595
618
|
|
|
619
|
+
useLayoutEffect(() => {
|
|
620
|
+
const pendingRows = Math.max(0, Number(pendingReadbackRowsRef.current) || 0);
|
|
621
|
+
if (pendingRows <= 0 || Math.max(0, Number(maxScrollRowsRef.current) || 0) <= 0) return;
|
|
622
|
+
pendingReadbackRowsRef.current = 0;
|
|
623
|
+
scrollTranscriptRows(pendingRows);
|
|
624
|
+
});
|
|
625
|
+
|
|
596
626
|
// Leading-edge coalescer for edge-drag auto-scroll + wheel deltas: the first
|
|
597
627
|
// delta after an idle period flushes immediately (single wheel ticks/short
|
|
598
628
|
// drags stay responsive), while a flood of deltas within SCROLL_COALESCE_MS
|