diff --git a/webview-ui/src/components/common/VersionIndicator.tsx b/webview-ui/src/components/common/VersionIndicator.tsx index b0c517fdaf..1776a2d39a 100644 --- a/webview-ui/src/components/common/VersionIndicator.tsx +++ b/webview-ui/src/components/common/VersionIndicator.tsx @@ -13,7 +13,7 @@ const VersionIndicator: React.FC = ({ onClick, className return ( diff --git a/webview-ui/src/components/history/DeleteButton.tsx b/webview-ui/src/components/history/DeleteButton.tsx index 3e99027546..bd91803627 100644 --- a/webview-ui/src/components/history/DeleteButton.tsx +++ b/webview-ui/src/components/history/DeleteButton.tsx @@ -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"> diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index 2f156d0418..e7b574c490 100644 --- a/webview-ui/src/components/history/HistoryView.tsx +++ b/webview-ui/src/components/history/HistoryView.tsx @@ -222,7 +222,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 && ( -
+
{t("history:selectedItems", { selected: selectedTaskIds.length, total: tasks.length })}
diff --git a/webview-ui/src/components/history/TaskItem.tsx b/webview-ui/src/components/history/TaskItem.tsx index 5338819dd3..d661d99930 100644 --- a/webview-ui/src/components/history/TaskItem.tsx +++ b/webview-ui/src/components/history/TaskItem.tsx @@ -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}> -
+
{/* Selection checkbox - only in full variant */} {!isCompact && isSelectionMode && (
- {/* Header with metadata */} - - - {/* Task content */}
{item.highlight ? undefined : item.task}
+ - {/* Task Item Footer */} - - - {/* Workspace info */} {showWorkspace && item.workspace && (
diff --git a/webview-ui/src/components/history/TaskItemFooter.tsx b/webview-ui/src/components/history/TaskItemFooter.tsx index 424cf1eadb..135d24d2c0 100644 --- a/webview-ui/src/components/history/TaskItemFooter.tsx +++ b/webview-ui/src/components/history/TaskItemFooter.tsx @@ -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 = ({ item, variant, isSelectionMode = false }) => { +const TaskItemFooter: React.FC = ({ item, variant, isSelectionMode = false, onDelete }) => { return ( -
-
- {!!(item.cacheReads || item.cacheWrites) && ( - - - {formatLargeNumber(item.cacheWrites || 0)} - - {formatLargeNumber(item.cacheReads || 0)} - - )} - - {/* Full Tokens */} - {!!(item.tokensIn || item.tokensOut) && ( - - ↑ {formatLargeNumber(item.tokensIn || 0)} - ↓ {formatLargeNumber(item.tokensOut || 0)} - - )} - - {/* Full Cost */} +
+
+ {/* Datetime with time-ago format */} + + {formatTimeAgo(item.ts)} + + · + {/* Cost */} {!!item.totalCost && ( - - - {"$" + item.totalCost.toFixed(2)} - - )} - - {!!item.size && ( - - - {prettyBytes(item.size)} + + {"$" + item.totalCost.toFixed(2)} )}
{/* Action Buttons for non-compact view */} {!isSelectionMode && ( -
+
{variant === "full" && } + {onDelete && }
)}
diff --git a/webview-ui/src/components/history/TaskItemHeader.tsx b/webview-ui/src/components/history/TaskItemHeader.tsx deleted file mode 100644 index bdddb090c8..0000000000 --- a/webview-ui/src/components/history/TaskItemHeader.tsx +++ /dev/null @@ -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 = ({ item, isSelectionMode, onDelete }) => { - return ( -
-
- - {formatDate(item.ts)} - -
- - {/* Action Buttons */} - {!isSelectionMode && ( -
- {onDelete && } -
- )} -
- ) -} - -export default TaskItemHeader diff --git a/webview-ui/src/components/history/__tests__/HistoryPreview.spec.tsx b/webview-ui/src/components/history/__tests__/HistoryPreview.spec.tsx index 7951574963..20e7fcbdf3 100644 --- a/webview-ui/src/components/history/__tests__/HistoryPreview.spec.tsx +++ b/webview-ui/src/components/history/__tests__/HistoryPreview.spec.tsx @@ -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", () => { diff --git a/webview-ui/src/components/history/__tests__/TaskItem.spec.tsx b/webview-ui/src/components/history/__tests__/TaskItem.spec.tsx index 9d4a939a1e..6995d5840c 100644 --- a/webview-ui/src/components/history/__tests__/TaskItem.spec.tsx +++ b/webview-ui/src/components/history/__tests__/TaskItem.spec.tsx @@ -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( { />, ) - // 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( { />, ) - // 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") }) }) diff --git a/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx b/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx index 661cecf122..5c568bb65b 100644 --- a/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx +++ b/webview-ui/src/components/history/__tests__/TaskItemFooter.spec.tsx @@ -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() - // 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() + + // Should show copy button but not export button + expect(screen.getByTestId("copy-prompt-button")).toBeInTheDocument() + expect(screen.queryByTestId("export")).not.toBeInTheDocument() + }) + + it("hides action buttons in selection mode", () => { + render() + + // 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() + }) - render() + it("shows delete button when not in selection mode and onDelete is provided", () => { + render() - // 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 + expect(screen.getByTestId("delete-task-button")).toBeInTheDocument() }) - it("does not render cache information when not present", () => { - const mockItemWithoutCache = { - ...mockItem, - cacheReads: 0, - cacheWrites: 0, - } + it("does not show delete button in selection mode", () => { + render() + + expect(screen.queryByTestId("delete-task-button")).not.toBeInTheDocument() + }) - render() + it("does not show delete button when onDelete is not provided", () => { + render() - // Cache section should not be present - expect(screen.queryByTestId("cache-compact")).not.toBeInTheDocument() + expect(screen.queryByTestId("delete-task-button")).not.toBeInTheDocument() }) }) diff --git a/webview-ui/src/components/history/__tests__/TaskItemHeader.spec.tsx b/webview-ui/src/components/history/__tests__/TaskItemHeader.spec.tsx deleted file mode 100644 index 090bf2521f..0000000000 --- a/webview-ui/src/components/history/__tests__/TaskItemHeader.spec.tsx +++ /dev/null @@ -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 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() - - expect(screen.getByRole("button")).toBeInTheDocument() - }) -}) diff --git a/webview-ui/src/i18n/locales/ca/common.json b/webview-ui/src/i18n/locales/ca/common.json index 13ad7a2ca7..2351c99d01 100644 --- a/webview-ui/src/i18n/locales/ca/common.json +++ b/webview-ui/src/i18n/locales/ca/common.json @@ -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" } } diff --git a/webview-ui/src/i18n/locales/de/common.json b/webview-ui/src/i18n/locales/de/common.json index c873e25d12..0fec0ba964 100644 --- a/webview-ui/src/i18n/locales/de/common.json +++ b/webview-ui/src/i18n/locales/de/common.json @@ -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" } } diff --git a/webview-ui/src/i18n/locales/en/common.json b/webview-ui/src/i18n/locales/en/common.json index af0dfcdf80..b4bc816a2b 100644 --- a/webview-ui/src/i18n/locales/en/common.json +++ b/webview-ui/src/i18n/locales/en/common.json @@ -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" } } diff --git a/webview-ui/src/i18n/locales/es/common.json b/webview-ui/src/i18n/locales/es/common.json index ee0a924d43..a733673470 100644 --- a/webview-ui/src/i18n/locales/es/common.json +++ b/webview-ui/src/i18n/locales/es/common.json @@ -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" } } diff --git a/webview-ui/src/i18n/locales/fr/common.json b/webview-ui/src/i18n/locales/fr/common.json index 40c12e2afb..4c4ad83bc4 100644 --- a/webview-ui/src/i18n/locales/fr/common.json +++ b/webview-ui/src/i18n/locales/fr/common.json @@ -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" } } diff --git a/webview-ui/src/i18n/locales/hi/common.json b/webview-ui/src/i18n/locales/hi/common.json index 227e25637e..7e809bd0a7 100644 --- a/webview-ui/src/i18n/locales/hi/common.json +++ b/webview-ui/src/i18n/locales/hi/common.json @@ -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}} साल पहले" } } diff --git a/webview-ui/src/i18n/locales/id/common.json b/webview-ui/src/i18n/locales/id/common.json index 3a3a3f5a78..86818bb084 100644 --- a/webview-ui/src/i18n/locales/id/common.json +++ b/webview-ui/src/i18n/locales/id/common.json @@ -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" } } diff --git a/webview-ui/src/i18n/locales/it/common.json b/webview-ui/src/i18n/locales/it/common.json index 20886d126b..94f637ac3d 100644 --- a/webview-ui/src/i18n/locales/it/common.json +++ b/webview-ui/src/i18n/locales/it/common.json @@ -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" } } diff --git a/webview-ui/src/i18n/locales/ja/common.json b/webview-ui/src/i18n/locales/ja/common.json index a7390de32a..a3f6a90a22 100644 --- a/webview-ui/src/i18n/locales/ja/common.json +++ b/webview-ui/src/i18n/locales/ja/common.json @@ -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}}年前" } } diff --git a/webview-ui/src/i18n/locales/ko/common.json b/webview-ui/src/i18n/locales/ko/common.json index 2164c65624..83d56930df 100644 --- a/webview-ui/src/i18n/locales/ko/common.json +++ b/webview-ui/src/i18n/locales/ko/common.json @@ -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}}년 전" } } diff --git a/webview-ui/src/i18n/locales/nl/common.json b/webview-ui/src/i18n/locales/nl/common.json index 4b72bccb9c..d81570c705 100644 --- a/webview-ui/src/i18n/locales/nl/common.json +++ b/webview-ui/src/i18n/locales/nl/common.json @@ -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" } } diff --git a/webview-ui/src/i18n/locales/pl/common.json b/webview-ui/src/i18n/locales/pl/common.json index 6ec9e6661a..77679ef7c5 100644 --- a/webview-ui/src/i18n/locales/pl/common.json +++ b/webview-ui/src/i18n/locales/pl/common.json @@ -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" } } diff --git a/webview-ui/src/i18n/locales/pt-BR/common.json b/webview-ui/src/i18n/locales/pt-BR/common.json index 964ba893f2..3fb4273d89 100644 --- a/webview-ui/src/i18n/locales/pt-BR/common.json +++ b/webview-ui/src/i18n/locales/pt-BR/common.json @@ -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" } } diff --git a/webview-ui/src/i18n/locales/ru/common.json b/webview-ui/src/i18n/locales/ru/common.json index 772b797bba..f43c2e9e8b 100644 --- a/webview-ui/src/i18n/locales/ru/common.json +++ b/webview-ui/src/i18n/locales/ru/common.json @@ -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}} лет назад" } } diff --git a/webview-ui/src/i18n/locales/tr/common.json b/webview-ui/src/i18n/locales/tr/common.json index 7bbb6f3d84..2f3a7c957c 100644 --- a/webview-ui/src/i18n/locales/tr/common.json +++ b/webview-ui/src/i18n/locales/tr/common.json @@ -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" } } diff --git a/webview-ui/src/i18n/locales/vi/common.json b/webview-ui/src/i18n/locales/vi/common.json index 2d36482495..92aa029b01 100644 --- a/webview-ui/src/i18n/locales/vi/common.json +++ b/webview-ui/src/i18n/locales/vi/common.json @@ -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" } } diff --git a/webview-ui/src/i18n/locales/zh-CN/common.json b/webview-ui/src/i18n/locales/zh-CN/common.json index de6d1cd7fe..6ff0132370 100644 --- a/webview-ui/src/i18n/locales/zh-CN/common.json +++ b/webview-ui/src/i18n/locales/zh-CN/common.json @@ -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}}年前" } } diff --git a/webview-ui/src/i18n/locales/zh-TW/common.json b/webview-ui/src/i18n/locales/zh-TW/common.json index a3949a2a9d..3a3310797b 100644 --- a/webview-ui/src/i18n/locales/zh-TW/common.json +++ b/webview-ui/src/i18n/locales/zh-TW/common.json @@ -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}}年前" } } diff --git a/webview-ui/src/utils/__tests__/format.spec.ts b/webview-ui/src/utils/__tests__/format.spec.ts index 4ebd357b6d..4d642f3f44 100644 --- a/webview-ui/src/utils/__tests__/format.spec.ts +++ b/webview-ui/src/utils/__tests__/format.spec.ts @@ -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 = { + "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", + } + + let result = translations[key] || key + if (options?.count !== undefined) { + result = result.replace("{{count}}", options.count.toString()) + } + return result + }), + language: "en", + }, +})) + +describe("formatLargeNumber", () => { + it("should format billions", () => { + expect(formatLargeNumber(1500000000)).toBe("1.5b") + expect(formatLargeNumber(2000000000)).toBe("2.0b") + }) + + it("should format millions", () => { + expect(formatLargeNumber(1500000)).toBe("1.5m") + expect(formatLargeNumber(2000000)).toBe("2.0m") + }) + + it("should format thousands", () => { + expect(formatLargeNumber(1500)).toBe("1.5k") + expect(formatLargeNumber(2000)).toBe("2.0k") + }) + + it("should return string for small numbers", () => { + expect(formatLargeNumber(999)).toBe("999") + expect(formatLargeNumber(100)).toBe("100") + }) +}) describe("formatDate", () => { - it("formats a timestamp correctly", () => { - // January 15, 2023, 10:30 AM - const timestamp = new Date(2023, 0, 15, 10, 30).getTime() + 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") + }) - expect(result).toBe("JANUARY 15, 10:30 AM") + 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("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") + 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") + }) - // 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 minutes ago", () => { + const timestamp = new Date("2024-01-15T11:45:00").getTime() // 15 minutes ago + expect(formatTimeAgo(timestamp)).toBe("15 minutes ago") }) - 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") + 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") + }) - // Noon - 12:00 PM - const noonTimestamp = new Date(2023, 5, 15, 12, 0).getTime() - expect(formatDate(noonTimestamp)).toBe("JUNE 15, 12:00 PM") + 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") + }) - // 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 a day ago", () => { + const timestamp = new Date("2024-01-14T12:00:00").getTime() // 1 day ago + expect(formatTimeAgo(timestamp)).toBe("a day ago") }) - 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("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("converts the result to uppercase", () => { - const timestamp = new Date(2023, 8, 21, 16, 45).getTime() - const result = formatDate(timestamp) + 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") + }) - expect(result).toBe(result.toUpperCase()) - expect(result).toBe("SEPTEMBER 21, 4:45 PM") + 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") }) }) diff --git a/webview-ui/src/utils/format.ts b/webview-ui/src/utils/format.ts index 7f8d5d266c..29c7a2c966 100644 --- a/webview-ui/src/utils/format.ts +++ b/webview-ui/src/utils/format.ts @@ -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, }) +} + +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) - // Apply transformations based on locale or use default - if (locale === "en") { - return dateStr.replace(", ", " ").replace(" at", ",").toUpperCase() + 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") }