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
1,625 changes: 1,410 additions & 215 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"@strudel/repl": "^1.2.7",
"@strudel/web": "^1.2.6",
"@tailwindcss/oxide": "^4.1.17",
"@tambo-ai/react": "^0.65.3",
"@tambo-ai/react": "^1.1.0",
"@tambo-ai/typescript-sdk": "^0.79.0",
"@types/better-sqlite3": "^7.6.13",
"better-auth": "^1.4.7",
Expand Down
91 changes: 29 additions & 62 deletions src/app/chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ import { LoadingContextProvider } from "@/components/loading/context";
import type { Suggestion } from "@tambo-ai/react";
import {
TamboProvider,
useTamboThread,
useTamboThreadList,
useTambo,
useIsTamboTokenUpdating,
Expand Down Expand Up @@ -169,39 +168,39 @@ function useAuthIdentity() {
return { isPending, userId, userToken };
}

// Hook to get a stable context key for Tambo thread scoping.
// Hook to get a stable user key for Tambo thread scoping.
// - Authenticated users: Better Auth user id (stable across devices)
// - Anonymous users: persistent per-browser id stored in localStorage
// Changing this value changes which threads are visible in the UI.
// NOTE: This is not an auth token. Do not use this value for permissions or other security decisions.
// This hook assumes it only runs in the browser (client components).
function useContextKey({
function useUserKey({
userId,
isPending,
}: {
userId: string | null;
isPending: boolean;
}):
| { contextKey: string; isReady: true }
| { contextKey: null; isReady: false } {
const [contextKey, setContextKey] = React.useState<string | null>(null);
| { userKey: string; isReady: true }
| { userKey: null; isReady: false } {
const [userKey, setUserKey] = React.useState<string | null>(null);

React.useEffect(() => {
if (isPending) return;

if (userId) {
setContextKey(`strudel-user-${userId}`);
setUserKey(`strudel-user-${userId}`);
return;
}

setContextKey(getOrCreateAnonymousContextKey());
setUserKey(getOrCreateAnonymousContextKey());
}, [isPending, userId]);

if (contextKey) {
return { contextKey, isReady: true };
if (userKey) {
return { userKey, isReady: true };
}

return { contextKey: null, isReady: false };
return { userKey: null, isReady: false };
}

const strudelSuggestions: Suggestion[] = [
Expand All @@ -228,36 +227,24 @@ const strudelSuggestions: Suggestion[] = [
function AppContent() {
const [threadInitialized, setThreadInitialized] = React.useState(false);
const [showBetaModal, setShowBetaModal] = React.useState(false);
const lastContextKeyRef = React.useRef<string | null>(null);
const authIdentity = useAuthIdentity();
const contextKeyState = useContextKey({
userId: authIdentity.userId,
isPending: authIdentity.isPending,
});
const isTamboTokenUpdating = useIsTamboTokenUpdating();
// We intentionally wait for Better Auth identity to settle AND Tambo token exchange to complete
// before querying threads, to avoid 401 errors from API calls made before auth is ready.
const contextKeyAndIdentityReady =
contextKeyState.isReady && !authIdentity.isPending && !isTamboTokenUpdating;
const { data: sessionData } = useSession();
const { isPending } = useLoadingState();
const {
isReady: strudelIsReady,
setIsAiUpdating,
} = useStrudel();
const { thread, startNewThread, switchCurrentThread, isIdle } =
useTamboThread();
const { generationStage } = useTambo();
const { thread, startNewThread, switchThread, isStreaming, isWaiting } =
useTambo();

const isGenerating =
!isIdle && generationStage !== "IDLE" && generationStage !== "COMPLETE";
const isGenerating = isStreaming || isWaiting;

// Only query thread list when auth is fully ready
const readyContextKey = contextKeyAndIdentityReady
? contextKeyState.contextKey
: undefined;
// Wait for Tambo token exchange to complete before querying threads,
// to avoid 401 errors. Auth identity and userKey are already resolved
// by TamboAuthedProvider before this component renders.
const { data: threadList, isSuccess: threadListLoaded } = useTamboThreadList(
{ contextKey: readyContextKey },
{ enabled: contextKeyAndIdentityReady },
{},
{ enabled: !isTamboTokenUpdating },
);

// Track AI generation state to lock editor during updates
Expand All @@ -267,23 +254,20 @@ function AppContent() {

// Show beta modal on first login
React.useEffect(() => {
const userId = authIdentity.userId;
const userId = sessionData?.user?.id;
if (userId && !localStorage.getItem(BETA_MODAL_SHOWN_KEY)) {
setShowBetaModal(true);
localStorage.setItem(BETA_MODAL_SHOWN_KEY, "true");
}
}, [authIdentity.userId]);

// REPL initialization is now handled by StrudelStorageSync
// No need for explicit initialization here with single-REPL model
}, [sessionData?.user?.id]);

// Initialize: select most recent thread or create new
React.useEffect(() => {
if (!strudelIsReady || !threadListLoaded || threadInitialized) return;

const existingThreads = threadList?.items ?? [];
const existingThreads = threadList?.threads ?? [];
if (existingThreads.length > 0) {
switchCurrentThread(existingThreads[0].id, true);
switchThread(existingThreads[0].id);
} else {
startNewThread();
}
Expand All @@ -293,29 +277,10 @@ function AppContent() {
threadListLoaded,
threadInitialized,
threadList,
switchCurrentThread,
switchThread,
startNewThread,
]);

// Reset thread initialization when context key changes (e.g., login/logout)
React.useEffect(() => {
if (!contextKeyState.isReady) return;

const currentKey = contextKeyState.contextKey;
if (lastContextKeyRef.current !== null && lastContextKeyRef.current !== currentKey) {
setThreadInitialized(false);
}
lastContextKeyRef.current = currentKey;
}, [contextKeyState]);

// Thread/REPL association is no longer needed with single-REPL model
// All threads share the same REPL code

// Wait for auth and context key to be fully ready before rendering UI
if (!contextKeyAndIdentityReady) {
return <LoadingScreen />;
}

if (isPending || !strudelIsReady || !thread) {
return <LoadingScreen />;
}
Expand Down Expand Up @@ -346,7 +311,7 @@ function AppContent() {
{/* Input */}
<div className="p-3 border-t border-border">
<GenerationIndicator isGenerating={isGenerating} />
<MessageInput contextKey={readyContextKey}>
<MessageInput>
<MessageInputTextarea placeholder=">" />
<MessageInputToolbar>
<MessageInputNewThreadButton />
Expand All @@ -360,7 +325,6 @@ function AppContent() {

{/* Thread History Sidebar */}
<ThreadHistory
contextKey={readyContextKey}
position="right"
defaultCollapsed={true}
>
Expand Down Expand Up @@ -403,28 +367,31 @@ function TamboAuthedProvider({
children: React.ReactNode;
}) {
const { isPending, userId, userToken } = useAuthIdentity();
const userKeyState = useUserKey({ userId, isPending });

if (process.env.NODE_ENV !== "production" && userId && !userToken) {
console.warn("TamboAuthedProvider: userId present but userToken missing");
}

// Wait for Better Auth session to be fully resolved before initializing TamboProvider.
// This prevents 401 errors from attempting token exchange with an undefined/stale token.
if (isPending) {
if (isPending || !userKeyState.isReady) {
return <LoadingScreen />;
}

return (
<TamboProvider
tamboUrl={process.env.NEXT_PUBLIC_TAMBO_URL}
apiKey={apiKey}
userKey={userKeyState.userKey}
// Better Auth session token, exchanged by Tambo for a Tambo session token.
userToken={userToken ?? undefined}
tools={tools}
components={components}
contextHelpers={{
strudelState: strudelContextHelper,
}}
autoGenerateThreadName={true}
>
{children}
</TamboProvider>
Expand Down
98 changes: 13 additions & 85 deletions src/components/tambo/font-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
setEditorFontFamily,
setEditorFontSize,
} from "@/lib/editor-preferences";
import { useTamboStreamStatus } from "@tambo-ai/react";
import * as React from "react";
import { z } from "zod/v3";

Expand Down Expand Up @@ -36,56 +35,29 @@ export type FontSettingsProps = z.infer<typeof fontSettingsSchema>;

export const FontSettings = React.forwardRef<HTMLDivElement, FontSettingsProps>(
({ fontFamily, fontSize }, ref) => {
const { streamStatus, propStatus } =
useTamboStreamStatus<FontSettingsProps>();

// Track if we've done initial setup
const initializedRef = React.useRef(false);

// Local state for selected font and size
const [selectedFont, setSelectedFont] = React.useState<string | null>(null);
const [selectedFont, setSelectedFont] = React.useState<string>("monospace");
const [selectedSize, setSelectedSize] = React.useState<number>(14);

// Initialize from localStorage or AI props on mount
// On mount: apply AI prop if provided, otherwise read from localStorage
React.useEffect(() => {
if (initializedRef.current) return;
initializedRef.current = true;

const savedFontFamily = getFontFamily();
const savedFontSize = getFontSize();

// AI prop takes priority, then localStorage
if (fontFamily) {
setSelectedFont(fontFamily);
setEditorFontFamily(fontFamily);
} else if (savedFontFamily) {
setSelectedFont(savedFontFamily);
}

if (fontSize) {
setSelectedSize(fontSize);
setEditorFontSize(fontSize);
} else if (savedFontSize) {
setSelectedSize(savedFontSize);
} else {
const saved = getFontFamily();
if (saved) setSelectedFont(saved);
}
}, [fontFamily, fontSize]);
}, [fontFamily]);

// Handle AI prop changes after initial mount
React.useEffect(() => {
if (!initializedRef.current) return;
if (fontFamily && fontFamily !== selectedFont) {
setSelectedFont(fontFamily);
setEditorFontFamily(fontFamily);
}
}, [fontFamily, selectedFont]);

React.useEffect(() => {
if (!initializedRef.current) return;
if (fontSize && fontSize !== selectedSize) {
if (fontSize) {
setSelectedSize(fontSize);
setEditorFontSize(fontSize);
} else {
const saved = getFontSize();
if (saved) setSelectedSize(saved);
}
}, [fontSize, selectedSize]);
}, [fontSize]);

const handleFontSelect = (value: string) => {
setSelectedFont(value);
Expand All @@ -98,48 +70,24 @@ export const FontSettings = React.forwardRef<HTMLDivElement, FontSettingsProps>(
setEditorFontSize(size);
};

const isStreaming = streamStatus.isStreaming;

if (streamStatus.isPending) {
return (
<div
ref={ref}
className="w-full rounded-lg border border-border bg-card p-4"
>
<div className="text-sm text-muted-foreground animate-pulse">
Loading font settings...
</div>
</div>
);
}

return (
<div
ref={ref}
className="w-full rounded-lg border border-border bg-card p-4 space-y-4"
>
{/* Font Family Section */}
<div className="space-y-2">
<h3
className={cn(
"text-sm font-medium",
propStatus.fontFamily?.isStreaming && "animate-pulse",
)}
>
Font
</h3>
<h3 className="text-sm font-medium">Font</h3>
<div className="flex flex-wrap gap-2">
{fontFamilyOptions.map((option) => {
const isSelected = selectedFont === option.value;
return (
<button
key={option.value}
onClick={() => handleFontSelect(option.value)}
disabled={isStreaming}
className={cn(
"px-3 py-1.5 rounded-md text-sm border transition-all",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1",
"disabled:opacity-50 disabled:cursor-not-allowed",
isSelected
? "bg-primary text-primary-foreground border-primary"
: "bg-muted text-muted-foreground border-border hover:bg-muted/80 hover:text-foreground",
Expand All @@ -155,25 +103,13 @@ export const FontSettings = React.forwardRef<HTMLDivElement, FontSettingsProps>(
</button>
);
})}
{propStatus.fontFamily?.isStreaming && (
<span className="px-3 py-1.5 text-sm text-muted-foreground animate-pulse">
...
</span>
)}
</div>
</div>

{/* Font Size Section */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3
className={cn(
"text-sm font-medium",
propStatus.fontSize?.isStreaming && "animate-pulse",
)}
>
Font Size
</h3>
<h3 className="text-sm font-medium">Font Size</h3>
<span className="text-sm text-muted-foreground">
{selectedSize}px
</span>
Expand All @@ -186,10 +122,8 @@ export const FontSettings = React.forwardRef<HTMLDivElement, FontSettingsProps>(
max={24}
value={selectedSize}
onChange={handleSizeChange}
disabled={isStreaming}
className={cn(
"flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer",
"disabled:opacity-50 disabled:cursor-not-allowed",
"[&::-webkit-slider-thumb]:appearance-none",
"[&::-webkit-slider-thumb]:w-4",
"[&::-webkit-slider-thumb]:h-4",
Expand All @@ -209,12 +143,6 @@ export const FontSettings = React.forwardRef<HTMLDivElement, FontSettingsProps>(
<span className="text-xs text-muted-foreground">24</span>
</div>
</div>

{streamStatus.isError && streamStatus.streamError && (
<div className="pt-3 text-xs text-destructive">
Error: {streamStatus.streamError.message}
</div>
)}
</div>
);
},
Expand Down
Loading
Loading