Skip to content
Open
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
3 changes: 3 additions & 0 deletions src/App.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ function setupMockAPI(options: {
sendMessage: () => Promise.resolve({ success: true, data: undefined }),
resumeStream: () => Promise.resolve({ success: true, data: undefined }),
interruptStream: () => Promise.resolve({ success: true, data: undefined }),
clearQueue: () => Promise.resolve({ success: true, data: undefined }),
truncateHistory: () => Promise.resolve({ success: true, data: undefined }),
replaceChatHistory: () => Promise.resolve({ success: true, data: undefined }),
getInfo: () => Promise.resolve(null),
Expand Down Expand Up @@ -655,6 +656,7 @@ export const ActiveWorkspaceWithChat: Story = {
sendMessage: () => Promise.resolve({ success: true, data: undefined }),
resumeStream: () => Promise.resolve({ success: true, data: undefined }),
interruptStream: () => Promise.resolve({ success: true, data: undefined }),
clearQueue: () => Promise.resolve({ success: true, data: undefined }),
truncateHistory: () => Promise.resolve({ success: true, data: undefined }),
replaceChatHistory: () => Promise.resolve({ success: true, data: undefined }),
getInfo: () => Promise.resolve(null),
Expand Down Expand Up @@ -851,6 +853,7 @@ These tables should render cleanly without any disruptive copy or download actio
sendMessage: () => Promise.resolve({ success: true, data: undefined }),
resumeStream: () => Promise.resolve({ success: true, data: undefined }),
interruptStream: () => Promise.resolve({ success: true, data: undefined }),
clearQueue: () => Promise.resolve({ success: true, data: undefined }),
truncateHistory: () => Promise.resolve({ success: true, data: undefined }),
replaceChatHistory: () => Promise.resolve({ success: true, data: undefined }),
getInfo: () => Promise.resolve(null),
Expand Down
1 change: 1 addition & 0 deletions src/browser/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ const webApi: IPCApi = {
invokeIPC(IPC_CHANNELS.WORKSPACE_RESUME_STREAM, workspaceId, options),
interruptStream: (workspaceId, options) =>
invokeIPC(IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, workspaceId, options),
clearQueue: (workspaceId) => invokeIPC(IPC_CHANNELS.WORKSPACE_QUEUE_CLEAR, workspaceId),
truncateHistory: (workspaceId, percentage) =>
invokeIPC(IPC_CHANNELS.WORKSPACE_TRUNCATE_HISTORY, workspaceId, percentage),
replaceChatHistory: (workspaceId, summaryMessage) =>
Expand Down
33 changes: 30 additions & 3 deletions src/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { MessageRenderer } from "./Messages/MessageRenderer";
import { InterruptedBarrier } from "./Messages/ChatBarrier/InterruptedBarrier";
import { StreamingBarrier } from "./Messages/ChatBarrier/StreamingBarrier";
import { RetryBarrier } from "./Messages/ChatBarrier/RetryBarrier";
import { QueuedMessage } from "./Messages/QueuedMessage";
import { PinnedTodoList } from "./PinnedTodoList";
import { getAutoRetryKey, VIM_ENABLED_KEY } from "@/constants/storage";
import { ChatInput, type ChatInputAPI } from "./ChatInput/index";
Expand Down Expand Up @@ -113,8 +114,28 @@ const AIViewInner: React.FC<AIViewProps> = ({
setEditingMessage({ id: messageId, content });
}, []);

const handleEditLastUserMessage = useCallback(() => {
const handleEditQueuedMessage = useCallback(async () => {
const queuedMessage = workspaceState?.queuedMessage;
if (!queuedMessage) return;

await window.api.workspace.clearQueue(workspaceId);
chatInputAPI.current?.restoreText(queuedMessage.content);

// Restore images if present
if (queuedMessage.imageParts && queuedMessage.imageParts.length > 0) {
chatInputAPI.current?.restoreImages(queuedMessage.imageParts);
}
}, [workspaceId, workspaceState?.queuedMessage, chatInputAPI]);

const handleEditLastUserMessage = useCallback(async () => {
if (!workspaceState) return;

if (workspaceState.queuedMessage) {
await handleEditQueuedMessage();
return;
}

// Otherwise, edit last user message
const mergedMessages = mergeConsecutiveStreamErrors(workspaceState.messages);
const lastUserMessage = [...mergedMessages]
.reverse()
Expand All @@ -131,7 +152,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
element?.scrollIntoView({ behavior: "smooth", block: "center" });
});
}
}, [workspaceState, contentRef, setAutoScroll]);
}, [workspaceState, contentRef, setAutoScroll, handleEditQueuedMessage]);

const handleCancelEdit = useCallback(() => {
setEditingMessage(undefined);
Expand Down Expand Up @@ -435,6 +456,12 @@ const AIViewInner: React.FC<AIViewProps> = ({
}
/>
)}
{workspaceState?.queuedMessage && (
<QueuedMessage
message={workspaceState.queuedMessage}
onEdit={() => void handleEditQueuedMessage()}
/>
)}
</div>
{!autoScroll && (
<button
Expand Down Expand Up @@ -471,7 +498,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
isCompacting={isCompacting}
editingMessage={editingMessage}
onCancelEdit={handleCancelEdit}
onEditLastUserMessage={handleEditLastUserMessage}
onEditLastUserMessage={() => void handleEditLastUserMessage()}
canInterrupt={canInterrupt}
onReady={handleChatInputReady}
/>
Expand Down
41 changes: 33 additions & 8 deletions src/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
extractImagesFromDrop,
processImageFiles,
} from "@/utils/imageHandling";
import type { ImagePart } from "@/types/ipc";

import type { ThinkingLevel } from "@/types/thinking";
import type { MuxFrontendMetadata } from "@/types/message";
Expand Down Expand Up @@ -220,16 +221,27 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
[setInput]
);

// Method to restore images to input (used by queued message edit)
const restoreImages = useCallback((images: ImagePart[]) => {
const attachments: ImageAttachment[] = images.map((img, index) => ({
id: `restored-${Date.now()}-${index}`,
url: img.url,
mediaType: img.mediaType,
}));
setImageAttachments(attachments);
}, []);

// Provide API to parent via callback
useEffect(() => {
if (props.onReady) {
props.onReady({
focus: focusMessageInput,
restoreText,
appendText,
restoreImages,
});
}
}, [props.onReady, focusMessageInput, restoreText, appendText, props]);
}, [props.onReady, focusMessageInput, restoreText, appendText, restoreImages, props]);

