Skip to content
Closed
210 changes: 106 additions & 104 deletions app/actions.tsx
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
import { getCurrentUserIdOnServer } from "@/lib/auth/get-current-user"
import {
StreamableValue,
createAI,
Expand DownExpand 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
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 DownExpand 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
Copy Markdown
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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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
Copy Markdown
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 DownExpand 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
Copy Markdown
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
constcreatedAt=newDate()
constpath=`/search/${chatId}`
lettitle='Untitled Chat'
if(messages.length>0){
constfirstMessageContent=messages[0].content
if(typeoffirstMessageContent==='string'){
try{
constparsedContent=JSON.parse(firstMessageContent)
title=parsedContent.input?.substring(0,100)||'Untitled Chat'
}catch(e){
title=firstMessageContent.substring(0,100)
}
}elseif(Array.isArray(firstMessageContent)){
consttextPart=(
firstMessageContentas{type: string;text?: string}[]
).find(p=>p.type==='text')
title=
textPart&&textPart.text
? textPart.text.substring(0,100)
: 'Image Message'
}
}
constupdatedMessages: AIMessage[]=[
...messages,
{
id: nanoid(),
role: 'assistant',
content: `end`,
type: 'end'
}
]
constactualUserId=awaitgetCurrentUserIdOnServer()
if(!actualUserId){
console.error('onSetAIState: User not authenticated. Chat not saved.')
return
}
constchat: Chat={
id: chatId,
createdAt,
userId: actualUserId,
path,
title,
messages: updatedMessages
}
awaitsaveChat(chat,actualUserId)
}
onSetAIState: async({ state })=>{
'use server'
try{
if(!state.messages.some(e=>e.type==='response')){
return
}
const{ chatId, messages }=state
constcreatedAt=newDate()
constpath=`/search/${chatId}`
lettitle='Untitled Chat'
if(messages.length>0){
constfirstMessageContent=messages[0].content
if(typeoffirstMessageContent==='string'){
try{
constparsedContent=JSON.parse(firstMessageContent)
title=parsedContent.input?.substring(0,100)||'Untitled Chat'
}catch(e){
title=firstMessageContent.substring(0,100)
}
}elseif(Array.isArray(firstMessageContent)){
consttextPart=(
firstMessageContentas{type: string;text?: string}[]
).find(p=>p.type==='text')
title=
textPart&&textPart.text
? textPart.text.substring(0,100)
: 'Image Message'
}
}
constupdatedMessages: AIMessage[]=[
...messages,
{
id: nanoid(),
role: 'assistant',
content: `end`,
type: 'end'
}
]
constactualUserId=awaitgetCurrentUserIdOnServer()
if(!actualUserId){
console.error('onSetAIState: User not authenticated. Chat not saved.')
return
}
constchat: Chat={
id: chatId,
createdAt,
userId: actualUserId,
path,
title,
messages: updatedMessages
}
awaitsaveChat(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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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