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.
Files changed (176) hide show
  1. package/dist/bin/commands/doctor.js +20 -0
  2. package/dist/bin/commands/doctor.js.map +1 -1
  3. package/dist/bin/commands/init.js +7 -0
  4. package/dist/bin/commands/init.js.map +1 -1
  5. package/dist/bin/commands/slack.js +47 -5
  6. package/dist/bin/commands/slack.js.map +1 -1
  7. package/dist/bin/commands/tui/slash-command-runner.js +11 -2
  8. package/dist/bin/commands/tui/slash-command-runner.js.map +1 -1
  9. package/dist/server.js +18 -2
  10. package/dist/server.js.map +1 -1
  11. package/dist/src/agent/error-classifier.js +11 -2
  12. package/dist/src/agent/error-classifier.js.map +1 -1
  13. package/dist/src/agent/events/claude.js +7 -1
  14. package/dist/src/agent/events/claude.js.map +1 -1
  15. package/dist/src/agent/events/fulltext-bound.js +32 -0
  16. package/dist/src/agent/events/fulltext-bound.js.map +1 -0
  17. package/dist/src/agent/events/grok.js +7 -1
  18. package/dist/src/agent/events/grok.js.map +1 -1
  19. package/dist/src/agent/events/helpers.js +17 -4
  20. package/dist/src/agent/events/helpers.js.map +1 -1
  21. package/dist/src/agent/lifecycle-handler.js +56 -5
  22. package/dist/src/agent/lifecycle-handler.js.map +1 -1
  23. package/dist/src/agent/pi-runtime.js +30 -1
  24. package/dist/src/agent/pi-runtime.js.map +1 -1
  25. package/dist/src/agent/spawn/exit-drain.js +96 -0
  26. package/dist/src/agent/spawn/exit-drain.js.map +1 -0
  27. package/dist/src/agent/spawn/line-buffer.js +26 -0
  28. package/dist/src/agent/spawn/line-buffer.js.map +1 -0
  29. package/dist/src/agent/spawn/process-kill.js +31 -0
  30. package/dist/src/agent/spawn/process-kill.js.map +1 -1
  31. package/dist/src/agent/spawn.js +80 -23
  32. package/dist/src/agent/spawn.js.map +1 -1
  33. package/dist/src/agent/watchdog.js +21 -5
  34. package/dist/src/agent/watchdog.js.map +1 -1
  35. package/dist/src/cli/commands.js +68 -15
  36. package/dist/src/cli/commands.js.map +1 -1
  37. package/dist/src/cli/opencodex-models.js +67 -14
  38. package/dist/src/cli/opencodex-models.js.map +1 -1
  39. package/dist/src/cli/registry-live.js +55 -2
  40. package/dist/src/cli/registry-live.js.map +1 -1
  41. package/dist/src/cli/registry.js +8 -2
  42. package/dist/src/cli/registry.js.map +1 -1
  43. package/dist/src/core/config.js +26 -1
  44. package/dist/src/core/config.js.map +1 -1
  45. package/dist/src/messaging/channel-health.js +6 -0
  46. package/dist/src/messaging/channel-health.js.map +1 -1
  47. package/dist/src/messaging/channel-validate.js +85 -0
  48. package/dist/src/messaging/channel-validate.js.map +1 -0
  49. package/dist/src/routes/link-preview.js +14 -0
  50. package/dist/src/routes/link-preview.js.map +1 -1
  51. package/dist/src/routes/messaging.js +9 -0
  52. package/dist/src/routes/messaging.js.map +1 -1
  53. package/dist/src/routes/system.js +5 -0
  54. package/dist/src/routes/system.js.map +1 -1
  55. package/dist/src/slack/api.js +32 -3
  56. package/dist/src/slack/api.js.map +1 -1
  57. package/dist/src/slack/bot.js +37 -1
  58. package/dist/src/slack/bot.js.map +1 -1
  59. package/dist/src/slack/events.js +23 -0
  60. package/dist/src/slack/events.js.map +1 -1
  61. package/dist/src/slack/hot-notify.js +33 -0
  62. package/dist/src/slack/hot-notify.js.map +1 -0
  63. package/dist/src/slack/manifest.js +2 -1
  64. package/dist/src/slack/manifest.js.map +1 -1
  65. package/dist/src/slack/progress.js +98 -0
  66. package/dist/src/slack/progress.js.map +1 -0
  67. package/dist/src/slack/send-only-client.js +3 -2
  68. package/dist/src/slack/send-only-client.js.map +1 -1
  69. package/dist/src/slack/slack-file.js +4 -2
  70. package/dist/src/slack/slack-file.js.map +1 -1
  71. package/package.json +3 -1
  72. package/public/css/sidebar.css +200 -0
  73. package/public/dist/assets/Agent-BjbZVZ9p.js +1 -0
  74. package/public/dist/assets/CodeCanvas-CbfPP7Ad.js +3 -0
  75. package/public/dist/assets/{DocPanel-DtYwv3n3.js → DocPanel-Dz-J_l5M.js} +1 -1
  76. package/public/dist/assets/{Heartbeat-vDGhPfct.js → Heartbeat-DDmZARWw.js} +1 -1
  77. package/public/dist/assets/MarkdownRenderer-CXrilOsG.js +15 -0
  78. package/public/dist/assets/MarkdownRenderer-DZVh_xX2.js +1 -0
  79. package/public/dist/assets/{Memory-B_UZ-iCA.js → Memory-CUbviW7N.js} +1 -1
  80. package/public/dist/assets/{MilkdownWysiwygEditor-BNoLGbFC.js → MilkdownWysiwygEditor-Em7iC6y9.js} +8 -8
  81. package/public/dist/assets/ModelProvider-ngg8xTuB.js +1 -0
  82. package/public/dist/assets/{agent-meta-C8DqYXM6.js → agent-meta-Y3DE6sxh.js} +1 -1
  83. package/public/dist/assets/app-BzD_ZLvt.js +55 -0
  84. package/public/dist/assets/{app-Nu2VPaO5.css → app-DX4e6isX.css} +1 -1
  85. package/public/dist/assets/bgtask-badge-Dqbp-Y4e.js +1 -0
  86. package/public/dist/assets/{bgtask-badge-DXca8E7c.js → bgtask-badge-qMkKOZx0.js} +1 -1
  87. package/public/dist/assets/{react-dom-AWKMTIe7.js → bounded-set-CFlZiEF4.js} +1 -1
  88. package/public/dist/assets/{constants-CSZ011-j.js → constants-Df7-7Rky.js} +1 -1
  89. package/public/dist/assets/{employees-BL4qMxJj.js → employees-BnobWM_2.js} +1 -1
  90. package/public/dist/assets/{iframe-renderer-DUI0LYBy.js → iframe-renderer-C5vRbbl9.js} +2 -2
  91. package/public/dist/assets/iframe-renderer-Df4KIVyx.js +1 -0
  92. package/public/dist/assets/manager-DaN201mN.js +13 -0
  93. package/public/dist/assets/memory-D-bULTHl.js +1 -0
  94. package/public/dist/assets/{memory-B_Y0L0KD.js → memory-DF4kPwwC.js} +1 -1
  95. package/public/dist/assets/{message-history-Dl8dJzez.js → message-history-C4WOisGx.js} +1 -1
  96. package/public/dist/assets/message-history-ZBoza6ew.js +1 -0
  97. package/public/dist/assets/{render-BI1aqKoc.js → render-DYX6GRQ0.js} +1 -1
  98. package/public/dist/assets/{settings-B0Sj4sUH.js → settings-DbJpGoaX.js} +1 -1
  99. package/public/dist/assets/settings-SreihUje.js +1 -0
  100. package/public/dist/assets/settings-core-B1Y5Nb4Z.js +1 -0
  101. package/public/dist/assets/settings-core-DobZWIZ7.js +140 -0
  102. package/public/dist/assets/{sidebar-DOR9fTgN.js → sidebar-Cp22g0Gv.js} +3 -3
  103. package/public/dist/assets/skills-DwPIP08e.js +1 -0
  104. package/public/dist/assets/{skills-wt0fkV9Y.js → skills-IGquen_j.js} +1 -1
  105. package/public/dist/assets/slash-commands-BBVfwcnW.js +1 -0
  106. package/public/dist/assets/{slash-commands-BZbqTg3v.js → slash-commands-ZRDOL99E.js} +1 -1
  107. package/public/dist/assets/{trace-drawer-kMYzaVFW.js → trace-drawer-D8To8fYt.js} +1 -1
  108. package/public/dist/assets/ui-CAicpUZc.js +1 -0
  109. package/public/dist/assets/{ui-D4D5HUfo.js → ui-Hx_mNoDf.js} +1 -1
  110. package/public/dist/assets/{wiki-link-resolver-BfNXN9px.js → wiki-link-resolver-DyFzBTIQ.js} +1 -1
  111. package/public/dist/index.html +90 -47
  112. package/public/dist/manager/index.html +1 -1
  113. package/public/index.html +88 -45
  114. package/public/js/constants.ts +50 -0
  115. package/public/js/features/channel-onboarding-flow.ts +197 -0
  116. package/public/js/features/channel-onboarding.ts +322 -0
  117. package/public/js/features/channel-setup-guide.ts +26 -0
  118. package/public/js/features/channel-setup-rules.ts +32 -0
  119. package/public/js/features/notifications.ts +38 -0
  120. package/public/js/features/settings-channel.ts +4 -0
  121. package/public/js/features/settings-core.ts +73 -9
  122. package/public/js/features/settings-discord.ts +2 -0
  123. package/public/js/features/settings-slack.ts +60 -2
  124. package/public/js/features/settings-telegram.ts +2 -0
  125. package/public/js/features/settings-types.ts +1 -1
  126. package/public/js/features/settings.ts +1 -1
  127. package/public/js/features/transport-status-row.ts +7 -3
  128. package/public/js/main.ts +5 -1
  129. package/public/locales/en.json +65 -3
  130. package/public/locales/ja.json +76 -3
  131. package/public/locales/ko.json +65 -3
  132. package/public/locales/zh.json +76 -3
  133. package/public/manager/src/SidebarRailRouter.tsx +9 -3
  134. package/public/manager/src/browser-panel/use-embedded-target-sync.ts +19 -1
  135. package/public/manager/src/code/CodeCanvas.tsx +14 -2
  136. package/public/manager/src/code/CodeTranscript.tsx +33 -9
  137. package/public/manager/src/code/CodeWorkbench.tsx +1 -1
  138. package/public/manager/src/code/code-event-dedupe.ts +27 -3
  139. package/public/manager/src/code/use-code-transcript-scroll.ts +28 -3
  140. package/public/manager/src/code/use-throttled-markdown.ts +75 -0
  141. package/public/manager/src/code/useCodeTranscriptVirtualRows.ts +19 -0
  142. package/public/manager/src/electron-metrics.tsx +7 -0
  143. package/public/manager/src/jaw-ceo/useJawCeo.ts +6 -1
  144. package/public/manager/src/jaw-ceo/useJawCeoVirtualTimeline.ts +5 -0
  145. package/public/manager/src/lib/bounded-set.ts +15 -0
  146. package/public/manager/src/lib/fnv1a.ts +14 -0
  147. package/public/manager/src/notes/rendering/CodeBlock.tsx +13 -4
  148. package/public/manager/src/notes/rendering/MarkdownRenderer.tsx +14 -2
  149. package/public/manager/src/notes/rendering/highlight-cache.ts +111 -0
  150. package/public/manager/src/notes/useNotesExternalSync.ts +6 -1
  151. package/public/manager/src/settings/pages/Agent.tsx +25 -4
  152. package/public/manager/src/settings/pages/components/PerCliRow.tsx +13 -5
  153. package/public/manager/src/settings/pages/components/agent/agent-meta.ts +98 -0
  154. package/scripts/bundle-sidecar.sh +8 -0
  155. package/scripts/check-electron-no-native.cjs +90 -4
  156. package/scripts/check-native-load.cjs +157 -0
  157. package/scripts/check-sidecar-smoke.mjs +151 -0
  158. package/scripts/ensure-native-modules.cjs +222 -18
  159. package/scripts/release-gates.mjs +29 -0
  160. package/public/dist/assets/Agent-BgkP4EnS.js +0 -1
  161. package/public/dist/assets/CodeCanvas-BZo3dohZ.js +0 -3
  162. package/public/dist/assets/MarkdownRenderer-CHacL2M_.js +0 -15
  163. package/public/dist/assets/MarkdownRenderer-DyrB9FbZ.js +0 -1
  164. package/public/dist/assets/ModelProvider-CgUvs6pE.js +0 -1
  165. package/public/dist/assets/app-BGIAy4he.js +0 -55
  166. package/public/dist/assets/bgtask-badge-GRSiNILQ.js +0 -1
  167. package/public/dist/assets/iframe-renderer-BUSk0Rw9.js +0 -1
  168. package/public/dist/assets/manager-B-gpf-3j.js +0 -13
  169. package/public/dist/assets/memory-CX6YahTc.js +0 -1
  170. package/public/dist/assets/message-history-Det7u76S.js +0 -1
  171. package/public/dist/assets/settings-C7NutP6X.js +0 -1
  172. package/public/dist/assets/settings-core-CyX-px6_.js +0 -100
  173. package/public/dist/assets/settings-core-DVpFmrhY.js +0 -1
  174. package/public/dist/assets/skills-BWhKreQ2.js +0 -1
  175. package/public/dist/assets/slash-commands-Di4NDA4w.js +0 -1
  176. 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
