diff --git a/Dockerfile.webapp b/Dockerfile.webapp index afe4c2524a..4e7cf1fc80 100644 --- a/Dockerfile.webapp +++ b/Dockerfile.webapp @@ -26,11 +26,13 @@ FROM deps AS builder ARG POSTHOG_API_KEY="" ARG REVENUE_CAT_STRIPE="" +ARG HAPPY_SERVER_URL="" ENV NODE_ENV=production ENV APP_ENV=production ENV EXPO_PUBLIC_POSTHOG_API_KEY=$POSTHOG_API_KEY ENV EXPO_PUBLIC_REVENUE_CAT_STRIPE=$REVENUE_CAT_STRIPE +ENV EXPO_PUBLIC_HAPPY_SERVER_URL=$HAPPY_SERVER_URL COPY packages/happy-wire ./packages/happy-wire COPY packages/happy-app ./packages/happy-app diff --git a/packages/happy-app/sources/-session/SessionView.tsx b/packages/happy-app/sources/-session/SessionView.tsx index 044416fa37..4213164cd1 100644 --- a/packages/happy-app/sources/-session/SessionView.tsx +++ b/packages/happy-app/sources/-session/SessionView.tsx @@ -164,6 +164,7 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: const isLandscape = useIsLandscape(); const deviceType = useDeviceType(); const [message, setMessage] = React.useState(''); + const [images, setImages] = React.useState>([]); const realtimeStatus = useRealtimeStatus(); const { messages, isLoaded } = useSessionMessages(sessionId); const acknowledgedCliVersions = useLocalSetting('acknowledgedCliVersions'); @@ -316,11 +317,16 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session: dotColor: sessionStatus.statusDotColor, isPulsing: sessionStatus.isPulsing }} + images={images} + onImagePaste={(img) => setImages(prev => [...prev, img])} + onRemoveImage={(index) => setImages(prev => prev.filter((_, i) => i !== index))} onSend={() => { - if (message.trim()) { + if (message.trim() || images.length > 0) { + const currentImages = images.length > 0 ? images : undefined; setMessage(''); + setImages([]); clearDraft(); - sync.sendMessage(sessionId, message); + sync.sendMessage(sessionId, message, undefined, currentImages); trackMessageSent(); } }} diff --git a/packages/happy-app/sources/components/ActiveSessionsGroup.tsx b/packages/happy-app/sources/components/ActiveSessionsGroup.tsx index d567b9fb97..2a1d7da46b 100644 --- a/packages/happy-app/sources/components/ActiveSessionsGroup.tsx +++ b/packages/happy-app/sources/components/ActiveSessionsGroup.tsx @@ -436,7 +436,7 @@ const CompactSessionRow = React.memo(({ session, selected, showBorder }: { sessi {/* No longer showing git status per item - it's in the header */} {/* Task status indicator */} - {session.todos && session.todos.length > 0 && (() => { + {Array.isArray(session.todos) && session.todos.length > 0 && (() => { const totalTasks = session.todos.length; const completedTasks = session.todos.filter(t => t.status === 'completed').length; diff --git a/packages/happy-app/sources/components/AgentInput.tsx b/packages/happy-app/sources/components/AgentInput.tsx index bb312aabbc..f34b0e10bb 100644 --- a/packages/happy-app/sources/components/AgentInput.tsx +++ b/packages/happy-app/sources/components/AgentInput.tsx @@ -76,6 +76,9 @@ interface AgentInputProps { minHeight?: number; profileId?: string | null; onProfileClick?: () => void; + images?: Array<{ base64: string; mediaType: string }>; + onImagePaste?: (image: { base64: string; mediaType: string }) => void; + onRemoveImage?: (index: number) => void; } const MAX_CONTEXT_SIZE = 190000; @@ -278,6 +281,30 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({ sendButtonIcon: { color: theme.colors.button.primary.tint, }, + imagePreviewContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + paddingHorizontal: 8, + paddingTop: 8, + gap: 8, + }, + imagePreviewWrapper: { + position: 'relative', + }, + imagePreview: { + width: 80, + height: 80, + borderRadius: 8, + }, + imageRemoveButton: { + position: 'absolute', + top: -6, + right: -6, + backgroundColor: 'rgba(0,0,0,0.6)', + borderRadius: 10, + width: 20, + height: 20, + }, })); const getContextWarning = (contextSize: number, alwaysShow: boolean = false, theme: Theme) => { @@ -300,7 +327,7 @@ export const AgentInput = React.memo(React.forwardRef 0; + const hasText = props.value.trim().length > 0 || (props.images && props.images.length > 0); // Check if this is a Codex or Gemini session // Use metadata.flavor for existing sessions, agentType prop for new sessions @@ -501,7 +528,7 @@ export const AgentInput = React.memo(React.forwardRef 0)) { props.onSend(); return true; // Key was handled } @@ -941,6 +968,25 @@ export const AgentInput = React.memo(React.forwardRef + {/* Image previews */} + {props.images && props.images.length > 0 && ( + + {props.images.map((img, index) => ( + + + props.onRemoveImage?.(index)} + > + + + + ))} + + )} {/* Input field */} diff --git a/packages/happy-app/sources/components/MultiTextInput.web.tsx b/packages/happy-app/sources/components/MultiTextInput.web.tsx index 0cec9ac15f..5abf226ac5 100644 --- a/packages/happy-app/sources/components/MultiTextInput.web.tsx +++ b/packages/happy-app/sources/components/MultiTextInput.web.tsx @@ -39,6 +39,7 @@ interface MultiTextInputProps { onKeyPress?: OnKeyPressCallback; onSelectionChange?: (selection: { start: number; end: number }) => void; onStateChange?: (state: TextInputState) => void; + onImagePaste?: (image: { base64: string; mediaType: string }) => void; } export const MultiTextInput = React.forwardRef((props, ref) => { @@ -125,6 +126,34 @@ export const MultiTextInput = React.forwardRef) => { + if (!props.onImagePaste) return; + + const items = e.clipboardData?.items; + if (!items) return; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.type.startsWith('image/')) { + e.preventDefault(); + const file = item.getAsFile(); + if (!file) continue; + + const reader = new FileReader(); + reader.onload = () => { + const dataUrl = reader.result as string; + // dataUrl format: "data:image/png;base64,iVBOR..." + const commaIndex = dataUrl.indexOf(','); + const base64 = dataUrl.substring(commaIndex + 1); + const mediaType = item.type; + props.onImagePaste!({ base64, mediaType }); + }; + reader.readAsDataURL(file); + return; // Only handle the first image + } + } + }, [props.onImagePaste]); + const handleSelect = React.useCallback((e: React.SyntheticEvent) => { const target = e.target as HTMLTextAreaElement; const selection = { @@ -196,6 +225,7 @@ export const MultiTextInput = React.forwardRef; } else if (block.type === 'table') { - return ; + return ; } else { return null; } @@ -235,7 +235,8 @@ function RenderTableBlock(props: { headers: string[], rows: string[][], first: boolean, - last: boolean + last: boolean, + selectable: boolean }) { const columnCount = props.headers.length; const rowCount = props.rows.length; @@ -261,7 +262,7 @@ function RenderTableBlock(props: { > {/* Header cell for this column */} - {header} + {header} {/* Data cells for this column */} {props.rows.map((row, rowIndex) => ( @@ -272,7 +273,7 @@ function RenderTableBlock(props: { isLastRow(rowIndex) && style.tableCellLast ]} > - {row[colIndex] ?? ''} + {row[colIndex] ?? ''} ))} @@ -526,6 +527,7 @@ const style = StyleSheet.create((theme) => ({ borderBottomWidth: 1, borderBottomColor: theme.colors.divider, alignItems: 'flex-start', + minHeight: 40, // padding (8+8) + lineHeight (24) to prevent empty cell collapse }, tableCellFirst: { borderTopWidth: 0, diff --git a/packages/happy-app/sources/components/markdown/parseMarkdownBlock.ts b/packages/happy-app/sources/components/markdown/parseMarkdownBlock.ts index 03d76563b8..fe122d47ef 100644 --- a/packages/happy-app/sources/components/markdown/parseMarkdownBlock.ts +++ b/packages/happy-app/sources/components/markdown/parseMarkdownBlock.ts @@ -23,12 +23,11 @@ function parseTable(lines: string[], startIndex: number): { table: MarkdownBlock return { table: null, nextIndex: startIndex }; } - // Extract header cells from the first line, filtering out empty cells that may result from leading/trailing pipes - const headerLine = tableLines[0].trim(); + // Extract header cells from the first line, stripping leading/trailing pipes but preserving empty interior cells + const headerLine = tableLines[0].trim().replace(/^\||\|$/g, ''); const headers = headerLine .split('|') - .map(cell => cell.trim()) - .filter(cell => cell.length > 0); + .map(cell => cell.trim()); if (headers.length === 0) { return { table: null, nextIndex: startIndex }; @@ -39,15 +38,11 @@ function parseTable(lines: string[], startIndex: number): { table: MarkdownBlock for (let i = 2; i < tableLines.length; i++) { const rowLine = tableLines[i].trim(); if (rowLine.startsWith('|')) { - const rowCells = rowLine + const rowCells = rowLine.replace(/^\||\|$/g, '') .split('|') - .map(cell => cell.trim()) - .filter(cell => cell.length > 0); + .map(cell => cell.trim()); - // Include rows that contain actual content, filtering out empty rows - if (rowCells.length > 0) { - rows.push(rowCells); - } + rows.push(rowCells); } } diff --git a/packages/happy-app/sources/sync/reducer/reducer.ts b/packages/happy-app/sources/sync/reducer/reducer.ts index dc36bb3edc..9653ed773f 100644 --- a/packages/happy-app/sources/sync/reducer/reducer.ts +++ b/packages/happy-app/sources/sync/reducer/reducer.ts @@ -1072,7 +1072,7 @@ export function reducer(state: ReducerState, messages: NormalizedMessage[], agen return { messages: newMessages, - todos: state.latestTodos?.todos, + todos: Array.isArray(state.latestTodos?.todos) ? state.latestTodos.todos : undefined, usage: state.latestUsage ? { inputTokens: state.latestUsage.inputTokens, outputTokens: state.latestUsage.outputTokens, diff --git a/packages/happy-app/sources/sync/sync.ts b/packages/happy-app/sources/sync/sync.ts index 7d95297ff5..9d7003faef 100644 --- a/packages/happy-app/sources/sync/sync.ts +++ b/packages/happy-app/sources/sync/sync.ts @@ -438,7 +438,7 @@ class Sync { this.backgroundSendStartedAt = null; } - async sendMessage(sessionId: string, text: string, displayText?: string) { + async sendMessage(sessionId: string, text: string, displayText?: string, images?: Array<{ base64: string; mediaType: string }>) { // Get encryption const encryption = this.encryption.getSessionEncryption(sessionId); @@ -483,7 +483,8 @@ class Sync { role: 'user', content: { type: 'text', - text + text, + ...(images && images.length > 0 && { images }), }, meta: { sentFrom, diff --git a/packages/happy-app/sources/sync/typesRaw.ts b/packages/happy-app/sources/sync/typesRaw.ts index 5ac33bb0f0..bc76a8e4d5 100644 --- a/packages/happy-app/sources/sync/typesRaw.ts +++ b/packages/happy-app/sources/sync/typesRaw.ts @@ -442,7 +442,11 @@ const rawRecordSchema = z.preprocess( 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(), }), meta: MessageMetaSchema.optional() }), @@ -514,6 +518,7 @@ export type NormalizedMessage = ({ content: { type: 'text'; text: string; + images?: Array<{ base64: string; mediaType: string }>; } } | { role: 'agent' diff --git a/packages/happy-cli/src/agent/acp/runAcp.ts b/packages/happy-cli/src/agent/acp/runAcp.ts index 678f413f1d..469c8d3cc6 100644 --- a/packages/happy-cli/src/agent/acp/runAcp.ts +++ b/packages/happy-cli/src/agent/acp/runAcp.ts @@ -104,7 +104,7 @@ function formatTextForConsole(text: string): string { return JSON.stringify(truncateForConsole(toSingleLine(text), ACP_EVENT_PREVIEW_CHARS)); } -function formatOptionalDetail(text: string | undefined, limit = ACP_EVENT_PREVIEW_CHARS): string { +function formatOptionalDetail(text: string | null | undefined, limit = ACP_EVENT_PREVIEW_CHARS): string { if (!text) { return ''; } diff --git a/packages/happy-cli/src/api/apiSession.ts b/packages/happy-cli/src/api/apiSession.ts index f8dbf61cdb..cb56eb65bb 100644 --- a/packages/happy-cli/src/api/apiSession.ts +++ b/packages/happy-cli/src/api/apiSession.ts @@ -313,25 +313,31 @@ export class ApiSessionClient extends EventEmitter { return; } - const batch = this.pendingOutbox.slice(); - const response = await axios.post( - `${configuration.serverUrl}/v3/sessions/${encodeURIComponent(this.sessionId)}/messages`, - { - messages: batch - }, - { - headers: this.authHeaders(), - timeout: 60000 - } - ); + const BATCH_LIMIT = 100; + let flushed = 0; + + while (flushed < this.pendingOutbox.length) { + const batch = this.pendingOutbox.slice(flushed, flushed + BATCH_LIMIT); + const response = await axios.post( + `${configuration.serverUrl}/v3/sessions/${encodeURIComponent(this.sessionId)}/messages`, + { + messages: batch + }, + { + headers: this.authHeaders(), + timeout: 60000 + } + ); - this.pendingOutbox.splice(0, batch.length); + const messages = Array.isArray(response.data.messages) ? response.data.messages : []; + const maxSeq = messages.reduce((acc, message) => ( + message.seq > acc ? message.seq : acc + ), this.lastSeq); + this.lastSeq = maxSeq; + flushed += batch.length; + } - const messages = Array.isArray(response.data.messages) ? response.data.messages : []; - const maxSeq = messages.reduce((acc, message) => ( - message.seq > acc ? message.seq : acc - ), this.lastSeq); - this.lastSeq = maxSeq; + this.pendingOutbox.splice(0, flushed); } private enqueueMessage(content: unknown, invalidate: boolean = true) { diff --git a/packages/happy-cli/src/claude/claudeRemote.ts b/packages/happy-cli/src/claude/claudeRemote.ts index d93215c8cf..a016425209 100644 --- a/packages/happy-cli/src/claude/claudeRemote.ts +++ b/packages/happy-cli/src/claude/claudeRemote.ts @@ -30,7 +30,7 @@ export async function claudeRemote(opts: { jsRuntime?: JsRuntime, // Dynamic parameters - nextMessage: () => Promise<{ message: string, mode: EnhancedMode } | null>, + nextMessage: () => Promise<{ message: string, images?: Array<{ base64: string, mediaType: string }>, mode: EnhancedMode } | null>, onReady: () => void, isAborted: (toolCallId: string) => boolean, @@ -145,13 +145,25 @@ export async function claudeRemote(opts: { } }; + // Build SDK content: plain string when no images, content array when images are present + function buildContent(text: string, images?: Array<{ base64: string, mediaType: string }>): string | Array> { + if (!images || images.length === 0) return text; + return [ + ...images.map(img => ({ + type: 'image' as const, + source: { type: 'base64' as const, media_type: img.mediaType, data: img.base64 }, + })), + { type: 'text' as const, text }, + ]; + } + // Push initial message let messages = new PushableAsyncIterable(); messages.push({ type: 'user', message: { role: 'user', - content: initial.message, + content: buildContent(initial.message, initial.images), }, }); @@ -213,7 +225,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 diff --git a/packages/happy-cli/src/claude/claudeRemoteLauncher.ts b/packages/happy-cli/src/claude/claudeRemoteLauncher.ts index b3472475d1..1d4525887a 100644 --- a/packages/happy-cli/src/claude/claudeRemoteLauncher.ts +++ b/packages/happy-cli/src/claude/claudeRemoteLauncher.ts @@ -292,6 +292,7 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | try { let pending: { message: string; + images?: Array<{ base64: string; mediaType: string }>; mode: EnhancedMode; } | null = null; @@ -357,6 +358,7 @@ export async function claudeRemoteLauncher(session: Session): Promise<'switch' | permissionHandler.handleModeChange(mode.permissionMode); return { message: msg.message, + images: msg.images, mode: msg.mode } } diff --git a/packages/happy-cli/src/claude/runClaude.ts b/packages/happy-cli/src/claude/runClaude.ts index b626c62b61..2846748133 100644 --- a/packages/happy-cli/src/claude/runClaude.ts +++ b/packages/happy-cli/src/claude/runClaude.ts @@ -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) }); diff --git a/packages/happy-cli/src/utils/MessageQueue2.ts b/packages/happy-cli/src/utils/MessageQueue2.ts index 45f254ed93..8388418442 100644 --- a/packages/happy-cli/src/utils/MessageQueue2.ts +++ b/packages/happy-cli/src/utils/MessageQueue2.ts @@ -1,7 +1,13 @@ import { logger } from "@/ui/logger"; +export interface ImageAttachment { + base64: string; + mediaType: string; +} + interface QueueItem { message: string; + images?: ImageAttachment[]; mode: T; modeHash: string; isolate?: boolean; // If true, this message must be processed alone @@ -37,7 +43,7 @@ export class MessageQueue2 { /** * 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'); } @@ -47,6 +53,7 @@ export class MessageQueue2 { this.queue.push({ message, + images, mode, modeHash, isolate: false @@ -221,7 +228,7 @@ export class MessageQueue2 { * 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, images?: ImageAttachment[], mode: T, isolate: boolean, hash: string } | null> { // If we have messages, return them immediately if (this.queue.length > 0) { return this.collectBatch(); @@ -245,13 +252,14 @@ export class MessageQueue2 { /** * 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, images?: ImageAttachment[], mode: T, hash: string, isolate: boolean } | null { if (this.queue.length === 0) { return null; } const firstItem = this.queue[0]; const sameModeMessages: string[] = []; + let allImages: ImageAttachment[] = []; let mode = firstItem.mode; let isolate = firstItem.isolate ?? false; const targetModeHash = firstItem.modeHash; @@ -260,6 +268,7 @@ export class MessageQueue2 { 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 @@ -268,6 +277,7 @@ export class MessageQueue2 { !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}`); } @@ -277,6 +287,7 @@ export class MessageQueue2 { return { message: combinedMessage, + images: allImages.length > 0 ? allImages : undefined, mode, hash: targetModeHash, isolate diff --git a/packages/happy-wire/src/legacyProtocol.ts b/packages/happy-wire/src/legacyProtocol.ts index c2f4f29a9c..725d750bf3 100644 --- a/packages/happy-wire/src/legacyProtocol.ts +++ b/packages/happy-wire/src/legacyProtocol.ts @@ -1,11 +1,18 @@ import * as z from 'zod'; import { MessageMetaSchema } from './messageMeta'; +export const ImageAttachmentSchema = z.object({ + base64: z.string(), + mediaType: z.string(), +}); +export type ImageAttachment = z.infer; + 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(),