Skip to content

Commit 0ee36b7

Browse files
authored
fix: fix re-rendering issues, implement connection retry and connection status (#41)
* feat: don't store apiClient in useState to avoid replacing apiClient on re-render * feat: reduce re-rendering in Conversations * feat: reduce re-rendering in ConversationContent * feat: implement retry for api connection * feat: show isConnecting status * style: remove console log * fix: bot review comments
1 parent 0be2013 commit 0ee36b7

File tree

9 files changed

+305
-201
lines changed

9 files changed

+305
-201
lines changed

src/components/ChatInput.tsx

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
import { Switch } from '@/components/ui/switch';
1515
import { Label } from '@/components/ui/label';
1616
import { type Observable } from '@legendapp/state';
17-
import { Computed, useObserveEffect } from '@legendapp/state/react';
17+
import { Computed, use$, useObserveEffect } from '@legendapp/state/react';
1818

1919
export interface ChatOptions {
2020
model?: string;
@@ -28,7 +28,7 @@ interface Props {
2828
isGenerating$: Observable<boolean>;
2929
defaultModel?: string;
3030
availableModels?: string[];
31-
autoFocus?: boolean;
31+
autoFocus$: Observable<boolean>;
3232
}
3333

3434
export const ChatInput: FC<Props> = ({
@@ -38,22 +38,27 @@ export const ChatInput: FC<Props> = ({
3838
isGenerating$,
3939
defaultModel = '',
4040
availableModels = [],
41-
autoFocus = false,
41+
autoFocus$,
4242
}) => {
4343
const [message, setMessage] = useState('');
4444
const [streamingEnabled, setStreamingEnabled] = useState(true);
4545
const [selectedModel, setSelectedModel] = useState(defaultModel || '');
46-
const api = useApi();
46+
const { isConnected$ } = useApi();
4747
const textareaRef = useRef<HTMLTextAreaElement>(null);
4848

49-
const isDisabled = isReadOnly || !api.isConnected;
49+
const isConnected = use$(isConnected$);
50+
const isDisabled = isReadOnly || !isConnected;
51+
const autoFocus = use$(autoFocus$);
5052

5153
// Focus the textarea when autoFocus is true and component is interactive
5254
useEffect(() => {
53-
if (autoFocus && textareaRef.current && !isReadOnly && api.isConnected) {
55+
if (autoFocus && textareaRef.current && !isReadOnly && isConnected) {
5456
textareaRef.current.focus();
57+
// Reset autoFocus$ to false after focusing
58+
autoFocus$.set(false);
5559
}
56-
}, [autoFocus, isReadOnly, api.isConnected]);
60+
// eslint-disable-next-line react-hooks/exhaustive-deps
61+
}, [autoFocus, isReadOnly, isConnected]);
5762

5863
// Global keyboard shortcut for interrupting generation with Escape key
5964
useObserveEffect(() => {
@@ -104,7 +109,7 @@ export const ChatInput: FC<Props> = ({
104109

105110
const placeholder = isReadOnly
106111
? 'This is a demo conversation (read-only)'
107-
: api.isConnected
112+
: isConnected
108113
? 'Send a message...'
109114
: 'Connect to gptme to send messages';
110115

src/components/ConnectionButton.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,19 @@ import { cn } from '@/lib/utils';
1616
import { Checkbox } from '@/components/ui/checkbox';
1717
import { Label } from '@/components/ui/label';
1818
import { toast } from 'sonner';
19+
import { use$ } from '@legendapp/state/react';
1920

2021
export const ConnectionButton: FC = () => {
2122
const [open, setOpen] = useState(false);
22-
const { connectionConfig, connect, isConnected } = useApi();
23+
const { connectionConfig, connect, isConnected$, isConnecting$ } = useApi();
2324
const [formState, setFormState] = useState({
2425
baseUrl: connectionConfig.baseUrl,
2526
authToken: connectionConfig.authToken || '',
2627
useAuthToken: connectionConfig.useAuthToken,
2728
});
2829

30+
const isConnected = use$(isConnected$);
31+
const isConnecting = use$(isConnecting$);
2932
const features = [
3033
'Create new conversations',
3134
'Access conversation history',
@@ -72,10 +75,13 @@ export const ConnectionButton: FC = () => {
7275
<Button
7376
variant="outline"
7477
size="xs"
75-
className={isConnected ? 'text-green-600' : 'text-muted-foreground'}
78+
className={cn(
79+
isConnected ? 'text-green-600' : 'text-muted-foreground',
80+
isConnecting && 'text-yellow-600'
81+
)}
7682
>
7783
<Network className="mr-2 h-3 w-3" />
78-
{isConnected ? 'Connected' : 'Connect'}
84+
{isConnected ? 'Connected' : isConnecting ? 'Connecting...' : 'Connect'}
7985
</Button>
8086
</DialogTrigger>
8187
<DialogContent className="sm:max-w-md">
@@ -164,8 +170,9 @@ export const ConnectionButton: FC = () => {
164170
<Button
165171
onClick={handleConnect}
166172
className={cn('w-full', isConnected && 'bg-green-600 hover:bg-green-700')}
173+
disabled={isConnecting}
167174
>
168-
{isConnected ? 'Reconnect' : 'Connect'}
175+
{isConnected ? 'Reconnect' : isConnecting ? 'Connecting...' : 'Connect'}
169176
</Button>
170177
</div>
171178
</DialogContent>

src/components/ConversationContent.tsx

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { FC } from 'react';
2-
import { useRef, useState, useEffect } from 'react';
2+
import { useRef, useEffect } from 'react';
33
import { ChatMessage } from './ChatMessage';
44
import { ChatInput, type ChatOptions } from './ChatInput';
55
import { useConversation } from '@/hooks/useConversation';
@@ -39,31 +39,20 @@ export const ConversationContent: FC<Props> = ({ conversation }) => {
3939
} = useConversation(conversation);
4040

4141
// State to track when to auto-focus the input
42-
const [shouldFocus, setShouldFocus] = useState(false);
42+
const shouldFocus$ = useObservable(false);
4343
// Store the previous conversation name to detect changes
4444
const prevConversationNameRef = useRef<string | null>(null);
4545

4646
// Detect when the conversation changes and set focus
4747
useEffect(() => {
4848
if (conversation.name !== prevConversationNameRef.current) {
4949
// New conversation detected - set focus flag
50-
setShouldFocus(true);
50+
shouldFocus$.set(true);
5151

5252
// Store the current conversation name for future comparisons
5353
prevConversationNameRef.current = conversation.name;
5454
}
55-
}, [conversation.name]);
56-
57-
// Reset focus flag after it's been used
58-
useEffect(() => {
59-
if (shouldFocus) {
60-
const timer = setTimeout(() => {
61-
setShouldFocus(false);
62-
}, 100);
63-
64-
return () => clearTimeout(timer);
65-
}
66-
}, [shouldFocus]);
55+
}, [conversation.name, shouldFocus$]);
6756

6857
const showInitialSystem$ = useObservable<boolean>(false);
6958

@@ -236,7 +225,7 @@ export const ConversationContent: FC<Props> = ({ conversation }) => {
236225
isGenerating$={isGenerating$}
237226
availableModels={AVAILABLE_MODELS}
238227
defaultModel={AVAILABLE_MODELS[0]}
239-
autoFocus={shouldFocus}
228+
autoFocus$={shouldFocus$}
240229
/>
241230
</main>
242231
);

src/components/ConversationList.tsx

Lines changed: 54 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import type { ConversationResponse } from '@/types/api';
99
import type { MessageRole } from '@/types/conversation';
1010

1111
import type { FC } from 'react';
12+
import { Computed, use$ } from '@legendapp/state/react';
13+
import { type Observable } from '@legendapp/state';
1214

1315
type MessageBreakdown = Partial<Record<MessageRole, number>>;
1416

@@ -23,7 +25,7 @@ export interface ConversationItem {
2325

2426
interface Props {
2527
conversations: ConversationItem[];
26-
selectedId: string | null;
28+
selectedId$: Observable<string | null>;
2729
onSelect: (id: string) => void;
2830
isLoading?: boolean;
2931
isError?: boolean;
@@ -33,14 +35,15 @@ interface Props {
3335

3436
export const ConversationList: FC<Props> = ({
3537
conversations,
36-
selectedId,
38+
selectedId$,
3739
onSelect,
3840
isLoading = false,
3941
isError = false,
4042
error,
4143
onRetry,
4244
}) => {
43-
const { api, isConnected } = useApi();
45+
const { api, isConnected$ } = useApi();
46+
const isConnected = use$(isConnected$);
4447

4548
if (!conversations) {
4649
return null;
@@ -96,48 +99,54 @@ export const ConversationList: FC<Props> = ({
9699
};
97100

98101
return (
99-
<div
100-
className={`cursor-pointer rounded-lg p-3 transition-colors hover:bg-accent ${
101-
selectedId === conv.name ? 'bg-accent' : ''
102-
}`}
103-
onClick={() => onSelect(conv.name)}
104-
>
105-
<div className="mb-1 font-medium">{stripDate(conv.name)}</div>
106-
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
107-
<Tooltip>
108-
<TooltipTrigger>
109-
<span className="flex items-center">
110-
<Clock className="mr-1 h-4 w-4" />
111-
{getRelativeTimeString(conv.lastUpdated)}
112-
</span>
113-
</TooltipTrigger>
114-
<TooltipContent>{conv.lastUpdated.toLocaleString()}</TooltipContent>
115-
</Tooltip>
116-
<Tooltip>
117-
<TooltipTrigger asChild>
118-
<span className="flex items-center">
119-
<MessageSquare className="mr-1 h-4 w-4" />
120-
{conv.messageCount}
121-
</span>
122-
</TooltipTrigger>
123-
<TooltipContent>
124-
<div className="whitespace-pre">
125-
{demoConv || messages?.log ? formatBreakdown(getMessageBreakdown()) : 'Loading...'}
126-
</div>
127-
</TooltipContent>
128-
</Tooltip>
129-
{conv.readonly && (
130-
<Tooltip>
131-
<TooltipTrigger>
132-
<span className="flex items-center">
133-
<Lock className="h-4 w-4" />
134-
</span>
135-
</TooltipTrigger>
136-
<TooltipContent>This conversation is read-only</TooltipContent>
137-
</Tooltip>
138-
)}
139-
</div>
140-
</div>
102+
<Computed>
103+
{() => (
104+
<div
105+
className={`cursor-pointer rounded-lg p-3 transition-colors hover:bg-accent ${
106+
selectedId$.get() === conv.name ? 'bg-accent' : ''
107+
}`}
108+
onClick={() => onSelect(conv.name)}
109+
>
110+
<div className="mb-1 font-medium">{stripDate(conv.name)}</div>
111+
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
112+
<Tooltip>
113+
<TooltipTrigger>
114+
<span className="flex items-center">
115+
<Clock className="mr-1 h-4 w-4" />
116+
{getRelativeTimeString(conv.lastUpdated)}
117+
</span>
118+
</TooltipTrigger>
119+
<TooltipContent>{conv.lastUpdated.toLocaleString()}</TooltipContent>
120+
</Tooltip>
121+
<Tooltip>
122+
<TooltipTrigger asChild>
123+
<span className="flex items-center">
124+
<MessageSquare className="mr-1 h-4 w-4" />
125+
{conv.messageCount}
126+
</span>
127+
</TooltipTrigger>
128+
<TooltipContent>
129+
<div className="whitespace-pre">
130+
{demoConv || messages?.log
131+
? formatBreakdown(getMessageBreakdown())
132+
: 'Loading...'}
133+
</div>
134+
</TooltipContent>
135+
</Tooltip>
136+
{conv.readonly && (
137+
<Tooltip>
138+
<TooltipTrigger>
139+
<span className="flex items-center">
140+
<Lock className="h-4 w-4" />
141+
</span>
142+
</TooltipTrigger>
143+
<TooltipContent>This conversation is read-only</TooltipContent>
144+
</Tooltip>
145+
)}
146+
</div>
147+
</div>
148+
)}
149+
</Computed>
141150
);
142151
};
143152

0 commit comments

Comments
 (0)