useEffect(() => {
const handleGlobalKeyDown = (event: KeyboardEvent) => {
Expand Down Expand Up @@ -300,18 +312,31 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
};
}, []);

// Allow external components (e.g., CommandPalette) to insert text
// Allow external components (e.g., CommandPalette, Queued message edits) to insert text
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail as { text?: string } | undefined;
if (!detail?.text) return;
setInput(detail.text);
setTimeout(() => inputRef.current?.focus(), 0);
const customEvent = e as CustomEvent<{
text: string;
mode?: "append" | "replace";
imageParts?: ImagePart[];
}>;

const { text, mode = "append", imageParts } = customEvent.detail;

if (mode === "replace") {
restoreText(text);
} else {
appendText(text);
}

if (imageParts && imageParts.length > 0) {
restoreImages(imageParts);
}
};
window.addEventListener(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, handler as EventListener);
return () =>
window.removeEventListener(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, handler as EventListener);
}, [setInput]);
}, [appendText, restoreText, restoreImages]);

// Allow external components to open the Model Selector
useEffect(() => {
Expand Down Expand Up @@ -830,7 +855,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
: KEYBINDS.INTERRUPT_STREAM_NORMAL;
hints.push(`${formatKeybind(interruptKeybind)} to interrupt`);
}
hints.push(`${formatKeybind(KEYBINDS.SEND_MESSAGE)} to send`);
hints.push(`${formatKeybind(KEYBINDS.SEND_MESSAGE)} to ${canInterrupt ? "queue" : "send"}`);
hints.push(`${formatKeybind(KEYBINDS.OPEN_MODEL_SELECTOR)} to change model`);
hints.push(`/vim to toggle Vim mode (${vimEnabled ? "on" : "off"})`);

Expand Down
2 changes: 2 additions & 0 deletions src/components/ChatInput/types.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { FrontendWorkspaceMetadata } from "@/types/workspace";
import type { ImagePart } from "@/types/ipc";

export interface ChatInputAPI {
focus: () => void;
restoreText: (text: string) => void;
appendText: (text: string) => void;
restoreImages: (images: ImagePart[]) => void;
}

// Workspace variant: full functionality for existing workspaces
Expand Down
7 changes: 6 additions & 1 deletion src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,12 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({ getSlashContext
shortcutHint: `${formatKeybind(KEYBINDS.SEND_MESSAGE)} to insert`,
run: () => {
const text = s.replacement;
window.dispatchEvent(createCustomEvent(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, { text }));
window.dispatchEvent(
createCustomEvent(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, {
text,
mode: "append",
})
);
},
})),
},
Expand Down
1 change: 1 addition & 0 deletions src/components/Messages/MessageRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface MessageRendererProps {
message: DisplayedMessage;
className?: string;
onEditUserMessage?: (messageId: string, content: string) => void;
onEditQueuedMessage?: () => void;
workspaceId?: string;
isCompacting?: boolean;
}
Expand Down
4 changes: 2 additions & 2 deletions src/components/Messages/MessageWindow.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import React, { useState, useMemo } from "react";
import type { MuxMessage, DisplayedMessage } from "@/types/message";
import type { MuxMessage, DisplayedMessage, QueuedMessage } from "@/types/message";
import { HeaderButton } from "../tools/shared/ToolPrimitives";
import { formatTimestamp } from "@/utils/ui/dateTime";
import { TooltipWrapper, Tooltip } from "../Tooltip";
Expand All @@ -19,7 +19,7 @@ interface MessageWindowProps {
label: ReactNode;
borderColor: string;
backgroundColor?: string;
message: MuxMessage | DisplayedMessage;
message: MuxMessage | DisplayedMessage | QueuedMessage;
buttons?: ButtonConfig[];
kebabMenuItems?: KebabMenuItem[]; // Optional kebab menu items (provide empty array to use kebab with only Show JSON)
children: ReactNode;
Expand Down
53 changes: 53 additions & 0 deletions src/components/Messages/QueuedMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React from "react";
import type { QueuedMessage as QueuedMessageType } from "@/types/message";
import type { ButtonConfig } from "./MessageWindow";
import { MessageWindow } from "./MessageWindow";

interface QueuedMessageProps {
message: QueuedMessageType;
className?: string;
onEdit?: () => void;
}

export const QueuedMessage: React.FC<QueuedMessageProps> = ({ message, className, onEdit }) => {
const { content } = message;

const buttons: ButtonConfig[] = onEdit
? [
{
label: "Edit",
onClick: onEdit,
},
]
: [];

return (
<>
<MessageWindow
label="queued"
borderColor="var(--color-user-border)"
message={message}
className={className}
buttons={buttons}
>
{content && (
<pre className="text-subtle m-0 font-mono text-xs leading-4 break-words whitespace-pre-wrap opacity-90">
{content}
</pre>
)}
{message.imageParts && message.imageParts.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{message.imageParts.map((img, idx) => (
<img
key={idx}
src={img.url}
alt={`Attachment ${idx + 1}`}
className="border-border-light max-h-[300px] max-w-80 rounded border"
/>
))}
</div>
)}
</MessageWindow>
</>
);
};
5 changes: 4 additions & 1 deletion src/constants/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

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

