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
11 changes: 6 additions & 5 deletions components/chat-area.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
deleteDocumentAction,
} from "@/lib/actions/chat";
import { toast } from "sonner";
import { Greeting } from "@/components/greeting";

interface ChatAreaProps {
currentChat: ChatModel | undefined;
Expand Down Expand Up @@ -286,12 +287,12 @@ export function ChatArea({
className="min-h-0 flex-1 overflow-y-auto px-4 pb-28 md:px-6"
>
{localMessages.length === 0 ? (
<div className="flex h-full min-h-100 flex-col items-center justify-center p-8 text-center opacity-50 select-none">
<div className="flex h-full min-h-100 flex-col items-center justify-center p-8 text-center select-none">
<div className="mb-6 flex size-20 items-center justify-center rounded-3xl bg-primary/10">
<FileUp className="size-10 text-primary" />
</div>
<h3 className="mb-2 text-xl font-semibold">Ready to assist</h3>
<p className="max-w-sm text-sm text-balance">
<Greeting userName={userName} />
<p className="max-w-sm text-sm text-balance text-muted-foreground">
Upload your documents to get started with RAG-powered analysis, or
simply start typing to chat.
</p>
Expand Down Expand Up @@ -320,7 +321,7 @@ export function ChatArea({
{showDateDivider && ts !== null && (
<div className="my-2 flex items-center gap-1">
<Separator className="flex-1" />
<span className="min-w-max text-xs font-semibold text-muted-foreground">
<span className="min-w-max text-xs font-semibold text-muted-foreground" suppressHydrationWarning>
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

suppressHydrationWarning is a workaround, not a fix — verify SSR path.

suppressHydrationWarning silences the mismatch but does not prevent the server from rendering a timestamp string under the server's timezone that differs from the client's render. If the server renders these spans (e.g., initial page load with historical messages), end users will briefly see times in the server's TZ before the client re-renders after hydration. Given the TZ removal in lib/date-format.ts, this is now an expected difference.

If ChatArea is indeed a client component that only renders message timestamps after client-side data fetch, this is fine. Otherwise, consider the same pattern used by components/greeting.tsx (render a placeholder pre-hydration, real value post-hydration) to avoid the brief flash of incorrect times.

Also applies to: 381-381

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/chat-area.tsx` at line 324, The span using
suppressHydrationWarning in ChatArea is hiding a real SSR/client mismatch for
message timestamps; either ensure ChatArea is truly a client component that only
renders timestamps after client-side fetch, or implement the pre-hydration
placeholder pattern used in components/greeting.tsx: render a stable placeholder
(e.g., empty/placeholder timestamp) during SSR and swap to the real timestamp
after hydration in the ChatArea component (target the span with class "min-w-max
text-xs font-semibold text-muted-foreground" that currently has
suppressHydrationWarning), so server-rendered times are never shown and
hydration mismatches are avoided.

{formatChatLongDate(ts)}
</span>
<Separator className="flex-1" />
Expand Down Expand Up @@ -377,7 +378,7 @@ export function ChatArea({
{message.role === "assistant" ? "AI" : "You"}
</span>
{ts !== null ? (
<span>{formatChatTime(ts)}</span>
<span suppressHydrationWarning>{formatChatTime(ts)}</span>
) : (
<span>{message.timestamp}</span>
)}
Expand Down
7 changes: 4 additions & 3 deletions components/chat-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import { ModeToggle } from "@/components/mode-toggle";
import { authClient } from "@/lib/auth-client";
import { useRouter, useParams } from "next/navigation";
import { createChatAction, deleteChatAction } from "@/lib/actions/chat";
import Link from "next/link";

interface ChatSidebarProps {
chats: Chat[];
Expand Down Expand Up @@ -154,7 +155,7 @@ export function ChatSidebar({ chats, currentUser }: ChatSidebarProps) {
<Sidebar collapsible="offcanvas" className="border-r border-sidebar-border">
<SidebarHeader className="border-b border-sidebar-border p-4">
<div className="flex items-center justify-between gap-2 mb-4">
<div className="flex items-center gap-2">
<Link href="/" className="flex items-center gap-2">
<Image
src="/icon.png"
alt="Pointer RAG"
Expand All @@ -165,7 +166,7 @@ export function ChatSidebar({ chats, currentUser }: ChatSidebarProps) {
<span className="text-lg font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden">
Pointer RAG
</span>
</div>
</Link>
<div className="flex items-center gap-1">
<ModeToggle />
</div>
Expand Down Expand Up @@ -287,7 +288,7 @@ export function ChatSidebar({ chats, currentUser }: ChatSidebarProps) {
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{formatChatSidebarDate(chat.timestamp)}
<span suppressHydrationWarning>{formatChatSidebarDate(chat.timestamp)}</span>
</TooltipContent>
</Tooltip>

Expand Down
20 changes: 20 additions & 0 deletions components/greeting.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"use client";

import { useState, useEffect } from "react";
import { getGreeting } from "@/lib/greetings";

interface GreetingProps {
userName: string;
}

export function Greeting({ userName }: GreetingProps) {
const [greeting, setGreeting] = useState<string | null>(null);

useEffect(() => {
setGreeting(getGreeting(userName));
}, [userName]);

return (
<h2 className="mb-2 text-3xl font-semibold">{greeting || "Welcome"}</h2>
);
}
10 changes: 4 additions & 6 deletions lib/date-format.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,29 @@
const APP_DATE_LOCALE = "en-IN";
const APP_TIME_ZONE = "Asia/Kolkata";
export const APP_DATE_LOCALE = "en-IN";

const timeFormatter = new Intl.DateTimeFormat(APP_DATE_LOCALE, {
hour: "2-digit",
minute: "2-digit",
timeZone: APP_TIME_ZONE,
});

const longDateFormatter = new Intl.DateTimeFormat(APP_DATE_LOCALE, {
year: "numeric",
month: "long",
day: "numeric",
timeZone: APP_TIME_ZONE,
});

const shortDateFormatter = new Intl.DateTimeFormat(APP_DATE_LOCALE, {
year: "numeric",
month: "short",
day: "numeric",
timeZone: APP_TIME_ZONE,
});

// Pinned to UTC so the day-key is deterministic across server and client.
// This only drives the "show date divider?" logic, not the displayed text.
const dayKeyFormatter = new Intl.DateTimeFormat("en-CA", {
year: "numeric",
month: "2-digit",
day: "2-digit",
timeZone: APP_TIME_ZONE,
timeZone: "UTC",
});
Comment on lines 22 to 27
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

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

dayKeyFormatter no longer specifies a timeZone, so getStableDayKey() depends on the runtime timezone (server vs browser) and may produce different day keys for the same timestamp. Since ChatArea uses this to decide whether to render date dividers, this can cause hydration mismatches (dividers appearing/disappearing) around day boundaries. Consider making getStableDayKey timezone-invariant (e.g., derive the key from new Date(ts).toISOString().slice(0,10) / UTC) or explicitly pass a fixed timeZone for the day-key formatter while keeping display formatting local.

Copilot uses AI. Check for mistakes.

export function parseTimestampToMs(timestamp: string): number | null {
Expand Down
137 changes: 137 additions & 0 deletions lib/greetings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { APP_DATE_LOCALE } from "./date-format";

type GreetingCondition =
| "morning"
| "afternoon"
| "evening"
| "night"
| "daytime"
| "always"
| `day:${string}`;

interface GreetingTemplate {
template: string;
condition: GreetingCondition;
}

const GREETINGS: GreetingTemplate[] = [
// Morning (5:00 – 11:59)
{ template: "Good morning, {first_name}", condition: "morning" },

// Afternoon (12:00 – 16:59)
{ template: "Good afternoon, {first_name}", condition: "afternoon" },

// Evening (17:00 – 20:59)
{ template: "Good evening, {first_name}", condition: "evening" },
{ template: "Evening, {first_name}", condition: "evening" },

// Night (21:00 – 4:59)
{ template: "Hello, night owl", condition: "night" },
{
template: "What's on your mind tonight, {first_name}?",
condition: "night",
},
{ template: "It's late-night, {first_name}", condition: "night" },

// Daytime (12:00 – 20:59)
{ template: "How was your day, {first_name}?", condition: "daytime" },
{ template: "G'day, {first_name}", condition: "daytime" },

// Day-of-week specific
{ template: "Happy Monday, {first_name}", condition: "day:Monday" },
{ template: "Happy Tuesday, {first_name}", condition: "day:Tuesday" },
{ template: "Happy Wednesday, {first_name}", condition: "day:Wednesday" },
{ template: "Happy Thursday, {first_name}", condition: "day:Thursday" },
{ template: "Happy Friday, {first_name}", condition: "day:Friday" },
{ template: "That Friday feeling, {first_name}", condition: "day:Friday" },
{ template: "Happy Saturday, {first_name}", condition: "day:Saturday" },
{
template: "Welcome to the weekend, {first_name}",
condition: "day:Saturday",
},
{ template: "Happy Sunday, {first_name}", condition: "day:Sunday" },
{ template: "Sunday session, {first_name}?", condition: "day:Sunday" },
{
template: "Welcome to the weekend, {first_name}",
condition: "day:Sunday",
},

// Always applicable
{ template: "{first_name} returns!", condition: "always" },
{ template: "Back at it, {first_name}", condition: "always" },
{ template: "Greetings, {first_name}", condition: "always" },
{ template: "Hey there, {first_name}", condition: "always" },
{ template: "Hi {first_name}, how are you?", condition: "always" },
{ template: "How's it going, {first_name}?", condition: "always" },
{ template: "Welcome, {first_name}", condition: "always" },
{ template: "What's new, {first_name}?", condition: "always" },
{ template: "What's on your mind, {first_name}?", condition: "always" },
{ template: "What's up, {first_name}?", condition: "always" },
];

/**
* Returns a context-aware greeting based on the current time and day of week,
* using the user's local timezone (this function is only called client-side
* inside useEffect, so no SSR hydration concern).
*
* Extracts the first name from the full name for a friendlier tone.
*/
export function getGreeting(fullName: string): string {
const firstName = fullName.trim().split(/\s+/)[0] || "User";

const now = new Date();

// Use formatToParts to reliably extract the current hour in the user's local timezone
const hourFormatter = new Intl.DateTimeFormat(APP_DATE_LOCALE, {
hour: "numeric",
hourCycle: "h23",
});
const hourPart = hourFormatter
.formatToParts(now)
.find((p) => p.type === "hour");
const hourText = hourPart ? hourPart.value : hourFormatter.format(now);
const parsedHour = parseInt(hourText, 10);
const hour = !Number.isNaN(parsedHour) ? parsedHour % 24 : 0;

// Use formatToParts to get the weekday name in the user's local timezone
const dayFormatter = new Intl.DateTimeFormat("en-US", {
weekday: "long",
});
const dayPart = dayFormatter
.formatToParts(now)
.find((p) => p.type === "weekday");
const dayOfWeek = dayPart?.value ?? "";

// Filter greetings to only those eligible for the current context (excluding "always")
const specificGreetings = GREETINGS.filter((g) => {
switch (g.condition) {
case "morning":
return hour >= 5 && hour < 12;
case "afternoon":
return hour >= 12 && hour < 17;
case "evening":
return hour >= 17 && hour < 21;
case "night":
return hour >= 21 || hour < 5;
case "daytime":
return hour >= 12 && hour < 21;
default:
if (g.condition.startsWith("day:")) {
return g.condition.slice(4) === dayOfWeek;
}
return false;
}
});

const alwaysGreetings = GREETINGS.filter((g) => g.condition === "always");

// If we have specific greetings, give them an 60% chance to be used over generic ones
// to avoid generic greetings crowding out the context-aware ones.
const pool =
specificGreetings.length > 0 && Math.random() < 0.6
? specificGreetings
: alwaysGreetings;

const selected = pool[Math.floor(Math.random() * pool.length)];
return selected.template.replaceAll("{first_name}", () => firstName);
}
Loading