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
3 changes: 3 additions & 0 deletions src/browser/App.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,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 @@ -1118,6 +1119,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 @@ -1408,6 +1410,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/browser/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type { DisplayedMessage } from "@/common/types/message";
import type { RuntimeConfig } from "@/common/types/runtime";
import { useAIViewKeybinds } from "@/browser/hooks/useAIViewKeybinds";
import { evictModelFromLRU } from "@/browser/hooks/useModelLRU";
import { QueuedMessage } from "./Messages/QueuedMessage";

interface AIViewProps {
workspaceId: string;
Expand Down Expand Up @@ -141,8 +142,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 @@ -159,7 +180,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 @@ -458,6 +479,12 @@ const AIViewInner: React.FC<AIViewProps> = ({
/>
)}
</div>
{workspaceState?.queuedMessage && (
<QueuedMessage
message={workspaceState.queuedMessage}
onEdit={() => void handleEditQueuedMessage()}
/>
)}
</div>
{!autoScroll && (
<button
Expand All @@ -479,7 +506,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/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ function createTokenCountResource(promise: Promise<number>): TokenCountReader {

// Import types from local types file
import type { ChatInputProps, ChatInputAPI } from "./types";
import type { ImagePart } from "@/common/types/ipc";
export type { ChatInputProps, ChatInputAPI };

export const ChatInput: React.FC<ChatInputProps> = (props) => {
Expand Down Expand Up @@ -225,16 +226,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 @@ -305,18 +317,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 @@ -835,7 +860,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/browser/components/ChatInput/types.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { ImagePart } from "@/common/types/ipc";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";

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/browser/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,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/browser/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/browser/components/Messages/MessageWindow.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { cn } from "@/common/lib/utils";
import type { DisplayedMessage, MuxMessage } from "@/common/types/message";
import type { DisplayedMessage, MuxMessage, QueuedMessage } from "@/common/types/message";
import { formatTimestamp } from "@/browser/utils/ui/dateTime";
import { Code2Icon } from "lucide-react";
import type { ReactNode } from "react";
Expand All @@ -19,7 +19,7 @@ export interface ButtonConfig {
interface MessageWindowProps {
label: ReactNode;
variant?: "assistant" | "user";
message: MuxMessage | DisplayedMessage;
message: MuxMessage | DisplayedMessage | QueuedMessage;
buttons?: ButtonConfig[];
children: ReactNode;
className?: string;
Expand Down
55 changes: 55 additions & 0 deletions src/browser/components/Messages/QueuedMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React from "react";
import type { ButtonConfig } from "./MessageWindow";
import { MessageWindow } from "./MessageWindow";
import type { QueuedMessage as QueuedMessageType } from "@/common/types/message";
import { Pencil } from "lucide-react";

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,
icon: <Pencil />,
},
]
: [];

return (
<>
<MessageWindow
label="Queued"
variant="user"
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>
</>
);
};
37 changes: 36 additions & 1 deletion src/browser/stores/WorkspaceStore.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import assert from "@/common/utils/assert";
import type { MuxMessage, DisplayedMessage } from "@/common/types/message";
import type { MuxMessage, DisplayedMessage, QueuedMessage } from "@/common/types/message";
import { createMuxMessage } from "@/common/types/message";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
import type { WorkspaceChatMessage } from "@/common/types/ipc";
Expand All @@ -14,6 +14,8 @@ import {
isStreamError,
isDeleteMessage,
isMuxMessage,
isQueuedMessageChanged,
isRestoreToInput,
} from "@/common/types/ipc";
import { MapStore } from "./MapStore";
import { createDisplayUsage } from "@/common/utils/tokens/displayUsage";
Expand All @@ -32,6 +34,7 @@ import { createFreshRetryState } from "@/browser/utils/messages/retryState";
export interface WorkspaceState {
name: string; // User-facing workspace name (e.g., "feature-branch")
messages: DisplayedMessage[];
queuedMessage: QueuedMessage | null;
canInterrupt: boolean;
isCompacting: boolean;
loading: boolean;
Expand Down Expand Up @@ -111,6 +114,7 @@ export class WorkspaceStore {
private historicalMessages = new Map<string, MuxMessage[]>();
private pendingStreamEvents = new Map<string, WorkspaceChatMessage[]>();
private workspaceMetadata = new Map<string, FrontendWorkspaceMetadata>(); // Store metadata for name lookup
private queuedMessages = new Map<string, QueuedMessage | null>(); // Cached queued messages

/**
* Map of event types to their handlers. This is the single source of truth for:
Expand Down Expand Up @@ -201,6 +205,36 @@ export class WorkspaceStore {
aggregator.handleMessage(data);
this.states.bump(workspaceId);
},
"queued-message-changed": (workspaceId, _aggregator, data) => {
if (!isQueuedMessageChanged(data)) return;

// Create QueuedMessage once here instead of on every render
// Use displayText which handles slash commands (shows /compact instead of expanded prompt)
// Show queued message if there's text OR images (support image-only queued messages)
const hasContent = data.queuedMessages.length > 0 || (data.imageParts?.length ?? 0) > 0;
const queuedMessage: QueuedMessage | null = hasContent
? {
id: `queued-${workspaceId}`,
content: data.displayText,
imageParts: data.imageParts,
}
: null;

this.queuedMessages.set(workspaceId, queuedMessage);
this.states.bump(workspaceId);
},
"restore-to-input": (workspaceId, _aggregator, data) => {
if (!isRestoreToInput(data)) return;

// Use INSERT_TO_CHAT_INPUT event with mode="replace"
window.dispatchEvent(
createCustomEvent(CUSTOM_EVENTS.INSERT_TO_CHAT_INPUT, {
text: data.text,
mode: "replace",
imageParts: data.imageParts,
})
);
},
};

// Cache of last known recency per workspace (for change detection)
Expand Down Expand Up @@ -305,6 +339,7 @@ export class WorkspaceStore {
return {
name: metadata?.name ?? workspaceId, // Fall back to ID if metadata missing
messages: aggregator.getDisplayedMessages(),
queuedMessage: this.queuedMessages.get(workspaceId) ?? null,
canInterrupt: activeStreams.length > 0,
isCompacting: aggregator.isCompacting(),
loading: !hasMessages && !isCaughtUp,
Expand Down
5 changes: 4 additions & 1 deletion src/common/constants/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import type { ThinkingLevel } from "@/common/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: "mux:insertToChatInput",

Expand Down Expand Up @@ -68,6 +69,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
Loading