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
6 changes: 5 additions & 1 deletion packages/happy-cli/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,11 @@ export const UserMessageSchema = z.object({
role: z.literal('user'),
content: z.object({
type: z.literal('text'),
text: z.string()
text: z.string(),
images: z.array(z.object({
base64: z.string(),
mediaType: z.string(),
})).optional(),
}),
localKey: z.string().optional(), // Mobile messages include this
meta: MessageMetaSchema.optional()
Expand Down
26 changes: 23 additions & 3 deletions packages/happy-cli/src/claude/claudeRemote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export async function claudeRemote(opts: {
jsRuntime?: JsRuntime,

// Dynamic parameters
nextMessage: () => Promise<{ message: string, mode: EnhancedMode } | null>,
nextMessage: () => Promise<{ message: string, mode: EnhancedMode, images?: Array<{ base64: string, mediaType: string }> } | null>,
onReady: () => void,
isAborted: (toolCallId: string) => boolean,

Expand Down Expand Up @@ -81,6 +81,26 @@ export async function claudeRemote(opts: {
});
}

// Build content for SDK message: string for text-only, content array for images+text
function buildContent(text: string, images?: Array<{ base64: string, mediaType: string }>): string | Array<any> {
if (!images || images.length === 0) {
return text;
}
const contentParts: Array<any> = [];
for (const img of images) {
contentParts.push({
type: 'image',
source: {
type: 'base64',
media_type: img.mediaType || 'image/png',
data: img.base64,
},
});
}
contentParts.push({ type: 'text', text });
return contentParts;
}

// Get initial message
const initial = await opts.nextMessage();
if (!initial) { // No initial message - exit
Expand Down Expand Up @@ -151,7 +171,7 @@ export async function claudeRemote(opts: {
type: 'user',
message: {
role: 'user',
content: initial.message,
content: buildContent(initial.message, initial.images),
},
});

Expand Down Expand Up @@ -213,7 +233,7 @@ export async function claudeRemote(opts: {
return;
}
mode = next.mode;
messages.push({ type: 'user', message: { role: 'user', content: next.message } });
messages.push({ type: 'user', message: { role: 'user', content: buildContent(next.message, next.images) } });
}

// Handle tool result
Expand Down
4 changes: 3 additions & 1 deletion packages/happy-cli/src/claude/claudeRemoteLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' |
let pending: {
message: string;
mode: EnhancedMode;
images?: Array<{ base64: string, mediaType: string }>;
} | null = null;

// Track session ID to detect when it actually changes
Expand Down Expand Up @@ -379,7 +380,8 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' |
permissionHandler.handleModeChange(mode.permissionMode);
return {
message: msg.message,
mode: msg.mode
mode: msg.mode,
images: msg.images,
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/happy-cli/src/claude/runClaude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ export async function runClaude(credentials: Credentials, options: StartOptions
allowedTools: messageAllowedTools,
disallowedTools: messageDisallowedTools
};
messageQueue.push(message.content.text, enhancedMode);
messageQueue.push(message.content.text, enhancedMode, (message.content as any).images);
logger.debugLargeJson('User message pushed to queue:', message)
});

Expand Down
21 changes: 16 additions & 5 deletions packages/happy-cli/src/utils/MessageQueue2.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { logger } from "@/ui/logger";

export interface ImageAttachment {
base64: string;
mediaType: string;
}

interface QueueItem<T> {
message: string;
mode: T;
modeHash: string;
isolate?: boolean; // If true, this message must be processed alone
images?: ImageAttachment[];
}

/**
Expand Down Expand Up @@ -37,7 +43,7 @@ export class MessageQueue2<T> {
/**
* Push a message to the queue with a mode.
*/
push(message: string, mode: T): void {
push(message: string, mode: T, images?: ImageAttachment[]): void {
if (this.closed) {
throw new Error('Cannot push to closed queue');
}
Expand All @@ -49,7 +55,8 @@ export class MessageQueue2<T> {
message,
mode,
modeHash,
isolate: false
isolate: false,
images,
});

// Trigger message handler if set
Expand Down Expand Up @@ -221,7 +228,7 @@ export class MessageQueue2<T> {
* Wait for messages and return all messages with the same mode as a single string
* Returns { message: string, mode: T } or null if aborted/closed
*/
async waitForMessagesAndGetAsString(abortSignal?: AbortSignal): Promise<{ message: string, mode: T, isolate: boolean, hash: string } | null> {
async waitForMessagesAndGetAsString(abortSignal?: AbortSignal): Promise<{ message: string, mode: T, isolate: boolean, hash: string, images?: ImageAttachment[] } | null> {
// If we have messages, return them immediately
if (this.queue.length > 0) {
return this.collectBatch();
Expand All @@ -245,13 +252,14 @@ export class MessageQueue2<T> {
/**
* Collect a batch of messages with the same mode, respecting isolation requirements
*/
private collectBatch(): { message: string, mode: T, hash: string, isolate: boolean } | null {
private collectBatch(): { message: string, mode: T, hash: string, isolate: boolean, images?: ImageAttachment[] } | null {
if (this.queue.length === 0) {
return null;
}

const firstItem = this.queue[0];
const sameModeMessages: string[] = [];
const allImages: ImageAttachment[] = [];
let mode = firstItem.mode;
let isolate = firstItem.isolate ?? false;
const targetModeHash = firstItem.modeHash;
Expand All @@ -260,6 +268,7 @@ export class MessageQueue2<T> {
if (firstItem.isolate) {
const item = this.queue.shift()!;
sameModeMessages.push(item.message);
if (item.images) allImages.push(...item.images);
logger.debug(`[MessageQueue2] Collected isolated message with mode hash: ${targetModeHash}`);
} else {
// Collect all messages with the same mode until we hit an isolated message
Expand All @@ -268,6 +277,7 @@ export class MessageQueue2<T> {
!this.queue[0].isolate) {
const item = this.queue.shift()!;
sameModeMessages.push(item.message);
if (item.images) allImages.push(...item.images);
}
logger.debug(`[MessageQueue2] Collected batch of ${sameModeMessages.length} messages with mode hash: ${targetModeHash}`);
}
Expand All @@ -279,7 +289,8 @@ export class MessageQueue2<T> {
message: combinedMessage,
mode,
hash: targetModeHash,
isolate
isolate,
images: allImages.length > 0 ? allImages : undefined,
};
}

Expand Down
6 changes: 6 additions & 0 deletions packages/happy-wire/src/legacyProtocol.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import * as z from 'zod';
import { MessageMetaSchema } from './messageMeta';

export const ImageAttachmentSchema = z.object({
base64: z.string(),
mediaType: z.string(),
});

export const UserMessageSchema = z.object({
role: z.literal('user'),
content: z.object({
type: z.literal('text'),
text: z.string(),
images: z.array(ImageAttachmentSchema).optional(),
}),
localKey: z.string().optional(),
meta: MessageMetaSchema.optional(),
Expand Down