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
2 changes: 2 additions & 0 deletions Dockerfile.webapp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions packages/happy-app/sources/-session/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Array<{ base64: string; mediaType: string }>>([]);
const realtimeStatus = useRealtimeStatus();
const { messages, isLoaded } = useSessionMessages(sessionId);
const acknowledgedCliVersions = useLocalSetting('acknowledgedCliVersions');
Expand Down Expand Up @@ -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();
}
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
51 changes: 49 additions & 2 deletions packages/happy-app/sources/components/AgentInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) => {
Expand All @@ -300,7 +327,7 @@ export const AgentInput = React.memo(React.forwardRef<MultiTextInputHandle, Agen
const { theme } = useUnistyles();
const screenWidth = useWindowDimensions().width;

const hasText = props.value.trim().length > 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
Expand Down Expand Up @@ -501,7 +528,7 @@ export const AgentInput = React.memo(React.forwardRef<MultiTextInputHandle, Agen
// Original key handling
if (Platform.OS === 'web') {
if (agentInputEnterToSend && event.key === 'Enter' && !event.shiftKey) {
if (props.value.trim()) {
if (props.value.trim() || (props.images && props.images.length > 0)) {
props.onSend();
return true; // Key was handled
}
Expand Down Expand Up @@ -941,6 +968,25 @@ export const AgentInput = React.memo(React.forwardRef<MultiTextInputHandle, Agen

{/* Box 2: Action Area (Input + Send) */}
<View style={styles.unifiedPanel}>
{/* Image previews */}
{props.images && props.images.length > 0 && (
<View style={styles.imagePreviewContainer}>
{props.images.map((img, index) => (
<View key={index} style={styles.imagePreviewWrapper}>
<RNImage
source={{ uri: `data:${img.mediaType};base64,${img.base64}` }}
style={styles.imagePreview}
/>
<Pressable
style={styles.imageRemoveButton}
onPress={() => props.onRemoveImage?.(index)}
>
<Ionicons name="close-circle" size={20} color="#fff" />
</Pressable>
</View>
))}
</View>
)}
{/* Input field */}
<View style={[styles.inputContainer, props.minHeight ? { minHeight: props.minHeight } : undefined]}>
<MultiTextInput
Expand All @@ -953,6 +999,7 @@ export const AgentInput = React.memo(React.forwardRef<MultiTextInputHandle, Agen
onKeyPress={handleKeyPress}
onStateChange={handleInputStateChange}
maxHeight={120}
onImagePaste={props.onImagePaste}
/>
</View>

Expand Down
30 changes: 30 additions & 0 deletions packages/happy-app/sources/components/MultiTextInput.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<MultiTextInputHandle, MultiTextInputProps>((props, ref) => {
Expand Down Expand Up @@ -125,6 +126,34 @@ export const MultiTextInput = React.forwardRef<MultiTextInputHandle, MultiTextIn
}
}, [onChangeText, onStateChange, onSelectionChange]);

const handlePaste = React.useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>) => {
const target = e.target as HTMLTextAreaElement;
const selection = {
Expand Down Expand Up @@ -196,6 +225,7 @@ export const MultiTextInput = React.forwardRef<MultiTextInputHandle, MultiTextIn
onChange={handleChange}
onSelect={handleSelect}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
maxRows={maxRows}
autoCapitalize="sentences"
autoCorrect="on"
Expand Down
10 changes: 6 additions & 4 deletions packages/happy-app/sources/components/markdown/MarkdownView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export const MarkdownView = React.memo((props: {
} else if (block.type === 'options') {
return <RenderOptionsBlock items={block.items} key={index} first={index === 0} last={index === blocks.length - 1} selectable={selectable} onOptionPress={props.onOptionPress} />;
} else if (block.type === 'table') {
return <RenderTableBlock headers={block.headers} rows={block.rows} key={index} first={index === 0} last={index === blocks.length - 1} />;
return <RenderTableBlock headers={block.headers} rows={block.rows} key={index} first={index === 0} last={index === blocks.length - 1} selectable={selectable} />;
} else {
return null;
}
Expand Down Expand Up @@ -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;
Expand All @@ -261,7 +262,7 @@ function RenderTableBlock(props: {
>
{/* Header cell for this column */}
<View style={[style.tableCell, style.tableHeaderCell, style.tableCellFirst]}>
<Text style={style.tableHeaderText}>{header}</Text>
<Text selectable={props.selectable} style={style.tableHeaderText}>{header}</Text>
</View>
{/* Data cells for this column */}
{props.rows.map((row, rowIndex) => (
Expand All @@ -272,7 +273,7 @@ function RenderTableBlock(props: {
isLastRow(rowIndex) && style.tableCellLast
]}
>
<Text style={style.tableCellText}>{row[colIndex] ?? ''}</Text>
<Text selectable={props.selectable} style={style.tableCellText}>{row[colIndex] ?? ''}</Text>
</View>
))}
</View>
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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);
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/happy-app/sources/sync/reducer/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions packages/happy-app/sources/sync/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -483,7 +483,8 @@ class Sync {
role: 'user',
content: {
type: 'text',
text
text,
...(images && images.length > 0 && { images }),
},
meta: {
sentFrom,
Expand Down
7 changes: 6 additions & 1 deletion packages/happy-app/sources/sync/typesRaw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}),
Expand Down Expand Up @@ -514,6 +518,7 @@ export type NormalizedMessage = ({
content: {
type: 'text';
text: string;
images?: Array<{ base64: string; mediaType: string }>;
}
} | {
role: 'agent'
Expand Down
2 changes: 1 addition & 1 deletion packages/happy-cli/src/agent/acp/runAcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 '';
}
Expand Down
40 changes: 23 additions & 17 deletions packages/happy-cli/src/api/apiSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,25 +313,31 @@ export class ApiSessionClient extends EventEmitter {
return;
}

const batch = this.pendingOutbox.slice();
const response = await axios.post<V3PostSessionMessagesResponse>(
`${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<V3PostSessionMessagesResponse>(
`${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) {
Expand Down
Loading