feat: clean up task list in HistoryPreview and History components (#6687)

Co-authored-by: Roo Code <roomote@roocode.com>
Co-authored-by: Bruno Bergher <me@brunobergher.comexport>
Co-authored-by: Bruno Bergher <me@brunobergher.com>
Co-authored-by: Matt Rubens <mrubens@users.noreply.github.com>
This commit is contained in:
roomote[bot] 2025-08-05 11:05:55 -07:00 committed by GitHub
parent 477b85de03
commit 24584f54ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 567 additions and 220 deletions

View file

@ -13,7 +13,7 @@ const VersionIndicator: React.FC<VersionIndicatorProps> = ({ onClick, className
return (
<button
onClick={onClick}
className={`text-xs text-vscode-descriptionForeground hover:text-vscode-foreground transition-colors cursor-pointer px-2 py-1 rounded border border-vscode-panel-border hover:border-vscode-focusBorder ${className}`}
className={`text-xs text-vscode-descriptionForeground hover:text-vscode-foreground transition-colors cursor-pointer px-2 py-1 rounded border ${className}`}
aria-label={t("chat:versionIndicator.ariaLabel", { version: Package.version })}>
v{Package.version}
</button>

View file

@ -31,7 +31,7 @@ export const DeleteButton = ({ itemId, onDelete }: DeleteButtonProps) => {
size="icon"
data-testid="delete-task-button"
onClick={handleDeleteClick}
className="group-hover:opacity-100 opacity-50 transition-opacity">
className="opacity-70">
<span className="codicon codicon-trash size-4 align-middle text-vscode-descriptionForeground" />
</Button>
</StandardTooltip>

View file

@ -222,7 +222,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
</div>
</TabHeader>
<TabContent className="p-0">
<TabContent className="px-2 py-0">
<Virtuoso
className="flex-1 overflow-y-scroll"
data={tasks}
@ -243,7 +243,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
isSelected={selectedTaskIds.includes(item.id)}
onToggleSelection={toggleTaskSelection}
onDelete={setDeleteTaskId}
className="m-2 mr-0"
className="m-2"
/>
)}
/>
@ -251,7 +251,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
{/* Fixed action bar at bottom - only shown in selection mode with selected items */}
{isSelectionMode && selectedTaskIds.length > 0 && (
<div className="fixed bottom-0 left-0 right-0 bg-vscode-editor-background border-t border-vscode-panel-border p-2 flex justify-between items-center">
<div className="fixed bottom-0 left-0 right-2 bg-vscode-editor-background border-t border-vscode-panel-border p-2 flex justify-between items-center">
<div className="text-vscode-foreground">
{t("history:selectedItems", { selected: selectedTaskIds.length, total: tasks.length })}
</div>

View file

@ -5,7 +5,6 @@ import { vscode } from "@/utils/vscode"
import { cn } from "@/lib/utils"
import { Checkbox } from "@/components/ui/checkbox"
import TaskItemHeader from "./TaskItemHeader"
import TaskItemFooter from "./TaskItemFooter"
interface DisplayHistoryItem extends HistoryItem {
@ -48,11 +47,11 @@ const TaskItem = ({
key={item.id}
data-testid={`task-item-${item.id}`}
className={cn(
"cursor-pointer group bg-vscode-editor-background rounded relative overflow-hidden hover:border-vscode-toolbar-hoverBackground/60",
"cursor-pointer group bg-vscode-editor-background rounded relative overflow-hidden border border-transparent hover:bg-vscode-list-hoverBackground transition-colors",
className,
)}
onClick={handleClick}>
<div className="flex gap-2 p-3">
<div className={(!isCompact && isSelectionMode ? "pl-3 pb-3" : "pl-4") + " flex gap-3 px-3 pt-3 pb-1"}>
{/* Selection checkbox - only in full variant */}
{!isCompact && isSelectionMode && (
<div
@ -69,24 +68,25 @@ const TaskItem = ({
)}
<div className="flex-1 min-w-0">
{/* Header with metadata */}
<TaskItemHeader item={item} isSelectionMode={isSelectionMode} onDelete={onDelete} />
{/* Task content */}
<div
className={cn("overflow-hidden whitespace-pre-wrap text-vscode-foreground text-ellipsis", {
"text-base line-clamp-3": !isCompact,
"line-clamp-2": isCompact,
})}
className={cn(
"overflow-hidden whitespace-pre-wrap text-vscode-foreground text-ellipsis line-clamp-2",
{
"text-base": !isCompact,
},
!isCompact && isSelectionMode ? "mb-1" : "",
)}
data-testid="task-content"
{...(item.highlight ? { dangerouslySetInnerHTML: { __html: item.highlight } } : {})}>
{item.highlight ? undefined : item.task}
</div>
<TaskItemFooter
item={item}
variant={variant}
isSelectionMode={isSelectionMode}
onDelete={onDelete}
/>
{/* Task Item Footer */}
<TaskItemFooter item={item} variant={variant} isSelectionMode={isSelectionMode} />
{/* Workspace info */}
{showWorkspace && item.workspace && (
<div className="flex flex-row gap-1 text-vscode-descriptionForeground text-xs mt-1">
<span className="codicon codicon-folder scale-80" />

View file

@ -1,59 +1,41 @@
import React from "react"
import type { HistoryItem } from "@roo-code/types"
import { Coins, FileIcon } from "lucide-react"
import prettyBytes from "pretty-bytes"
import { formatLargeNumber } from "@/utils/format"
import { formatTimeAgo } from "@/utils/format"
import { CopyButton } from "./CopyButton"
import { ExportButton } from "./ExportButton"
import { DeleteButton } from "./DeleteButton"
import { StandardTooltip } from "../ui/standard-tooltip"
export interface TaskItemFooterProps {
item: HistoryItem
variant: "compact" | "full"
isSelectionMode?: boolean
onDelete?: (taskId: string) => void
}
const TaskItemFooter: React.FC<TaskItemFooterProps> = ({ item, variant, isSelectionMode = false }) => {
const TaskItemFooter: React.FC<TaskItemFooterProps> = ({ item, variant, isSelectionMode = false, onDelete }) => {
return (
<div className="text-xs text-vscode-descriptionForeground flex justify-between items-center mt-1">
<div className="flex gap-2">
{!!(item.cacheReads || item.cacheWrites) && (
<span className="flex items-center" data-testid="cache-compact">
<i className="mr-1 codicon codicon-cloud-upload text-sm! text-vscode-descriptionForeground" />
<span className="inline-block mr-1">{formatLargeNumber(item.cacheWrites || 0)}</span>
<i className="mr-1 codicon codicon-cloud-download text-sm! text-vscode-descriptionForeground" />
<span>{formatLargeNumber(item.cacheReads || 0)}</span>
</span>
)}
{/* Full Tokens */}
{!!(item.tokensIn || item.tokensOut) && (
<span className="flex items-center gap-1">
<span data-testid="tokens-in-footer-compact"> {formatLargeNumber(item.tokensIn || 0)}</span>
<span data-testid="tokens-out-footer-compact"> {formatLargeNumber(item.tokensOut || 0)}</span>
</span>
)}
{/* Full Cost */}
<div className="text-xs text-vscode-descriptionForeground flex justify-between items-center">
<div className="flex gap-2 items-center text-vscode-descriptionForeground/60">
{/* Datetime with time-ago format */}
<StandardTooltip content={new Date(item.ts).toLocaleString()}>
<span className="first-letter:uppercase">{formatTimeAgo(item.ts)}</span>
</StandardTooltip>
<span>·</span>
{/* Cost */}
{!!item.totalCost && (
<span className="flex items-center">
<Coins className="inline-block size-[1em] mr-1" />
<span data-testid="cost-footer-compact">{"$" + item.totalCost.toFixed(2)}</span>
</span>
)}
{!!item.size && (
<span className="flex items-center">
<FileIcon className="inline-block size-[1em] mr-1" />
<span data-testid="size-footer-compact">{prettyBytes(item.size)}</span>
<span className="flex items-center" data-testid="cost-footer-compact">
{"$" + item.totalCost.toFixed(2)}
</span>
)}
</div>
{/* Action Buttons for non-compact view */}
{!isSelectionMode && (
<div className="flex flex-row gap-0 items-center opacity-50 hover:opacity-100">
<div className="flex flex-row gap-0 items-center text-vscode-descriptionForeground/60 hover:text-vscode-descriptionForeground">
<CopyButton itemTask={item.task} />
{variant === "full" && <ExportButton itemId={item.id} />}
{onDelete && <DeleteButton itemId={item.id} onDelete={onDelete} />}
</div>
)}
</div>

View file

@ -1,37 +0,0 @@
import React from "react"
import type { HistoryItem } from "@roo-code/types"
import { formatDate } from "@/utils/format"
import { DeleteButton } from "./DeleteButton"
import { cn } from "@/lib/utils"
export interface TaskItemHeaderProps {
item: HistoryItem
isSelectionMode: boolean
onDelete?: (taskId: string) => void
}
const TaskItemHeader: React.FC<TaskItemHeaderProps> = ({ item, isSelectionMode, onDelete }) => {
return (
<div
className={cn("flex justify-between items-center", {
// this is to balance out the margin when we don't have a delete button
// because the delete button sorta pushes the date up due to its size
"mb-1": !onDelete,
})}>
<div className="flex items-center flex-wrap gap-x-2 text-xs">
<span className="text-vscode-descriptionForeground font-medium text-sm uppercase">
{formatDate(item.ts)}
</span>
</div>
{/* Action Buttons */}
{!isSelectionMode && (
<div className="flex flex-row gap-0 items-center opacity-20 group-hover:opacity-50 hover:opacity-100">
{onDelete && <DeleteButton itemId={item.id} onDelete={onDelete} />}
</div>
)}
</div>
)
}
export default TaskItemHeader

View file

@ -148,6 +148,8 @@ describe("HistoryPreview", () => {
expect(screen.getByTestId("task-item-task-2")).toBeInTheDocument()
expect(screen.getByTestId("task-item-task-3")).toBeInTheDocument()
expect(screen.queryByTestId("task-item-task-4")).not.toBeInTheDocument()
expect(screen.queryByTestId("task-item-task-5")).not.toBeInTheDocument()
expect(screen.queryByTestId("task-item-task-6")).not.toBeInTheDocument()
})
it("renders only 1 task when there is only 1 task", () => {

View file

@ -9,6 +9,12 @@ vi.mock("@src/i18n/TranslationContext", () => ({
}),
}))
vi.mock("@/utils/format", () => ({
formatTimeAgo: vi.fn(() => "2 hours ago"),
formatDate: vi.fn(() => "January 15 at 2:30 PM"),
formatLargeNumber: vi.fn((num: number) => num.toString()),
}))
const mockTask = {
id: "1",
number: 1,
@ -74,16 +80,10 @@ describe("TaskItem", () => {
expect(screen.getByTestId("export")).toBeInTheDocument()
})
it("displays cache information when present", () => {
const mockTaskWithCache = {
...mockTask,
cacheReads: 10,
cacheWrites: 5,
}
it("displays time ago information", () => {
render(
<TaskItem
item={mockTaskWithCache}
item={mockTask}
variant="full"
isSelected={false}
onToggleSelection={vi.fn()}
@ -91,22 +91,14 @@ describe("TaskItem", () => {
/>,
)
// Should display cache information in the footer
expect(screen.getByTestId("cache-compact")).toBeInTheDocument()
expect(screen.getByText("5")).toBeInTheDocument() // cache writes
expect(screen.getByText("10")).toBeInTheDocument() // cache reads
// Should display time ago format
expect(screen.getByText(/ago/)).toBeInTheDocument()
})
it("does not display cache information when not present", () => {
const mockTaskWithoutCache = {
...mockTask,
cacheReads: 0,
cacheWrites: 0,
}
it("applies hover effect class", () => {
render(
<TaskItem
item={mockTaskWithoutCache}
item={mockTask}
variant="full"
isSelected={false}
onToggleSelection={vi.fn()}
@ -114,7 +106,7 @@ describe("TaskItem", () => {
/>,
)
// Cache section should not be present
expect(screen.queryByTestId("cache-compact")).not.toBeInTheDocument()
const taskItem = screen.getByTestId("task-item-1")
expect(taskItem).toHaveClass("hover:bg-vscode-list-hoverBackground")
})
})

View file

@ -8,6 +8,12 @@ vi.mock("@src/i18n/TranslationContext", () => ({
}),
}))
vi.mock("@/utils/format", () => ({
formatTimeAgo: vi.fn(() => "2 hours ago"),
formatDate: vi.fn(() => "January 15 at 2:30 PM"),
formatLargeNumber: vi.fn((num: number) => num.toString()),
}))
const mockItem = {
id: "1",
number: 1,
@ -20,12 +26,11 @@ const mockItem = {
}
describe("TaskItemFooter", () => {
it("renders token information", () => {
it("renders time ago information", () => {
render(<TaskItemFooter item={mockItem} variant="full" />)
// Check for token counts using testids since the text is split across elements
expect(screen.getByTestId("tokens-in-footer-compact")).toBeInTheDocument()
expect(screen.getByTestId("tokens-out-footer-compact")).toBeInTheDocument()
// Should show time ago format
expect(screen.getByText(/ago/)).toBeInTheDocument()
})
it("renders cost information", () => {
@ -43,31 +48,38 @@ describe("TaskItemFooter", () => {
expect(screen.getByTestId("export")).toBeInTheDocument()
})
it("renders cache information when present", () => {
const mockItemWithCache = {
...mockItem,
cacheReads: 5,
cacheWrites: 3,
}
it("hides export button in compact variant", () => {
render(<TaskItemFooter item={mockItem} variant="compact" />)
render(<TaskItemFooter item={mockItemWithCache} variant="full" />)
// Check for cache display using testid
expect(screen.getByTestId("cache-compact")).toBeInTheDocument()
expect(screen.getByText("3")).toBeInTheDocument() // cache writes
expect(screen.getByText("5")).toBeInTheDocument() // cache reads
// Should show copy button but not export button
expect(screen.getByTestId("copy-prompt-button")).toBeInTheDocument()
expect(screen.queryByTestId("export")).not.toBeInTheDocument()
})
it("does not render cache information when not present", () => {
const mockItemWithoutCache = {
...mockItem,
cacheReads: 0,
cacheWrites: 0,
}
it("hides action buttons in selection mode", () => {
render(<TaskItemFooter item={mockItem} variant="full" isSelectionMode={true} />)
render(<TaskItemFooter item={mockItemWithoutCache} variant="full" />)
// Should not show any action buttons
expect(screen.queryByTestId("copy-prompt-button")).not.toBeInTheDocument()
expect(screen.queryByTestId("export")).not.toBeInTheDocument()
expect(screen.queryByTestId("delete-task-button")).not.toBeInTheDocument()
})
// Cache section should not be present
expect(screen.queryByTestId("cache-compact")).not.toBeInTheDocument()
it("shows delete button when not in selection mode and onDelete is provided", () => {
render(<TaskItemFooter item={mockItem} variant="full" isSelectionMode={false} onDelete={vi.fn()} />)
expect(screen.getByTestId("delete-task-button")).toBeInTheDocument()
})
it("does not show delete button in selection mode", () => {
render(<TaskItemFooter item={mockItem} variant="full" isSelectionMode={true} onDelete={vi.fn()} />)
expect(screen.queryByTestId("delete-task-button")).not.toBeInTheDocument()
})
it("does not show delete button when onDelete is not provided", () => {
render(<TaskItemFooter item={mockItem} variant="full" isSelectionMode={false} />)
expect(screen.queryByTestId("delete-task-button")).not.toBeInTheDocument()
})
})

View file

@ -1,35 +0,0 @@
import { render, screen } from "@/utils/test-utils"
import TaskItemHeader from "../TaskItemHeader"
vi.mock("@src/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => key,
}),
}))
const mockItem = {
id: "1",
number: 1,
task: "Test task",
ts: Date.now(),
tokensIn: 100,
tokensOut: 50,
totalCost: 0.002,
workspace: "/test/workspace",
}
describe("TaskItemHeader", () => {
it("renders date information", () => {
render(<TaskItemHeader item={mockItem} isSelectionMode={false} onDelete={vi.fn()} />)
// TaskItemHeader shows the formatted date, not the task text
expect(screen.getByText(/\w+ \d{1,2}, \d{1,2}:\d{2} \w{2}/)).toBeInTheDocument() // Date format like "JUNE 14, 10:15 AM"
})
it("shows delete button when not in selection mode", () => {
render(<TaskItemHeader item={mockItem} isSelectionMode={false} onDelete={vi.fn()} />)
expect(screen.getByRole("button")).toBeInTheDocument()
})
})

View file

@ -67,5 +67,21 @@
"editMessage": "Editar missatge",
"editWarning": "Editar aquest missatge eliminarà tots els missatges posteriors de la conversa. Vols continuar?",
"proceed": "Continuar"
},
"time_ago": {
"just_now": "ara mateix",
"seconds_ago": "fa {{count}} segons",
"minute_ago": "fa un minut",
"minutes_ago": "fa {{count}} minuts",
"hour_ago": "fa una hora",
"hours_ago": "fa {{count}} hores",
"day_ago": "fa un dia",
"days_ago": "fa {{count}} dies",
"week_ago": "fa una setmana",
"weeks_ago": "fa {{count}} setmanes",
"month_ago": "fa un mes",
"months_ago": "fa {{count}} mesos",
"year_ago": "fa un any",
"years_ago": "fa {{count}} anys"
}
}

View file

@ -67,5 +67,21 @@
"editMessage": "Nachricht bearbeiten",
"editWarning": "Das Bearbeiten dieser Nachricht wird alle nachfolgenden Nachrichten in der Unterhaltung löschen. Möchtest du fortfahren?",
"proceed": "Fortfahren"
},
"time_ago": {
"just_now": "gerade eben",
"seconds_ago": "vor {{count}} Sekunden",
"minute_ago": "vor einer Minute",
"minutes_ago": "vor {{count}} Minuten",
"hour_ago": "vor einer Stunde",
"hours_ago": "vor {{count}} Stunden",
"day_ago": "vor einem Tag",
"days_ago": "vor {{count}} Tagen",
"week_ago": "vor einer Woche",
"weeks_ago": "vor {{count}} Wochen",
"month_ago": "vor einem Monat",
"months_ago": "vor {{count}} Monaten",
"year_ago": "vor einem Jahr",
"years_ago": "vor {{count}} Jahren"
}
}

View file

@ -67,5 +67,21 @@
"editMessage": "Edit Message",
"editWarning": "Editing this message will delete all subsequent messages in the conversation. Do you want to proceed?",
"proceed": "Proceed"
},
"time_ago": {
"just_now": "just now",
"seconds_ago": "{{count}} seconds ago",
"minute_ago": "a minute ago",
"minutes_ago": "{{count}} minutes ago",
"hour_ago": "an hour ago",
"hours_ago": "{{count}} hours ago",
"day_ago": "a day ago",
"days_ago": "{{count}} days ago",
"week_ago": "a week ago",
"weeks_ago": "{{count}} weeks ago",
"month_ago": "a month ago",
"months_ago": "{{count}} months ago",
"year_ago": "a year ago",
"years_ago": "{{count}} years ago"
}
}

View file

@ -67,5 +67,21 @@
"editMessage": "Editar mensaje",
"editWarning": "Editar este mensaje eliminará todos los mensajes posteriores en la conversación. ¿Deseas continuar?",
"proceed": "Continuar"
},
"time_ago": {
"just_now": "ahora mismo",
"seconds_ago": "hace {{count}} segundos",
"minute_ago": "hace un minuto",
"minutes_ago": "hace {{count}} minutos",
"hour_ago": "hace una hora",
"hours_ago": "hace {{count}} horas",
"day_ago": "hace un día",
"days_ago": "hace {{count}} días",
"week_ago": "hace una semana",
"weeks_ago": "hace {{count}} semanas",
"month_ago": "hace un mes",
"months_ago": "hace {{count}} meses",
"year_ago": "hace un año",
"years_ago": "hace {{count}} años"
}
}

View file

@ -67,5 +67,21 @@
"editMessage": "Modifier le message",
"editWarning": "Modifier ce message supprimera tous les messages suivants dans la conversation. Voulez-vous continuer ?",
"proceed": "Continuer"
},
"time_ago": {
"just_now": "à l'instant",
"seconds_ago": "il y a {{count}} secondes",
"minute_ago": "il y a une minute",
"minutes_ago": "il y a {{count}} minutes",
"hour_ago": "il y a une heure",
"hours_ago": "il y a {{count}} heures",
"day_ago": "il y a un jour",
"days_ago": "il y a {{count}} jours",
"week_ago": "il y a une semaine",
"weeks_ago": "il y a {{count}} semaines",
"month_ago": "il y a un mois",
"months_ago": "il y a {{count}} mois",
"year_ago": "il y a un an",
"years_ago": "il y a {{count}} ans"
}
}

View file

@ -67,5 +67,21 @@
"editMessage": "संदेश संपादित करें",
"editWarning": "इस संदेश को संपादित करने से बातचीत के सभी बाद के संदेश हट जाएंगे। क्या आप जारी रखना चाहते हैं?",
"proceed": "जारी रखें"
},
"time_ago": {
"just_now": "अभी",
"seconds_ago": "{{count}} सेकंड पहले",
"minute_ago": "एक मिनट पहले",
"minutes_ago": "{{count}} मिनट पहले",
"hour_ago": "एक घंटे पहले",
"hours_ago": "{{count}} घंटे पहले",
"day_ago": "एक दिन पहले",
"days_ago": "{{count}} दिन पहले",
"week_ago": "एक सप्ताह पहले",
"weeks_ago": "{{count}} सप्ताह पहले",
"month_ago": "एक महीने पहले",
"months_ago": "{{count}} महीने पहले",
"year_ago": "एक साल पहले",
"years_ago": "{{count}} साल पहले"
}
}

View file

@ -67,5 +67,21 @@
"editMessage": "Edit Pesan",
"editWarning": "Mengedit pesan ini akan menghapus semua pesan selanjutnya dalam percakapan. Apakah kamu ingin melanjutkan?",
"proceed": "Lanjutkan"
},
"time_ago": {
"just_now": "baru saja",
"seconds_ago": "{{count}} detik yang lalu",
"minute_ago": "satu menit yang lalu",
"minutes_ago": "{{count}} menit yang lalu",
"hour_ago": "satu jam yang lalu",
"hours_ago": "{{count}} jam yang lalu",
"day_ago": "satu hari yang lalu",
"days_ago": "{{count}} hari yang lalu",
"week_ago": "satu minggu yang lalu",
"weeks_ago": "{{count}} minggu yang lalu",
"month_ago": "satu bulan yang lalu",
"months_ago": "{{count}} bulan yang lalu",
"year_ago": "satu tahun yang lalu",
"years_ago": "{{count}} tahun yang lalu"
}
}

View file

@ -67,5 +67,21 @@
"editMessage": "Modifica Messaggio",
"editWarning": "Modificando questo messaggio verranno eliminati tutti i messaggi successivi nella conversazione. Vuoi procedere?",
"proceed": "Procedi"
},
"time_ago": {
"just_now": "proprio ora",
"seconds_ago": "{{count}} secondi fa",
"minute_ago": "un minuto fa",
"minutes_ago": "{{count}} minuti fa",
"hour_ago": "un'ora fa",
"hours_ago": "{{count}} ore fa",
"day_ago": "un giorno fa",
"days_ago": "{{count}} giorni fa",
"week_ago": "una settimana fa",
"weeks_ago": "{{count}} settimane fa",
"month_ago": "un mese fa",
"months_ago": "{{count}} mesi fa",
"year_ago": "un anno fa",
"years_ago": "{{count}} anni fa"
}
}

View file

@ -67,5 +67,21 @@
"editMessage": "メッセージを編集",
"editWarning": "このメッセージを編集すると、会話内の後続のメッセージもすべて削除されます。続行しますか?",
"proceed": "続行"
},
"time_ago": {
"just_now": "たった今",
"seconds_ago": "{{count}}秒前",
"minute_ago": "1分前",
"minutes_ago": "{{count}}分前",
"hour_ago": "1時間前",
"hours_ago": "{{count}}時間前",
"day_ago": "1日前",
"days_ago": "{{count}}日前",
"week_ago": "1週間前",
"weeks_ago": "{{count}}週間前",
"month_ago": "1ヶ月前",
"months_ago": "{{count}}ヶ月前",
"year_ago": "1年前",
"years_ago": "{{count}}年前"
}
}

View file

@ -67,5 +67,21 @@
"editMessage": "메시지 편집",
"editWarning": "이 메시지를 편집하면 대화의 모든 후속 메시지가 삭제됩니다. 계속하시겠습니까?",
"proceed": "계속"
},
"time_ago": {
"just_now": "방금",
"seconds_ago": "{{count}}초 전",
"minute_ago": "1분 전",
"minutes_ago": "{{count}}분 전",
"hour_ago": "1시간 전",
"hours_ago": "{{count}}시간 전",
"day_ago": "1일 전",
"days_ago": "{{count}}일 전",
"week_ago": "1주일 전",
"weeks_ago": "{{count}}주일 전",
"month_ago": "1개월 전",
"months_ago": "{{count}}개월 전",
"year_ago": "1년 전",
"years_ago": "{{count}}년 전"
}
}

View file

@ -67,5 +67,21 @@
"editMessage": "Bericht Bewerken",
"editWarning": "Het bewerken van dit bericht zal alle volgende berichten in het gesprek verwijderen. Wil je doorgaan?",
"proceed": "Doorgaan"
},
"time_ago": {
"just_now": "zojuist",
"seconds_ago": "{{count}} seconden geleden",
"minute_ago": "een minuut geleden",
"minutes_ago": "{{count}} minuten geleden",
"hour_ago": "een uur geleden",
"hours_ago": "{{count}} uur geleden",
"day_ago": "een dag geleden",
"days_ago": "{{count}} dagen geleden",
"week_ago": "een week geleden",
"weeks_ago": "{{count}} weken geleden",
"month_ago": "een maand geleden",
"months_ago": "{{count}} maanden geleden",
"year_ago": "een jaar geleden",
"years_ago": "{{count}} jaar geleden"
}
}

View file

@ -67,5 +67,21 @@
"editMessage": "Edytuj Wiadomość",
"editWarning": "Edycja tej wiadomości spowoduje usunięcie wszystkich kolejnych wiadomości w rozmowie. Czy chcesz kontynuować?",
"proceed": "Kontynuuj"
},
"time_ago": {
"just_now": "przed chwilą",
"seconds_ago": "{{count}} sekund temu",
"minute_ago": "minutę temu",
"minutes_ago": "{{count}} minut temu",
"hour_ago": "godzinę temu",
"hours_ago": "{{count}} godzin temu",
"day_ago": "dzień temu",
"days_ago": "{{count}} dni temu",
"week_ago": "tydzień temu",
"weeks_ago": "{{count}} tygodni temu",
"month_ago": "miesiąc temu",
"months_ago": "{{count}} miesięcy temu",
"year_ago": "rok temu",
"years_ago": "{{count}} lat temu"
}
}

View file

@ -67,5 +67,21 @@
"editMessage": "Editar Mensagem",
"editWarning": "Editar esta mensagem irá excluir todas as mensagens subsequentes na conversa. Deseja prosseguir?",
"proceed": "Prosseguir"
},
"time_ago": {
"just_now": "agora mesmo",
"seconds_ago": "há {{count}} segundos",
"minute_ago": "há um minuto",
"minutes_ago": "há {{count}} minutos",
"hour_ago": "há uma hora",
"hours_ago": "há {{count}} horas",
"day_ago": "há um dia",
"days_ago": "há {{count}} dias",
"week_ago": "há uma semana",
"weeks_ago": "há {{count}} semanas",
"month_ago": "há um mês",
"months_ago": "há {{count}} meses",
"year_ago": "há um ano",
"years_ago": "há {{count}} anos"
}
}

