Skip to content
Open
210 changes: 106 additions & 104 deletions app/actions.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getCurrentUserIdOnServer } from "@/lib/auth/get-current-user"
import {
StreamableValue,
createAI,
Expand Down Expand Up @@ -37,6 +38,19 @@ async function submit(formData?: FormData, skip?: boolean) {
'use server'

const aiState = getMutableAIState<typeof AI>()
const currentMessages = aiState.get().messages;
const sanitizedHistory = currentMessages.map((m: any) => {
if (m.role === "user" && Array.isArray(m.content)) {
return {
...m,
content: m.content.map((part: any) =>
part.type === "image" ? { ...part, image: "IMAGE_PROCESSED" } : part
)
}
}
return m
});

const uiStream = createStreamableUI()
const isGenerating = createStreamableValue(true)
const isCollapsed = createStreamableValue(false)
Expand All @@ -51,6 +65,7 @@ async function submit(formData?: FormData, skip?: boolean) {
}

if (action === 'resolution_search') {
const isQCX = formData?.get('isQCX') === 'true';
const file_mapbox = formData?.get('file_mapbox') as File;
const file_google = formData?.get('file_google') as File;
const file = (formData?.get('file') as File) || file_mapbox || file_google;
Expand Down Expand Up @@ -81,7 +96,7 @@ async function submit(formData?: FormData, skip?: boolean) {
message.type !== 'resolution_search_result'
);

const userInput = 'Analyze this map view.';
const userInput = isQCX ? 'Perform QCX-TERRA ANALYSIS on this Google Satellite image.' : 'Analyze this map view.';
const content: CoreMessage['content'] = [
{ type: 'text', text: userInput },
{ type: 'image', image: dataUrl, mimeType: file.type }
Expand All @@ -90,7 +105,7 @@ async function submit(formData?: FormData, skip?: boolean) {
aiState.update({
...aiState.get(),
messages: [
...aiState.get().messages,
...sanitizedHistory,
{ id: nanoid(), role: 'user', content, type: 'input' }
]
});
Expand All @@ -112,6 +127,7 @@ async function submit(formData?: FormData, skip?: boolean) {
}

const analysisResult = await streamResult.object;
console.log('[ResolutionSearch] Analysis result:', !!analysisResult.summary, !!analysisResult.geoJson);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Remove debug console.log before merging.

This log statement leaks internal state details (!!analysisResult.summary, !!analysisResult.geoJson) into production logs. If intentional for observability, use a structured logger at an appropriate level instead.

Proposed fix
-        console.log('[ResolutionSearch] Analysis result:', !!analysisResult.summary, !!analysisResult.geoJson);
📝 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
console.log('[ResolutionSearch] Analysis result:', !!analysisResult.summary, !!analysisResult.geoJson);
🤖 Prompt for AI Agents
In `@app/actions.tsx` at line 129, Remove the debug console.log call that prints
internal flags ("console.log('[ResolutionSearch] Analysis result:',
!!analysisResult.summary, !!analysisResult.geoJson)") before merging; either
delete the line or replace it with a structured logger at an appropriate log
level (e.g., debug) via your app's logging utility, and if keeping it, log
context (analysisResult id/state) rather than raw booleans to avoid leaking
internal state.

summaryStream.done(analysisResult.summary || 'Analysis complete.');

if (analysisResult.geoJson) {
Expand All @@ -134,19 +150,6 @@ async function submit(formData?: FormData, skip?: boolean) {
}
return m
})

const currentMessages = aiState.get().messages;
const sanitizedHistory = currentMessages.map((m: any) => {
if (m.role === "user" && Array.isArray(m.content)) {
return {
...m,
content: m.content.map((part: any) =>
part.type === "image" ? { ...part, image: "IMAGE_PROCESSED" } : part
)
}
}
return m
});
const relatedQueries = await querySuggestor(uiStream, sanitizedMessages);
uiStream.append(
<Section title="Follow-up">
Expand All @@ -159,7 +162,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 @@ -239,7 +242,7 @@ async function submit(formData?: FormData, skip?: boolean) {
aiState.update({
...aiState.get(),
messages: [
...aiState.get().messages,
...sanitizedHistory,
{
id: nanoid(),
role: 'user',
Expand All @@ -265,7 +268,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 @@ -332,6 +335,7 @@ async function submit(formData?: FormData, skip?: boolean) {
const maxMessages = useSpecificAPI ? 5 : 10
messages.splice(0, Math.max(messages.length - maxMessages, 0))


const messageParts: {
type: 'text' | 'image'
text?: string
Expand Down Expand Up @@ -382,7 +386,7 @@ async function submit(formData?: FormData, skip?: boolean) {
aiState.update({
...aiState.get(),
messages: [
...aiState.get().messages,
...sanitizedHistory,
{
id: nanoid(),
role: 'user',
Expand Down Expand Up @@ -418,7 +422,7 @@ async function submit(formData?: FormData, skip?: boolean) {
aiState.done({
...aiState.get(),
messages: [
...aiState.get().messages,
...sanitizedHistory,
{
id: nanoid(),
role: 'assistant',
Expand Down Expand Up @@ -459,7 +463,7 @@ async function submit(formData?: FormData, skip?: boolean) {
aiState.update({
...aiState.get(),
messages: [
...aiState.get().messages,
...sanitizedHistory,
{
id: groupeId,
role: 'tool',
Expand Down Expand Up @@ -510,7 +514,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 @@ -552,6 +556,7 @@ async function clearChat() {

const aiState = getMutableAIState<typeof AI>()


aiState.done({
chatId: nanoid(),
messages: []
Expand All @@ -578,88 +583,7 @@ const initialAIState: AIState = {

const initialUIState: UIState = []

export const AI = createAI<AIState, UIState>({
actions: {
submit,
clearChat
},
initialUIState,
initialAIState,
onGetUIState: async () => {
'use server'

const aiState = getAIState() as AIState
if (aiState) {
const uiState = getUIStateFromAIState(aiState)
return uiState
}
return initialUIState
},
onSetAIState: async ({ state }) => {
'use server'

if (!state.messages.some(e => e.type === 'response')) {
return
}

const { chatId, messages } = state
const createdAt = new Date()
const path = `/search/${chatId}`

let title = 'Untitled Chat'
if (messages.length > 0) {
const firstMessageContent = messages[0].content
if (typeof firstMessageContent === 'string') {
try {
const parsedContent = JSON.parse(firstMessageContent)
title = parsedContent.input?.substring(0, 100) || 'Untitled Chat'
} catch (e) {
title = firstMessageContent.substring(0, 100)
}
} else if (Array.isArray(firstMessageContent)) {
const textPart = (
firstMessageContent as { type: string; text?: string }[]
).find(p => p.type === 'text')
title =
textPart && textPart.text
? textPart.text.substring(0, 100)
: 'Image Message'
}
}

const updatedMessages: AIMessage[] = [
...messages,
{
id: nanoid(),
role: 'assistant',
content: `end`,
type: 'end'
}
]

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

if (!actualUserId) {
console.error('onSetAIState: User not authenticated. Chat not saved.')
return
}

const chat: Chat = {
id: chatId,
createdAt,
userId: actualUserId,
path,
title,
messages: updatedMessages
}
await saveChat(chat, actualUserId)
}
})

export const getUIStateFromAIState = (aiState: AIState): UIState => {
export const getUIStateFromAIState = async (aiState: AIState): Promise<UIState> => {
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

async is unnecessary — function body contains no await.

getUIStateFromAIState is marked async but performs no asynchronous operations. The async keyword adds an unnecessary Promise wrapper. Minor nit; no functional impact since the caller already awaits it.

🤖 Prompt for AI Agents
In `@app/actions.tsx` at line 586, The function getUIStateFromAIState is marked
async but contains no await; remove the async keyword and make it return
synchronously by changing its signature from async (aiState: AIState):
Promise<UIState> to (aiState: AIState): UIState, update any type annotations or
imports relying on the Promise return type, and ensure callers still compile
(they can still await a non-Promise value but adjust types where necessary).

const chatId = aiState.chatId
const isSharePage = aiState.isSharePage
return aiState.messages
Expand Down Expand Up @@ -846,3 +770,81 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => {
})
.filter(message => message !== null) as UIState
}
export const AI = createAI<AIState, UIState>({
actions: {
submit,
clearChat
},
initialUIState,
initialAIState,
onGetUIState: async () => {
'use server'

const aiState = getAIState() as AIState
if (aiState) {
const uiState = await getUIStateFromAIState(aiState)
return uiState
}
return initialUIState
},
onSetAIState: async ({ state }) => {
'use server'

if (!state.messages.some(e => e.type === 'response')) {
return
}

const { chatId, messages } = state
const createdAt = new Date()
const path = `/search/${chatId}`

let title = 'Untitled Chat'
if (messages.length > 0) {
const firstMessageContent = messages[0].content
if (typeof firstMessageContent === 'string') {
try {
const parsedContent = JSON.parse(firstMessageContent)
title = parsedContent.input?.substring(0, 100) || 'Untitled Chat'
} catch (e) {
title = firstMessageContent.substring(0, 100)
}
} else if (Array.isArray(firstMessageContent)) {
const textPart = (
firstMessageContent as { type: string; text?: string }[]
).find(p => p.type === 'text')
title =
textPart && textPart.text
? textPart.text.substring(0, 100)
: 'Image Message'
}
}

const updatedMessages: AIMessage[] = [
...messages,
{
id: nanoid(),
role: 'assistant',
content: `end`,
type: 'end'
}
]


const actualUserId = await getCurrentUserIdOnServer()

if (!actualUserId) {
console.error('onSetAIState: User not authenticated. Chat not saved.')
return
}

const chat: Chat = {
id: chatId,
createdAt,
userId: actualUserId,
path,
title,
messages: updatedMessages
}
await saveChat(chat, actualUserId)
}
Comment on lines +790 to +849
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

Wrap onSetAIState body in a try/catch to prevent unhandled rejections.

If getCurrentUserIdOnServer() or saveChat() throws an unexpected error (e.g., network failure, Supabase outage), the unhandled exception will propagate into the ai/rsc framework, potentially breaking the client-side state sync. saveChat has its own internal try/catch, but getCurrentUserIdOnServer and the surrounding logic do not.

♻️ Proposed fix
   onSetAIState: async ({ state }) => {
     'use server'
 
+    try {
       if (!state.messages.some(e => e.type === 'response')) {
         return
       }
 
       const { chatId, messages } = state
       // ... rest of the handler ...
       await saveChat(chat, actualUserId)
+    } catch (error) {
+      console.error('onSetAIState: Failed to persist chat:', error)
+    }
   }
📝 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
onSetAIState: async ({ state }) => {
'use server'
if (!state.messages.some(e => e.type === 'response')) {
return
}
const { chatId, messages } = state
const createdAt = new Date()
const path = `/search/${chatId}`
let title = 'Untitled Chat'
if (messages.length > 0) {
const firstMessageContent = messages[0].content
if (typeof firstMessageContent === 'string') {
try {
const parsedContent = JSON.parse(firstMessageContent)
title = parsedContent.input?.substring(0, 100) || 'Untitled Chat'
} catch (e) {
title = firstMessageContent.substring(0, 100)
}
} else if (Array.isArray(firstMessageContent)) {
const textPart = (
firstMessageContent as { type: string; text?: string }[]
).find(p => p.type === 'text')
title =
textPart && textPart.text
? textPart.text.substring(0, 100)
: 'Image Message'
}
}
const updatedMessages: AIMessage[] = [
...messages,
{
id: nanoid(),
role: 'assistant',
content: `end`,
type: 'end'
}
]
const actualUserId = await getCurrentUserIdOnServer()
if (!actualUserId) {
console.error('onSetAIState: User not authenticated. Chat not saved.')
return
}
const chat: Chat = {
id: chatId,
createdAt,
userId: actualUserId,
path,
title,
messages: updatedMessages
}
await saveChat(chat, actualUserId)
}
onSetAIState: async ({ state }) => {
'use server'
try {
if (!state.messages.some(e => e.type === 'response')) {
return
}
const { chatId, messages } = state
const createdAt = new Date()
const path = `/search/${chatId}`
let title = 'Untitled Chat'
if (messages.length > 0) {
const firstMessageContent = messages[0].content
if (typeof firstMessageContent === 'string') {
try {
const parsedContent = JSON.parse(firstMessageContent)
title = parsedContent.input?.substring(0, 100) || 'Untitled Chat'
} catch (e) {
title = firstMessageContent.substring(0, 100)
}
} else if (Array.isArray(firstMessageContent)) {
const textPart = (
firstMessageContent as { type: string; text?: string }[]
).find(p => p.type === 'text')
title =
textPart && textPart.text
? textPart.text.substring(0, 100)
: 'Image Message'
}
}
const updatedMessages: AIMessage[] = [
...messages,
{
id: nanoid(),
role: 'assistant',
content: `end`,
type: 'end'
}
]
const actualUserId = await getCurrentUserIdOnServer()
if (!actualUserId) {
console.error('onSetAIState: User not authenticated. Chat not saved.')
return
}
const chat: Chat = {
id: chatId,
createdAt,
userId: actualUserId,
path,
title,
messages: updatedMessages
}
await saveChat(chat, actualUserId)
} catch (error) {
console.error('onSetAIState: Failed to persist chat:', error)
}
}
🤖 Prompt for AI Agents
In `@app/actions.tsx` around lines 792 - 851, The onSetAIState handler needs its
entire body wrapped in a try/catch to prevent unhandled rejections from async
calls like getCurrentUserIdOnServer() and saveChat(); modify the onSetAIState
function so you wrap the existing logic (title extraction, updatedMessages
creation, actualUserId lookup, chat construction and await saveChat(chat,
actualUserId)) in a try block and catch any errors, logging them (console.error
or processLogger) and returning gracefully to avoid propagating exceptions into
the ai/rsc layer; ensure the catch covers both getCurrentUserIdOnServer and
saveChat calls and does not change the existing success path.

})
18 changes: 9 additions & 9 deletions components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ export const Header = () => {
<span className="sr-only">Chat</span>
</a>
</div>
<div className="absolute left-1 flex items-center">

<div className="absolute left-1 flex items-center z-50">
<Button variant="ghost" size="icon" onClick={toggleHistory} data-testid="logo-history-toggle">
<Image
src="/images/logo.svg"
Expand All @@ -68,27 +68,27 @@ export const Header = () => {

<div className="flex-1 hidden md:flex justify-center gap-10 items-center z-10">
<ProfileToggle/>

<MapToggle />

<Button variant="ghost" size="icon" onClick={toggleCalendar} title="Open Calendar" data-testid="calendar-toggle">
<CalendarDays className="h-[1.2rem] w-[1.2rem]" />
</Button>

<div id="header-search-portal" className="contents" />

<Button variant="ghost" size="icon" onClick={handleUsageToggle}>
<TentTree className="h-[1.2rem] w-[1.2rem]" />
</Button>

<ModeToggle />

<HistoryContainer location="header" />
</div>

{/* Mobile menu buttons */}
<div className="flex md:hidden gap-2">

<Button variant="ghost" size="icon" onClick={handleUsageToggle}>
<TentTree className="h-[1.2rem] w-[1.2rem]" />
</Button>
Expand Down
2 changes: 0 additions & 2 deletions components/mobile-icons-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import { AI } from '@/app/actions'
import { Button } from '@/components/ui/button'
import {
Search,
CircleUserRound,
Map,
CalendarDays,
TentTree,
Paperclip,
Expand Down
1 change: 1 addition & 0 deletions components/resolution-carousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ 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('isQCX', 'true')

const responseMessage = await actions.submit(formData)
setMessages((currentMessages: any[]) => [...currentMessages, responseMessage as any])
Expand Down
Loading