cli-jaw 2.2.11 → 2.2.13
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/dist/bin/commands/doctor.js +20 -0
- package/dist/bin/commands/doctor.js.map +1 -1
- package/dist/bin/commands/init.js +7 -0
- package/dist/bin/commands/init.js.map +1 -1
- package/dist/bin/commands/slack.js +47 -5
- package/dist/bin/commands/slack.js.map +1 -1
- package/dist/bin/commands/tui/slash-command-runner.js +11 -2
- package/dist/bin/commands/tui/slash-command-runner.js.map +1 -1
- package/dist/server.js +18 -2
- package/dist/server.js.map +1 -1
- package/dist/src/agent/error-classifier.js +11 -2
- package/dist/src/agent/error-classifier.js.map +1 -1
- package/dist/src/agent/events/claude.js +7 -1
- package/dist/src/agent/events/claude.js.map +1 -1
- package/dist/src/agent/events/fulltext-bound.js +32 -0
- package/dist/src/agent/events/fulltext-bound.js.map +1 -0
- package/dist/src/agent/events/grok.js +7 -1
- package/dist/src/agent/events/grok.js.map +1 -1
- package/dist/src/agent/events/helpers.js +17 -4
- package/dist/src/agent/events/helpers.js.map +1 -1
- package/dist/src/agent/lifecycle-handler.js +56 -5
- package/dist/src/agent/lifecycle-handler.js.map +1 -1
- package/dist/src/agent/pi-runtime.js +30 -1
- package/dist/src/agent/pi-runtime.js.map +1 -1
- package/dist/src/agent/spawn/exit-drain.js +96 -0
- package/dist/src/agent/spawn/exit-drain.js.map +1 -0
- package/dist/src/agent/spawn/line-buffer.js +26 -0
- package/dist/src/agent/spawn/line-buffer.js.map +1 -0
- package/dist/src/agent/spawn/process-kill.js +31 -0
- package/dist/src/agent/spawn/process-kill.js.map +1 -1
- package/dist/src/agent/spawn.js +80 -23
- package/dist/src/agent/spawn.js.map +1 -1
- package/dist/src/agent/watchdog.js +21 -5
- package/dist/src/agent/watchdog.js.map +1 -1
- package/dist/src/cli/commands.js +68 -15
- package/dist/src/cli/commands.js.map +1 -1
- package/dist/src/cli/opencodex-models.js +67 -14
- package/dist/src/cli/opencodex-models.js.map +1 -1
- package/dist/src/cli/registry-live.js +55 -2
- package/dist/src/cli/registry-live.js.map +1 -1
- package/dist/src/cli/registry.js +8 -2
- package/dist/src/cli/registry.js.map +1 -1
- package/dist/src/core/config.js +26 -1
- package/dist/src/core/config.js.map +1 -1
- package/dist/src/messaging/channel-health.js +6 -0
- package/dist/src/messaging/channel-health.js.map +1 -1
- package/dist/src/messaging/channel-validate.js +85 -0
- package/dist/src/messaging/channel-validate.js.map +1 -0
- package/dist/src/routes/link-preview.js +14 -0
- package/dist/src/routes/link-preview.js.map +1 -1
- package/dist/src/routes/messaging.js +9 -0
- package/dist/src/routes/messaging.js.map +1 -1
- package/dist/src/routes/system.js +5 -0
- package/dist/src/routes/system.js.map +1 -1
- package/dist/src/slack/api.js +32 -3
- package/dist/src/slack/api.js.map +1 -1
- package/dist/src/slack/bot.js +37 -1
- package/dist/src/slack/bot.js.map +1 -1
- package/dist/src/slack/events.js +23 -0
- package/dist/src/slack/events.js.map +1 -1
- package/dist/src/slack/hot-notify.js +33 -0
- package/dist/src/slack/hot-notify.js.map +1 -0
- package/dist/src/slack/manifest.js +2 -1
- package/dist/src/slack/manifest.js.map +1 -1
- package/dist/src/slack/progress.js +98 -0
- package/dist/src/slack/progress.js.map +1 -0
- package/dist/src/slack/send-only-client.js +3 -2
- package/dist/src/slack/send-only-client.js.map +1 -1
- package/dist/src/slack/slack-file.js +4 -2
- package/dist/src/slack/slack-file.js.map +1 -1
- package/package.json +3 -1
- package/public/css/sidebar.css +200 -0
- package/public/dist/assets/Agent-BjbZVZ9p.js +1 -0
- package/public/dist/assets/CodeCanvas-CbfPP7Ad.js +3 -0
- package/public/dist/assets/{DocPanel-DtYwv3n3.js → DocPanel-Dz-J_l5M.js} +1 -1
- package/public/dist/assets/{Heartbeat-vDGhPfct.js → Heartbeat-DDmZARWw.js} +1 -1
- package/public/dist/assets/MarkdownRenderer-CXrilOsG.js +15 -0
- package/public/dist/assets/MarkdownRenderer-DZVh_xX2.js +1 -0
- package/public/dist/assets/{Memory-B_UZ-iCA.js → Memory-CUbviW7N.js} +1 -1
- package/public/dist/assets/{MilkdownWysiwygEditor-BNoLGbFC.js → MilkdownWysiwygEditor-Em7iC6y9.js} +8 -8
- package/public/dist/assets/ModelProvider-ngg8xTuB.js +1 -0
- package/public/dist/assets/{agent-meta-C8DqYXM6.js → agent-meta-Y3DE6sxh.js} +1 -1
- package/public/dist/assets/app-BzD_ZLvt.js +55 -0
- package/public/dist/assets/{app-Nu2VPaO5.css → app-DX4e6isX.css} +1 -1
- package/public/dist/assets/bgtask-badge-Dqbp-Y4e.js +1 -0
- package/public/dist/assets/{bgtask-badge-DXca8E7c.js → bgtask-badge-qMkKOZx0.js} +1 -1
- package/public/dist/assets/{react-dom-AWKMTIe7.js → bounded-set-CFlZiEF4.js} +1 -1
- package/public/dist/assets/{constants-CSZ011-j.js → constants-Df7-7Rky.js} +1 -1
- package/public/dist/assets/{employees-BL4qMxJj.js → employees-BnobWM_2.js} +1 -1
- package/public/dist/assets/{iframe-renderer-DUI0LYBy.js → iframe-renderer-C5vRbbl9.js} +2 -2
- package/public/dist/assets/iframe-renderer-Df4KIVyx.js +1 -0
- package/public/dist/assets/manager-DaN201mN.js +13 -0
- package/public/dist/assets/memory-D-bULTHl.js +1 -0
- package/public/dist/assets/{memory-B_Y0L0KD.js → memory-DF4kPwwC.js} +1 -1
- package/public/dist/assets/{message-history-Dl8dJzez.js → message-history-C4WOisGx.js} +1 -1
- package/public/dist/assets/message-history-ZBoza6ew.js +1 -0
- package/public/dist/assets/{render-BI1aqKoc.js → render-DYX6GRQ0.js} +1 -1
- package/public/dist/assets/{settings-B0Sj4sUH.js → settings-DbJpGoaX.js} +1 -1
- package/public/dist/assets/settings-SreihUje.js +1 -0
- package/public/dist/assets/settings-core-B1Y5Nb4Z.js +1 -0
- package/public/dist/assets/settings-core-DobZWIZ7.js +140 -0
- package/public/dist/assets/{sidebar-DOR9fTgN.js → sidebar-Cp22g0Gv.js} +3 -3
- package/public/dist/assets/skills-DwPIP08e.js +1 -0
- package/public/dist/assets/{skills-wt0fkV9Y.js → skills-IGquen_j.js} +1 -1
- package/public/dist/assets/slash-commands-BBVfwcnW.js +1 -0
- package/public/dist/assets/{slash-commands-BZbqTg3v.js → slash-commands-ZRDOL99E.js} +1 -1
- package/public/dist/assets/{trace-drawer-kMYzaVFW.js → trace-drawer-D8To8fYt.js} +1 -1
- package/public/dist/assets/ui-CAicpUZc.js +1 -0
- package/public/dist/assets/{ui-D4D5HUfo.js → ui-Hx_mNoDf.js} +1 -1
- package/public/dist/assets/{wiki-link-resolver-BfNXN9px.js → wiki-link-resolver-DyFzBTIQ.js} +1 -1
- package/public/dist/index.html +90 -47
- package/public/dist/manager/index.html +1 -1
- package/public/index.html +88 -45
- package/public/js/constants.ts +50 -0
- package/public/js/features/channel-onboarding-flow.ts +197 -0
- package/public/js/features/channel-onboarding.ts +322 -0
- package/public/js/features/channel-setup-guide.ts +26 -0
- package/public/js/features/channel-setup-rules.ts +32 -0
- package/public/js/features/notifications.ts +38 -0
- package/public/js/features/settings-channel.ts +4 -0
- package/public/js/features/settings-core.ts +73 -9
- package/public/js/features/settings-discord.ts +2 -0
- package/public/js/features/settings-slack.ts +60 -2
- package/public/js/features/settings-telegram.ts +2 -0
- package/public/js/features/settings-types.ts +1 -1
- package/public/js/features/settings.ts +1 -1
- package/public/js/features/transport-status-row.ts +7 -3
- package/public/js/main.ts +5 -1
- package/public/locales/en.json +65 -3
- package/public/locales/ja.json +76 -3
- package/public/locales/ko.json +65 -3
- package/public/locales/zh.json +76 -3
- package/public/manager/src/SidebarRailRouter.tsx +9 -3
- package/public/manager/src/browser-panel/use-embedded-target-sync.ts +19 -1
- package/public/manager/src/code/CodeCanvas.tsx +14 -2
- package/public/manager/src/code/CodeTranscript.tsx +33 -9
- package/public/manager/src/code/CodeWorkbench.tsx +1 -1
- package/public/manager/src/code/code-event-dedupe.ts +27 -3
- package/public/manager/src/code/use-code-transcript-scroll.ts +28 -3
- package/public/manager/src/code/use-throttled-markdown.ts +75 -0
- package/public/manager/src/code/useCodeTranscriptVirtualRows.ts +19 -0
- package/public/manager/src/electron-metrics.tsx +7 -0
- package/public/manager/src/jaw-ceo/useJawCeo.ts +6 -1
- package/public/manager/src/jaw-ceo/useJawCeoVirtualTimeline.ts +5 -0
- package/public/manager/src/lib/bounded-set.ts +15 -0
- package/public/manager/src/lib/fnv1a.ts +14 -0
- package/public/manager/src/notes/rendering/CodeBlock.tsx +13 -4
- package/public/manager/src/notes/rendering/MarkdownRenderer.tsx +14 -2
- package/public/manager/src/notes/rendering/highlight-cache.ts +111 -0
- package/public/manager/src/notes/useNotesExternalSync.ts +6 -1
- package/public/manager/src/settings/pages/Agent.tsx +25 -4
- package/public/manager/src/settings/pages/components/PerCliRow.tsx +13 -5
- package/public/manager/src/settings/pages/components/agent/agent-meta.ts +98 -0
- package/scripts/bundle-sidecar.sh +8 -0
- package/scripts/check-electron-no-native.cjs +90 -4
- package/scripts/check-native-load.cjs +157 -0
- package/scripts/check-sidecar-smoke.mjs +151 -0
- package/scripts/ensure-native-modules.cjs +222 -18
- package/scripts/release-gates.mjs +29 -0
- package/public/dist/assets/Agent-BgkP4EnS.js +0 -1
- package/public/dist/assets/CodeCanvas-BZo3dohZ.js +0 -3
- package/public/dist/assets/MarkdownRenderer-CHacL2M_.js +0 -15
- package/public/dist/assets/MarkdownRenderer-DyrB9FbZ.js +0 -1
- package/public/dist/assets/ModelProvider-CgUvs6pE.js +0 -1
- package/public/dist/assets/app-BGIAy4he.js +0 -55
- package/public/dist/assets/bgtask-badge-GRSiNILQ.js +0 -1
- package/public/dist/assets/iframe-renderer-BUSk0Rw9.js +0 -1
- package/public/dist/assets/manager-B-gpf-3j.js +0 -13
- package/public/dist/assets/memory-CX6YahTc.js +0 -1
- package/public/dist/assets/message-history-Det7u76S.js +0 -1
- package/public/dist/assets/settings-C7NutP6X.js +0 -1
- package/public/dist/assets/settings-core-CyX-px6_.js +0 -100
- package/public/dist/assets/settings-core-DVpFmrhY.js +0 -1
- package/public/dist/assets/skills-BWhKreQ2.js +0 -1
- package/public/dist/assets/slash-commands-Di4NDA4w.js +0 -1
- package/public/dist/assets/ui-BghexWrB.js +0 -1
|
@@ -1,15 +1,33 @@
|
|
|
1
|
-
import { lazy, Suspense, useCallback, type KeyboardEvent, type RefObject } from 'react';
|
|
1
|
+
import { lazy, Suspense, useCallback, useRef, type KeyboardEvent, type RefObject } from 'react';
|
|
2
2
|
import type { ToolContent, TranscriptEntry } from './code-types';
|
|
3
3
|
import { useCodeTranscriptVirtualRows } from './useCodeTranscriptVirtualRows';
|
|
4
|
+
import { useThrottledMarkdown } from './use-throttled-markdown';
|
|
4
5
|
|
|
5
6
|
const MarkdownRenderer = lazy(() => import('../notes/rendering/MarkdownRenderer').then(m => ({ default: m.MarkdownRenderer })));
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* D1: assistant markdown is re-parsed in full on every update, so a per-token
|
|
10
|
+
* render is O(L²) in message length. Throttle the text adaptively (80ms under
|
|
11
|
+
* 2k chars up to 400ms past ~50k) before it reaches the markdown pipeline.
|
|
12
|
+
* Trailing-edge, so the final text always lands without a turn-done signal.
|
|
13
|
+
*/
|
|
14
|
+
function AssistantMarkdown({ text, onOpenLocalFile }: { text: string; onOpenLocalFile?: ((path: string) => void) | undefined }) {
|
|
15
|
+
const throttled = useThrottledMarkdown(text);
|
|
16
|
+
return (
|
|
17
|
+
<Suspense fallback={<span>{throttled}</span>}>
|
|
18
|
+
<MarkdownRenderer markdown={throttled} tableMode="linear" onLocalFileOpen={onOpenLocalFile} />
|
|
19
|
+
</Suspense>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
7
23
|
type CodeTranscriptProps = {
|
|
8
24
|
messages: TranscriptEntry[];
|
|
9
25
|
sending: boolean;
|
|
10
26
|
workingDir: string;
|
|
11
27
|
transcriptRef: RefObject<HTMLDivElement | null>;
|
|
12
28
|
onOpenLocalFile?: ((path: string) => void) | undefined;
|
|
29
|
+
/** Switching sessions must discard index-keyed row measurements (050 D2). */
|
|
30
|
+
sessionId?: string | null | undefined;
|
|
13
31
|
};
|
|
14
32
|
|
|
15
33
|
function renderToolContent(content: ToolContent, index: number) {
|
|
@@ -165,9 +183,7 @@ function renderCodeMessage(msg: TranscriptEntry, i: number, onOpenLocalFile?: ((
|
|
|
165
183
|
<span className="code-message-role">{msg.role === 'user' ? 'You' : 'JWC'}</span>
|
|
166
184
|
<div className="code-message-text">
|
|
167
185
|
{msg.role === 'assistant' ? (
|
|
168
|
-
<
|
|
169
|
-
<MarkdownRenderer markdown={msg.text} tableMode="linear" onLocalFileOpen={onOpenLocalFile} />
|
|
170
|
-
</Suspense>
|
|
186
|
+
<AssistantMarkdown text={msg.text} onOpenLocalFile={onOpenLocalFile} />
|
|
171
187
|
) : msg.text}
|
|
172
188
|
</div>
|
|
173
189
|
</>
|
|
@@ -185,18 +201,26 @@ function renderSendingMessage() {
|
|
|
185
201
|
);
|
|
186
202
|
}
|
|
187
203
|
|
|
188
|
-
export function CodeTranscript({ messages, sending, workingDir, transcriptRef, onOpenLocalFile }: CodeTranscriptProps) {
|
|
204
|
+
export function CodeTranscript({ messages, sending, workingDir, transcriptRef, onOpenLocalFile, sessionId }: CodeTranscriptProps) {
|
|
189
205
|
const showSending = sending && messages[messages.length - 1]?.role !== 'assistant';
|
|
190
206
|
const rowCount = messages.length + (showSending ? 1 : 0);
|
|
207
|
+
// D3: these two callbacks used to depend on `messages`. Every streaming
|
|
208
|
+
// token makes a new array identity, so both were re-created, which
|
|
209
|
+
// invalidated the virtualizer's layout effect and forced a full
|
|
210
|
+
// setOptions + _willUpdate + setSnapshot — a second render pass per token.
|
|
211
|
+
// Read through a ref instead so only `count` actually changes.
|
|
212
|
+
const messagesRef = useRef(messages);
|
|
213
|
+
messagesRef.current = messages;
|
|
191
214
|
const getItemKey = useCallback((index: number): string | number => {
|
|
192
|
-
const msg =
|
|
215
|
+
const msg = messagesRef.current[index];
|
|
193
216
|
return msg ? transcriptMessageKey(msg, index) : `sending-${index}`;
|
|
194
|
-
}, [
|
|
217
|
+
}, []);
|
|
195
218
|
const estimateSize = useCallback((index: number): number => (
|
|
196
|
-
estimateTranscriptRowSize(
|
|
197
|
-
), [
|
|
219
|
+
estimateTranscriptRowSize(messagesRef.current[index])
|
|
220
|
+
), []);
|
|
198
221
|
const virtual = useCodeTranscriptVirtualRows({
|
|
199
222
|
count: rowCount,
|
|
223
|
+
resetKey: sessionId ?? null,
|
|
200
224
|
scrollElementRef: transcriptRef,
|
|
201
225
|
getItemKey,
|
|
202
226
|
estimateSize,
|
|
@@ -95,7 +95,7 @@ export function CodeWorkbench(props: CodeWorkbenchProps) {
|
|
|
95
95
|
{props.transportState === 'reconnecting' ? 'Live updates reconnecting…' : 'Live updates disconnected — retrying.'}
|
|
96
96
|
</section>
|
|
97
97
|
)}
|
|
98
|
-
<CodeTranscript messages={props.messages} sending={props.sending} workingDir={props.codeWorkingDir} transcriptRef={props.transcriptRef} onOpenLocalFile={props.onOpenLocalFile} />
|
|
98
|
+
<CodeTranscript messages={props.messages} sending={props.sending} workingDir={props.codeWorkingDir} transcriptRef={props.transcriptRef} onOpenLocalFile={props.onOpenLocalFile} sessionId={props.activeSessionId} />
|
|
99
99
|
<CodePermissionQueue permissions={props.permissions} onAnswer={props.onPermissionAnswer} />
|
|
100
100
|
<div className="code-composer-dock">
|
|
101
101
|
<div className="code-composer-surface" aria-label="Code composer controls">
|
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
import type { CodeSessionReplayEvent } from './code-session-client';
|
|
2
2
|
import type { CodeEvent } from './useCodeEvents';
|
|
3
|
+
import { fnv1a32 } from '../lib/fnv1a';
|
|
4
|
+
import { addBounded } from '../lib/bounded-set';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* D3 (260803 unit, 050 phase): the dedupe key used to embed the FULL chunk
|
|
8
|
+
* text, so the set retained roughly a second copy of the whole transcript and
|
|
9
|
+
* grew for the session's lifetime.
|
|
10
|
+
*
|
|
11
|
+
* Collision domain, stated precisely: `stableId` is a message id or SSE event
|
|
12
|
+
* id, and a message id is minted once per TURN — every delta of one assistant
|
|
13
|
+
* turn shares it. So within a turn the text really was the discriminator, and
|
|
14
|
+
* hashing it does concentrate risk there. What bounds it is that the key also
|
|
15
|
+
* carries the text length, so only same-length chunks of the same turn can
|
|
16
|
+
* collide: roughly 10^3 same-length items against 2^32, i.e. ~10^-4.
|
|
17
|
+
*
|
|
18
|
+
* The consequence of a collision is a hard drop — `shouldDropDuplicateCodeChunk`
|
|
19
|
+
* returns before the append — so it is worth keeping that number honest rather
|
|
20
|
+
* than calling stableId "unique".
|
|
21
|
+
*/
|
|
22
|
+
const MAX_SEEN_KEYS = 2000;
|
|
3
23
|
|
|
4
24
|
type ChunkLikeEvent = Pick<CodeEvent, 'event' | 'sessionId' | 'update' | 'sseEventId'>;
|
|
5
25
|
export type AssistantChunkMergeAction = 'append' | 'drop' | 'replace';
|
|
@@ -29,14 +49,18 @@ export function codeChunkEventKey(event: ChunkLikeEvent, text: string): string |
|
|
|
29
49
|
: '';
|
|
30
50
|
const stableId = messageId ? `msg:${messageId}` : sseEventId ? `sse:${sseEventId}` : '';
|
|
31
51
|
if (!stableId) return null;
|
|
32
|
-
return `${event.sessionId ?? ''}:${event.event}:${stableId}:${text}`;
|
|
52
|
+
return `${event.sessionId ?? ''}:${event.event}:${stableId}:${text.length}:${fnv1a32(text)}`;
|
|
33
53
|
}
|
|
34
54
|
|
|
35
55
|
export function rememberCodeChunkEvents(seen: Set<string>, events: CodeSessionReplayEvent[]): void {
|
|
36
56
|
for (const event of events) {
|
|
37
57
|
const text = textFromCodeChunk(event.update);
|
|
38
58
|
const key = codeChunkEventKey(event, text);
|
|
39
|
-
|
|
59
|
+
// Seeding from a session replay must NOT evict its own earlier entries:
|
|
60
|
+
// a replay longer than the bound would drop keys for content already on
|
|
61
|
+
// screen, and the next matching live chunk would be appended again as
|
|
62
|
+
// "new". Size the bound to the replay, then let the live path trim.
|
|
63
|
+
if (key) addBounded(seen, key, Math.max(MAX_SEEN_KEYS, events.length));
|
|
40
64
|
}
|
|
41
65
|
}
|
|
42
66
|
|
|
@@ -44,7 +68,7 @@ export function shouldDropDuplicateCodeChunk(seen: Set<string>, event: ChunkLike
|
|
|
44
68
|
const key = codeChunkEventKey(event, text);
|
|
45
69
|
if (!key) return false;
|
|
46
70
|
if (seen.has(key)) return true;
|
|
47
|
-
seen
|
|
71
|
+
addBounded(seen, key, MAX_SEEN_KEYS);
|
|
48
72
|
return false;
|
|
49
73
|
}
|
|
50
74
|
|
|
@@ -12,16 +12,41 @@ export function useCodeTranscriptScroll(messages: TranscriptEntry[], sending: bo
|
|
|
12
12
|
const latestTranscriptFootprint = useMemo(() => {
|
|
13
13
|
const last = messages[messages.length - 1];
|
|
14
14
|
if (!last) return 'empty';
|
|
15
|
-
|
|
15
|
+
// D5: this used to JSON.stringify the whole toolContent on every
|
|
16
|
+
// `messages` identity change — i.e. every streaming token — which
|
|
17
|
+
// serializes hundreds of KB just to build a change-detection string.
|
|
18
|
+
// Length fields discriminate the same transitions at O(items) cost.
|
|
19
|
+
const toolContentSize = last.toolContent?.reduce((total, content) => {
|
|
20
|
+
const c = content as { text?: string; diff?: string; output?: string };
|
|
21
|
+
return total + (c.text?.length ?? 0) + (c.diff?.length ?? 0) + (c.output?.length ?? 0);
|
|
22
|
+
}, 0) ?? 0;
|
|
16
23
|
return `${messages.length}:${last.role}:${last.text.length}:${last.toolOutput?.length ?? 0}:${toolContentSize}:${last.toolStatus ?? ''}`;
|
|
17
24
|
}, [messages]);
|
|
18
25
|
|
|
26
|
+
// D4: coalesce every scroll request onto a single in-flight frame. The old
|
|
27
|
+
// shape queued a rAF *plus* a nested 80ms timeout per call, and callers fire
|
|
28
|
+
// per SSE event, so ~40 tok/s produced ~120 layout-forcing callbacks per
|
|
29
|
+
// second with two smooth-scroll animations fighting each other.
|
|
30
|
+
const pendingFrameRef = useRef<number | null>(null);
|
|
31
|
+
const settleTimerRef = useRef<number | null>(null);
|
|
32
|
+
|
|
33
|
+
useEffect(() => () => {
|
|
34
|
+
if (pendingFrameRef.current !== null) cancelAnimationFrame(pendingFrameRef.current);
|
|
35
|
+
if (settleTimerRef.current !== null) window.clearTimeout(settleTimerRef.current);
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
19
38
|
const scrollTranscriptToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
|
|
20
|
-
|
|
39
|
+
if (pendingFrameRef.current !== null) return;
|
|
40
|
+
pendingFrameRef.current = window.requestAnimationFrame(() => {
|
|
41
|
+
pendingFrameRef.current = null;
|
|
21
42
|
const node = transcriptRef.current;
|
|
22
43
|
if (!node) return;
|
|
23
44
|
node.scrollTo({ top: node.scrollHeight, behavior });
|
|
24
|
-
|
|
45
|
+
// One trailing correction, not one per call: content that lands
|
|
46
|
+
// after this frame still needs a final snap.
|
|
47
|
+
if (settleTimerRef.current !== null) window.clearTimeout(settleTimerRef.current);
|
|
48
|
+
settleTimerRef.current = window.setTimeout(() => {
|
|
49
|
+
settleTimerRef.current = null;
|
|
25
50
|
const latest = transcriptRef.current;
|
|
26
51
|
if (!latest) return;
|
|
27
52
|
latest.scrollTo({ top: latest.scrollHeight, behavior: 'auto' });
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* D1 (260803 unit, 020 phase) — adaptive streaming throttle.
|
|
5
|
+
*
|
|
6
|
+
* Rendering assistant markdown re-parses the FULL accumulated text on every
|
|
7
|
+
* update (remark → rehype → sanitize → KaTeX), so per-render cost grows with
|
|
8
|
+
* message length and a naive per-token render is O(L²) overall.
|
|
9
|
+
*
|
|
10
|
+
* The legacy UI reached this conclusion first; these constants are lifted
|
|
11
|
+
* verbatim from public/js/streaming-render.ts:18-30 rather than re-derived,
|
|
12
|
+
* so both renderers behave the same under load.
|
|
13
|
+
*/
|
|
14
|
+
const FULL_RENDER_THRESHOLD = 2000;
|
|
15
|
+
const THROTTLE_MS = 80;
|
|
16
|
+
const THROTTLE_MAX_MS = 400;
|
|
17
|
+
|
|
18
|
+
export function throttleMsFor(textLength: number): number {
|
|
19
|
+
if (textLength <= FULL_RENDER_THRESHOLD) return THROTTLE_MS;
|
|
20
|
+
const scaled = THROTTLE_MS * (textLength / FULL_RENDER_THRESHOLD);
|
|
21
|
+
return Math.min(THROTTLE_MAX_MS, Math.round(scaled));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Returns `text` throttled by the adaptive interval above. Growth is rate
|
|
26
|
+
* limited; a shrink or replacement (new message, session switch) applies
|
|
27
|
+
* immediately so the view never shows stale content from another turn.
|
|
28
|
+
*
|
|
29
|
+
* Always trailing-edge: the final value lands even if the last change arrives
|
|
30
|
+
* inside a throttle window, so no explicit "turn done" signal is needed.
|
|
31
|
+
*/
|
|
32
|
+
export function useThrottledMarkdown(text: string): string {
|
|
33
|
+
const [visible, setVisible] = useState(text);
|
|
34
|
+
const lastEmitRef = useRef(0);
|
|
35
|
+
const timerRef = useRef<number | null>(null);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
// Only a pure append is streaming growth. A prefix-only check would
|
|
39
|
+
// treat an in-place edit past the compared window as growth and hold
|
|
40
|
+
// stale text for up to one interval; comparing the full visible string
|
|
41
|
+
// is exact, and only runs on the fast path.
|
|
42
|
+
const isAppend = text.length >= visible.length && text.startsWith(visible);
|
|
43
|
+
if (!isAppend) {
|
|
44
|
+
if (timerRef.current !== null) {
|
|
45
|
+
window.clearTimeout(timerRef.current);
|
|
46
|
+
timerRef.current = null;
|
|
47
|
+
}
|
|
48
|
+
lastEmitRef.current = Date.now();
|
|
49
|
+
setVisible(text);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (text === visible) return;
|
|
53
|
+
|
|
54
|
+
const wait = throttleMsFor(text.length);
|
|
55
|
+
const since = Date.now() - lastEmitRef.current;
|
|
56
|
+
if (since >= wait) {
|
|
57
|
+
lastEmitRef.current = Date.now();
|
|
58
|
+
setVisible(text);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
|
|
63
|
+
timerRef.current = window.setTimeout(() => {
|
|
64
|
+
timerRef.current = null;
|
|
65
|
+
lastEmitRef.current = Date.now();
|
|
66
|
+
setVisible(text);
|
|
67
|
+
}, wait - since);
|
|
68
|
+
}, [text, visible]);
|
|
69
|
+
|
|
70
|
+
useEffect(() => () => {
|
|
71
|
+
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
return visible;
|
|
75
|
+
}
|
|
@@ -24,6 +24,12 @@ export type CodeTranscriptVirtualRows = {
|
|
|
24
24
|
|
|
25
25
|
export function useCodeTranscriptVirtualRows(args: {
|
|
26
26
|
count: number;
|
|
27
|
+
/**
|
|
28
|
+
* Changing this discards measurements. Normally the session id: row heights
|
|
29
|
+
* are keyed by index, so carrying them into a different transcript retains
|
|
30
|
+
* memory AND mis-measures the new rows.
|
|
31
|
+
*/
|
|
32
|
+
resetKey?: string | null;
|
|
27
33
|
scrollElementRef: RefObject<HTMLDivElement | null>;
|
|
28
34
|
getItemKey: (index: number) => string | number;
|
|
29
35
|
estimateSize?: (index: number) => number;
|
|
@@ -57,6 +63,19 @@ export function useCodeTranscriptVirtualRows(args: {
|
|
|
57
63
|
|
|
58
64
|
useEffect(() => virtualizer._didMount(), [virtualizer]);
|
|
59
65
|
|
|
66
|
+
// D2 (260803 unit, 050 phase): the instance lives in a ref that was never
|
|
67
|
+
// cleared, so measured row sizes survived session switches.
|
|
68
|
+
//
|
|
69
|
+
// Use the public `measure()` rather than emptying `measurementsCache`.
|
|
70
|
+
// The durable store is `itemSizeCache` (private, cleared only by
|
|
71
|
+
// `measure()`); `measurementsCache` is reassigned to a fresh lazy view on
|
|
72
|
+
// every recompute, so clearing it releases nothing.
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
virtualizer.measure();
|
|
75
|
+
}, [virtualizer, args.resetKey]);
|
|
76
|
+
|
|
77
|
+
useEffect(() => () => { virtualizerRef.current = null; }, []);
|
|
78
|
+
|
|
60
79
|
useLayoutEffect(() => {
|
|
61
80
|
virtualizer.setOptions({
|
|
62
81
|
...virtualizer.options,
|
|
@@ -77,6 +77,13 @@ export function ElectronMetricsPanel(props: ElectronMetricsPanelProps = {}) {
|
|
|
77
77
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
78
78
|
|
|
79
79
|
const tick = async () => {
|
|
80
|
+
// D4: skip the work while the window is hidden, but keep the timer
|
|
81
|
+
// chain alive so it resumes instantly on return. A poller that
|
|
82
|
+
// exists to REPORT memory pressure should not be creating idle cost.
|
|
83
|
+
if (typeof document !== 'undefined' && document.hidden) {
|
|
84
|
+
if (!cancelled) timer = setTimeout(tick, POLL_INTERVAL_MS);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
80
87
|
try {
|
|
81
88
|
const result = await fetchElectronMetrics();
|
|
82
89
|
if (cancelled) return;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { addBounded } from '../lib/bounded-set';
|
|
2
3
|
import type { ManagerEvent } from '../types';
|
|
3
4
|
import {
|
|
4
5
|
continueJawCeoCompletion,
|
|
@@ -90,6 +91,9 @@ function toJawCeoEvent(event: ManagerEvent): JawCeoManagerEvent | null {
|
|
|
90
91
|
};
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
/** Bound for the CEO console's event dedupe set (050 D3). */
|
|
95
|
+
const MAX_SEEN_EVENT_KEYS = 2000;
|
|
96
|
+
|
|
93
97
|
export type UseJawCeoArgs = {
|
|
94
98
|
selectedPort: number | null;
|
|
95
99
|
documentVisible: boolean;
|
|
@@ -162,7 +166,8 @@ export function useJawCeo(args: UseJawCeoArgs): JawCeoController {
|
|
|
162
166
|
if (!event) continue;
|
|
163
167
|
const key = eventKey(event);
|
|
164
168
|
if (seenEventKeysRef.current.has(key)) continue;
|
|
165
|
-
|
|
169
|
+
// Same unbounded-growth pattern as the code transcript (050 D3).
|
|
170
|
+
addBounded(seenEventKeysRef.current, key, MAX_SEEN_EVENT_KEYS);
|
|
166
171
|
freshEvents.push(event);
|
|
167
172
|
}
|
|
168
173
|
if (freshEvents.length === 0) return;
|
|
@@ -81,6 +81,11 @@ export function useJawCeoVirtualTimeline(args: {
|
|
|
81
81
|
|
|
82
82
|
useEffect(() => virtualizer._didMount(), [virtualizer]);
|
|
83
83
|
|
|
84
|
+
// D2 sibling (050 phase): release the instance on unmount. The CEO console
|
|
85
|
+
// has no per-session key to reset against — it renders one continuous
|
|
86
|
+
// timeline — so only the retention half applies here.
|
|
87
|
+
useEffect(() => () => { virtualizerRef.current = null; }, []);
|
|
88
|
+
|
|
84
89
|
useLayoutEffect(() => {
|
|
85
90
|
virtualizer.setOptions({
|
|
86
91
|
...virtualizer.options,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Insertion-ordered Set with a FIFO bound. `Set` already preserves insertion
|
|
3
|
+
* order, so the oldest entry is simply the first key — no side queue needed.
|
|
4
|
+
*
|
|
5
|
+
* Used for streaming dedupe sets, which otherwise grow for the lifetime of a
|
|
6
|
+
* session (260803 unit, 050 phase D3).
|
|
7
|
+
*/
|
|
8
|
+
export function addBounded(set: Set<string>, key: string, max: number): void {
|
|
9
|
+
set.add(key);
|
|
10
|
+
while (set.size > max) {
|
|
11
|
+
const oldest = set.values().next();
|
|
12
|
+
if (oldest.done) return;
|
|
13
|
+
set.delete(oldest.value);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FNV-1a 32-bit. Cheap, dependency-free, and good enough for cache/dedupe keys
|
|
3
|
+
* where the alternative is embedding a full copy of the hashed text.
|
|
4
|
+
* t3code uses the same function for its highlight cache keys
|
|
5
|
+
* (apps/web/src/components/ChatMarkdown.tsx:307).
|
|
6
|
+
*/
|
|
7
|
+
export function fnv1a32(input: string): string {
|
|
8
|
+
let hash = 0x811c9dc5;
|
|
9
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
10
|
+
hash ^= input.charCodeAt(i);
|
|
11
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
12
|
+
}
|
|
13
|
+
return hash.toString(36);
|
|
14
|
+
}
|
|
@@ -1,15 +1,22 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
1
|
+
import { memo, useMemo, useState } from 'react';
|
|
2
2
|
import { copyText } from '../../clipboard/copy-text';
|
|
3
|
-
import {
|
|
3
|
+
import { highlightCodeCached } from './highlight-cache';
|
|
4
4
|
|
|
5
5
|
type CodeBlockProps = {
|
|
6
6
|
code: string;
|
|
7
7
|
language?: string;
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
function CodeBlockImpl(props: CodeBlockProps) {
|
|
11
11
|
const [copied, setCopied] = useState(false);
|
|
12
|
-
|
|
12
|
+
// D2: highlighting used to run synchronously in the render body on every
|
|
13
|
+
// parent render — i.e. on every streaming token — and again on each
|
|
14
|
+
// virtualized remount. useMemo covers re-renders, the module cache covers
|
|
15
|
+
// remounts, and the cache applies the oversize cutoff.
|
|
16
|
+
const result = useMemo(
|
|
17
|
+
() => highlightCodeCached(props.code, props.language),
|
|
18
|
+
[props.code, props.language],
|
|
19
|
+
);
|
|
13
20
|
const label = result.language || 'text';
|
|
14
21
|
|
|
15
22
|
async function copyCode(): Promise<void> {
|
|
@@ -39,3 +46,5 @@ export function CodeBlock(props: CodeBlockProps) {
|
|
|
39
46
|
</div>
|
|
40
47
|
);
|
|
41
48
|
}
|
|
49
|
+
|
|
50
|
+
export const CodeBlock = memo(CodeBlockImpl);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Children, createElement, isValidElement, useMemo } from 'react';
|
|
1
|
+
import { Children, createElement, isValidElement, memo, useMemo } from 'react';
|
|
2
2
|
import type { MouseEvent } from 'react';
|
|
3
3
|
import type { ComponentProps, CSSProperties, ReactNode } from 'react';
|
|
4
4
|
import ReactMarkdown from 'react-markdown';
|
|
@@ -188,7 +188,7 @@ function splitChildrenWithLocalFileLinks(
|
|
|
188
188
|
return out;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
-
|
|
191
|
+
function MarkdownRendererImpl(props: MarkdownRendererProps) {
|
|
192
192
|
const renderedMarkdown = useMemo(
|
|
193
193
|
() => splitPreviewFrontmatter(props.markdown).body,
|
|
194
194
|
[props.markdown],
|
|
@@ -201,6 +201,11 @@ export function MarkdownRenderer(props: MarkdownRendererProps) {
|
|
|
201
201
|
onNavigate: props.onWikiLinkNavigate ?? NOOP_NAVIGATE,
|
|
202
202
|
}), [props.outgoing, props.notes, props.onWikiLinkNavigate]);
|
|
203
203
|
|
|
204
|
+
// D1: this whole block used to run in the render body, minting ~20 fresh
|
|
205
|
+
// component identities per render. React keys reconciliation off element
|
|
206
|
+
// type identity, so new types mean unmount + remount of the entire rendered
|
|
207
|
+
// subtree — on every streaming token, for the full accumulated text.
|
|
208
|
+
const components = useMemo<Components>(() => {
|
|
204
209
|
const inlineTransform = (children: ReactNode, keyPrefix: string): ReactNode => {
|
|
205
210
|
const withWikiLinks = splitChildrenWithWikiLinks(children, wikiCtx, keyPrefix);
|
|
206
211
|
return splitChildrenWithLocalFileLinks(withWikiLinks, props.onLocalFileOpen, keyPrefix);
|
|
@@ -304,6 +309,8 @@ export function MarkdownRenderer(props: MarkdownRendererProps) {
|
|
|
304
309
|
components.th = linearCellTransform('th');
|
|
305
310
|
components.td = linearCellTransform('td');
|
|
306
311
|
}
|
|
312
|
+
return components;
|
|
313
|
+
}, [wikiCtx, props.onLocalFileOpen, props.tableMode]);
|
|
307
314
|
|
|
308
315
|
return (
|
|
309
316
|
<ReactMarkdown
|
|
@@ -320,3 +327,8 @@ export function MarkdownRenderer(props: MarkdownRendererProps) {
|
|
|
320
327
|
</ReactMarkdown>
|
|
321
328
|
);
|
|
322
329
|
}
|
|
330
|
+
|
|
331
|
+
// D1: memo so an unchanged past message is not re-rendered when a sibling
|
|
332
|
+
// updates. Default shallow compare is correct here — every prop is either a
|
|
333
|
+
// primitive or a reference the caller already keeps stable.
|
|
334
|
+
export const MarkdownRenderer = memo(MarkdownRendererImpl);
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { highlightCode, normalizeCodeLanguage, type HighlightResult } from './highlight-languages';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* D2 (260803 unit, 020 phase): `highlightCode` is pure but was called straight
|
|
5
|
+
* from a render body, so a large block was re-tokenized on every parent render
|
|
6
|
+
* and again every time a virtualized row scrolled back into view.
|
|
7
|
+
*
|
|
8
|
+
* Bounds are deliberately smaller than t3code's 500 entries / 50MB
|
|
9
|
+
* (apps/web/src/components/ChatMarkdown.tsx:110): highlighting is one panel
|
|
10
|
+
* among many here and this phase exists to reduce RAM, not trade it.
|
|
11
|
+
*/
|
|
12
|
+
const MAX_ENTRIES = 200;
|
|
13
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Skip highlighting entirely past this size. hljs cost is superlinear in
|
|
17
|
+
* pathological input, and a minified bundle pasted into a message should not
|
|
18
|
+
* stall the main thread. t3code caps per line (tokenizeMaxLineLength: 1_000);
|
|
19
|
+
* hljs has no such knob, so we cap the whole block instead.
|
|
20
|
+
*/
|
|
21
|
+
const MAX_HIGHLIGHT_CHARS = 100_000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Reject pathological single lines before hljs ever sees them. A minified
|
|
25
|
+
* bundle pasted into a message is one enormous line, and tokenizer cost is
|
|
26
|
+
* superlinear there. t3code enforces the same idea with
|
|
27
|
+
* `tokenizeMaxLineLength: 1_000` (apps/web/src/components/DiffWorkerPoolProvider.tsx:75);
|
|
28
|
+
* hljs exposes no equivalent option, so we pre-check instead.
|
|
29
|
+
*/
|
|
30
|
+
const MAX_HIGHLIGHT_LINE_CHARS = 1_000;
|
|
31
|
+
|
|
32
|
+
type Entry = { result: HighlightResult; bytes: number };
|
|
33
|
+
|
|
34
|
+
// Map preserves insertion order, which gives LRU for free: delete + re-set on
|
|
35
|
+
// hit moves an entry to the newest position.
|
|
36
|
+
const cache = new Map<string, Entry>();
|
|
37
|
+
let cachedBytes = 0;
|
|
38
|
+
|
|
39
|
+
function estimateBytes(key: string, result: HighlightResult): number {
|
|
40
|
+
// The key embeds a full copy of the source, so counting only the html
|
|
41
|
+
// would undercount retention by roughly half.
|
|
42
|
+
return (key.length + result.html.length) * 2;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function evictUntilWithinBounds(): void {
|
|
46
|
+
while ((cache.size > MAX_ENTRIES || cachedBytes > MAX_BYTES) && cache.size > 0) {
|
|
47
|
+
const oldest = cache.keys().next();
|
|
48
|
+
if (oldest.done) break;
|
|
49
|
+
const entry = cache.get(oldest.value);
|
|
50
|
+
cache.delete(oldest.value);
|
|
51
|
+
if (entry) cachedBytes -= entry.bytes;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function escapeHtml(code: string): string {
|
|
56
|
+
return code.replace(/[&<>"']/g, value => ({
|
|
57
|
+
'&': '&',
|
|
58
|
+
'<': '<',
|
|
59
|
+
'>': '>',
|
|
60
|
+
'"': '"',
|
|
61
|
+
"'": ''',
|
|
62
|
+
})[value] ?? value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hasOverlongLine(code: string): boolean {
|
|
66
|
+
let lineStart = 0;
|
|
67
|
+
for (let i = 0; i < code.length; i += 1) {
|
|
68
|
+
if (code.charCodeAt(i) !== 10) continue;
|
|
69
|
+
if (i - lineStart > MAX_HIGHLIGHT_LINE_CHARS) return true;
|
|
70
|
+
lineStart = i + 1;
|
|
71
|
+
}
|
|
72
|
+
return code.length - lineStart > MAX_HIGHLIGHT_LINE_CHARS;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function highlightCodeCached(code: string, language?: string): HighlightResult {
|
|
76
|
+
// Normalize first: `TypeScript`, `typescript`, and `language-typescript`
|
|
77
|
+
// all resolve to one result, so they must share one cache entry.
|
|
78
|
+
const normalized = normalizeCodeLanguage(language);
|
|
79
|
+
|
|
80
|
+
if (code.length > MAX_HIGHLIGHT_CHARS || hasOverlongLine(code)) {
|
|
81
|
+
return { html: escapeHtml(code), language: normalized, highlighted: false };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const key = `${normalized}\u0000${code}`;
|
|
85
|
+
const hit = cache.get(key);
|
|
86
|
+
if (hit) {
|
|
87
|
+
cache.delete(key);
|
|
88
|
+
cache.set(key, hit);
|
|
89
|
+
return hit.result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const result = highlightCode(code, language);
|
|
93
|
+
const bytes = estimateBytes(key, result);
|
|
94
|
+
// A single oversized entry would evict everything else for no benefit.
|
|
95
|
+
if (bytes <= MAX_BYTES) {
|
|
96
|
+
cache.set(key, { result, bytes });
|
|
97
|
+
cachedBytes += bytes;
|
|
98
|
+
evictUntilWithinBounds();
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Test seam. */
|
|
104
|
+
export function __resetHighlightCache(): void {
|
|
105
|
+
cache.clear();
|
|
106
|
+
cachedBytes = 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function __highlightCacheStats(): { entries: number; bytes: number } {
|
|
110
|
+
return { entries: cache.size, bytes: cachedBytes };
|
|
111
|
+
}
|
|
@@ -31,7 +31,12 @@ export function useNotesExternalSync(active: boolean): void {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
void poll();
|
|
34
|
-
|
|
34
|
+
// D4: a hidden window cannot show an external change, so polling for
|
|
35
|
+
// one is pure idle cost. Matches useRemindersFeed's guard.
|
|
36
|
+
const timer = setInterval(() => {
|
|
37
|
+
if (typeof document !== 'undefined' && document.hidden) return;
|
|
38
|
+
void poll();
|
|
39
|
+
}, POLL_INTERVAL_MS);
|
|
35
40
|
return () => { cancelled = true; clearInterval(timer); };
|
|
36
41
|
}, [active]);
|
|
37
42
|
}
|
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
optionList,
|
|
18
18
|
runtimeEffortFor,
|
|
19
19
|
runtimeModelFor,
|
|
20
|
+
coerceEffortForModel,
|
|
21
|
+
effortChoicesForModel,
|
|
20
22
|
type ActiveOverride,
|
|
21
23
|
type CliMeta,
|
|
22
24
|
type PerCliEntry,
|
|
@@ -193,8 +195,8 @@ export default function Agent({ port, client, dirty, registerSave }: SettingsPag
|
|
|
193
195
|
? optionList(activeMeta.modelsByProvider?.[activeProvider] || activeMeta.models, draft.model)
|
|
194
196
|
: optionList(activeMeta.models, draft.model);
|
|
195
197
|
const activeEffortOptions = hasProviders
|
|
196
|
-
? (activeMeta.effortsByProvider?.[activeProvider] || activeMeta.efforts)
|
|
197
|
-
: activeMeta.
|
|
198
|
+
? effortChoicesForModel(activeMeta, draft.model, activeMeta.effortsByProvider?.[activeProvider] || activeMeta.efforts, activeProvider)
|
|
199
|
+
: effortChoicesForModel(activeMeta, draft.model);
|
|
198
200
|
const workingDirError = draft.workingDir.trim() ? null : 'Required';
|
|
199
201
|
|
|
200
202
|
if (state.kind === 'loading') return <PageLoading />;
|
|
@@ -248,7 +250,9 @@ export default function Agent({ port, client, dirty, registerSave }: SettingsPag
|
|
|
248
250
|
const models = activeMeta.modelsByProvider?.[next] || [];
|
|
249
251
|
const efforts = activeMeta.effortsByProvider?.[next] || [];
|
|
250
252
|
const nextModel = models.includes(draft.model) ? draft.model : (models[0] || '');
|
|
251
|
-
|
|
253
|
+
// Resolve against the NEW provider+model pair: the same model
|
|
254
|
+
// id can allow different efforts per provider.
|
|
255
|
+
const nextEffort = coerceEffortForModel(activeMeta, nextModel, draft.effort, efforts, next);
|
|
252
256
|
setRuntimeDraft({ ...draft, provider: next, model: nextModel, effort: nextEffort });
|
|
253
257
|
setEntry(`perCli.${draft.cli}.provider`, {
|
|
254
258
|
value: next,
|
|
@@ -267,12 +271,29 @@ export default function Agent({ port, client, dirty, registerSave }: SettingsPag
|
|
|
267
271
|
});
|
|
268
272
|
}}
|
|
269
273
|
onModelChange={(next) => {
|
|
270
|
-
|
|
274
|
+
// Efforts are per-model on a live opencodex catalog, so a
|
|
275
|
+
// model switch can strand an effort the new model does not
|
|
276
|
+
// support — and that value would reach the wire verbatim.
|
|
277
|
+
const nextEffort = coerceEffortForModel(
|
|
278
|
+
activeMeta,
|
|
279
|
+
next,
|
|
280
|
+
draft.effort,
|
|
281
|
+
hasProviders ? (activeMeta.effortsByProvider?.[activeProvider] || activeMeta.efforts) : undefined,
|
|
282
|
+
hasProviders ? activeProvider : undefined,
|
|
283
|
+
);
|
|
284
|
+
setRuntimeDraft({ ...draft, model: next, effort: nextEffort });
|
|
271
285
|
setEntry(`activeOverrides.${draft.cli}.model`, {
|
|
272
286
|
value: next,
|
|
273
287
|
original: runtimeModelFor(draft.cli, perCli, activeOverrides),
|
|
274
288
|
valid: next.trim().length > 0,
|
|
275
289
|
});
|
|
290
|
+
if (nextEffort !== draft.effort) {
|
|
291
|
+
setEntry(`activeOverrides.${draft.cli}.effort`, {
|
|
292
|
+
value: nextEffort,
|
|
293
|
+
original: runtimeEffortFor(draft.cli, perCli, activeOverrides),
|
|
294
|
+
valid: true,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
276
297
|
}}
|
|
277
298
|
onEffortChange={(next) => {
|
|
278
299
|
setRuntimeDraft({ ...draft, effort: next });
|