View file

@ -67,5 +67,21 @@
"editMessage": "Редактировать Сообщение",
"editWarning": "Редактирование этого сообщения приведет к удалению всех последующих сообщений в разговоре. Хотите продолжить?",
"proceed": "Продолжить"
},
"time_ago": {
"just_now": "только что",
"seconds_ago": "{{count}} секунд назад",
"minute_ago": "минуту назад",
"minutes_ago": "{{count}} минут назад",
"hour_ago": "час назад",
"hours_ago": "{{count}} часов назад",
"day_ago": "день назад",
"days_ago": "{{count}} дней назад",
"week_ago": "неделю назад",
"weeks_ago": "{{count}} недель назад",
"month_ago": "месяц назад",
"months_ago": "{{count}} месяцев назад",
"year_ago": "год назад",
"years_ago": "{{count}} лет назад"
}
}

View file

@ -67,5 +67,21 @@
"editMessage": "Mesajı Düzenle",
"editWarning": "Bu mesajı düzenlemek, konuşmadaki sonraki tüm mesajları da silecektir. Devam etmek istiyor musun?",
"proceed": "Devam Et"
},
"time_ago": {
"just_now": "şimdi",
"seconds_ago": "{{count}} saniye önce",
"minute_ago": "bir dakika önce",
"minutes_ago": "{{count}} dakika önce",
"hour_ago": "bir saat önce",
"hours_ago": "{{count}} saat önce",
"day_ago": "bir gün önce",
"days_ago": "{{count}} gün önce",
"week_ago": "bir hafta önce",
"weeks_ago": "{{count}} hafta önce",
"month_ago": "bir ay önce",
"months_ago": "{{count}} ay önce",
"year_ago": "bir yıl önce",
"years_ago": "{{count}} yıl önce"
}
}

