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
55 changes: 54 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import NewWorkspaceModal from "./components/NewWorkspaceModal";
import { AIView } from "./components/AIView";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sidenote: App.tsx is becoming a monster but that can be handled elsewhere

import { ErrorBoundary } from "./components/ErrorBoundary";
import { TipsCarousel } from "./components/TipsCarousel";
import { usePersistedState } from "./hooks/usePersistedState";
import { usePersistedState, updatePersistedState } from "./hooks/usePersistedState";
import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds";
import { useProjectManagement } from "./hooks/useProjectManagement";
import { useWorkspaceManagement } from "./hooks/useWorkspaceManagement";
Expand All @@ -21,6 +21,12 @@ import { CommandPalette } from "./components/CommandPalette";
import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sources";
import { useGitStatus } from "./hooks/useGitStatus";

import type { ThinkingLevel } from "./types/thinking";
import { CUSTOM_EVENTS } from "./constants/events";
import { getThinkingLevelKey } from "./constants/storage";

const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"];

// Global Styles with nice fonts
const globalStyles = css`
* {
Expand Down Expand Up @@ -277,6 +283,51 @@ function AppInner() {
close: closeCommandPalette,
} = useCommandRegistry();

const getThinkingLevelForWorkspace = useCallback((workspaceId: string): ThinkingLevel => {
if (!workspaceId) {
return "off";
}

if (typeof window === "undefined" || !window.localStorage) {
return "off";
}

try {
const key = getThinkingLevelKey(workspaceId);
const stored = window.localStorage.getItem(key);
if (!stored || stored === "undefined") {
return "off";
}
const parsed = JSON.parse(stored) as ThinkingLevel;
return THINKING_LEVELS.includes(parsed) ? parsed : "off";
} catch (error) {
console.warn("Failed to read thinking level", error);
return "off";
}
}, []);

const setThinkingLevelFromPalette = useCallback((workspaceId: string, level: ThinkingLevel) => {
if (!workspaceId) {
return;
}

const normalized = THINKING_LEVELS.includes(level) ? level : "off";
const key = getThinkingLevelKey(workspaceId);

// Use the utility function which handles localStorage and event dispatch
// ThinkingProvider will pick this up via its listener
updatePersistedState(key, normalized);

// Dispatch toast notification event for UI feedback
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, {
detail: { workspaceId, level: normalized },
})
);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here re coupling

}, []);

const registerParamsRef = useRef<BuildSourcesParams | null>(null);

const openNewWorkspaceFromPalette = useCallback(
Expand Down Expand Up @@ -343,6 +394,8 @@ function AppInner() {
workspaceMetadata,
selectedWorkspace,
streamingModels,
getThinkingLevel: getThinkingLevelForWorkspace,
onSetThinkingLevel: setThinkingLevelFromPalette,
onOpenNewWorkspaceModal: openNewWorkspaceFromPalette,
onCreateWorkspace: createWorkspaceFromPalette,
onSelectWorkspace: selectWorkspaceFromPalette,
Expand Down
41 changes: 37 additions & 4 deletions src/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ChatToggles } from "./ChatToggles";
import { use1MContext } from "@/hooks/use1MContext";
import { modeToToolPolicy } from "@/utils/ui/modeUtils";
import { ToggleGroup } from "./ToggleGroup";
import { CUSTOM_EVENTS } from "@/constants/events";
import type { UIMode } from "@/types/mode";
import {
getSlashCommandSuggestions,
Expand All @@ -25,6 +26,8 @@ import { ModelSelector, type ModelSelectorRef } from "./ModelSelector";
import { useModelLRU } from "@/hooks/useModelLRU";
import { VimTextArea } from "./VimTextArea";

import type { ThinkingLevel } from "@/types/thinking";

const InputSection = styled.div`
position: relative;
padding: 5px 15px 15px 15px; /* Reduced top padding from 15px to 5px */
Expand Down Expand Up @@ -399,8 +402,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({
setInput(detail.text);
setTimeout(() => inputRef.current?.focus(), 0);
};
window.addEventListener("cmux:insertToChatInput", handler as EventListener);
return () => window.removeEventListener("cmux:insertToChatInput", handler as EventListener);
window.addEventListener(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, handler as EventListener);
return () =>
window.removeEventListener(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, handler as EventListener);
}, [setInput]);

