Skip to content

Commit 7b8bf90

Browse files
committed
Polish the chat sidebar: relative timestamps, tag chips, hover states
Show a compact relative time per chat, render tags as chips instead of plain "#tag" text, add an active-row accent border, and loosen row spacing so the list breathes a bit more.
1 parent c3903df commit 7b8bf90

3 files changed

Lines changed: 77 additions & 5 deletions

File tree

‎frontend/src/components/layout.tsx‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import type { ChatOptions, ChatSession, Project, PromptPreset } from "@/types/el
3535
import { extractVariables, fillTemplate } from "@/lib/prompt-templates";
3636
import { PromptVariableDialog } from "@/components/prompt-variable-dialog";
3737
import { DEFAULT_KEYBINDINGS, matchesBinding, subscribeKeybindings, type KeybindingAction } from "@/lib/keybindings";
38+
import { formatRelativeTime } from "@/lib/format-time";
3839

3940
function SessionRow({
4041
session,
@@ -68,17 +69,30 @@ function SessionRow({
6869
<div
6970
onClick={onOpen}
7071
className={cn(
71-
"group flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-muted",
72-
active ? "bg-muted text-foreground" : "text-muted-foreground"
72+
"group relative flex cursor-pointer items-center gap-2 rounded-lg border-l-2 px-2 py-2 text-sm transition-colors",
73+
active
74+
? "border-primary bg-muted text-foreground"
75+
: "border-transparent text-muted-foreground hover:border-border hover:bg-muted/60"
7376
)}
7477
>
7578
<MessageSquare className="size-3.5 shrink-0" />
7679
<span className="flex-1 truncate">{session.title}</span>
7780
{tags.length > 0 && (
78-
<span className="hidden shrink-0 truncate text-[10px] text-muted-foreground sm:inline">
79-
{tags.map((tg) => `#${tg}`).join(" ")}
81+
<span className="hidden shrink-0 items-center gap-1 sm:flex">
82+
{tags.slice(0, 2).map((tg) => (
83+
<span
84+
key={tg}
85+
className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] leading-none text-muted-foreground group-hover:bg-background"
86+
>
87+
{tg}
88+
</span>
89+
))}
90+
{tags.length > 2 && <span className="text-[10px] text-muted-foreground">+{tags.length - 2}</span>}
8091
</span>
8192
)}
93+
<span className="hidden shrink-0 text-[10px] tabular-nums text-muted-foreground/70 group-hover:hidden sm:inline">
94+
{formatRelativeTime(session.updatedAt)}
95+
</span>
8296
<Popover>
8397
<PopoverTrigger
8498
render={
@@ -475,7 +489,7 @@ function ProjectGroup({
475489
</Popover>
476490
</div>
477491
{!collapsed && (
478-
<div className="ml-4 flex flex-col gap-0.5 border-l border-border pl-2">
492+
<div className="ml-4 flex flex-col gap-1 border-l border-border pl-2">
479493
{sessions.length === 0 && (
480494
<p className="px-2 py-1 text-xs text-muted-foreground">No chats yet.</p>
481495
)}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, it, expect, vi, afterEach } from "vitest";
2+
import { formatRelativeTime } from "./format-time";
3+
4+
describe("formatRelativeTime", () => {
5+
const now = new Date("2026-01-15T12:00:00.000Z");
6+
7+
afterEach(() => {
8+
vi.useRealTimers();
9+
});
10+
11+
it("returns 'now' for a timestamp seconds ago", () => {
12+
vi.useFakeTimers();
13+
vi.setSystemTime(now);
14+
expect(formatRelativeTime(new Date(now.getTime() - 30_000).toISOString())).toBe("now");
15+
});
16+
17+
it("returns minutes for a timestamp under an hour ago", () => {
18+
vi.useFakeTimers();
19+
vi.setSystemTime(now);
20+
expect(formatRelativeTime(new Date(now.getTime() - 5 * 60_000).toISOString())).toBe("5m");
21+
});
22+
23+
it("returns hours for a timestamp under a day ago", () => {
24+
vi.useFakeTimers();
25+
vi.setSystemTime(now);
26+
expect(formatRelativeTime(new Date(now.getTime() - 3 * 3_600_000).toISOString())).toBe("3h");
27+
});
28+
29+
it("returns days for a timestamp under a week ago", () => {
30+
vi.useFakeTimers();
31+
vi.setSystemTime(now);
32+
expect(formatRelativeTime(new Date(now.getTime() - 2 * 86_400_000).toISOString())).toBe("2d");
33+
});
34+
35+
it("falls back to a short date for anything a month or older", () => {
36+
vi.useFakeTimers();
37+
vi.setSystemTime(now);
38+
const monthAgo = new Date(now.getTime() - 40 * 86_400_000).toISOString();
39+
expect(formatRelativeTime(monthAgo)).not.toMatch(/^\d+[mhdw]$/);
40+
});
41+
});

‎frontend/src/lib/format-time.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Compact relative timestamps for the chat sidebar ("5m", "2h", "3d") —
2+
// falls back to an absolute short date once a conversation is old enough
3+
// that "Nw" stops being a useful at-a-glance signal.
4+
export function formatRelativeTime(iso: string): string {
5+
const diffMs = Date.now() - new Date(iso).getTime();
6+
const diffSec = Math.round(diffMs / 1000);
7+
if (diffSec < 60) return "now";
8+
const diffMin = Math.round(diffSec / 60);
9+
if (diffMin < 60) return `${diffMin}m`;
10+
const diffHour = Math.round(diffMin / 60);
11+
if (diffHour < 24) return `${diffHour}h`;
12+
const diffDay = Math.round(diffHour / 24);
13+
if (diffDay < 7) return `${diffDay}d`;
14+
const diffWeek = Math.round(diffDay / 7);
15+
if (diffWeek < 5) return `${diffWeek}w`;
16+
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" });
17+
}

0 commit comments

Comments
 (0)