export const CUSTOM_EVENTS = {
/**
Expand All @@ -16,7 +17,7 @@ export const CUSTOM_EVENTS = {

/**
* Event to insert text into the chat input
* Detail: { text: string }
* Detail: { text: string, mode?: "replace" | "append", imageParts?: ImagePart[] }
*/
INSERT_TO_CHAT_INPUT: "cmux:insertToChatInput",

Expand Down Expand Up @@ -63,6 +64,8 @@ export interface CustomEventPayloads {
};
[CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT]: {
text: string;
mode?: "replace" | "append";
imageParts?: ImagePart[];
};
[CUSTOM_EVENTS.OPEN_MODEL_SELECTOR]: never; // No payload
[CUSTOM_EVENTS.RESUME_CHECK_REQUESTED]: {
Expand Down
1 change: 1 addition & 0 deletions src/constants/ipc-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const IPC_CHANNELS = {
WORKSPACE_SEND_MESSAGE: "workspace:sendMessage",
WORKSPACE_RESUME_STREAM: "workspace:resumeStream",
WORKSPACE_INTERRUPT_STREAM: "workspace:interruptStream",
WORKSPACE_QUEUE_CLEAR: "workspace:queue:clear",
WORKSPACE_TRUNCATE_HISTORY: "workspace:truncateHistory",
WORKSPACE_REPLACE_HISTORY: "workspace:replaceHistory",
WORKSPACE_STREAM_HISTORY: "workspace:streamHistory",
Expand Down
2 changes: 2 additions & 0 deletions src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ const api: IPCApi = {
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_RESUME_STREAM, workspaceId, options),
interruptStream: (workspaceId: string, options?: { abandonPartial?: boolean }) =>
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_INTERRUPT_STREAM, workspaceId, options),
clearQueue: (workspaceId: string) =>
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_QUEUE_CLEAR, workspaceId),
truncateHistory: (workspaceId, percentage) =>
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_TRUNCATE_HISTORY, workspaceId, percentage),
replaceChatHistory: (workspaceId, summaryMessage) =>
Expand Down
Loading