Another take on chat timestamps (#2214)

Co-authored-by: Nahiyan Khan <nahiyan@squareup.com>
This commit is contained in:
Zane
2025-04-17 09:37:52 -07:00
committed by GitHub
parent 8d5ba09647
commit a1fe3bcbf1
8 changed files with 92 additions and 79 deletions

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useRef } from 'react';
import LinkPreview from './LinkPreview'; import LinkPreview from './LinkPreview';
import GooseResponseForm from './GooseResponseForm'; import GooseResponseForm from './GooseResponseForm';
import { extractUrls } from '../utils/urlUtils'; import { extractUrls } from '../utils/urlUtils';
import { formatMessageTimestamp } from '../utils/timeUtils';
import MarkdownContent from './MarkdownContent'; import MarkdownContent from './MarkdownContent';
import ToolCallWithResponse from './ToolCallWithResponse'; import ToolCallWithResponse from './ToolCallWithResponse';
import { import {
@@ -39,6 +40,9 @@ export default function GooseMessage({
// Extract text content from the message // Extract text content from the message
let textContent = getTextContent(message); let textContent = getTextContent(message);
// Memoize the timestamp
const timestamp = useMemo(() => formatMessageTimestamp(message.created), [message.created]);
// Get tool requests from the message // Get tool requests from the message
const toolRequests = getToolRequests(message); const toolRequests = getToolRequests(message);
@@ -116,35 +120,47 @@ export default function GooseMessage({
{textContent && ( {textContent && (
<div className="flex flex-col group"> <div className="flex flex-col group">
<div <div
className={`goose-message-content bg-bgSubtle rounded-2xl px-4 py-2 ${toolRequests.length > 0 ? 'rounded-b-none' : ''}`} className={`goose-message-content bg-bgSubtle rounded-2xl px-4 py-2 ${toolRequests.length > 0 ? 'rounded-b-none' : 'rounded-bl-none'}`}
> >
<div ref={contentRef}>{<MarkdownContent content={textContent} />}</div> <div ref={contentRef}>{<MarkdownContent content={textContent} />}</div>
</div> </div>
{/* Only show MessageCopyLink if there's text content and no tool requests/responses */} {/* Only show MessageCopyLink if there's text content and no tool requests/responses */}
{textContent && message.content.every((content) => content.type === 'text') && ( <div className="relative flex justify-end z-[-1]">
<div className="flex justify-end mr-2"> {toolRequests.length === 0 && (
<MessageCopyLink text={textContent} contentRef={contentRef} /> <div className="absolute left-0 text-xs text-textSubtle pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
</div> {timestamp}
)} </div>
)}
{textContent && message.content.every((content) => content.type === 'text') && (
<div className="absolute left-0 pt-1">
<MessageCopyLink text={textContent} contentRef={contentRef} />
</div>
)}
</div>
</div> </div>
)} )}
{toolRequests.length > 0 && ( {toolRequests.length > 0 && (
<div <div className="relative flex flex-col w-full">
className={`goose-message-tool bg-bgApp border border-borderSubtle dark:border-gray-700 ${textContent ? '' : 'rounded-t-2xl'} rounded-b-2xl px-4 pt-4 pb-2`} <div
> className={`goose-message-tool bg-bgApp border border-borderSubtle dark:border-gray-700 ${textContent ? '' : 'rounded-t-2xl'} rounded-b-2xl rounded-bl-none px-4 pt-4 pb-2`}
{toolRequests.map((toolRequest) => ( >
<ToolCallWithResponse {toolRequests.map((toolRequest) => (
// If the message is resumed and not matched tool response, it means the tool is broken or cancelled. <ToolCallWithResponse
isCancelledMessage={ // If the message is resumed and not matched tool response, it means the tool is broken or cancelled.
messageIndex < messageHistoryIndex && isCancelledMessage={
toolResponsesMap.get(toolRequest.id) == undefined messageIndex < messageHistoryIndex &&
} toolResponsesMap.get(toolRequest.id) == undefined
key={toolRequest.id} }
toolRequest={toolRequest} key={toolRequest.id}
toolResponse={toolResponsesMap.get(toolRequest.id)} toolRequest={toolRequest}
/> toolResponse={toolResponsesMap.get(toolRequest.id)}
))} />
))}
</div>
<div className="text-xs text-textSubtle pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
{timestamp}
</div>
</div> </div>
)} )}

View File

@@ -51,7 +51,7 @@ export default function MessageCopyLink({ text, contentRef }: MessageCopyLinkPro
return ( return (
<button <button
onClick={handleCopy} onClick={handleCopy}
className="flex items-center gap-1 text-xs text-textSubtle hover:text-textProminent transition-all duration-200 mt-1 opacity-0 transform -translate-y-2 group-hover:translate-y-0 group-hover:opacity-100" className="flex items-center gap-1 text-xs text-textSubtle hover:cursor-pointer hover:text-textProminent transition-all duration-200 opacity-0 group-hover:opacity-100 -translate-y-4 group-hover:translate-y-0"
> >
<Copy className="h-3 w-3" /> <Copy className="h-3 w-3" />
<span>{copied ? 'Copied!' : 'Copy'}</span> <span>{copied ? 'Copied!' : 'Copy'}</span>

View File

@@ -1,9 +1,10 @@
import React, { useRef } from 'react'; import React, { useRef, useMemo } from 'react';
import LinkPreview from './LinkPreview'; import LinkPreview from './LinkPreview';
import { extractUrls } from '../utils/urlUtils'; import { extractUrls } from '../utils/urlUtils';
import MarkdownContent from './MarkdownContent'; import MarkdownContent from './MarkdownContent';
import { Message, getTextContent } from '../types/message'; import { Message, getTextContent } from '../types/message';
import MessageCopyLink from './MessageCopyLink'; import MessageCopyLink from './MessageCopyLink';
import { formatMessageTimestamp } from '../utils/timeUtils';
interface UserMessageProps { interface UserMessageProps {
message: Message; message: Message;
@@ -15,6 +16,9 @@ export default function UserMessage({ message }: UserMessageProps) {
// Extract text content from the message // Extract text content from the message
const textContent = getTextContent(message); const textContent = getTextContent(message);
// Memoize the timestamp
const timestamp = useMemo(() => formatMessageTimestamp(message.created), [message.created]);
// Extract URLs which explicitly contain the http:// or https:// protocol // Extract URLs which explicitly contain the http:// or https:// protocol
const urls = extractUrls(textContent, []); const urls = extractUrls(textContent, []);
@@ -30,8 +34,13 @@ export default function UserMessage({ message }: UserMessageProps) {
/> />
</div> </div>
</div> </div>
<div className="flex justify-end"> <div className="relative h-[22px] flex justify-end">
<MessageCopyLink text={textContent} contentRef={contentRef} /> <div className="absolute right-0 text-xs text-textSubtle pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
{timestamp}
</div>
<div className="absolute right-0 pt-1">
<MessageCopyLink text={textContent} contentRef={contentRef} />
</div>
</div> </div>
</div> </div>

View File

@@ -11,7 +11,8 @@ import {
LoaderCircle, LoaderCircle,
} from 'lucide-react'; } from 'lucide-react';
import { type SessionDetails } from '../../sessions'; import { type SessionDetails } from '../../sessions';
import { SessionHeaderCard, SessionMessages, formatDate } from './SessionViewComponents'; import { SessionHeaderCard, SessionMessages } from './SessionViewComponents';
import { formatMessageTimestamp } from '../../utils/timeUtils';
import { createSharedSession } from '../../sharedSessions'; import { createSharedSession } from '../../sharedSessions';
import { Modal, ModalContent } from '../ui/modal'; import { Modal, ModalContent } from '../ui/modal';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
@@ -120,7 +121,7 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
<div className="flex items-center text-sm text-textSubtle mt-1 space-x-5"> <div className="flex items-center text-sm text-textSubtle mt-1 space-x-5">
<span className="flex items-center"> <span className="flex items-center">
<Calendar className="w-4 h-4 mr-1" /> <Calendar className="w-4 h-4 mr-1" />
{formatDate(session.messages[0]?.created)} {formatMessageTimestamp(session.messages[0]?.created)}
</span> </span>
<span className="flex items-center"> <span className="flex items-center">
<MessageSquareText className="w-4 h-4 mr-1" /> <MessageSquareText className="w-4 h-4 mr-1" />

View File

@@ -14,6 +14,7 @@ import { Button } from '../ui/button';
import BackButton from '../ui/BackButton'; import BackButton from '../ui/BackButton';
import { ScrollArea } from '../ui/scroll-area'; import { ScrollArea } from '../ui/scroll-area';
import { View, ViewOptions } from '../../App'; import { View, ViewOptions } from '../../App';
import { formatMessageTimestamp } from '../../utils/timeUtils';
interface SessionListViewProps { interface SessionListViewProps {
setView: (view: View, viewOptions?: ViewOptions) => void; setView: (view: View, viewOptions?: ViewOptions) => void;
@@ -45,29 +46,6 @@ const SessionListView: React.FC<SessionListViewProps> = ({ setView, onSelectSess
} }
}; };
// Format date to be more readable
// eg. "10:39 PM, Feb 28, 2025"
const formatDateString = (dateString: string) => {
try {
const date = new Date(dateString);
const time = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: 'numeric',
hour12: true,
}).format(date);
const dateStr = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(date);
return `${time}, ${dateStr}`;
} catch (e) {
return dateString;
}
};
return ( return (
<div className="h-screen w-full"> <div className="h-screen w-full">
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div> <div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>
@@ -115,7 +93,9 @@ const SessionListView: React.FC<SessionListViewProps> = ({ setView, onSelectSess
<div className="flex gap-3"> <div className="flex gap-3">
<div className="flex items-center text-textSubtle text-sm"> <div className="flex items-center text-textSubtle text-sm">
<Calendar className="w-3 h-3 mr-1 flex-shrink-0" /> <Calendar className="w-3 h-3 mr-1 flex-shrink-0" />
<span className="truncate">{formatDateString(session.modified)}</span> <span className="truncate">
{formatMessageTimestamp(Date.parse(session.modified) / 1000)}
</span>
</div> </div>
<div className="flex items-center text-textSubtle text-sm"> <div className="flex items-center text-textSubtle text-sm">
<Folder className="w-3 h-3 mr-1 flex-shrink-0" /> <Folder className="w-3 h-3 mr-1 flex-shrink-0" />

View File

@@ -8,31 +8,7 @@ import MarkdownContent from '../MarkdownContent';
import ToolCallWithResponse from '../ToolCallWithResponse'; import ToolCallWithResponse from '../ToolCallWithResponse';
import { ToolRequestMessageContent, ToolResponseMessageContent } from '../../types/message'; import { ToolRequestMessageContent, ToolResponseMessageContent } from '../../types/message';
import { type Message } from '../../types/message'; import { type Message } from '../../types/message';
import { formatMessageTimestamp } from '../../utils/timeUtils';
/**
* Format a timestamp into a human-readable date string
*/
export const formatDate = (timestamp: number) => {
const date = new Date(timestamp * 1000);
const getOrdinal = (n: number) => {
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
};
const hours = date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
const month = date.toLocaleString('en-US', { month: 'short' });
const day = getOrdinal(date.getDate());
const year = date.getFullYear();
return `${hours}, ${month} ${day}, ${year}`;
};
/** /**
* Get tool responses map from messages * Get tool responses map from messages
@@ -166,7 +142,7 @@ export const SessionMessages: React.FC<SessionMessagesProps> = ({
{message.role === 'user' ? 'You' : 'Goose'} {message.role === 'user' ? 'You' : 'Goose'}
</span> </span>
<span className="text-xs text-textSubtle"> <span className="text-xs text-textSubtle">
{new Date(message.created * 1000).toLocaleTimeString()} {formatMessageTimestamp(message.created)}
</span> </span>
</div> </div>

View File

@@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import { Calendar, MessageSquareText, Folder, Target } from 'lucide-react'; import { Calendar, MessageSquareText, Folder, Target } from 'lucide-react';
import { type SharedSessionDetails } from '../../sharedSessions'; import { type SharedSessionDetails } from '../../sharedSessions';
import { SessionHeaderCard, SessionMessages, formatDate } from './SessionViewComponents'; import { SessionHeaderCard, SessionMessages } from './SessionViewComponents';
import { formatMessageTimestamp } from '../../utils/timeUtils';
interface SharedSessionViewProps { interface SharedSessionViewProps {
session: SharedSessionDetails | null; session: SharedSessionDetails | null;
@@ -32,7 +33,7 @@ const SharedSessionView: React.FC<SharedSessionViewProps> = ({
<div className="flex items-center text-sm text-textSubtle mt-1 space-x-5"> <div className="flex items-center text-sm text-textSubtle mt-1 space-x-5">
<span className="flex items-center"> <span className="flex items-center">
<Calendar className="w-4 h-4 mr-1" /> <Calendar className="w-4 h-4 mr-1" />
{formatDate(session.messages[0]?.created)} {formatMessageTimestamp(session.messages[0]?.created)}
</span> </span>
<span className="flex items-center"> <span className="flex items-center">
<MessageSquareText className="w-4 h-4 mr-1" /> <MessageSquareText className="w-4 h-4 mr-1" />

View File

@@ -0,0 +1,30 @@
export function formatMessageTimestamp(timestamp: number): string {
// Convert from Unix timestamp (seconds) to milliseconds
const date = new Date(timestamp * 1000);
const now = new Date();
// Format time as HH:MM AM/PM
const timeStr = date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
// Check if the message is from today
if (
date.getDate() === now.getDate() &&
date.getMonth() === now.getMonth() &&
date.getFullYear() === now.getFullYear()
) {
return timeStr;
}
// If not today, format as MM/DD/YYYY HH:MM AM/PM
const dateStr = date.toLocaleDateString('en-US', {
month: '2-digit',
day: '2-digit',
year: 'numeric',
});
return `${dateStr} ${timeStr}`;
}