View file

@ -67,5 +67,21 @@
"editMessage": "Chỉnh Sửa Tin Nhắn",
"editWarning": "Chỉnh sửa tin nhắn này sẽ xóa tất cả các tin nhắn tiếp theo trong cuộc trò chuyện. Bạn có muốn tiếp tục không?",
"proceed": "Tiếp Tục"
},
"time_ago": {
"just_now": "vừa xong",
"seconds_ago": "{{count}} giây trước",
"minute_ago": "một phút trước",
"minutes_ago": "{{count}} phút trước",
"hour_ago": "một giờ trước",
"hours_ago": "{{count}} giờ trước",
"day_ago": "một ngày trước",
"days_ago": "{{count}} ngày trước",
"week_ago": "một tuần trước",
"weeks_ago": "{{count}} tuần trước",
"month_ago": "một tháng trước",
"months_ago": "{{count}} tháng trước",
"year_ago": "một năm trước",
"years_ago": "{{count}} năm trước"
}
}

View file

@ -67,5 +67,21 @@
"editMessage": "编辑消息",
"editWarning": "编辑此消息将删除对话中的所有后续消息。是否继续?",
"proceed": "继续"
},
"time_ago": {
"just_now": "刚刚",
"seconds_ago": "{{count}}秒前",
"minute_ago": "1分钟前",
"minutes_ago": "{{count}}分钟前",
"hour_ago": "1小时前",
"hours_ago": "{{count}}小时前",
"day_ago": "1天前",
"days_ago": "{{count}}天前",
"week_ago": "1周前",
"weeks_ago": "{{count}}周前",
"month_ago": "1个月前",
"months_ago": "{{count}}个月前",
"year_ago": "1年前",
"years_ago": "{{count}}年前"
}
}