// Allow external components to open the Model Selector
Expand All @@ -409,10 +413,39 @@ export const ChatInput: React.FC<ChatInputProps> = ({
// Open the inline ModelSelector and let it take focus itself
modelSelectorRef.current?.open();
};
window.addEventListener("cmux:openModelSelector", handler as EventListener);
return () => window.removeEventListener("cmux:openModelSelector", handler as EventListener);
window.addEventListener(CUSTOM_EVENTS.OPEN_MODEL_SELECTOR, handler as EventListener);
return () =>
window.removeEventListener(CUSTOM_EVENTS.OPEN_MODEL_SELECTOR, handler as EventListener);
}, []);

// Show toast when thinking level is changed via command palette
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ workspaceId: string; level: ThinkingLevel }>).detail;
if (!detail || detail.workspaceId !== workspaceId || !detail.level) {
return;
}

const level = detail.level;
const levelDescriptions: Record<ThinkingLevel, string> = {
off: "Off — fastest responses",
low: "Low — adds light reasoning",
medium: "Medium — balanced reasoning",
high: "High — maximum reasoning depth",
};

setToast({
id: Date.now().toString(),
type: "success",
message: `Thinking effort set to ${levelDescriptions[level]}`,
});
};

window.addEventListener(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, handler as EventListener);
return () =>
window.removeEventListener(CUSTOM_EVENTS.THINKING_LEVEL_TOAST, handler as EventListener);
}, [workspaceId, setToast]);

