Skip to content
Merged
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
55 changes: 49 additions & 6 deletions app/actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,14 @@ import type { FeatureCollection } from 'geojson'
import { Spinner } from '@/components/ui/spinner'
import { Section } from '@/components/section'
import { FollowupPanel } from '@/components/followup-panel'
import { inquire, researcher, taskManager, querySuggestor, resolutionSearch, type DrawnFeature } from '@/lib/agents'
import { taskManager } from '@/lib/agents/task-manager'
import { inquire } from '@/lib/agents/inquire'
import { querySuggestor } from '@/lib/agents/query-suggestor'
import { researcher } from '@/lib/agents/researcher'
import { resolutionSearch, type DrawnFeature } from '@/lib/agents/resolution-search'
import { writer } from '@/lib/agents/writer'
import { saveChat, getSystemPrompt, generateReportContext } from '@/lib/actions/chat'

import { Chat, AIMessage } from '@/lib/types'
import { UserMessage } from '@/components/user-message'
import { BotMessage } from '@/components/message'
Expand Down Expand Up @@ -50,6 +55,43 @@ async function submit(formData?: FormData, skip?: boolean) {
console.error('Failed to parse drawnFeatures:', e);
}

// PERSISTENCE: Always append drawing_context for durable feature history
if (drawnFeatures.length > 0) {
aiState.update({
...aiState.get(),
messages: [
...aiState.get().messages,
{
id: nanoid(),
role: 'data',
content: JSON.stringify(drawnFeatures),
type: 'drawing_context'
}
]
});
Comment on lines +58 to +71

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid appending unchanged drawing snapshots.

Carousel re-triggers can submit the same drawnFeatures repeatedly, and this stores a full duplicate drawing_context each time. Since Line 71 later scans every historical context, large geometries will grow AI state and parsing cost unnecessarily.

Proposed guard for unchanged drawing context
-  if (drawnFeatures.length > 0) {
+  const serializedDrawnFeatures =
+    drawnFeatures.length > 0 ? JSON.stringify(drawnFeatures) : ''
+  const latestDrawingContext = [...aiState.get().messages]
+    .reverse()
+    .find(m => m.type === 'drawing_context')
+
+  if (
+    serializedDrawnFeatures &&
+    latestDrawingContext?.content !== serializedDrawnFeatures
+  ) {
     aiState.update({
       ...aiState.get(),
       messages: [
         ...aiState.get().messages,
         {
           id: nanoid(),
           role: 'data',
-          content: JSON.stringify(drawnFeatures),
+          content: serializedDrawnFeatures,
           type: 'drawing_context'
         }
       ]
     });
   }
📝 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
// PERSISTENCE: Always append drawing_context for durable feature history
if (drawnFeatures.length > 0) {
aiState.update({
...aiState.get(),
messages: [
...aiState.get().messages,
{
id: nanoid(),
role: 'data',
content: JSON.stringify(drawnFeatures),
type: 'drawing_context'
}
]
});
// PERSISTENCE: Always append drawing_context for durable feature history
const serializedDrawnFeatures =
drawnFeatures.length > 0 ? JSON.stringify(drawnFeatures) : ''
const latestDrawingContext = [...aiState.get().messages]
.reverse()
.find(m => m.type === 'drawing_context')
if (
serializedDrawnFeatures &&
latestDrawingContext?.content !== serializedDrawnFeatures
) {
aiState.update({
...aiState.get(),
messages: [
...aiState.get().messages,
{
id: nanoid(),
role: 'data',
content: serializedDrawnFeatures,
type: 'drawing_context'
}
]
});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/actions.tsx` around lines 53 - 66, The drawing context persistence in the
aiState.update block is appending duplicate drawing snapshots whenever
drawnFeatures repeats, which inflates history and parsing cost. Add a guard
before pushing the new message in app/actions.tsx that compares the current
drawnFeatures payload against the most recent drawing_context entry in
aiState.get().messages, and skip the update when the serialized geometry is
unchanged. Keep the fix localized around the drawnFeatures append logic and the
aiState.get()/aiState.update flow.

}

// PERSISTENCE: build merged drawing set from all historical drawing_context messages
const mergedDrawnFeatures = [...drawnFeatures];
const historicalDrawingContexts = aiState.get().messages.filter(m => m.type === 'drawing_context');
historicalDrawingContexts.forEach(m => {
try {
const historicalFeatures = JSON.parse(m.content as string) as DrawnFeature[];
historicalFeatures.forEach(hf => {
if (!mergedDrawnFeatures.some(f => f.id === hf.id)) {
mergedDrawnFeatures.push(hf);
}
});
} catch (e) {
console.error('Failed to parse historical drawing context:', e);
}
});

const { getCurrentUserIdOnServer } = await import(
"@/lib/auth/get-current-user"
)
const userId = (await getCurrentUserIdOnServer()) || "anonymous";

if (action === 'generate_report_context') {
const messagesString = formData?.get('messages');
if (typeof messagesString !== 'string') {
Expand Down Expand Up @@ -92,6 +134,7 @@ async function submit(formData?: FormData, skip?: boolean) {
message.type !== 'followup' &&
message.type !== 'related' &&
message.type !== 'end' &&
message.type !== 'drawing_context' &&
message.type !== 'resolution_search_result'
);

Expand All @@ -115,7 +158,7 @@ async function submit(formData?: FormData, skip?: boolean) {

async function processResolutionSearch() {
try {
const streamResult = await resolutionSearch(messages, timezone, drawnFeatures, location);
const streamResult = await resolutionSearch(messages, timezone, mergedDrawnFeatures, location);

let fullSummary = '';
for await (const partialObject of streamResult.partialObjectStream) {
Expand Down Expand Up @@ -192,7 +235,7 @@ async function submit(formData?: FormData, skip?: boolean) {
aiState.done({
...aiState.get(),
messages: [
...aiState.get().messages,
...sanitizedHistory,
{
id: groupeId,
role: 'assistant',
Expand Down Expand Up @@ -357,6 +400,7 @@ async function submit(formData?: FormData, skip?: boolean) {
message.type !== 'followup' &&
message.type !== 'related' &&
message.type !== 'end' &&
message.type !== 'drawing_context' &&
Comment thread
coderabbitai[bot] marked this conversation as resolved.
message.type !== 'resolution_search_result'
)
.map((m: any) => {
Expand Down Expand Up @@ -488,7 +532,6 @@ async function submit(formData?: FormData, skip?: boolean) {
} as CoreMessage)
}

const userId = 'anonymous'
const currentSystemPrompt = (await getSystemPrompt(userId)) || ''
const mapProvider = formData?.get('mapProvider') as 'mapbox' | 'google'

Expand Down Expand Up @@ -539,7 +582,7 @@ async function submit(formData?: FormData, skip?: boolean) {
messages,
mapProvider,
useSpecificAPI,
drawnFeatures
mergedDrawnFeatures
)
answer = fullResponse
toolOutputs = toolResponses
Expand Down Expand Up @@ -729,7 +772,7 @@ export const AI = createAI<AIState, UIState>({
]

const { getCurrentUserIdOnServer } = await import(
'@/lib/auth/get-current-user'
"@/lib/auth/get-current-user"
)
const actualUserId = await getCurrentUserIdOnServer()

Expand Down
37 changes: 28 additions & 9 deletions app/search/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@ export interface SearchPageProps {
}

export async function generateMetadata({ params }: SearchPageProps) {
const { id } = await params; // Keep as is for now
// TODO: Metadata generation might need authenticated user if chats are private
// For now, assuming getChat can be called or it handles anon access for metadata appropriately
const userId = await getCurrentUserIdOnServer(); // Attempt to get user for metadata
const chat = await getChat(id, userId || 'anonymous'); // Pass userId or 'anonymous' if none
const { id } = await params;
const userId = await getCurrentUserIdOnServer();

if (!userId) {
return { title: 'Search' };
}

const chat = await getChat(id, userId);
return {
title: chat?.title?.toString().slice(0, 50) || 'Search',
};
Expand Down Expand Up @@ -59,16 +62,32 @@ export default async function SearchPage({ params }: SearchPageProps) {
};
});

// PERSISTENCE: Seed map context from latest persisted drawing data
const latestDrawingContext = [...dbMessages]
.reverse()
.find(m => m.role === 'data');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

latestDrawingContext currently assumes every latest role: "data" message has the { drawnFeatures, cameraState } shape, but this PR also writes drawing_context entries as raw arrays in app/actions.tsx (content: JSON.stringify(drawnFeatures)).

When that array payload (or another data payload like calendar metadata) is newest, this parse path falls back to drawnFeatures: [] and missing cameraState, so reopened chats lose hydrated map context.

Suggested fix: select only drawing-context-shaped payloads (or support both array + object shapes defensively), and ideally align the write format so role: "data" messages use one schema.

For example:

const latestDrawingContext = [...dbMessages].reverse().find((m) => {
  if (m.role !== 'data') return false;
  try {
    const parsed = JSON.parse(m.content as string);
    return Array.isArray(parsed) || Array.isArray(parsed?.drawnFeatures);
  } catch {
    return false;
  }
});

const parsed = latestDrawingContext ? JSON.parse(latestDrawingContext.content as string) : null;
const drawnFeatures = Array.isArray(parsed) ? parsed : parsed?.drawnFeatures ?? [];
const cameraState = Array.isArray(parsed) ? undefined : parsed?.cameraState;


let initialMapData = undefined;
if (latestDrawingContext) {
try {
const parsed = JSON.parse(latestDrawingContext.content as string);
initialMapData = {
drawnFeatures: parsed.drawnFeatures || [],
cameraState: parsed.cameraState,
};
} catch (e) {
console.error('Failed to parse latest drawing context:', e);
}
}
Comment on lines +65 to +81

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect how drawing_context messages are created/persisted: role + type used.
rg -nP -C4 "drawing_context" app/actions.tsx
# Inspect updateDrawingContext server action and its persisted role
rg -nP -C6 "updateDrawingContext" --type=ts --type=tsx
# Confirm role values written to the messages table
rg -nP -C4 "role:\s*'data'|role:\s*\"data\"" --type=ts --type=tsx

Repository: QueueLab/QCX

Length of output: 1817


Mismatched data structure prevents map rehydration

While the role is correctly set to 'data' in app/actions.tsx (line 61), the data shape saved differs from what app/search/[id]/page.tsx expects to read.

  • Persistence (app/actions.tsx line 62): Saves content as JSON.stringify(drawnFeatures), which is a bare array.
  • Rehydration (app/search/[id]/page.tsx lines 70–74): Parses the content and expects an object with a drawnFeatures key (parsed.drawnFeatures).

Because the saved data is an array but the loader treats it as an object, parsed.drawnFeatures evaluates to undefined, and the map defaults to an empty list on reload.

Align the structures so persistence stores the same object shape used for seeding:

// In app/actions.tsx (line 62)
- content: JSON.stringify(drawnFeatures),
+ content: JSON.stringify({ drawnFeatures, cameraState: ... }), // Include cameraState if needed

// In app/search/[id]/page.tsx (lines 70-72)
- const parsed = JSON.parse(latestDrawingContext.content as string);
- initialMapData = { drawnFeatures: parsed.drawnFeatures || [], ... };
+ const parsed = JSON.parse(latestDrawingContext.content as string) as any[];
+ initialMapData = { drawnFeatures: parsed, ... };
// OR update the save side to match the load side

Ensure the content schema is consistent end-to-end to restore persistence.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/search/`[id]/page.tsx around lines 62 - 78, The map rehydration logic is
parsing a persisted payload with the wrong shape: latestDrawingContext in
app/search/[id]/page.tsx expects an object with drawnFeatures and cameraState,
but the data written from app/actions.tsx is a bare array. Update the
persistence and loading flow to use the same schema end-to-end, and in the
page.tsx parsing path keep using the latestDrawingContext/content but read the
fields from the actual saved object shape so initialMapData is populated
correctly.


return (
<AI
initialAIState={{
chatId: chat.id,
messages: initialMessages, // Use the transformed messages from the database
// isSharePage: true, // This was in PR#533, but share functionality is removed.
// If needed for styling or other logic, it can be set.
messages: initialMessages,
}}
>
<MapDataProvider>
<MapDataProvider initialData={initialMapData}>
<Chat id={id} />
</MapDataProvider>
</AI>
Expand Down
10 changes: 5 additions & 5 deletions components/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { useProfileToggle, ProfileToggleEnum } from "@/components/profile-toggle
import { useUsageToggle } from "@/components/usage-toggle-context";
import SettingsView from "@/components/settings/settings-view";
import { UsageView } from "@/components/usage-view";
import { MapDataProvider, useMapData } from './map/map-data-context'; // Add this and useMapData
import { useMapData } from './map/map-data-context'; // Add this and useMapData
import { updateDrawingContext } from '@/lib/actions/chat'; // Import the server action
import dynamic from 'next/dynamic'
import { HeaderSearchButton } from './header-search-button'
Expand Down Expand Up @@ -137,7 +137,7 @@ export function Chat({ id }: ChatProps) {
// Mobile layout
if (isMobile) {
return (
<MapDataProvider> {/* Add Provider */}
<>
<HeaderSearchButton />
<div className="mobile-layout-container">
<div className="mobile-map-section">
Expand Down Expand Up @@ -177,13 +177,13 @@ export function Chat({ id }: ChatProps) {
)}
</div>
</div>
</MapDataProvider>
</>
);
}

// Desktop layout
return (
<MapDataProvider> {/* Add Provider */}
<>
<HeaderSearchButton />
<div className="flex justify-start items-start">
{/* This is the new div for scrolling */}
Expand Down Expand Up @@ -223,6 +223,6 @@ export function Chat({ id }: ChatProps) {
{activeView ? <SettingsView /> : isUsageOpen ? <UsageView /> : <MapProvider />}
</div>
</div>
</MapDataProvider>
</>
);
}
4 changes: 2 additions & 2 deletions components/map/map-data-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ interface MapDataContextType {

const MapDataContext = createContext<MapDataContextType | undefined>(undefined);

export const MapDataProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [mapData, setMapData] = useState<MapData>({ drawnFeatures: [], markers: [] });
export const MapDataProvider: React.FC<{ children: ReactNode; initialData?: MapData }> = ({ children, initialData }) => {
const [mapData, setMapData] = useState<MapData>(initialData || { drawnFeatures: [], markers: [] });
Comment on lines +41 to +42

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

initialData may drop markers, leaving mapData.markers undefined after reload.

When initialData is provided (from app/search/[id]/page.tsx, which builds { drawnFeatures, cameraState } only), it is used verbatim, so markers is never defaulted to []. Any consumer doing mapData.markers.map(...) will fail after a chat reload, unlike the no-initialData path which guarantees markers: [].

🛡️ Proposed fix to preserve defaults
-  const [mapData, setMapData] = useState<MapData>(initialData || { drawnFeatures: [], markers: [] });
+  const [mapData, setMapData] = useState<MapData>({
+    drawnFeatures: [],
+    markers: [],
+    ...initialData,
+  });
📝 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
export const MapDataProvider: React.FC<{ children: ReactNode; initialData?: MapData }> = ({ children, initialData }) => {
const [mapData, setMapData] = useState<MapData>(initialData || { drawnFeatures: [], markers: [] });
export const MapDataProvider: React.FC<{ children: ReactNode; initialData?: MapData }> = ({ children, initialData }) => {
const [mapData, setMapData] = useState<MapData>({
drawnFeatures: [],
markers: [],
...initialData,
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/map/map-data-context.tsx` around lines 41 - 42, MapDataProvider is
passing initialData through verbatim, so MapData.markers can stay undefined when
app/search/[id]/page.tsx omits it. Update the MapDataProvider initialization to
merge initialData with the default MapData shape, ensuring markers (and any
other missing fields) always fall back to an empty array. Use the
MapDataProvider and its useState<MapData> setup to locate the fix.


return (
<MapDataContext.Provider value={{ mapData, setMapData }}>
Expand Down
10 changes: 10 additions & 0 deletions components/resolution-carousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { UserMessage } from './user-message'
import { toast } from 'sonner'
import { CompareSlider } from './compare-slider'
import { compressImage } from '@/lib/utils/image-utils'
import { useMapData } from './map/map-data-context'

interface ResolutionCarouselProps {
mapboxImage?: string | null
Expand All @@ -28,6 +29,7 @@ export function ResolutionCarousel({ mapboxImage, googleImage, initialImage }: R
const actions = useActions<typeof AI>() as any
const [, setMessages] = useUIState<typeof AI>()
const [isAnalyzing, setIsAnalyzing] = React.useState(false)
const { mapData } = useMapData()

const handleQCXAnalysis = async () => {
if (!googleImage) return
Expand All @@ -52,6 +54,14 @@ export function ResolutionCarousel({ mapboxImage, googleImage, initialImage }: R
const formData = new FormData()
formData.append('file', blob, 'google_analysis.png')
formData.append('action', 'resolution_search')
formData.append('timezone', mapData.currentTimezone || 'UTC')
formData.append('drawnFeatures', JSON.stringify(mapData.drawnFeatures || []))

const center = mapData.cameraState?.center || mapData.targetPosition;
if (center) {
formData.append('latitude', center.lat.toString())
formData.append('longitude', center.lng.toString())
}

const responseMessage = await actions.submit(formData)
setMessages((currentMessages: any[]) => [...currentMessages, responseMessage as any])
Expand Down
4 changes: 2 additions & 2 deletions lib/actions/chat-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export type NewMessage = typeof messages.$inferInsert;
* @returns The chat object if found and accessible, otherwise null.
*/
export async function getChat(id: string, userId: string): Promise<Chat | null> {
if (!userId) {
if (!userId || userId === 'anonymous') {
console.warn('getChat called without userId');
// Potentially allow fetching public chats if userId is null for anonymous users
const result = await db.select().from(chats).where(and(eq(chats.id, id), eq(chats.visibility, 'public'))).limit(1);
Expand Down Expand Up @@ -51,7 +51,7 @@ export async function getChatsPage(
limit: number = 20,
offset: number = 0
): Promise<{ chats: Chat[]; nextOffset: number | null }> {
if (!userId) {
if (!userId || userId === 'anonymous') {
console.error('getChatsPage called without userId.');
return { chats: [], nextOffset: null };
}
Expand Down
8 changes: 4 additions & 4 deletions lib/actions/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@ export async function getChats(userId?: string | null): Promise<DrizzleChat[]> {
}

export async function getChat(id: string, userId: string): Promise<DrizzleChat | null> {
if (!userId) {
console.warn('getChat called without userId.')
if (!userId || userId === 'anonymous') {
console.warn('getChat: Invalid or missing userId.');
return null;
}
try {
Expand Down Expand Up @@ -185,8 +185,8 @@ export async function saveChat(chat: OldChatType, userId: string): Promise<strin

export async function updateDrawingContext(chatId: string, contextData: { drawnFeatures: any[], cameraState: any }) {
const userId = await getCurrentUserIdOnServer();
if (!userId) {
console.error('updateDrawingContext: Could not get current user ID.');
if (!userId || userId === 'anonymous') {
console.error('updateDrawingContext: Invalid or missing user ID.');
return { error: 'User not authenticated' };
}

Expand Down
8 changes: 4 additions & 4 deletions lib/auth/get-current-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,24 +58,24 @@ export async function getSupabaseUserAndSessionOnServer(): Promise<{
return { user: null, session: null, error: new Error('Missing Supabase environment variables') };
}

const cookieStore = cookies();
const cookieStore = await cookies();
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
async get(name: string): Promise<string | undefined> {
const cookie = (await cookieStore).get(name); // Use the correct get method
const cookie = cookieStore.get(name); // Use the correct get method
return cookie?.value; // Return the value or undefined
},
async set(name: string, value: string, options: CookieOptions): Promise<void> {
try {
const store = await cookieStore;
const store = cookieStore;
store.set({ name, value, ...options }); // Set cookie with options
} catch (error) {
// console.warn(`[Auth] Failed to set cookie ${name}:`, error);
}
},
async remove(name: string, options: CookieOptions): Promise<void> {
try {
const store = await cookieStore;
const store = cookieStore;
store.set({ name, value: '', ...options, maxAge: 0 }); // Delete cookie by setting maxAge to 0
} catch (error) {
// console.warn(`[Auth] Failed to delete cookie ${name}:`, error);
Expand Down