View file

@ -67,5 +67,21 @@
"editMessage": "編輯訊息",
"editWarning": "編輯此訊息將刪除對話中的所有後續訊息。是否繼續?",
"proceed": "繼續"
},
"time_ago": {
"just_now": "剛剛",
"seconds_ago": "{{count}}秒前",
"minute_ago": "1分鐘前",
"minutes_ago": "{{count}}分鐘前",
"hour_ago": "1小時前",
"hours_ago": "{{count}}小時前",
"day_ago": "1天前",
"days_ago": "{{count}}天前",
"week_ago": "1週前",
"weeks_ago": "{{count}}週前",
"month_ago": "1個月前",
"months_ago": "{{count}}個月前",
"year_ago": "1年前",
"years_ago": "{{count}}年前"
}
}

View file

@ -1,51 +1,154 @@
// npx vitest src/utils/__tests__/format.spec.ts
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { formatLargeNumber, formatDate, formatTimeAgo } from "../format"
import { formatDate } from "../format"
// Mock i18next
vi.mock("i18next", () => ({
default: {
t: vi.fn((key: string, options?: any) => {
// Mock translations for testing
const translations: Record<string, string> = {
"common:number_format.billion_suffix": "b",
"common:number_format.million_suffix": "m",
"common:number_format.thousand_suffix": "k",
"common:time_ago.just_now": "just now",
"common:time_ago.seconds_ago": "{{count}} seconds ago",
"common:time_ago.minute_ago": "a minute ago",
"common:time_ago.minutes_ago": "{{count}} minutes ago",
"common:time_ago.hour_ago": "an hour ago",
"common:time_ago.hours_ago": "{{count}} hours ago",
"common:time_ago.day_ago": "a day ago",
"common:time_ago.days_ago": "{{count}} days ago",
"common:time_ago.week_ago": "a week ago",
"common:time_ago.weeks_ago": "{{count}} weeks ago",
"common:time_ago.month_ago": "a month ago",
"common:time_ago.months_ago": "{{count}} months ago",
"common:time_ago.year_ago": "a year ago",
"common:time_ago.years_ago": "{{count}} years ago",
}
describe("formatDate", () => {
it("formats a timestamp correctly", () => {
// January 15, 2023, 10:30 AM
const timestamp = new Date(2023, 0, 15, 10, 30).getTime()
const result = formatDate(timestamp)
let result = translations[key] || key
if (options?.count !== undefined) {
result = result.replace("{{count}}", options.count.toString())
}
return result
}),
language: "en",
},
}))
expect(result).toBe("JANUARY 15, 10:30 AM")
describe("formatLargeNumber", () => {
it("should format billions", () => {
expect(formatLargeNumber(1500000000)).toBe("1.5b")
expect(formatLargeNumber(2000000000)).toBe("2.0b")
})
it("handles different months correctly", () => {
// February 28, 2023, 3:45 PM
const timestamp1 = new Date(2023, 1, 28, 15, 45).getTime()
expect(formatDate(timestamp1)).toBe("FEBRUARY 28, 3:45 PM")
// December 31, 2023, 11:59 PM
const timestamp2 = new Date(2023, 11, 31, 23, 59).getTime()
expect(formatDate(timestamp2)).toBe("DECEMBER 31, 11:59 PM")
it("should format millions", () => {
expect(formatLargeNumber(1500000)).toBe("1.5m")
expect(formatLargeNumber(2000000)).toBe("2.0m")
})
it("handles AM/PM correctly", () => {
// Morning time - 7:05 AM
const morningTimestamp = new Date(2023, 5, 15, 7, 5).getTime()
expect(formatDate(morningTimestamp)).toBe("JUNE 15, 7:05 AM")
// Noon - 12:00 PM
const noonTimestamp = new Date(2023, 5, 15, 12, 0).getTime()
expect(formatDate(noonTimestamp)).toBe("JUNE 15, 12:00 PM")
// Evening time - 8:15 PM
const eveningTimestamp = new Date(2023, 5, 15, 20, 15).getTime()
expect(formatDate(eveningTimestamp)).toBe("JUNE 15, 8:15 PM")
it("should format thousands", () => {
expect(formatLargeNumber(1500)).toBe("1.5k")
expect(formatLargeNumber(2000)).toBe("2.0k")
})
it("handles single-digit minutes with leading zeros", () => {
// 9:05 AM
const timestamp = new Date(2023, 3, 10, 9, 5).getTime()
expect(formatDate(timestamp)).toBe("APRIL 10, 9:05 AM")
})
it("converts the result to uppercase", () => {
const timestamp = new Date(2023, 8, 21, 16, 45).getTime()
const result = formatDate(timestamp)
expect(result).toBe(result.toUpperCase())
expect(result).toBe("SEPTEMBER 21, 4:45 PM")
it("should return string for small numbers", () => {
expect(formatLargeNumber(999)).toBe("999")
expect(formatLargeNumber(100)).toBe("100")
})
})
describe("formatDate", () => {
it("should format date in English", () => {
const timestamp = new Date("2024-01-15T14:30:00").getTime()
const result = formatDate(timestamp)
// The exact format depends on the locale, but it should contain the date components
expect(result).toMatch(/january|jan/i)
expect(result).toMatch(/15/)
})
})
describe("formatTimeAgo", () => {
let originalDateNow: () => number
beforeEach(() => {
// Mock Date.now to have a consistent "now" time
originalDateNow = Date.now
Date.now = vi.fn(() => new Date("2024-01-15T12:00:00").getTime())
})
afterEach(() => {
// Restore original Date.now
Date.now = originalDateNow
})
it('should return "just now" for very recent times', () => {
const timestamp = new Date("2024-01-15T11:59:35").getTime() // 25 seconds ago
expect(formatTimeAgo(timestamp)).toBe("just now")
})
it("should format seconds ago", () => {
const timestamp = new Date("2024-01-15T11:59:15").getTime() // 45 seconds ago
expect(formatTimeAgo(timestamp)).toBe("45 seconds ago")
})
it("should format a minute ago", () => {
const timestamp = new Date("2024-01-15T11:59:00").getTime() // 1 minute ago
expect(formatTimeAgo(timestamp)).toBe("a minute ago")
})
it("should format minutes ago", () => {
const timestamp = new Date("2024-01-15T11:45:00").getTime() // 15 minutes ago
expect(formatTimeAgo(timestamp)).toBe("15 minutes ago")
})
it("should format an hour ago", () => {
const timestamp = new Date("2024-01-15T11:00:00").getTime() // 1 hour ago
expect(formatTimeAgo(timestamp)).toBe("an hour ago")
})
it("should format hours ago", () => {
const timestamp = new Date("2024-01-15T09:00:00").getTime() // 3 hours ago
expect(formatTimeAgo(timestamp)).toBe("3 hours ago")
})
it("should format a day ago", () => {
const timestamp = new Date("2024-01-14T12:00:00").getTime() // 1 day ago
expect(formatTimeAgo(timestamp)).toBe("a day ago")
})
it("should format days ago", () => {
const timestamp = new Date("2024-01-12T12:00:00").getTime() // 3 days ago
expect(formatTimeAgo(timestamp)).toBe("3 days ago")
})
it("should format a week ago", () => {
const timestamp = new Date("2024-01-08T12:00:00").getTime() // 7 days ago
expect(formatTimeAgo(timestamp)).toBe("a week ago")
})
it("should format weeks ago", () => {
const timestamp = new Date("2024-01-01T12:00:00").getTime() // 14 days ago
expect(formatTimeAgo(timestamp)).toBe("2 weeks ago")
})
it("should format a month ago", () => {
const timestamp = new Date("2023-12-15T12:00:00").getTime() // ~1 month ago
expect(formatTimeAgo(timestamp)).toBe("a month ago")
})
it("should format months ago", () => {
const timestamp = new Date("2023-10-15T12:00:00").getTime() // ~3 months ago
expect(formatTimeAgo(timestamp)).toBe("3 months ago")
})
it("should format a year ago", () => {
const timestamp = new Date("2023-01-15T12:00:00").getTime() // 1 year ago
expect(formatTimeAgo(timestamp)).toBe("a year ago")
})
it("should format years ago", () => {
const timestamp = new Date("2021-01-15T12:00:00").getTime() // 3 years ago
expect(formatTimeAgo(timestamp)).toBe("3 years ago")
})
})

View file

@ -17,19 +17,59 @@ export const formatDate = (timestamp: number) => {
const date = new Date(timestamp)
const locale = i18next.language || "en"
// Get date format style from translations or use default transformations
const dateStr = date.toLocaleString(locale, {
return date.toLocaleString(locale, {
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
})
}
// Apply transformations based on locale or use default
if (locale === "en") {
return dateStr.replace(", ", " ").replace(" at", ",").toUpperCase()
export const formatTimeAgo = (timestamp: number) => {
const now = Date.now()
const diff = now - timestamp
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
const weeks = Math.floor(days / 7)
const months = Math.floor(days / 30)
const years = Math.floor(days / 365)
if (years > 0) {
return years === 1
? i18next.t("common:time_ago.year_ago")
: i18next.t("common:time_ago.years_ago", { count: years })
}
if (months > 0) {
return months === 1
? i18next.t("common:time_ago.month_ago")
: i18next.t("common:time_ago.months_ago", { count: months })
}
if (weeks > 0) {
return weeks === 1
? i18next.t("common:time_ago.week_ago")
: i18next.t("common:time_ago.weeks_ago", { count: weeks })
}
if (days > 0) {
return days === 1
? i18next.t("common:time_ago.day_ago")
: i18next.t("common:time_ago.days_ago", { count: days })
}
if (hours > 0) {
return hours === 1
? i18next.t("common:time_ago.hour_ago")
: i18next.t("common:time_ago.hours_ago", { count: hours })
}
if (minutes > 0) {
return minutes === 1
? i18next.t("common:time_ago.minute_ago")
: i18next.t("common:time_ago.minutes_ago", { count: minutes })
}
if (seconds > 30) {
return i18next.t("common:time_ago.seconds_ago", { count: seconds })
}
return dateStr.toUpperCase()
return i18next.t("common:time_ago.just_now")
}