// Handle command selection
const handleCommandSelect = useCallback(
(suggestion: SlashSuggestion) => {
Expand Down
5 changes: 4 additions & 1 deletion src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useCommandRegistry } from "@/contexts/CommandRegistryContext";
import type { CommandAction } from "@/contexts/CommandRegistryContext";
import { formatKeybind, KEYBINDS, isEditableElement, matchesKeybind } from "@/utils/ui/keybinds";
import { getSlashCommandSuggestions } from "@/utils/slashCommands/suggestions";
import { CUSTOM_EVENTS } from "@/constants/events";

const Overlay = styled.div`
position: fixed;
Expand Down Expand Up @@ -254,7 +255,9 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({ getSlashContext
shortcutHint: `${formatKeybind(KEYBINDS.SEND_MESSAGE)} to insert`,
run: () => {
const text = s.replacement;
window.dispatchEvent(new CustomEvent("cmux:insertToChatInput", { detail: { text } }));
window.dispatchEvent(
new CustomEvent(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, { detail: { text } })
);
},
})),
},
Expand Down
30 changes: 30 additions & 0 deletions src/constants/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Custom Event Constants
* These are window-level custom events used for cross-component communication
*/

export const CUSTOM_EVENTS = {
/**
* Event to show a toast notification when thinking level changes
* Detail: { workspaceId: string, level: ThinkingLevel }
*/
THINKING_LEVEL_TOAST: "cmux:thinkingLevelToast",

/**
* Event to insert text into the chat input
* Detail: { text: string }
*/
INSERT_TO_CHAT_INPUT: "cmux:insertToChatInput",

/**
* Event to open the model selector
* No detail
*/
OPEN_MODEL_SELECTOR: "cmux:openModelSelector",
} as const;

/**
* Helper to create a storage change event name for a specific key
* Used by usePersistedState for same-tab synchronization
*/
export const getStorageChangeEvent = (key: string): string => `storage-change:${key}`;
10 changes: 10 additions & 0 deletions src/constants/storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* LocalStorage Key Constants and Helpers
* These keys are used for persisting state in localStorage
*/

/**
* Helper to create a thinking level storage key for a workspace
* Format: "thinkingLevel:{workspaceId}"
*/
export const getThinkingLevelKey = (workspaceId: string): string => `thinkingLevel:${workspaceId}`;
7 changes: 5 additions & 2 deletions src/contexts/ThinkingContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import React, { createContext, useContext } from "react";
import type { ThinkingLevel } from "@/types/thinking";
import { usePersistedState } from "@/hooks/usePersistedState";
import { getThinkingLevelKey } from "@/constants/storage";

interface ThinkingContextType {
thinkingLevel: ThinkingLevel;
Expand All @@ -16,9 +17,11 @@ interface ThinkingProviderProps {
}

export const ThinkingProvider: React.FC<ThinkingProviderProps> = ({ workspaceId, children }) => {
const key = getThinkingLevelKey(workspaceId);
const [thinkingLevel, setThinkingLevel] = usePersistedState<ThinkingLevel>(
`thinkingLevel:${workspaceId}`,
"off"
key,
"off",
{ listener: true } // Listen for changes from command palette and other sources
);

return (
Expand Down
38 changes: 35 additions & 3 deletions src/hooks/usePersistedState.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,39 @@
import type { Dispatch, SetStateAction } from "react";
import { useState, useCallback, useEffect } from "react";
import { getStorageChangeEvent } from "@/constants/events";

type SetValue<T> = T | ((prev: T) => T);

/**
* Update a persisted state value from outside the hook.
* This is useful when you need to update state from a different component/context
* that doesn't have access to the setter (e.g., command palette updating workspace state).
*
* @param key - The same localStorage key used in usePersistedState
* @param value - The new value to set
*/
export function updatePersistedState<T>(key: string, value: T): void {
if (typeof window === "undefined" || !window.localStorage) {
return;
}

try {
if (value === undefined || value === null) {
window.localStorage.removeItem(key);
} else {
window.localStorage.setItem(key, JSON.stringify(value));
}

// Dispatch custom event for same-tab synchronization
const customEvent = new CustomEvent(getStorageChangeEvent(key), {
detail: { key, newValue: value },
});
window.dispatchEvent(customEvent);
} catch (error) {
console.warn(`Error writing to localStorage key "${key}":`, error);
}
}

interface UsePersistedStateOptions {
/** Enable listening to storage changes from other components/tabs */
listener?: boolean;
Expand Down Expand Up @@ -85,7 +116,7 @@ export function usePersistedState<T>(
}

// Dispatch custom event for same-tab synchronization
const customEvent = new CustomEvent(`storage-change:${key}`, {
const customEvent = new CustomEvent(getStorageChangeEvent(key), {
detail: { key, newValue },
});
window.dispatchEvent(customEvent);
Expand Down Expand Up @@ -137,16 +168,17 @@ export function usePersistedState<T>(
};

// Listen to both storage events (cross-tab) and custom events (same-tab)
const storageChangeEvent = getStorageChangeEvent(key);
window.addEventListener("storage", handleStorageChange);
window.addEventListener(`storage-change:${key}`, handleStorageChange);
window.addEventListener(storageChangeEvent, handleStorageChange);

return () => {
// Cancel pending animation frame
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
window.removeEventListener("storage", handleStorageChange);
window.removeEventListener(`storage-change:${key}`, handleStorageChange);
window.removeEventListener(storageChangeEvent, handleStorageChange);
};
}, [key, options?.listener]);

Expand Down
23 changes: 23 additions & 0 deletions src/utils/commands/sources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const mk = (over: Partial<Parameters<typeof buildCoreSources>[0]> = {}) => {
workspaceId: "w1",
},
streamingModels: new Map<string, string>(),
getThinkingLevel: () => "off",
onSetThinkingLevel: () => undefined,
onCreateWorkspace: async () => {
await Promise.resolve();
},
Expand All @@ -47,3 +49,24 @@ test("buildCoreSources includes create/switch workspace actions", () => {
expect(titles.includes("Open Current Workspace in Terminal")).toBe(true);
expect(titles.includes("Open Workspace in Terminal…")).toBe(true);
});

test("buildCoreSources adds thinking effort command", () => {
const sources = mk({ getThinkingLevel: () => "medium" });
const actions = sources.flatMap((s) => s());
const thinkingAction = actions.find((a) => a.id === "thinking:set-level");

expect(thinkingAction).toBeDefined();
expect(thinkingAction?.subtitle).toContain("Medium");
});

test("thinking effort command submits selected level", async () => {
const onSetThinkingLevel = jest.fn();
const sources = mk({ onSetThinkingLevel, getThinkingLevel: () => "low" });
const actions = sources.flatMap((s) => s());
const thinkingAction = actions.find((a) => a.id === "thinking:set-level");

expect(thinkingAction?.prompt).toBeDefined();
await thinkingAction!.prompt!.onSubmit({ thinkingLevel: "high" });

expect(onSetThinkingLevel).toHaveBeenCalledWith("w1", "high");
});
Loading