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
13 changes: 8 additions & 5 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { CalendarToggleProvider } from '@/components/calendar-toggle-context'
import { MapLoadingProvider } from '@/components/map-loading-context';
import ConditionalLottie from '@/components/conditional-lottie';
import { MapProvider } from '@/components/map/map-context'
import { StreamingProvider } from '@/components/streaming-context'

const fontSans = FontSans({
subsets: ['latin'],
Expand Down Expand Up @@ -60,8 +61,9 @@ export default function RootLayout({
<CalendarToggleProvider>
<MapToggleProvider>
<ProfileToggleProvider>
<ThemeProvider
attribute="class"
<StreamingProvider>
<ThemeProvider
attribute="class"
defaultTheme="earth"
enableSystem
disableTransitionOnChange
Expand All @@ -77,9 +79,10 @@ export default function RootLayout({
<Toaster />
</MapLoadingProvider>
</MapProvider>
</ThemeProvider>
</ProfileToggleProvider>
</MapToggleProvider>
</ThemeProvider>
</StreamingProvider>
</ProfileToggleProvider>
</MapToggleProvider>
</CalendarToggleProvider>
<Analytics />
<SpeedInsights />
Expand Down
13 changes: 12 additions & 1 deletion components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import React from 'react'
import Image from 'next/image'
import { useCalendarToggle } from './calendar-toggle-context'
import { useStreaming } from './streaming-context'
import { ModeToggle } from './mode-toggle'
import { cn } from '@/lib/utils'
import HistoryContainer from './history-container'
Expand All @@ -18,6 +19,7 @@ import { ProfileToggle } from './profile-toggle'

export const Header = () => {
const { toggleCalendar } = useCalendarToggle()
const { isStreaming } = useStreaming()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useStreaming() throws if used outside of StreamingProvider, but StreamingProvider is only mounted under Chat. If Header is rendered at a higher level (e.g., in app/layout.tsx), this will crash at runtime. The provider must wrap Header (or the whole app tree), not just the Chat subtree.

Suggestion

Move StreamingProvider to a top-level provider that wraps Header and page content (e.g., via an app/providers.tsx client component used in app/layout.tsx), and remove the per-Chat wrappers to avoid duplication. For example:

  • app/providers.tsx (client):
'use client'
import { StreamingProvider } from '@/components/streaming-context'
export function Providers({ children }: { children: React.ReactNode }) {
  return <StreamingProvider>{children}</StreamingProvider>
}
  • app/layout.tsx (server):
import { Providers } from './providers'
import { Header } from '@/components/header'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers>
          <Header />
          {children}
        </Providers>
      </body>
    </html>
  )
}

Then delete the StreamingProvider wrappers added in components/chat.tsx.

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this refactor.

return (
<header className="fixed w-full p-1 md:p-2 flex justify-between items-center z-10 backdrop-blur md:backdrop-blur-none bg-background/80 md:bg-transparent">
<div>
Expand All @@ -28,7 +30,16 @@ export const Header = () => {

<div className="absolute left-1">
<Button variant="ghost" size="icon">
<Image src="/images/logo.svg" alt="Logo" width={24} height={24} className="h-6 w-auto" />
<Image
src="/images/logo.svg"
alt="Logo"
width={24}
height={24}
className={cn(
"h-6 w-auto transition-transform duration-1000",
isStreaming && "animate-spin-ccw"
)}
/>
</Button>
</div>

Expand Down
7 changes: 7 additions & 0 deletions components/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,16 @@ import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import 'katex/dist/katex.min.css'
import { useStreaming } from './streaming-context'
import { useEffect } from 'react'

export function BotMessage({ content }: { content: StreamableValue<string> }) {
const [data, error, pending] = useStreamableValue(content)
const { setIsStreaming } = useStreaming()

useEffect(() => {
setIsStreaming(pending)
}, [pending, setIsStreaming])
Comment on lines +15 to +19

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using a single boolean setIsStreaming(pending) in each BotMessage can oscillate the global state incorrectly if multiple messages stream concurrently or if a component unmounts mid-stream. This can leave the header stuck spinning or stop it too early.

Suggestion

Refactor the streaming context to track a reference count and expose start()/stop() functions, then call those from BotMessage on pending transitions with an unmount cleanup.

  • streaming-context.tsx:
export interface StreamingContextType {
  isStreaming: boolean
  start: () => void
  stop: () => void
}

export const StreamingProvider = ({ children }: { children: React.ReactNode }) => {
  const [count, setCount] = useState(0)
  const start = () => setCount(c => c + 1)
  const stop = () => setCount(c => Math.max(0, c - 1))
  return (
    <StreamingContext.Provider value={{ isStreaming: count > 0, start, stop }}>
      {children}
    </StreamingContext.Provider>
  )
}
  • components/message.tsx:
const { start, stop } = useStreaming()
useEffect(() => {
  if (pending) start()
  else stop()
  return () => { if (pending) stop() }
}, [pending, start, stop])

Reply with "@CharlieHelps yes please" if you'd like me to add a commit implementing this safer ref-count approach.

Comment on lines +17 to +19
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Approve with suggestion: Add explanatory comment.

The implementation correctly tracks streaming state by relying on stable setIsStreaming identity. Only the active streaming message's pending changes will trigger updates.

Consider adding a comment explaining the behavior:

+ // Update global streaming state when this message's streaming status changes.
+ // Only the newest message will have pending=true, older messages remain pending=false.
  useEffect(() => {
    setIsStreaming(pending)
  }, [pending, setIsStreaming])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
setIsStreaming(pending)
}, [pending, setIsStreaming])
// Update global streaming state when this message's streaming status changes.
// Only the newest message will have pending=true, older messages remain pending=false.
useEffect(() => {
setIsStreaming(pending)
}, [pending, setIsStreaming])
🤖 Prompt for AI Agents
In components/message.tsx around lines 17 to 19, add a brief explanatory comment
above the useEffect explaining that the effect intentionally depends on the
stable setIsStreaming setter and that only changes to the active streaming
message's pending prop should update the isStreaming state (e.g., "Depend on
stable setIsStreaming; only the active message's pending toggles will trigger
updates"). Keep the comment concise and clarify intent so future readers know
this dependency is deliberate.


// Currently, sometimes error occurs after finishing the stream.
if (error) return <div>Error</div>
Expand Down
29 changes: 29 additions & 0 deletions components/streaming-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use client'

import { createContext, useContext, useState, ReactNode } from 'react'

interface StreamingContextType {
isStreaming: boolean
setIsStreaming: (streaming: boolean) => void
}

const StreamingContext = createContext<StreamingContextType | undefined>(undefined)

export const useStreaming = () => {
const context = useContext(StreamingContext)
if (!context) {
// Return default values if used outside provider (e.g., during SSR)
return { isStreaming: false, setIsStreaming: () => {} }
}
return context
}

export const StreamingProvider = ({ children }: { children: ReactNode }) => {
const [isStreaming, setIsStreaming] = useState(false)

return (
<StreamingContext.Provider value={{ isStreaming, setIsStreaming }}>
{children}
</StreamingContext.Provider>
)
}
Comment on lines +21 to +29
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Memoize the context value to prevent unnecessary re-renders.

The context value object is recreated on every render, causing all consumers (Header, BotMessage) to re-render even when isStreaming hasn't changed. While setIsStreaming from useState is stable, the wrapper object is not.

Apply this diff to memoize the value:

-import { createContext, useContext, useState, ReactNode } from 'react'
+import { createContext, useContext, useState, ReactNode, useMemo } from 'react'

 export const StreamingProvider = ({ children }: { children: ReactNode }) => {
   const [isStreaming, setIsStreaming] = useState(false)

+  const value = useMemo(
+    () => ({ isStreaming, setIsStreaming }),
+    [isStreaming]
+  )
+
   return (
-    <StreamingContext.Provider value={{ isStreaming, setIsStreaming }}>
+    <StreamingContext.Provider value={value}>
       {children}
     </StreamingContext.Provider>
   )
 }
🤖 Prompt for AI Agents
In components/streaming-context.tsx around lines 21 to 29, the context value
object is recreated every render causing unnecessary consumer re-renders; fix by
memoizing the provider value: import and use React.useMemo to create the value
object ({ isStreaming, setIsStreaming }) and only recompute when isStreaming or
setIsStreaming change (depend on isStreaming is sufficient since setIsStreaming
is stable), then pass that memoized value to StreamingContext.Provider.

5 changes: 5 additions & 0 deletions tailwind.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,15 @@ const config = {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
"spin-ccw": {
from: { transform: "rotate(0deg)" },
to: { transform: "rotate(-360deg)" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"spin-ccw": "spin-ccw 2s linear infinite",
},
fontFamily: {
sans: ["var(--font-sans)", ...fontFamily.sans],
Expand Down