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
4 changes: 3 additions & 1 deletion bun.lock
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "mux",
Expand Down Expand Up @@ -32,6 +31,7 @@
"ai": "^5.0.101",
"ai-tokenizer": "^1.0.4",
"chalk": "^5.6.2",
"comlink": "^4.4.2",
"commander": "^14.0.2",
"cors": "^2.8.5",
"crc-32": "^1.2.2",
Expand Down Expand Up @@ -1737,6 +1737,8 @@

"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],

"comlink": ["comlink@4.4.2", "", {}, "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g=="],

"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],

"commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
Expand Down
2 changes: 2 additions & 0 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ Avoid mock-heavy tests that verify implementation details rather than behavior.

- Parent components own localStorage interactions; children announce intent only.
- Use `usePersistedState`/`readPersistedState`/`updatePersistedState` helpersβ€”never call `localStorage` directly.
- When a component needs to read persisted state it doesn't own (to avoid layout flash), use `readPersistedState` in `useState` initializer: `useState(() => readPersistedState(key, default))`.
- When multiple components need the same persisted value, use `usePersistedState` with identical keys and `{ listener: true }` for automatic cross-component sync.
- Avoid destructuring props in function signatures; access via `props.field` to keep rename-friendly code.

## Module Imports
Expand Down
22 changes: 22 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,28 @@ export default defineConfig([
],
},
},
{
// Shiki must only be imported in the highlight worker to avoid blocking main thread
// Type-only imports are allowed (erased at compile time)
files: ["src/**/*.ts", "src/**/*.tsx"],
ignores: ["src/browser/workers/highlightWorker.ts"],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
{
group: ["shiki"],
importNamePattern: "^(?!type\\s)",
allowTypeImports: true,
message:
"Shiki must only be imported in highlightWorker.ts to avoid blocking the main thread. Use highlightCode() from highlightWorkerClient.ts instead.",
},
],
},
],
},
},
{
// Renderer process (frontend) architectural boundary - prevent Node.js API usage
files: ["src/**/*.ts", "src/**/*.tsx"],
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"ai": "^5.0.101",
"ai-tokenizer": "^1.0.4",
"chalk": "^5.6.2",
"comlink": "^4.4.2",
"commander": "^14.0.2",
"cors": "^2.8.5",
"crc-32": "^1.2.2",
Expand Down
15 changes: 11 additions & 4 deletions src/browser/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import { InterruptedBarrier } from "./Messages/ChatBarrier/InterruptedBarrier";
import { StreamingBarrier } from "./Messages/ChatBarrier/StreamingBarrier";
import { RetryBarrier } from "./Messages/ChatBarrier/RetryBarrier";
import { PinnedTodoList } from "./PinnedTodoList";
import { getAutoRetryKey, VIM_ENABLED_KEY } from "@/common/constants/storage";
import {
getAutoRetryKey,
VIM_ENABLED_KEY,
RIGHT_SIDEBAR_TAB_KEY,
} from "@/common/constants/storage";
import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults";
import { ChatInput, type ChatInputAPI } from "./ChatInput/index";
import { RightSidebar, type TabType } from "./RightSidebar";
Expand All @@ -22,7 +26,7 @@ import { ProviderOptionsProvider } from "@/browser/contexts/ProviderOptionsConte
import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
import { useAutoScroll } from "@/browser/hooks/useAutoScroll";
import { useOpenTerminal } from "@/browser/hooks/useOpenTerminal";
import { usePersistedState } from "@/browser/hooks/usePersistedState";
import { readPersistedState, usePersistedState } from "@/browser/hooks/usePersistedState";
import { useThinking } from "@/browser/contexts/ThinkingContext";
import {
useWorkspaceState,
Expand Down Expand Up @@ -75,8 +79,11 @@ const AIViewInner: React.FC<AIViewProps> = ({
const chatAreaRef = useRef<HTMLDivElement>(null);

// Track active tab to conditionally enable resize functionality
// RightSidebar notifies us of tab changes via onTabChange callback
const [activeTab, setActiveTab] = useState<TabType>("costs");
// Initialize from persisted value to avoid layout flash; RightSidebar owns the state
// and notifies us of changes via onTabChange callback
const [activeTab, setActiveTab] = useState<TabType>(() =>
readPersistedState<TabType>(RIGHT_SIDEBAR_TAB_KEY, "costs")
);

const isReviewTabActive = activeTab === "review";

Expand Down
37 changes: 4 additions & 33 deletions src/browser/components/Messages/MarkdownComponents.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
import type { ReactNode } from "react";
import React, { useState, useEffect } from "react";
import { Mermaid } from "./Mermaid";
import {
getShikiHighlighter,
mapToShikiLang,
SHIKI_DARK_THEME,
SHIKI_LIGHT_THEME,
} from "@/browser/utils/highlighting/shikiHighlighter";
import { useTheme } from "@/browser/contexts/ThemeContext";
import { highlightCode } from "@/browser/utils/highlighting/highlightWorkerClient";
import { extractShikiLines } from "@/browser/utils/highlighting/shiki-shared";
import { useTheme } from "@/browser/contexts/ThemeContext";
import { CopyButton } from "@/browser/components/ui/CopyButton";

interface CodeProps {
Expand Down Expand Up @@ -57,37 +52,13 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ code, language }) => {
useEffect(() => {
let cancelled = false;
const isLight = themeMode === "light" || themeMode === "solarized-light";
const shikiTheme = isLight ? SHIKI_LIGHT_THEME : SHIKI_DARK_THEME;
const theme = isLight ? "light" : "dark";

setHighlightedLines(null);

async function highlight() {
try {
const highlighter = await getShikiHighlighter();
const shikiLang = mapToShikiLang(language);

// Load language on-demand if not already loaded
// This is race-safe: concurrent loads of the same language are idempotent
const loadedLangs = highlighter.getLoadedLanguages();
if (!loadedLangs.includes(shikiLang)) {
try {
// TypeScript doesn't know shikiLang is valid, but we handle errors gracefully
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
await highlighter.loadLanguage(shikiLang as any);
} catch {
// Language not available in Shiki bundle - fall back to plain text
console.warn(`Language '${shikiLang}' not available in Shiki, using plain text`);
if (!cancelled) {
setHighlightedLines(null);
}
return;
}
}

const html = highlighter.codeToHtml(code, {
lang: shikiLang,
theme: shikiTheme,
});
const html = await highlightCode(code, language, theme);

if (!cancelled) {
const lines = extractShikiLines(html);
Expand Down
6 changes: 4 additions & 2 deletions src/browser/components/RightSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from "react";
import { RIGHT_SIDEBAR_TAB_KEY, RIGHT_SIDEBAR_COLLAPSED_KEY } from "@/common/constants/storage";
import { usePersistedState } from "@/browser/hooks/usePersistedState";
import { useWorkspaceUsage } from "@/browser/stores/WorkspaceStore";
import { useProviderOptions } from "@/browser/hooks/useProviderOptions";
Expand Down Expand Up @@ -100,7 +101,7 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
isCreating = false,
}) => {
// Global tab preference (not per-workspace)
const [selectedTab, setSelectedTab] = usePersistedState<TabType>("right-sidebar-tab", "costs");
const [selectedTab, setSelectedTab] = usePersistedState<TabType>(RIGHT_SIDEBAR_TAB_KEY, "costs");

// Trigger for focusing Review panel (preserves hunk selection)
const [focusTrigger, setFocusTrigger] = React.useState(0);
Expand Down Expand Up @@ -167,7 +168,7 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
// Persist collapsed state globally (not per-workspace) since chat area width is shared
// This prevents animation flash when switching workspaces - sidebar maintains its state
const [showCollapsed, setShowCollapsed] = usePersistedState<boolean>(
"right-sidebar:collapsed",
RIGHT_SIDEBAR_COLLAPSED_KEY,
false
);

Expand Down Expand Up @@ -297,6 +298,7 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
className="h-full"
>
<ReviewPanel
key={workspaceId}
workspaceId={workspaceId}
workspacePath={workspacePath}
onReviewNote={onReviewNote}
Expand Down
5 changes: 3 additions & 2 deletions src/browser/components/RightSidebar/CodeReview/HunkViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ export const HunkViewer = React.memo<HunkViewerProps>(

// Track if hunk is visible in viewport for lazy syntax highlighting
// Use ref for visibility to avoid re-renders when visibility changes
const isVisibleRef = React.useRef(true); // Start visible to avoid flash
const [isVisible, setIsVisible] = React.useState(true);
// Start as not visible to avoid eagerly highlighting off-screen hunks
const isVisibleRef = React.useRef(false);
const [isVisible, setIsVisible] = React.useState(false);

// Use IntersectionObserver to track visibility
React.useEffect(() => {
Expand Down
Loading