Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"terser": "^5.48.0",
"typescript": "^5.6.3",
"vite": "^6.0.7"
},
Expand Down
65 changes: 60 additions & 5 deletions desktop/frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions desktop/frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
import { ShellExpandProvider, useShellExpand } from "./lib/shellExpand";
import {
Expand Down Expand Up @@ -506,6 +506,12 @@ export default function App() {
return completedToolsAfter >= 2 || finalAssistantAfter || readinessNoticeAfter || staleByTime;
}, [showTodos, state.items, state.running, todoEntry, todoNow]);

// useDeferredValue lets React prioritise Composer input (high-priority) over
// Transcript re-renders (low-priority) during streaming. When a keystroke
// and a transcript update collide, the keystroke is processed immediately
// and the transcript re-render is deferred to idle time.
const deferredItems = useDeferredValue(state.items);

useEffect(() => {
if (!pendingPlanRevision || state.running) return;
const text = pendingPlanRevision;
Expand Down Expand Up @@ -1354,7 +1360,7 @@ export default function App() {
</div>
) : (
<Transcript
items={state.items}
items={deferredItems}
live={state.live}
footerHeight={footerHeight}
onPrompt={send}
Expand Down
45 changes: 26 additions & 19 deletions desktop/frontend/src/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,34 +223,41 @@ export function Composer({

// --- slash argument completion ("/cmd <args>") --- mirrors the CLI: once past
// the command word, the backend suggests sub-commands (/skill → list/show/…,
// /mcp → add/remove, /model → refs). Fetched from app.SlashArgs.
// /mcp → add/remove, /model → refs). Fetched from app.SlashArgs. Debounced
// by 120ms so rapid typing doesn't flood the backend with IPC calls — the
// menu only updates after the user pauses.
const [argRes, setArgRes] = useState<SlashArgsResult | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (!text.startsWith("/") || !/\s/.test(text)) {
setArgRes(null);
return;
}
let live = true;
app
.SlashArgs(text)
.then((r) => {
if (!live) return;
// Drop suggestions that wouldn't change the input — the token is already
// fully typed (e.g. "/skill list" offering "list"). Otherwise the menu
// lingers on a complete command and Enter keeps "accepting" a no-op
// instead of sending. (Defense-in-depth: the backend filters these too.)
// r.items can arrive as null (an empty Go slice serializes to JSON null),
// so guard before filtering — otherwise the throw is swallowed and the
// stale menu from the previous keystroke lingers (the /skill list bug).
const items = asArray(r?.items);
const from = r?.from ?? 0;
const useful = items.filter((it) => text.slice(0, from) + it.insert !== text);
setArgRes(useful.length > 0 ? { items: useful, from } : null);
setActive(0);
})
.catch(() => {});
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
app
.SlashArgs(text)
.then((r) => {
if (!live) return;
// Drop suggestions that wouldn't change the input — the token is already
// fully typed (e.g. "/skill list" offering "list"). Otherwise the menu
// lingers on a complete command and Enter keeps "accepting" a no-op
// instead of sending. (Defense-in-depth: the backend filters these too.)
// r.items can arrive as null (an empty Go slice serializes to JSON null),
// so guard before filtering — otherwise the throw is swallowed and the
// stale menu from the previous keystroke lingers (the /skill list bug).
const items = asArray(r?.items);
const from = r?.from ?? 0;
const useful = items.filter((it) => text.slice(0, from) + it.insert !== text);
setArgRes(useful.length > 0 ? { items: useful, from } : null);
setActive(0);
})
.catch(() => {});
}, 120);
return () => {
live = false;
clearTimeout(debounceRef.current);
};
}, [text]);

Expand Down
9 changes: 8 additions & 1 deletion desktop/frontend/src/components/Markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useDeferredValue } from "react";
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import remarkGfm from "remark-gfm";
Expand Down Expand Up @@ -58,10 +59,16 @@ function normalizeMath(s: string): string {
}

export function Markdown({ text }: { text: string }) {
// useDeferredValue lets React prioritise the plain-text streaming frame over
// the expensive markdown parse+render. If a new text delta arrives while the
// markdown tree is still diffing, React can abort the in-progress render and
// start fresh with the latest text — keeping the UI responsive during the
// final markdown pass on long responses.
const deferred = useDeferredValue(text);
return (
<div className="md">
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]} components={components}>
{normalizeMath(text)}
{normalizeMath(deferred)}
</ReactMarkdown>
</div>
);
Expand Down
66 changes: 64 additions & 2 deletions desktop/frontend/src/lib/highlight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,77 @@ export function resolveLang(lang?: string): string {
return hljs.getLanguage(resolved) ? resolved : "";
}

// LRU cache for highlighted output. The same code block can re-render many
// times (Re-renders due to streaming updates that don't change this block,
// React's StrictMode double-invoke in dev, hover-reveals of the toolbar's
// child elements). highlight.highlight() is a real lexer walk that shows
// up in the profile for large blocks; a 200-entry LRU keyed on the
// resolved language plus a fast hash of the code keeps the steady-state
// cost at a Map.get(). The Map is a plain LRU, not a WeakMap, so a large
// (non-streaming) transcript will eventually evict; the size of 200 is
// chosen to cover the visible viewport plus a small overshoot — most
// transcripts re-render the same ~30 visible blocks.
const HL_CACHE_MAX = 200;

// djb2 hash for the cache key. We only use the hash inside the key
// (Map<number, string>), not as a security primitive; collisions are fine
// because we ALSO store the original code alongside the entry and verify
// before serving the cached value. The hash is what makes the key
// constant-time to compare for Map.get(); comparing the full source
// string would be O(n) on every render and dwarf the savings.
function hashCode(s: string): number {
let h = 5381;
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
return h;
}

interface CacheEntry {
code: string;
html: string;
}
const hlCache = new Map<number, CacheEntry>();

function cacheGet(code: string, lang: string): string | null {
const key = hashCode(lang + "\0" + code);
const e = hlCache.get(key);
if (!e) return null;
// Defend against the (rare) hash collision: the stored code must match
// the queried code exactly. We move the entry to the end of the Map to
// mark it most-recently-used.
if (e.code !== code) return null;
hlCache.delete(key);
hlCache.set(key, e);
return e.html;
}

function cachePut(code: string, lang: string, html: string): void {
const key = hashCode(lang + "\0" + code);
if (hlCache.has(key)) hlCache.delete(key); // refresh
hlCache.set(key, { code, html });
while (hlCache.size > HL_CACHE_MAX) {
// Map iteration is insertion-order; the first key is the oldest.
const oldest = hlCache.keys().next().value;
if (oldest === undefined) break;
hlCache.delete(oldest);
}
}

// highlightToHtml returns highlighted HTML (token <span>s) for the given code, or
// escaped plain text when the language is unknown. ignoreIllegals so partial /
// streaming snippets never throw.
// streaming snippets never throw. The LRU cache shaves the bulk of the work
// when a transcript re-renders the same blocks (most common: a streaming
// update changes the *next* block, not this one).
export function highlightToHtml(code: string, lang?: string): string {
const resolved = resolveLang(lang);
if (!resolved) return escapeHtml(code);
const cached = cacheGet(code, resolved);
if (cached !== null) return cached;
let html: string;
try {
return hljs.highlight(code, { language: resolved, ignoreIllegals: true }).value;
html = hljs.highlight(code, { language: resolved, ignoreIllegals: true }).value;
} catch {
return escapeHtml(code);
}
cachePut(code, resolved, html);
return html;
}
Loading
Loading