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
56 changes: 50 additions & 6 deletions packages/happy-app/sources/components/ChatList.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,65 @@
import * as React from 'react';
import { useSession, useSessionMessages } from "@/sync/storage";
import { ActivityIndicator, FlatList, Platform, View } from 'react-native';
import { ActivityIndicator, FlatList, Platform, Pressable, Text, View } from 'react-native';
import { useCallback } from 'react';
import { useHeaderHeight } from '@/utils/responsive';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useUnistyles } from 'react-native-unistyles';
import { MessageView } from './MessageView';
import { Metadata, Session } from '@/sync/storageTypes';
import { ChatFooter } from './ChatFooter';
import { Message } from '@/sync/typesMessage';
import { Typography } from '@/constants/Typography';
import { sync } from '@/sync/sync';

export const ChatList = React.memo((props: { session: Session }) => {
const { messages } = useSessionMessages(props.session.id);
const { messages, hasOlderMessages, isLoadingOlder } = useSessionMessages(props.session.id);
return (
<ChatListInternal
metadata={props.session.metadata}
sessionId={props.session.id}
messages={messages}
hasOlderMessages={hasOlderMessages}
isLoadingOlder={isLoadingOlder}
/>
)
});

const ListHeader = React.memo(() => {
const LoadOlderMessages = React.memo((props: { sessionId: string; hasOlderMessages: boolean; isLoadingOlder: boolean }) => {
const { theme } = useUnistyles();
const headerHeight = useHeaderHeight();
const safeArea = useSafeAreaInsets();
return <View style={{ flexDirection: 'row', alignItems: 'center', height: headerHeight + safeArea.top + 32 }} />;

const handlePress = useCallback(() => {
void sync.fetchOlderMessages(props.sessionId).catch(() => {});
}, [props.sessionId]);

return (
<View style={{ alignItems: 'center', paddingTop: headerHeight + safeArea.top + 16, paddingBottom: 16 }}>
{props.hasOlderMessages && (
props.isLoadingOlder ? (
<ActivityIndicator size="small" color={theme.colors.textSecondary} />
) : (
<Pressable
onPress={handlePress}
style={{
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
backgroundColor: theme.colors.surface,
}}
>
<Text style={{ color: theme.colors.textSecondary, fontSize: 13, ...Typography.default() }}>
Load older messages
</Text>
</Pressable>
)
)}
{!props.hasOlderMessages && (
<View style={{ height: 16 }} />
)}
</View>
);
});

const ListFooter = React.memo((props: { sessionId: string }) => {
Expand All @@ -37,6 +73,8 @@ const ChatListInternal = React.memo((props: {
metadata: Metadata | null,
sessionId: string,
messages: Message[],
hasOlderMessages: boolean,
isLoadingOlder: boolean,
}) => {
const keyExtractor = useCallback((item: any) => item.id, []);
const renderItem = useCallback(({ item }: { item: any }) => (
Expand All @@ -55,7 +93,13 @@ const ChatListInternal = React.memo((props: {
keyboardDismissMode={Platform.OS === 'ios' ? 'interactive' : 'none'}
renderItem={renderItem}
ListHeaderComponent={<ListFooter sessionId={props.sessionId} />}
ListFooterComponent={<ListHeader />}
ListFooterComponent={
<LoadOlderMessages
sessionId={props.sessionId}
hasOlderMessages={props.hasOlderMessages}
isLoadingOlder={props.isLoadingOlder}
/>
}
/>
)
});
});
5 changes: 4 additions & 1 deletion packages/happy-app/sources/hooks/useDemoMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ export function useDemoMessages(messages: Message[]) {
messages: sortedMessages,
messagesMap: messagesMap,
reducerState: createReducer(),
isLoaded: true
isLoaded: true,
hasOlderMessages: false,
oldestLoadedSeq: null,
isLoadingOlder: false
}
}
}));
Expand Down
26 changes: 18 additions & 8 deletions packages/happy-app/sources/sync/apiSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,11 @@ class ApiSocket {
throw new Error(`Session encryption not found for ${sessionId}`);
}

const result = await this.socket!.emitWithAck('rpc-call', {
const result = await this.socket!.timeout(30000).emitWithAck('rpc-call', {
method: `${sessionId}:${method}`,
params: await sessionEncryption.encryptRaw(params)
});

if (result.ok) {
return await sessionEncryption.decryptRaw(result.result) as R;
}
Expand All @@ -137,7 +137,7 @@ class ApiSocket {
throw new Error(`Machine encryption not found for ${machineId}`);
}

const result = await this.socket!.emitWithAck('rpc-call', {
const result = await this.socket!.timeout(30000).emitWithAck('rpc-call', {
method: `${machineId}:${method}`,
params: await machineEncryption.encryptRaw(params)
});
Expand All @@ -157,7 +157,7 @@ class ApiSocket {
if (!this.socket) {
throw new Error('Socket not connected');
}
return await this.socket.emitWithAck(event, data);
return await this.socket.timeout(15000).emitWithAck(event, data);
}

//
Expand All @@ -180,10 +180,20 @@ class ApiSocket {
...options?.headers
};

return fetch(url, {
...options,
headers
});
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 60000);
if (options?.signal) {
options.signal.addEventListener('abort', () => controller.abort(), { once: true });
}
try {
return await fetch(url, {
...options,
headers,
signal: controller.signal
});
} finally {
clearTimeout(timeout);
}
}

//
Expand Down
41 changes: 36 additions & 5 deletions packages/happy-app/sources/sync/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ interface SessionMessages {
messagesMap: Record<string, Message>;
reducerState: ReducerState;
isLoaded: boolean;
hasOlderMessages: boolean;
oldestLoadedSeq: number | null;
isLoadingOlder: boolean;
}

// Machine type is now imported from storageTypes - represents persisted machine data
Expand Down Expand Up @@ -103,6 +106,7 @@ interface StorageState {
applyReady: () => void;
applyMessages: (sessionId: string, messages: NormalizedMessage[]) => { changed: string[], hasReadyEvent: boolean };
applyMessagesLoaded: (sessionId: string) => void;
setOlderMessagesState: (sessionId: string, update: { hasOlderMessages?: boolean; oldestLoadedSeq?: number | null; isLoadingOlder?: boolean }) => void;
applySettings: (settings: Settings, version: number) => void;
applySettingsLocal: (settings: Partial<Settings>) => void;
applyLocalSettings: (settings: Partial<LocalSettings>) => void;
Expand Down Expand Up @@ -434,7 +438,10 @@ export const storage = create<StorageState>()((set, get) => {
messages: messagesArray,
messagesMap: mergedMessagesMap,
reducerState: existingSessionMessages.reducerState, // The reducer modifies state in-place, so this has the updates
isLoaded: existingSessionMessages.isLoaded
isLoaded: existingSessionMessages.isLoaded,
hasOlderMessages: existingSessionMessages.hasOlderMessages,
oldestLoadedSeq: existingSessionMessages.oldestLoadedSeq,
isLoadingOlder: existingSessionMessages.isLoadingOlder
};

// IMPORTANT: Copy latestUsage from reducerState to Session for immediate availability
Expand Down Expand Up @@ -490,7 +497,10 @@ export const storage = create<StorageState>()((set, get) => {
messages: [],
messagesMap: {},
reducerState: createReducer(),
isLoaded: false
isLoaded: false,
hasOlderMessages: false,
oldestLoadedSeq: null,
isLoadingOlder: false
};

// Get the session's agentState if available
Expand Down Expand Up @@ -608,7 +618,10 @@ export const storage = create<StorageState>()((set, get) => {
reducerState,
messages,
messagesMap,
isLoaded: true
isLoaded: true,
hasOlderMessages: false,
oldestLoadedSeq: null,
isLoadingOlder: false
} satisfies SessionMessages
}
};
Expand All @@ -627,6 +640,22 @@ export const storage = create<StorageState>()((set, get) => {

return result;
}),
setOlderMessagesState: (sessionId: string, update: { hasOlderMessages?: boolean; oldestLoadedSeq?: number | null; isLoadingOlder?: boolean }) => set((state) => {
const existingSession = state.sessionMessages[sessionId];
if (!existingSession) return state;
return {
...state,
sessionMessages: {
...state.sessionMessages,
[sessionId]: {
...existingSession,
...(update.hasOlderMessages !== undefined && { hasOlderMessages: update.hasOlderMessages }),
...(update.oldestLoadedSeq !== undefined && { oldestLoadedSeq: update.oldestLoadedSeq }),
...(update.isLoadingOlder !== undefined && { isLoadingOlder: update.isLoadingOlder }),
}
}
};
}),
applySettingsLocal: (settings: Partial<Settings>) => set((state) => {
saveSettings(applySettings(state.settings, settings), state.settingsVersion ?? 0);
return {
Expand Down Expand Up @@ -1078,12 +1107,14 @@ export function useSession(id: string): Session | null {

const emptyArray: unknown[] = [];

export function useSessionMessages(sessionId: string): { messages: Message[], isLoaded: boolean } {
export function useSessionMessages(sessionId: string): { messages: Message[], isLoaded: boolean, hasOlderMessages: boolean, isLoadingOlder: boolean } {
return storage(useShallow((state) => {
const session = state.sessionMessages[sessionId];
return {
messages: session?.messages ?? emptyArray,
isLoaded: session?.isLoaded ?? false
isLoaded: session?.isLoaded ?? false,
hasOlderMessages: session?.hasOlderMessages ?? false,
isLoadingOlder: session?.isLoadingOlder ?? false
};
}));
}
Expand Down
Loading