Skip to content

Commit 370aba7

Browse files
authored
feat(web): improve model selector with favorites, search, and performance (#490)
- Add favorites system with Convex sync (favoriteModels in profiles table) - Redesign model selector with T3 Chat-style two-column layout - Add provider sidebar that hides when searching - Improve search: simple substring match, normalized for dashes/spaces - Add useDeferredValue for smooth typing during search - Fix open/close flash by cancelling pending close timeouts - Add 'Add X suggested' button showing missing default count - Disable cmd+k command palette temporarily - Remove loading states for instant render - Add localStorage caching for models and chats
1 parent 5ba7484 commit 370aba7

11 files changed

Lines changed: 634 additions & 445 deletions

File tree

apps/server/convex/schema.ts

Lines changed: 5 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,10 @@ export default defineSchema({
3636
avatarUrl: v.optional(v.string()),
3737
encryptedOpenRouterKey: v.optional(v.string()),
3838
fileUploadCount: v.optional(v.number()),
39-
// Future expansion for user preferences
39+
favoriteModels: v.optional(v.array(v.string())),
4040
preferences: v.optional(
4141
v.object({
4242
theme: v.optional(v.string()),
43-
// Add more preferences as needed
4443
})
4544
),
4645
createdAt: v.number(),
@@ -70,63 +69,45 @@ export default defineSchema({
7069
chatId: v.id("chats"),
7170
clientMessageId: v.optional(v.string()),
7271
role: v.string(),
73-
// Content can be encrypted (prefixed with enc_v1:). Max length: 100KB (102400 bytes)
7472
content: v.string(),
75-
// Model ID used to generate this message (e.g., "x-ai/grok-4-fast")
7673
modelId: v.optional(v.string()),
77-
// DEPRECATED: Use chainOfThoughtParts instead. Kept for backward compatibility.
78-
// Reasoning content from models with reasoning capabilities (e.g., Claude 4, GPT-5, DeepSeek R1)
7974
reasoning: v.optional(v.string()),
80-
// Time spent thinking in milliseconds (for reasoning models)
8175
thinkingTimeMs: v.optional(v.number()),
82-
// Whether reasoning was requested for this message (used to show "redacted" state when
83-
// provider doesn't return reasoning data)
8476
reasoningRequested: v.optional(v.boolean()),
85-
// DEPRECATED: Use chainOfThoughtParts instead. Kept for backward compatibility.
86-
// Tool invocations that occurred during this message's generation
8777
toolInvocations: v.optional(
8878
v.array(
8979
v.object({
9080
toolName: v.string(),
9181
toolCallId: v.string(),
92-
state: v.string(), // "input-streaming" | "input-available" | "output-available" | "output-error"
82+
state: v.string(),
9383
input: v.optional(v.any()),
9484
output: v.optional(v.any()),
9585
errorText: v.optional(v.string()),
9686
})
9787
)
9888
),
99-
// NEW: Unified chain of thought parts - preserves exact stream order
100-
// This replaces the separate reasoning and toolInvocations fields
101-
// Each part has an index representing its position in the original stream
10289
chainOfThoughtParts: v.optional(
10390
v.array(
10491
v.object({
105-
// Part type: "reasoning" for thinking, "tool" for tool calls
10692
type: v.union(v.literal("reasoning"), v.literal("tool")),
107-
// Original position in the AI stream (for ordering)
10893
index: v.number(),
109-
// For reasoning parts
11094
text: v.optional(v.string()),
111-
// For tool parts
11295
toolName: v.optional(v.string()),
11396
toolCallId: v.optional(v.string()),
114-
state: v.optional(v.string()), // "input-streaming" | "input-available" | "output-available" | "output-error"
97+
state: v.optional(v.string()),
11598
input: v.optional(v.any()),
11699
output: v.optional(v.any()),
117100
errorText: v.optional(v.string()),
118101
})
119102
)
120103
),
121-
// Token usage for this message
122104
tokenUsage: v.optional(
123105
v.object({
124106
promptTokens: v.number(),
125107
completionTokens: v.number(),
126108
totalTokens: v.number(),
127109
})
128110
),
129-
// File attachments
130111
attachments: v.optional(
131112
v.array(
132113
v.object({
@@ -139,32 +120,22 @@ export default defineSchema({
139120
})
140121
)
141122
),
142-
// ERROR HANDLING: Store error information for failed AI responses (like T3.chat)
143-
// This allows errors to be displayed inline in the conversation history
144123
error: v.optional(
145124
v.object({
146-
// Error classification: "rate_limit", "auth_error", "model_error", "network_error", "content_filter", "context_length", "unknown"
147125
code: v.string(),
148-
// Human-readable error message
149126
message: v.string(),
150-
// Optional detailed error info (stack trace, API response, etc.) for debugging
151127
details: v.optional(v.string()),
152-
// Which provider/model caused the error
153128
provider: v.optional(v.string()),
154-
// Whether the user can retry this request
155129
retryable: v.optional(v.boolean()),
156130
})
157131
),
158-
// Message type to distinguish content types: "text" (default), "error", "system"
159-
// "error" messages display with error styling, "system" for notifications
160132
messageType: v.optional(
161133
v.union(v.literal("text"), v.literal("error"), v.literal("system"))
162134
),
163135
createdAt: v.number(),
164136
status: v.optional(v.string()),
165137
userId: v.optional(v.id("users")),
166138
deletedAt: v.optional(v.number()),
167-
// Stream ID for persistent text streaming - links to @convex-dev/persistent-text-streaming
168139
streamId: v.optional(v.string()),
169140
})
170141
.index("by_chat", ["chatId", "createdAt"])
@@ -173,7 +144,8 @@ export default defineSchema({
173144
.index("by_user_status", ["userId", "status", "createdAt"])
174145
.index("by_chat_not_deleted", ["chatId", "deletedAt", "createdAt"])
175146
.index("by_user_created", ["userId", "createdAt"])
176-
.index("by_stream_id", ["streamId"]),
147+
.index("by_stream_id", ["streamId"])
148+
.index("by_chat_status", ["chatId", "status", "deletedAt"]),
177149
fileUploads: defineTable({
178150
userId: v.id("users"),
179151
chatId: v.id("chats"),

apps/server/convex/users.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,3 +351,59 @@ export const removeOpenRouterKey = mutation({
351351
return { success: true };
352352
},
353353
});
354+
355+
export const getFavoriteModels = query({
356+
args: {
357+
userId: v.id("users"),
358+
},
359+
returns: v.union(v.array(v.string()), v.null()),
360+
handler: async (ctx, args) => {
361+
const profile = await getProfileByUserId(ctx, args.userId);
362+
// Return null if favorites have never been set (allows frontend to apply defaults)
363+
// Return [] if user explicitly cleared all favorites
364+
if (!profile) return null;
365+
return profile.favoriteModels ?? null;
366+
},
367+
});
368+
369+
export const toggleFavoriteModel = mutation({
370+
args: {
371+
userId: v.id("users"),
372+
modelId: v.string(),
373+
},
374+
returns: v.object({ isFavorite: v.boolean(), favorites: v.array(v.string()) }),
375+
handler: async (ctx, args) => {
376+
const profile = await getOrCreateProfile(ctx, args.userId);
377+
const currentFavorites = profile.favoriteModels ?? [];
378+
const isFavorite = currentFavorites.includes(args.modelId);
379+
380+
const newFavorites = isFavorite
381+
? currentFavorites.filter((id) => id !== args.modelId)
382+
: [...currentFavorites, args.modelId];
383+
384+
await ctx.db.patch(profile._id, {
385+
favoriteModels: newFavorites,
386+
updatedAt: Date.now(),
387+
});
388+
389+
return { isFavorite: !isFavorite, favorites: newFavorites };
390+
},
391+
});
392+
393+
export const setFavoriteModels = mutation({
394+
args: {
395+
userId: v.id("users"),
396+
modelIds: v.array(v.string()),
397+
},
398+
returns: v.object({ success: v.boolean() }),
399+
handler: async (ctx, args) => {
400+
const profile = await getOrCreateProfile(ctx, args.userId);
401+
402+
await ctx.db.patch(profile._id, {
403+
favoriteModels: args.modelIds,
404+
updatedAt: Date.now(),
405+
});
406+
407+
return { success: true };
408+
},
409+
});

apps/web/src/components/app-sidebar.tsx

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect } from "react";
1+
import { useEffect, useRef } from "react";
22
import { useNavigate, useParams } from "@tanstack/react-router";
33
import { useQuery } from "convex/react";
44
import { api } from "@server/convex/_generated/api";
@@ -19,6 +19,8 @@ import {
1919
} from "./ui/sidebar";
2020
import { PlusIcon, ChatIcon, SidebarIcon, ChevronRightIcon } from "@/components/icons";
2121

22+
const CHATS_CACHE_KEY = "openchat-chats-cache";
23+
2224
// Group chats by time periods
2325
interface ChatItem {
2426
_id: string;
@@ -114,40 +116,41 @@ export function AppSidebar() {
114116
convexClient && user?.id ? { externalId: user.id } : "skip",
115117
);
116118

117-
// Then fetch chat history using the Convex user ID
118119
const chatsResult = useQuery(
119120
api.chats.list,
120121
convexClient && convexUser?._id ? { userId: convexUser._id } : "skip",
121122
);
122123

123-
const chats = chatsResult?.chats ?? [];
124+
const cachedChatsRef = useRef<ChatItem[] | null>(null);
124125

125-
// Loading state with timeout protection
126-
// If queries don't resolve after 10 seconds, stop showing loading skeleton
127-
// This prevents eternally stuck loading states on connection issues
128-
const [loadingTimeout, setLoadingTimeout] = useState(false);
129126
useEffect(() => {
130-
if (!user?.id) return;
131-
132-
const timer = setTimeout(() => {
133-
setLoadingTimeout(true);
134-
}, 10000); // 10 second timeout
135-
136-
// Clear timeout if data loads
137-
if (convexUser !== undefined && chatsResult !== undefined) {
138-
clearTimeout(timer);
139-
setLoadingTimeout(false);
127+
if (typeof window === "undefined") return;
128+
try {
129+
const stored = localStorage.getItem(CHATS_CACHE_KEY);
130+
if (stored && !cachedChatsRef.current) {
131+
cachedChatsRef.current = JSON.parse(stored);
132+
}
133+
} catch {}
134+
}, []);
135+
136+
useEffect(() => {
137+
if (chatsResult?.chats && chatsResult.chats.length > 0) {
138+
cachedChatsRef.current = chatsResult.chats as unknown as ChatItem[];
139+
try {
140+
localStorage.setItem(CHATS_CACHE_KEY, JSON.stringify(chatsResult.chats));
141+
} catch {}
140142
}
141-
142-
return () => clearTimeout(timer);
143-
}, [user?.id, convexUser, chatsResult]);
143+
}, [chatsResult?.chats]);
144+
145+
const chats = (chatsResult?.chats ?? cachedChatsRef.current ?? []) as unknown as ChatItem[];
144146

145-
// Show loading while user OR chats are loading (prevents flash of "No chats yet")
146-
// But don't show loading forever if connection fails (timeout after 10s)
147-
const isLoadingChats = user?.id
148-
? !loadingTimeout && (convexUser === undefined || chatsResult === undefined)
147+
const hasCachedChats = chats.length > 0;
148+
149+
const isLoadingChats = user?.id && !hasCachedChats
150+
? convexUser === undefined || chatsResult === undefined
149151
: false;
150-
const grouped = groupChatsByTime(chats as unknown as ChatItem[]);
152+
153+
const grouped = groupChatsByTime(chats);
151154

152155
const handleNewChat = () => {
153156
if (isMobile) {

apps/web/src/components/chat-interface.tsx

Lines changed: 2 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -35,19 +35,8 @@ import {
3535
usePromptInputController,
3636
type PromptInputMessage,
3737
} from "./ai-elements/prompt-input";
38-
import {
39-
ModelSelector,
40-
ModelSelectorTrigger,
41-
ModelSelectorContent,
42-
ModelSelectorInput,
43-
ModelSelectorList,
44-
ModelSelectorEmpty,
45-
ModelSelectorGroup,
46-
ModelSelectorItem,
47-
ModelSelectorLogo,
48-
ModelSelectorName,
49-
} from "./ai-elements/model-selector";
50-
import { useModelStore, useModels, type Model, type ReasoningEffort } from "@/stores/model";
38+
import { ConnectedModelSelector } from "./model-selector";
39+
import { useModelStore, type ReasoningEffort } from "@/stores/model";
5140
import { useWebSearch } from "@/stores/provider";
5241
import {
5342
DropdownMenu,
@@ -63,7 +52,6 @@ import { usePersistentChat } from "@/hooks/use-persistent-chat";
6352
import { toast } from "sonner";
6453
import {
6554
ChevronDownIcon,
66-
CheckIcon,
6755
PaperclipIcon,
6856
ArrowUpIcon,
6957
SquareIcon,
@@ -886,70 +874,6 @@ function ModelConfigPopover({ disabled }: ModelConfigPopoverProps) {
886874
);
887875
}
888876

889-
// Connected Model Selector - Uses AI Elements pattern with SVG logos
890-
interface ConnectedModelSelectorProps {
891-
disabled?: boolean;
892-
}
893-
894-
function ConnectedModelSelector({ disabled }: ConnectedModelSelectorProps) {
895-
const { selectedModelId, setSelectedModel } = useModelStore();
896-
const { models, modelsByFamily, families, isLoading } = useModels();
897-
const [open, setOpen] = useState(false);
898-
899-
const selectedModel = models.find((m: Model) => m.id === selectedModelId);
900-
901-
return (
902-
<ModelSelector open={open} onOpenChange={setOpen}>
903-
<ModelSelectorTrigger
904-
disabled={disabled || isLoading}
905-
className={cn(
906-
"flex items-center gap-2",
907-
"h-8 px-3 rounded-full",
908-
"text-sm text-muted-foreground",
909-
"bg-muted/50 hover:bg-muted hover:text-foreground",
910-
"border border-border/50",
911-
"transition-all duration-150",
912-
"disabled:opacity-50 disabled:cursor-not-allowed",
913-
)}
914-
>
915-
{selectedModel && <ModelSelectorLogo provider={selectedModel.providerId || "openrouter"} />}
916-
<span className="truncate max-w-[140px]">
917-
{isLoading ? "Loading..." : selectedModel?.name || "Select model"}
918-
</span>
919-
<ChevronDownIcon className="size-3.5 opacity-50" />
920-
</ModelSelectorTrigger>
921-
<ModelSelectorContent className="w-[420px]">
922-
<ModelSelectorInput placeholder="Search models..." />
923-
<ModelSelectorList>
924-
<ModelSelectorEmpty>No models found.</ModelSelectorEmpty>
925-
{families.map((family: string) => (
926-
<ModelSelectorGroup key={family} heading={family}>
927-
{(modelsByFamily[family] || []).map((model: Model) => (
928-
<ModelSelectorItem
929-
key={model.id}
930-
value={model.id}
931-
onSelect={() => {
932-
setSelectedModel(model.id);
933-
setOpen(false);
934-
}}
935-
>
936-
<ModelSelectorLogo provider={model.providerId || "openrouter"} />
937-
<ModelSelectorName>{model.name}</ModelSelectorName>
938-
{model.id === selectedModelId ? (
939-
<CheckIcon className="ml-auto size-4" />
940-
) : (
941-
<div className="ml-auto size-4" />
942-
)}
943-
</ModelSelectorItem>
944-
))}
945-
</ModelSelectorGroup>
946-
))}
947-
</ModelSelectorList>
948-
</ModelSelectorContent>
949-
</ModelSelector>
950-
);
951-
}
952-
953877
// Pill Button Component for Search/Attach
954878
interface PillButtonProps {
955879
icon: React.ReactNode;

apps/web/src/components/command-palette.tsx

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -430,18 +430,4 @@ function CommandItem({
430430
* Should be used in the root layout
431431
*/
432432
export function useCommandPaletteShortcut() {
433-
const { toggleCommandPalette } = useUIStore();
434-
435-
useEffect(() => {
436-
function handleKeyDown(e: KeyboardEvent) {
437-
// CMD+K (Mac) or Ctrl+K (Windows/Linux)
438-
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
439-
e.preventDefault();
440-
toggleCommandPalette();
441-
}
442-
}
443-
444-
document.addEventListener("keydown", handleKeyDown);
445-
return () => document.removeEventListener("keydown", handleKeyDown);
446-
}, [toggleCommandPalette]);
447433
}

0 commit comments

Comments
 (0)