Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
17 changes: 11 additions & 6 deletions components/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { MapDataProvider, useMapData } from './map/map-data-context'; // Add thi
import { updateDrawingContext } from '@/lib/actions/chat'; // Import the server action
import dynamic from 'next/dynamic'
import { HeaderSearchButton } from './header-search-button'
import { StreamingProvider } from './streaming-context'

type ChatProps = {
id?: string // This is the chatId
Expand Down Expand Up @@ -84,8 +85,9 @@ export function Chat({ id }: ChatProps) {
// Mobile layout
if (isMobile) {
return (
<MapDataProvider> {/* Add Provider */}
<HeaderSearchButton />
<StreamingProvider>
<MapDataProvider> {/* Add Provider */}
<HeaderSearchButton />

Choose a reason for hiding this comment

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

Placing StreamingProvider inside Chat means Header (which uses useStreaming) is very likely outside the provider, leading to runtime errors and/or state desynchronization. After lifting the provider to app/layout.tsx (or another common root), these nested providers should be removed to prevent multiple isolated contexts that won’t share state.

Suggestion

Remove the StreamingProvider wrappers from both the mobile and desktop branches of Chat once the provider is placed at the app root. For example:

// Mobile layout
return (
  <MapDataProvider>
    <HeaderSearchButton />
    {/* ...rest unchanged... */}
  </MapDataProvider>
)

// Desktop layout
return (
  <MapDataProvider>
    <HeaderSearchButton />
    {/* ...rest unchanged... */}
  </MapDataProvider>
)

Reply with "@CharlieHelps yes please" if you'd like me to add a commit that removes these wrappers.

<div className="mobile-layout-container">
<div className="mobile-map-section">
{activeView ? <SettingsView /> : <Mapbox />}
Expand All @@ -110,14 +112,16 @@ export function Chat({ id }: ChatProps) {
)}
</div>
</div>
</MapDataProvider>
</MapDataProvider>
</StreamingProvider>
);
}

// Desktop layout
return (
<MapDataProvider> {/* Add Provider */}
<HeaderSearchButton />
<StreamingProvider>
<MapDataProvider> {/* Add Provider */}
<HeaderSearchButton />
<div className="flex justify-start items-start">
{/* This is the new div for scrolling */}
<div className="w-1/2 flex flex-col space-y-3 md:space-y-4 px-8 sm:px-12 pt-12 md:pt-14 pb-4 h-[calc(100vh-0.5in)] overflow-y-auto">
Expand Down Expand Up @@ -145,6 +149,7 @@ export function Chat({ id }: ChatProps) {
{activeView ? <SettingsView /> : <Mapbox />}
</div>
</div>
</MapDataProvider>
</MapDataProvider>
</StreamingProvider>
);
}
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