- <Suspense fallback={<span>{msg.text}</span>}>
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 = messages[index];
215
+ const msg = messagesRef.current[index];
193
216
  return msg ? transcriptMessageKey(msg, index) : `sending-${index}`;
194
- }, [messages]);
217
+ }, []);
195
218
  const estimateSize = useCallback((index: number): number => (
196
- estimateTranscriptRowSize(messages[index])
197
- ), [messages]);
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
- if (key) seen.add(key);
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.add(key);
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
- const toolContentSize = last.toolContent?.reduce((total, content) => total + JSON.stringify(content).length, 0) ?? 0;
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
- window.requestAnimationFrame(() => {
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
- window.setTimeout(() => {
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
- seenEventKeysRef.current.add(key);
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 { highlightCode } from './highlight-languages';
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
- export function CodeBlock(props: CodeBlockProps) {
10
+ function CodeBlockImpl(props: CodeBlockProps) {
11
11
  const [copied, setCopied] = useState(false);
12
- const result = highlightCode(props.code, props.language);
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
- export function MarkdownRenderer(props: MarkdownRendererProps) {
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
+ '&': '&amp;',
58
+ '<': '&lt;',
59
+ '>': '&gt;',
60
+ '"': '&quot;',
61
+ "'": '&#39;',
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
- const timer = setInterval(() => void poll(), POLL_INTERVAL_MS);
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.efforts;
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
- const nextEffort = efforts.includes(draft.effort) ? draft.effort : '';
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
- setRuntimeDraft({ ...draft, model: next });
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 });