diff --git a/.env.example b/.env.example index 1b8f3344..830a951a 100644 --- a/.env.example +++ b/.env.example @@ -57,12 +57,12 @@ VITE_CONVEX_SITE_URL=http://localhost:3210 # VITE_POSTHOG_HOST=https://us.i.posthog.com # ============================================================================== -# OPTIONAL: Rate Limiting Configuration +# OPTIONAL: Redis (Resumable Streams) # ============================================================================== -# Rate limiting mode: "redis" | "memory" (auto-detects if not set) -# RATE_LIMIT_MODE=memory +# Local Redis (recommended for resumable streams in dev) +# REDIS_URL=redis://localhost:6379 -# Upstash Redis (for distributed rate limiting in production) +# Upstash Redis (for resumable streams in production) # Get credentials from: https://console.upstash.com/redis # UPSTASH_REDIS_REST_URL=https://your-redis.upstash.io # UPSTASH_REDIS_REST_TOKEN=your_token_here diff --git a/apps/server/convex/_generated/api.d.ts b/apps/server/convex/_generated/api.d.ts index 8f8ff0a5..8a77603e 100644 --- a/apps/server/convex/_generated/api.d.ts +++ b/apps/server/convex/_generated/api.d.ts @@ -9,6 +9,7 @@ */ import type * as auth from "../auth.js"; +import type * as backgroundStream from "../backgroundStream.js"; import type * as chats from "../chats.js"; import type * as config_constants from "../config/constants.js"; import type * as crons from "../crons.js"; @@ -40,6 +41,7 @@ import type { declare const fullApi: ApiFromModules<{ auth: typeof auth; + backgroundStream: typeof backgroundStream; chats: typeof chats; "config/constants": typeof config_constants; crons: typeof crons; diff --git a/apps/server/convex/auth.ts b/apps/server/convex/auth.ts index 4bb12156..01aa4a52 100644 --- a/apps/server/convex/auth.ts +++ b/apps/server/convex/auth.ts @@ -61,7 +61,7 @@ export const createAuth = ( oAuthProxy({ productionURL: PRODUCTION_CONVEX_SITE_URL, currentURL: convexSiteUrl, - }) + }) as unknown as typeof plugins[number] ); } @@ -110,6 +110,6 @@ export const createAuth = ( export const getCurrentUser = query({ args: {}, handler: async (ctx) => { - return authComponent.getAuthUser(ctx); + return authComponent.getAuthUser(ctx as unknown as GenericCtx); }, }); diff --git a/apps/server/convex/backgroundStream.ts b/apps/server/convex/backgroundStream.ts new file mode 100644 index 00000000..f67ba1a6 --- /dev/null +++ b/apps/server/convex/backgroundStream.ts @@ -0,0 +1,415 @@ +import { v } from "convex/values"; +import { mutation, query, internalMutation, internalAction, internalQuery } from "./_generated/server"; +import { internal } from "./_generated/api"; + +export const startStream = mutation({ + args: { + chatId: v.id("chats"), + userId: v.id("users"), + messageId: v.string(), + model: v.string(), + provider: v.string(), + apiKey: v.optional(v.string()), + messages: v.array(v.object({ + role: v.string(), + content: v.string(), + })), + options: v.optional(v.object({ + reasoningEffort: v.optional(v.string()), + enableWebSearch: v.optional(v.boolean()), + maxSteps: v.optional(v.number()), + })), + }, + returns: v.id("streamJobs"), + handler: async (ctx, args) => { + const chat = await ctx.db.get(args.chatId); + if (!chat || chat.userId !== args.userId) { + throw new Error("Chat not found or unauthorized"); + } + + const existingActiveStream = await ctx.db + .query("streamJobs") + .withIndex("by_chat", (q) => + q.eq("chatId", args.chatId).eq("status", "running") + ) + .first(); + + if (existingActiveStream) { + throw new Error("Stream already in progress for this chat"); + } + + const jobId = await ctx.db.insert("streamJobs", { + chatId: args.chatId, + userId: args.userId, + messageId: args.messageId, + status: "pending", + model: args.model, + provider: args.provider, + messages: args.messages, + options: args.options, + content: "", + createdAt: Date.now(), + }); + + await ctx.db.patch(args.chatId, { + activeStreamId: `job-${jobId}`, + status: "streaming", + updatedAt: Date.now(), + }); + + await ctx.scheduler.runAfter(0, internal.backgroundStream.executeStream, { + jobId, + apiKey: args.apiKey, + }); + + return jobId; + }, +}); + +export const getStreamJob = query({ + args: { + jobId: v.id("streamJobs"), + userId: v.id("users"), + }, + returns: v.union( + v.object({ + _id: v.id("streamJobs"), + status: v.string(), + content: v.string(), + reasoning: v.optional(v.string()), + error: v.optional(v.string()), + messageId: v.string(), + }), + v.null() + ), + handler: async (ctx, args) => { + const job = await ctx.db.get(args.jobId); + if (!job || job.userId !== args.userId) return null; + + return { + _id: job._id, + status: job.status, + content: job.content, + reasoning: job.reasoning, + error: job.error, + messageId: job.messageId, + }; + }, +}); + +export const getActiveStreamJob = query({ + args: { + chatId: v.id("chats"), + userId: v.id("users"), + }, + returns: v.union( + v.object({ + _id: v.id("streamJobs"), + status: v.string(), + content: v.string(), + reasoning: v.optional(v.string()), + error: v.optional(v.string()), + messageId: v.string(), + }), + v.null() + ), + handler: async (ctx, args) => { + const jobs = await ctx.db + .query("streamJobs") + .withIndex("by_chat", (q) => + q.eq("chatId", args.chatId).eq("status", "running") + ) + .first(); + + if (!jobs || jobs.userId !== args.userId) { + const pending = await ctx.db + .query("streamJobs") + .withIndex("by_chat", (q) => + q.eq("chatId", args.chatId).eq("status", "pending") + ) + .first(); + + if (!pending || pending.userId !== args.userId) return null; + + return { + _id: pending._id, + status: pending.status, + content: pending.content, + reasoning: pending.reasoning, + error: pending.error, + messageId: pending.messageId, + }; + } + + return { + _id: jobs._id, + status: jobs.status, + content: jobs.content, + reasoning: jobs.reasoning, + error: jobs.error, + messageId: jobs.messageId, + }; + }, +}); + +export const updateStreamContent = internalMutation({ + args: { + jobId: v.id("streamJobs"), + content: v.string(), + reasoning: v.optional(v.string()), + status: v.optional(v.union( + v.literal("pending"), + v.literal("running"), + v.literal("completed"), + v.literal("error") + )), + error: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const job = await ctx.db.get(args.jobId); + if (!job) return; + + const updates: Record = { + content: args.content, + }; + + if (args.reasoning !== undefined) { + updates.reasoning = args.reasoning; + } + if (args.status !== undefined) { + updates.status = args.status; + if (args.status === "running" && !job.startedAt) { + updates.startedAt = Date.now(); + } + if (args.status === "completed" || args.status === "error") { + updates.completedAt = Date.now(); + } + } + if (args.error !== undefined) { + updates.error = args.error; + } + + await ctx.db.patch(args.jobId, updates); + }, +}); + +export const completeStream = internalMutation({ + args: { + jobId: v.id("streamJobs"), + content: v.string(), + reasoning: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const job = await ctx.db.get(args.jobId); + if (!job) return; + + await ctx.db.patch(args.jobId, { + status: "completed", + content: args.content, + reasoning: args.reasoning, + completedAt: Date.now(), + }); + + await ctx.db.patch(job.chatId, { + activeStreamId: undefined, + status: "idle", + updatedAt: Date.now(), + }); + + const existingMessage = await ctx.db + .query("messages") + .withIndex("by_client_id", (q) => + q.eq("chatId", job.chatId).eq("clientMessageId", job.messageId) + ) + .first(); + + if (!existingMessage) { + await ctx.db.insert("messages", { + chatId: job.chatId, + clientMessageId: job.messageId, + role: "assistant", + content: args.content, + reasoning: args.reasoning, + createdAt: Date.now(), + }); + } else { + await ctx.db.patch(existingMessage._id, { + content: args.content, + reasoning: args.reasoning, + }); + } + }, +}); + +export const failStream = internalMutation({ + args: { + jobId: v.id("streamJobs"), + error: v.string(), + partialContent: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const job = await ctx.db.get(args.jobId); + if (!job) return; + + await ctx.db.patch(args.jobId, { + status: "error", + error: args.error, + content: args.partialContent || job.content, + completedAt: Date.now(), + }); + + await ctx.db.patch(job.chatId, { + activeStreamId: undefined, + status: "idle", + updatedAt: Date.now(), + }); + }, +}); + +export const executeStream = internalAction({ + args: { + jobId: v.id("streamJobs"), + apiKey: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const job = await ctx.runQuery(internal.backgroundStream.getJobInternal, { + jobId: args.jobId + }); + + if (!job) { + console.error("[BackgroundStream] Job not found:", args.jobId); + return; + } + + await ctx.runMutation(internal.backgroundStream.updateStreamContent, { + jobId: args.jobId, + content: "", + status: "running", + }); + + const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY; + const apiKey = job.provider === "osschat" ? OPENROUTER_API_KEY : args.apiKey; + + if (!apiKey) { + await ctx.runMutation(internal.backgroundStream.failStream, { + jobId: args.jobId, + error: "No API key available", + }); + return; + } + + try { + const timeoutMs = 5 * 60 * 1000; // 5 minutes + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}`, + "HTTP-Referer": process.env.CONVEX_SITE_URL || "https://osschat.io", + "X-Title": "OSSChat", + }, + body: JSON.stringify({ + model: job.model, + messages: job.messages.map((m: { role: string; content: string }) => ({ + role: m.role, + content: m.content, + })), + stream: true, + }), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const errorText = await response.text(); + await ctx.runMutation(internal.backgroundStream.failStream, { + jobId: args.jobId, + error: `OpenRouter API error: ${response.status} - ${errorText}`, + }); + return; + } + + const reader = response.body?.getReader(); + if (!reader) { + await ctx.runMutation(internal.backgroundStream.failStream, { + jobId: args.jobId, + error: "No response body", + }); + return; + } + + const decoder = new TextDecoder(); + let fullContent = ""; + let fullReasoning = ""; + let buffer = ""; + let updateCounter = 0; + const UPDATE_INTERVAL = 5; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const data = line.slice(6).trim(); + if (data === "[DONE]") continue; + + try { + const parsed = JSON.parse(data); + const delta = parsed.choices?.[0]?.delta; + + if (delta?.content) { + fullContent += delta.content; + updateCounter++; + } + if (delta?.reasoning) { + fullReasoning += delta.reasoning; + updateCounter++; + } + + if (updateCounter >= UPDATE_INTERVAL) { + await ctx.runMutation(internal.backgroundStream.updateStreamContent, { + jobId: args.jobId, + content: fullContent, + reasoning: fullReasoning || undefined, + }); + updateCounter = 0; + } + } catch (parseError) { + console.error("[BackgroundStream] Failed to parse SSE chunk:", data); + } + } + } + + await ctx.runMutation(internal.backgroundStream.completeStream, { + jobId: args.jobId, + content: fullContent, + reasoning: fullReasoning || undefined, + }); + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + await ctx.runMutation(internal.backgroundStream.failStream, { + jobId: args.jobId, + error: errorMessage, + }); + } + }, +}); + +export const getJobInternal = internalQuery({ + args: { + jobId: v.id("streamJobs"), + }, + handler: async (ctx, args) => { + return await ctx.db.get(args.jobId); + }, +}); diff --git a/apps/server/convex/chats.ts b/apps/server/convex/chats.ts index 51ac25cb..2acaf25c 100644 --- a/apps/server/convex/chats.ts +++ b/apps/server/convex/chats.ts @@ -17,8 +17,8 @@ const chatDoc = v.object({ lastMessageAt: v.optional(v.number()), deletedAt: v.optional(v.number()), messageCount: v.optional(v.number()), - // Chat streaming status: "idle" | "streaming" - used for stream resumption status: v.optional(v.union(v.literal("idle"), v.literal("streaming"))), + activeStreamId: v.optional(v.string()), }); // Optimized chat list response: exclude redundant fields to reduce bandwidth @@ -310,13 +310,11 @@ export const updateTitle = mutation({ }, returns: v.null(), handler: async (ctx, args) => { - // Verify user owns the chat const chat = await ctx.db.get(args.chatId); if (!chat || chat.userId !== args.userId || chat.deletedAt) { return null; } - // Only update if title is still "New Chat" or empty if (chat.title === "New Chat" || !chat.title) { const sanitizedTitle = sanitizeTitle(args.title.trim().slice(0, 100)); await ctx.db.patch(args.chatId, { @@ -329,3 +327,41 @@ export const updateTitle = mutation({ }, }); +export const setActiveStream = mutation({ + args: { + chatId: v.id("chats"), + userId: v.id("users"), + streamId: v.union(v.string(), v.null()), + }, + returns: v.null(), + handler: async (ctx, args) => { + const chat = await ctx.db.get(args.chatId); + if (!chat || chat.userId !== args.userId || chat.deletedAt) { + return null; + } + + await ctx.db.patch(args.chatId, { + activeStreamId: args.streamId ?? undefined, + status: args.streamId ? "streaming" : "idle", + updatedAt: Date.now(), + }); + + return null; + }, +}); + +export const getActiveStream = query({ + args: { + chatId: v.id("chats"), + userId: v.id("users"), + }, + returns: v.union(v.string(), v.null()), + handler: async (ctx, args) => { + const chat = await ctx.db.get(args.chatId); + if (!chat || chat.userId !== args.userId || chat.deletedAt) { + return null; + } + return chat.activeStreamId ?? null; + }, +}); + diff --git a/apps/server/convex/messages.ts b/apps/server/convex/messages.ts index c6021743..3d345f8d 100644 --- a/apps/server/convex/messages.ts +++ b/apps/server/convex/messages.ts @@ -336,6 +336,7 @@ export const streamUpsert = mutation({ reasoning: v.optional(v.string()), thinkingTimeMs: v.optional(v.number()), toolInvocations: v.optional(v.array(toolInvocationValidator)), + chainOfThoughtParts: v.optional(v.array(chainOfThoughtPartValidator)), createdAt: v.optional(v.number()), status: v.optional(v.string()), attachments: v.optional( @@ -377,6 +378,7 @@ export const streamUpsert = mutation({ reasoning: args.reasoning, thinkingTimeMs: args.thinkingTimeMs, toolInvocations: args.toolInvocations, + chainOfThoughtParts: args.chainOfThoughtParts, createdAt: timestamp, status: args.status ?? "streaming", clientMessageId: args.clientMessageId, diff --git a/apps/server/convex/schema.ts b/apps/server/convex/schema.ts index 4e90ace2..a015ef38 100644 --- a/apps/server/convex/schema.ts +++ b/apps/server/convex/schema.ts @@ -47,18 +47,14 @@ export default defineSchema({ }).index("by_user", ["userId"]), chats: defineTable({ userId: v.id("users"), - // Title can be encrypted (prefixed with enc_v1:) title: v.string(), createdAt: v.number(), updatedAt: v.number(), lastMessageAt: v.optional(v.number()), deletedAt: v.optional(v.number()), - // PERFORMANCE OPTIMIZATION: Track message count to avoid expensive queries - // This field is maintained by message insert/delete operations messageCount: v.optional(v.number()), - // Chat streaming status: "idle" | "streaming" - used to show spinner in sidebar - // and enable stream resumption on page reload status: v.optional(v.union(v.literal("idle"), v.literal("streaming"))), + activeStreamId: v.optional(v.string()), }) .index("by_user", ["userId", "updatedAt"]) .index("by_user_created", ["userId", "createdAt"]) @@ -212,4 +208,38 @@ export default defineSchema({ }) .index("by_user", ["userId"]) .index("by_user_chat", ["userId", "chatId"]), + + // Background streaming jobs - allows AI generation to continue even if client disconnects + streamJobs: defineTable({ + chatId: v.id("chats"), + userId: v.id("users"), + messageId: v.string(), + status: v.union( + v.literal("pending"), + v.literal("running"), + v.literal("completed"), + v.literal("error") + ), + model: v.string(), + provider: v.string(), + messages: v.array(v.object({ + role: v.string(), + content: v.string(), + })), + options: v.optional(v.object({ + reasoningEffort: v.optional(v.string()), + enableWebSearch: v.optional(v.boolean()), + maxSteps: v.optional(v.number()), + })), + content: v.string(), + reasoning: v.optional(v.string()), + error: v.optional(v.string()), + tokenCount: v.optional(v.number()), + startedAt: v.optional(v.number()), + completedAt: v.optional(v.number()), + createdAt: v.number(), + }) + .index("by_chat", ["chatId", "status"]) + .index("by_user", ["userId", "status"]) + .index("by_status", ["status", "createdAt"]), }); diff --git a/apps/server/convex/users.ts b/apps/server/convex/users.ts index f14819c8..3f58712c 100644 --- a/apps/server/convex/users.ts +++ b/apps/server/convex/users.ts @@ -1,5 +1,7 @@ import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; +import type { GenericCtx } from "@convex-dev/better-auth"; +import type { DataModel } from "./_generated/dataModel"; import { incrementStat, STAT_KEYS } from "./lib/dbStats"; import { rateLimiter } from "./lib/rateLimiter"; import { throwRateLimitError } from "./lib/rateLimitUtils"; @@ -183,7 +185,7 @@ export const ensure = mutation({ export const getCurrentAuthUser = query({ args: {}, handler: async (ctx) => { - return authComponent.getAuthUser(ctx); + return authComponent.getAuthUser(ctx as unknown as GenericCtx); }, }); diff --git a/apps/web/package.json b/apps/web/package.json index 4261c492..c9e11f9b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,11 +17,11 @@ "check": "prettier --write . && eslint --fix" }, "dependencies": { - "@ai-sdk/react": "^2.0.117", + "@ai-sdk/react": "3.0.39", "@base-ui/react": "^1.0.0", "@convex-dev/better-auth": "^0.10.5", "@fontsource-variable/nunito-sans": "^5.2.7", - "@openrouter/ai-sdk-provider": "^1.5.4", + "@openrouter/ai-sdk-provider": "6.0.0-alpha.1", "@radix-ui/react-use-controllable-state": "^1.2.2", "@tailwindcss/vite": "^4.0.6", "@tanstack/react-query": "^5.90.12", @@ -31,7 +31,7 @@ "@tanstack/router-plugin": "^1.132.0", "@valyu/ai-sdk": "^1.0.3", "@xyflow/react": "^12.10.0", - "ai": "^6.0.5", + "ai": "6.0.37", "ai-elements": "^1.6.3", "better-auth": "^1.4.7", "class-variance-authority": "^0.7.1", @@ -47,6 +47,8 @@ "posthog-js": "^1.311.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "redis": "^5.10.0", + "resumable-stream": "^2.2.10", "server": "workspace:*", "shadcn": "^3.6.2", "shiki": "^3.20.0", @@ -73,6 +75,7 @@ "typescript": "^5.7.2", "vite": "^7.1.7", "vitest": "^3.0.5", - "web-vitals": "^5.1.0" + "web-vitals": "^5.1.0", + "zod": "^4.3.5" } } diff --git a/apps/web/src/components/ai-elements/message.tsx b/apps/web/src/components/ai-elements/message.tsx index e091c8b3..fef7165b 100644 --- a/apps/web/src/components/ai-elements/message.tsx +++ b/apps/web/src/components/ai-elements/message.tsx @@ -89,13 +89,13 @@ export const MessageContent = ({ children, className, ...props }: MessageContent export interface MessageResponseProps extends ComponentProps<"div"> { children: string; + isStreaming?: boolean; } -export const MessageResponse = ({ children, className, ...props }: MessageResponseProps) => { +export const MessageResponse = ({ children, className, isStreaming, ...props }: MessageResponseProps) => { const { from } = useMessage(); const isUser = from === "user"; - // User messages: simple bubble styling if (isUser) { return (
{children || ""} + {isStreaming && ( +
); }; diff --git a/apps/web/src/components/ai-elements/prompt-input.tsx b/apps/web/src/components/ai-elements/prompt-input.tsx index 74fe11a5..b6c59b31 100644 --- a/apps/web/src/components/ai-elements/prompt-input.tsx +++ b/apps/web/src/components/ai-elements/prompt-input.tsx @@ -42,6 +42,7 @@ import { SquareIcon, XIcon, } from "lucide-react"; +import { motion } from "motion/react"; import { nanoid } from "nanoid"; import { type ChangeEvent, @@ -1032,16 +1033,22 @@ export const PromptInputSubmit = ({ } return ( - - {children ?? Icon} - + + {children ?? Icon} + + ); }; diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index a03294dc..31b330ca 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -30,11 +30,11 @@ interface ChatItem { } // Skeleton for loading chat items -function ChatItemSkeleton() { +function ChatItemSkeleton({ delay = 0 }: { delay?: number }) { return (
-
-
+
0 && `[animation-delay:${delay}ms]`)} style={delay > 0 ? { animationDelay: `${delay}ms` } : undefined} /> +
0 && `[animation-delay:${delay}ms]`)} style={delay > 0 ? { animationDelay: `${delay}ms` } : undefined} />
); } @@ -241,10 +241,10 @@ export function AppSidebar() { {isLoadingChats ? (
- - - - + + + +
) : chats.length === 0 ? (
diff --git a/apps/web/src/components/chat-interface.tsx b/apps/web/src/components/chat-interface.tsx index b0494e60..c97a7562 100644 --- a/apps/web/src/components/chat-interface.tsx +++ b/apps/web/src/components/chat-interface.tsx @@ -15,6 +15,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useNavigate } from "@tanstack/react-router"; +import { motion, AnimatePresence } from "motion/react"; import type { UIMessagePart, UIDataTypes, UITools } from "ai"; import { cn } from "@/lib/utils"; import { Button } from "./ui/button"; @@ -75,6 +76,16 @@ function useIsMobile() { return isMobile; } +function useIsMac() { + const [isMac, setIsMac] = useState(true); + + useEffect(() => { + setIsMac(navigator.platform.toLowerCase().includes("mac")); + }, []); + + return isMac; +} + // Auto-scroll component - scrolls to bottom when messages change function AutoScroll({ messageCount }: { messageCount: number }) { const { scrollToBottom, isAtBottom } = useConversationScroll(); @@ -118,34 +129,7 @@ function LoadingIndicator() { ); } -// Realistic skeleton for loading messages (matches actual message styling exactly) -function MessagesLoadingSkeleton() { - return ( -
- {/* User message skeleton - matches MessageResponse user: rounded-2xl bg-primary px-4 py-3 */} -
-
-
-
-
-
-
-
-
- {/* Assistant message skeleton - matches prose text: text-[15px] leading-relaxed text-foreground/90 */} -
-
-
-
-
-
-
-
- ); -} - -// Note: ErrorDisplay removed - errors are now shown inline as messages via InlineErrorMessage // Inline error message component (like T3.chat) - displayed in message thread interface InlineErrorMessageProps { @@ -161,6 +145,31 @@ interface InlineErrorMessageProps { function InlineErrorMessage({ error, onRetry }: InlineErrorMessageProps) { const [showDetails, setShowDetails] = useState(false); + const [retryCount, setRetryCount] = useState(0); + const [isRetrying, setIsRetrying] = useState(false); + + const MAX_RETRIES = 3; + const retriesRemaining = MAX_RETRIES - retryCount; + const canRetry = error.retryable && onRetry && retryCount < MAX_RETRIES; + + // Exponential backoff: 1s, 2s, 4s + const getBackoffDelay = (attempt: number) => Math.pow(2, attempt) * 1000; + + const handleRetry = async () => { + if (!canRetry || isRetrying) return; + + setIsRetrying(true); + const delay = getBackoffDelay(retryCount); + + // Wait for backoff delay + await new Promise((resolve) => setTimeout(resolve, delay)); + + setRetryCount((prev) => prev + 1); + setIsRetrying(false); + + // Call the retry function + onRetry?.(); + }; // Get human-readable error title based on code const getErrorTitle = (code: string) => { @@ -225,10 +234,25 @@ function InlineErrorMessage({ error, onRetry }: InlineErrorMessageProps) { )}
@@ -262,7 +286,6 @@ function buildChainOfThoughtSteps(parts: Array): { const steps: ChainOfThoughtStep[] = []; let isAnyStreaming = false; let hasTextContent = false; - let reasoningCount = 0; // Process parts in their original order (as they came from the stream) for (let i = 0; i < parts.length; i++) { @@ -277,7 +300,6 @@ function buildChainOfThoughtSteps(parts: Array): { if (part.type === "reasoning") { const isStreaming = part.state === "streaming"; if (isStreaming) isAnyStreaming = true; - reasoningCount++; // Each reasoning part is its own step (so they can collapse independently) steps.push({ @@ -1087,6 +1109,8 @@ function PremiumPromptInputInner({ const controller = usePromptInputController(); const hasContent = controller.textInput.value.trim().length > 0; const fileInputRef = useRef(null); + const isMac = useIsMac(); + const focusShortcut = isMac ? "⌘L" : "Ctrl+L"; const handleAttachClick = () => { fileInputRef.current?.click(); @@ -1124,7 +1148,7 @@ function PremiumPromptInputInner({ (null); // Use persistent chat hook with Convex integration - const { messages, sendMessage, status, error, stop, isLoadingMessages } = usePersistentChat({ + const { messages, sendMessage, status, error, stop, isNewChat } = usePersistentChat({ chatId, onChatCreated: (newChatId) => { // Navigate to the new chat page @@ -1214,8 +1238,7 @@ export function ChatInterface({ chatId }: ChatInterfaceProps) { >; }>; isLoading: boolean; - isLoadingMessages: boolean; - chatId?: string; + isNewChat: boolean; error: Error | null; stop: () => void; handleSubmit: (message: PromptInputMessage) => Promise; @@ -1244,9 +1266,8 @@ interface ChatInterfaceContentProps { function ChatInterfaceContent({ messages, isLoading, - isLoadingMessages, - chatId, - error: _error, // Errors are now shown inline as messages + isNewChat, + error: _error, stop, handleSubmit, textareaRef, @@ -1300,16 +1321,11 @@ function ChatInterfaceContent({ {/* Mobile: extra top padding to clear hamburger menu (fixed left-3 top-3 size-11 = 12px + 44px + 8px breathing room = 64px) */} - {/* Smart loading: skeleton for existing chats, StartScreen for new */} - {messages.length === 0 ? ( - chatId && isLoadingMessages ? ( - - ) : ( - - ) - ) : ( - <> - {messages.map((message) => { + {messages.length === 0 && isNewChat ? ( + + ) : messages.length === 0 ? null : ( + + {messages.map((message, index) => { // Cast to include our custom error fields const msg = message as typeof message & { error?: { @@ -1325,11 +1341,19 @@ function ChatInterfaceContent({ // Render error messages with special styling (like T3.chat) if (msg.messageType === "error" && msg.error) { return ( - - - - - + + + + + + + ); } @@ -1381,38 +1405,61 @@ function ChatInterfaceContent({ } = buildChainOfThoughtSteps(allParts); return ( - - - {/* Thinking UI - shown for any reasoning or tool calls */} - {thinkingSteps.length > 0 && ( - 0} - /> - )} - - {/* Text content */} - {textParts.map((part, index) => ( - {part.text || ""} - ))} - - {/* File attachments */} - {fileParts.map((part, index) => ( - - ))} - - + + + + {/* Thinking UI - shown for any reasoning or tool calls */} + {thinkingSteps.length > 0 && ( + 0} + /> + )} + + {/* Text content */} + {textParts.map((part, index) => ( + + {part.text || ""} + + ))} + + {/* File attachments */} + {fileParts.map((part, index) => ( + + ))} + + + ); })} - {isLoading && messages[messages.length - 1]?.role === "user" && } + {isLoading && messages[messages.length - 1]?.role === "user" && ( + + + + )} {/* Note: Errors are now shown inline as messages via InlineErrorMessage */} - + )} diff --git a/apps/web/src/components/navigation-progress.tsx b/apps/web/src/components/navigation-progress.tsx new file mode 100644 index 00000000..dcee471e --- /dev/null +++ b/apps/web/src/components/navigation-progress.tsx @@ -0,0 +1,75 @@ +import { useRouterState } from "@tanstack/react-router" + +export function NavigationProgress() { + const isLoading = useRouterState({ + select: (state) => state.isLoading, + }) + + return ( + <> + +