@hienlh/ppm 0.13.0 → 0.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/dist/web/assets/{audio-preview-J5neETTY.js → audio-preview--hRMnXRZ.js} +1 -1
- package/dist/web/assets/chat-tab-4kL3DNxf.js +12 -0
- package/dist/web/assets/{code-editor-tMfcFaQ5.js → code-editor-Caq5_BaF.js} +2 -2
- package/dist/web/assets/{conflict-editor-FydCxWTC.js → conflict-editor-Dlo25nmt.js} +1 -1
- package/dist/web/assets/{database-viewer-Celi1puH.js → database-viewer-DcBl6OkV.js} +1 -1
- package/dist/web/assets/{diff-viewer-NgDJLTk9.js → diff-viewer-CCzPq1o-.js} +1 -1
- package/dist/web/assets/{extension-webview-xWAdCj3q.js → extension-webview-D7bGVSEd.js} +1 -1
- package/dist/web/assets/{image-preview-C6bFkdZD.js → image-preview-CfkqnhXJ.js} +1 -1
- package/dist/web/assets/{index-DtbAoxyy.js → index-BGFG66Gh.js} +14 -10
- package/dist/web/assets/index-Bce0weeW.css +2 -0
- package/dist/web/assets/{markdown-renderer-BAnnk1pI.js → markdown-renderer-DyAm7zuA.js} +1 -1
- package/dist/web/assets/{pdf-preview-BNuFTSOL.js → pdf-preview-CZPcuy5c.js} +1 -1
- package/dist/web/assets/{port-forwarding-tab-BbDlGxAs.js → port-forwarding-tab-3RNozlZ5.js} +1 -1
- package/dist/web/assets/{postgres-viewer-Cman1YRO.js → postgres-viewer-CXJv4TXc.js} +1 -1
- package/dist/web/assets/{settings-tab-n5X_Dbu4.js → settings-tab-Cnav4g2u.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-D6JT11uu.js → sqlite-viewer-C8WUEFhA.js} +1 -1
- package/dist/web/assets/{terminal-tab-B4kMthYo.js → terminal-tab-CaEsMxp8.js} +1 -1
- package/dist/web/assets/{video-preview-BftQOOzF.js → video-preview-Dfz71RGb.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/index.ts +0 -0
- package/src/server/index.ts +12 -4
- package/src/services/autostart-generator.ts +3 -1
- package/src/services/autostart-register.ts +17 -0
- package/src/services/sd-notify.ts +27 -0
- package/src/services/supervisor.ts +31 -5
- package/src/web/components/chat/chat-history-bar.tsx +2 -9
- package/src/web/components/chat/chat-history-panel.tsx +2 -9
- package/src/web/components/chat/chat-welcome.tsx +1 -18
- package/src/web/components/layout/draggable-tab.tsx +12 -5
- package/src/web/hooks/use-notification-badge.ts +7 -7
- package/src/web/lib/favicon.ts +37 -15
- package/src/web/lib/format-date.ts +21 -0
- package/src/web/styles/globals.css +12 -0
- package/bun.lock +0 -2062
- package/bunfig.toml +0 -2
- package/dist/web/assets/chat-tab-sVHRa1Fz.js +0 -12
- package/dist/web/assets/index-BMhiElt6.css +0 -2
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sd_notify helper — forwards messages to systemd via the `systemd-notify` binary.
|
|
3
|
+
* No-op on non-systemd platforms (NOTIFY_SOCKET unset).
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* await sdNotify("READY=1"); // mark unit active (Type=notify)
|
|
7
|
+
* await sdNotify(`MAINPID=${newPid}`); // handoff main process (NotifyAccess=all)
|
|
8
|
+
*
|
|
9
|
+
* Shelling out to `systemd-notify` avoids implementing AF_UNIX SOCK_DGRAM
|
|
10
|
+
* transport in Node/Bun (not supported by node:dgram). The binary ships with
|
|
11
|
+
* systemd itself, so availability matches systemd availability.
|
|
12
|
+
*/
|
|
13
|
+
export async function sdNotify(state: string): Promise<void> {
|
|
14
|
+
if (!process.env.NOTIFY_SOCKET) return; // not running under systemd
|
|
15
|
+
try {
|
|
16
|
+
const proc = Bun.spawn({
|
|
17
|
+
cmd: ["systemd-notify", state],
|
|
18
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
19
|
+
env: process.env,
|
|
20
|
+
});
|
|
21
|
+
await proc.exited;
|
|
22
|
+
} catch {
|
|
23
|
+
// best-effort: if systemd-notify is missing, startup still proceeds
|
|
24
|
+
// (Type=notify units without READY=1 will time out, but that's already
|
|
25
|
+
// the failure mode — this helper doesn't make it worse).
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
STATUS_FILE, PID_FILE,
|
|
20
20
|
} from "./supervisor-state.ts";
|
|
21
21
|
import { startStoppedPage, stopStoppedPage } from "./supervisor-stopped-page.ts";
|
|
22
|
+
import { sdNotify } from "./sd-notify.ts";
|
|
22
23
|
|
|
23
24
|
// ─── Constants ─────────────────────────────────────────────────────────
|
|
24
25
|
const MAX_RESTARTS = 10;
|
|
@@ -228,10 +229,22 @@ export async function spawnTunnel(port: number): Promise<void> {
|
|
|
228
229
|
return;
|
|
229
230
|
}
|
|
230
231
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
232
|
+
// Under systemd, wrap tunnel in a transient user scope so it lives in its
|
|
233
|
+
// own cgroup instead of ppm.service. This prevents systemd from SIGKILLing
|
|
234
|
+
// the tunnel when ppm.service cgroup is torn down during upgrade/restart,
|
|
235
|
+
// preserving the cloudflared trycloudflare URL across the new supervisor.
|
|
236
|
+
// INVOCATION_ID is set by systemd; absence means we're not under systemd.
|
|
237
|
+
const underSystemd = !!process.env.INVOCATION_ID && process.platform === "linux";
|
|
238
|
+
const tunnelCmd = underSystemd
|
|
239
|
+
? [
|
|
240
|
+
"systemd-run", "--user", "--scope", "--quiet", "--collect",
|
|
241
|
+
"--",
|
|
242
|
+
bin, "tunnel", "--url", `http://127.0.0.1:${port}`,
|
|
243
|
+
]
|
|
244
|
+
: [bin, "tunnel", "--url", `http://127.0.0.1:${port}`];
|
|
245
|
+
|
|
246
|
+
tunnelChild = Bun.spawn(tunnelCmd, { stderr: "pipe", stdout: "ignore", stdin: "ignore" });
|
|
247
|
+
if (underSystemd) log("INFO", "Tunnel spawned inside transient systemd-run scope (escapes ppm.service cgroup)");
|
|
235
248
|
|
|
236
249
|
try {
|
|
237
250
|
tunnelUrl = await extractUrlFromStderr(tunnelChild.stderr as ReadableStream<Uint8Array>);
|
|
@@ -481,7 +494,15 @@ async function selfReplace(): Promise<{ success: boolean; error?: string }> {
|
|
|
481
494
|
try {
|
|
482
495
|
const data = JSON.parse(readFileSync(STATUS_FILE(), "utf-8"));
|
|
483
496
|
if (data.supervisorPid && data.supervisorPid !== currentSupervisorPid) {
|
|
484
|
-
log("INFO", `New supervisor detected (PID: ${data.supervisorPid}),
|
|
497
|
+
log("INFO", `New supervisor detected (PID: ${data.supervisorPid}), handing off MainPID to systemd`);
|
|
498
|
+
// Tell systemd the new supervisor is now MainPID — required so that
|
|
499
|
+
// systemd does NOT tear down the ppm.service cgroup when this old
|
|
500
|
+
// supervisor exits 0. Needs NotifyAccess=all in unit file.
|
|
501
|
+
// No-op on non-systemd platforms (NOTIFY_SOCKET unset).
|
|
502
|
+
await sdNotify(`MAINPID=${data.supervisorPid}`);
|
|
503
|
+
// Small delay so systemd processes the datagram before our exit.
|
|
504
|
+
await Bun.sleep(300);
|
|
505
|
+
log("INFO", `Old supervisor exiting`);
|
|
485
506
|
// Children already killed, just clear remaining timers and exit
|
|
486
507
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
487
508
|
if (upgradeCheckTimer) clearInterval(upgradeCheckTimer);
|
|
@@ -919,6 +940,11 @@ export async function runSupervisor(opts: {
|
|
|
919
940
|
connectCloud(opts, serverArgs, logFd);
|
|
920
941
|
startCloudMonitor(opts, serverArgs, logFd);
|
|
921
942
|
|
|
943
|
+
// Signal readiness to systemd (Type=notify). No-op on non-systemd platforms.
|
|
944
|
+
// Must happen AFTER signal handlers + status.json are set up so systemd
|
|
945
|
+
// can race-freely promote us to MainPID and forward SIGUSR1/TERM.
|
|
946
|
+
await sdNotify("READY=1");
|
|
947
|
+
|
|
922
948
|
// Spawn server + tunnel in parallel
|
|
923
949
|
const promises: Promise<void>[] = [spawnServer(serverArgs, logFd)];
|
|
924
950
|
|
|
@@ -10,6 +10,7 @@ import { SessionContextMenu } from "./session-context-menu";
|
|
|
10
10
|
import { UsageDetailPanel } from "./usage-badge";
|
|
11
11
|
import { TeamActivityPanel } from "./team-activity-panel";
|
|
12
12
|
import { ProviderBadge } from "./provider-selector";
|
|
13
|
+
import { formatRelativeDate } from "@/lib/format-date";
|
|
13
14
|
import type { SessionInfo, SessionListResponse, ProjectTag } from "../../../types/chat";
|
|
14
15
|
import type { UsageInfo } from "../../../types/chat";
|
|
15
16
|
import type { TeamMessageItem } from "@/hooks/use-chat";
|
|
@@ -40,14 +41,6 @@ interface ChatHistoryBarProps {
|
|
|
40
41
|
onTeamOpen?: () => void;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
function formatDate(iso: string): string {
|
|
44
|
-
try {
|
|
45
|
-
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
46
|
-
} catch {
|
|
47
|
-
return "";
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
44
|
function relativeTime(iso: string): string {
|
|
52
45
|
const secs = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
|
|
53
46
|
if (secs < 5) return "now";
|
|
@@ -545,7 +538,7 @@ export function ChatHistoryBar({
|
|
|
545
538
|
</>
|
|
546
539
|
)}
|
|
547
540
|
{editingId !== session.id && session.updatedAt && (
|
|
548
|
-
<span className="text-[10px] text-text-subtle shrink-0 w-
|
|
541
|
+
<span className="text-[10px] text-text-subtle shrink-0 w-16 text-right">{formatRelativeDate(session.updatedAt)}</span>
|
|
549
542
|
)}
|
|
550
543
|
</div>
|
|
551
544
|
</SessionContextMenu>
|
|
@@ -2,20 +2,13 @@ import { useEffect, useState, useCallback } from "react";
|
|
|
2
2
|
import { MessageSquare, Loader2, RefreshCw } from "lucide-react";
|
|
3
3
|
import { api, projectUrl } from "@/lib/api-client";
|
|
4
4
|
import { useTabStore } from "@/stores/tab-store";
|
|
5
|
+
import { formatRelativeDate } from "@/lib/format-date";
|
|
5
6
|
import type { SessionInfo } from "../../../types/chat";
|
|
6
7
|
|
|
7
8
|
interface ChatHistoryPanelProps {
|
|
8
9
|
projectName?: string;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
function formatDate(iso: string): string {
|
|
12
|
-
try {
|
|
13
|
-
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
14
|
-
} catch {
|
|
15
|
-
return "";
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
12
|
export function ChatHistoryPanel({ projectName }: ChatHistoryPanelProps) {
|
|
20
13
|
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
|
21
14
|
const [loading, setLoading] = useState(false);
|
|
@@ -96,7 +89,7 @@ export function ChatHistoryPanel({ projectName }: ChatHistoryPanelProps) {
|
|
|
96
89
|
<div className="flex-1 min-w-0">
|
|
97
90
|
<p className="text-xs font-medium truncate text-text-primary">{session.title || "Untitled"}</p>
|
|
98
91
|
{session.updatedAt && (
|
|
99
|
-
<p className="text-[10px] text-text-subtle">{
|
|
92
|
+
<p className="text-[10px] text-text-subtle">{formatRelativeDate(session.updatedAt)}</p>
|
|
100
93
|
)}
|
|
101
94
|
</div>
|
|
102
95
|
</button>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback } from "react";
|
|
2
2
|
import { Bot, ChevronDown, ChevronUp, MessageSquare, Pin, PinOff } from "lucide-react";
|
|
3
3
|
import { api, projectUrl } from "@/lib/api-client";
|
|
4
|
+
import { formatRelativeDate } from "@/lib/format-date";
|
|
4
5
|
import { useProjectTags, TagChipBar } from "./tag-filter-chips";
|
|
5
6
|
import { SessionContextMenu } from "./session-context-menu";
|
|
6
7
|
import type { SessionInfo } from "../../../types/chat";
|
|
@@ -8,24 +9,6 @@ import type { SessionInfo } from "../../../types/chat";
|
|
|
8
9
|
const MAX_RECENT_SESSIONS = 5;
|
|
9
10
|
const FETCH_SESSIONS_LIMIT = 20;
|
|
10
11
|
|
|
11
|
-
function formatRelativeDate(iso: string): string {
|
|
12
|
-
try {
|
|
13
|
-
const date = new Date(iso);
|
|
14
|
-
const now = new Date();
|
|
15
|
-
const diffMs = now.getTime() - date.getTime();
|
|
16
|
-
const diffMin = Math.floor(diffMs / 60_000);
|
|
17
|
-
if (diffMin < 1) return "just now";
|
|
18
|
-
if (diffMin < 60) return `${diffMin}m ago`;
|
|
19
|
-
const diffHr = Math.floor(diffMin / 60);
|
|
20
|
-
if (diffHr < 24) return `${diffHr}h ago`;
|
|
21
|
-
const diffDay = Math.floor(diffHr / 24);
|
|
22
|
-
if (diffDay < 7) return `${diffDay}d ago`;
|
|
23
|
-
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
24
|
-
} catch {
|
|
25
|
-
return "";
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
12
|
interface ChatWelcomeProps {
|
|
30
13
|
projectName: string;
|
|
31
14
|
onSelectSession: (session: SessionInfo) => void;
|
|
@@ -100,13 +100,20 @@ export function DraggableTab({
|
|
|
100
100
|
colorStyle && "border-transparent",
|
|
101
101
|
)}
|
|
102
102
|
>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
103
|
+
<span
|
|
104
|
+
// No-tag: force neutral gray (overrides parent text-primary for active tabs) so untagged
|
|
105
|
+
// tabs don't look like they have a blue tag. Active state is still signaled via border + title color.
|
|
106
|
+
className={cn("relative", !tagColor && "text-text-secondary")}
|
|
107
|
+
style={tagColor ? { color: tagColor } : undefined}
|
|
108
|
+
>
|
|
107
109
|
<Icon className="size-4" />
|
|
108
110
|
{isStreaming ? (
|
|
109
|
-
|
|
111
|
+
// Messenger-style typing dots inside chat bubble — inherits current icon color
|
|
112
|
+
<span aria-hidden className="absolute inset-0 flex items-center justify-center gap-[1.5px]">
|
|
113
|
+
<span className="tab-typing-dot size-[2px] rounded-full bg-current" />
|
|
114
|
+
<span className="tab-typing-dot size-[2px] rounded-full bg-current" style={{ animationDelay: "0.15s" }} />
|
|
115
|
+
<span className="tab-typing-dot size-[2px] rounded-full bg-current" style={{ animationDelay: "0.3s" }} />
|
|
116
|
+
</span>
|
|
110
117
|
) : notificationType && !isActive ? (
|
|
111
118
|
<span className={cn("absolute -top-1 -right-1 size-2 rounded-full", notificationColor(notificationType))} />
|
|
112
119
|
) : null}
|
|
@@ -3,7 +3,7 @@ import { useNotificationStore, selectTotalUnread } from "@/stores/notification-s
|
|
|
3
3
|
import { useProjectStore } from "@/stores/project-store";
|
|
4
4
|
import { useSettingsStore } from "@/stores/settings-store";
|
|
5
5
|
import { useStreamingStore, selectAnyStreaming } from "@/stores/streaming-store";
|
|
6
|
-
import { setFavicon } from "@/lib/favicon";
|
|
6
|
+
import { setFavicon, STREAM_FRAME_COUNT } from "@/lib/favicon";
|
|
7
7
|
|
|
8
8
|
function buildTitle(unread: number, projectName?: string, deviceName?: string): string {
|
|
9
9
|
const parts = [projectName, deviceName || null, "PPM"].filter(Boolean).join(" - ");
|
|
@@ -30,13 +30,13 @@ export function useNotificationBadge(): void {
|
|
|
30
30
|
updateTitle();
|
|
31
31
|
|
|
32
32
|
if (anyStreaming) {
|
|
33
|
-
//
|
|
34
|
-
let
|
|
35
|
-
setFavicon(getHasBadge(),
|
|
33
|
+
// Cycle through typing-dots frames (3 dots + 1 rest frame, Messenger style) every 300ms
|
|
34
|
+
let frame = 0;
|
|
35
|
+
setFavicon(getHasBadge(), frame);
|
|
36
36
|
intervalRef.current = setInterval(() => {
|
|
37
|
-
|
|
38
|
-
setFavicon(getHasBadge(),
|
|
39
|
-
},
|
|
37
|
+
frame = (frame + 1) % STREAM_FRAME_COUNT;
|
|
38
|
+
setFavicon(getHasBadge(), frame);
|
|
39
|
+
}, 300);
|
|
40
40
|
} else {
|
|
41
41
|
setFavicon(getHasBadge());
|
|
42
42
|
}
|
package/src/web/lib/favicon.ts
CHANGED
|
@@ -1,30 +1,52 @@
|
|
|
1
1
|
const COLOR_PRIMARY = "#3b82f6";
|
|
2
|
-
const COLOR_STREAMING = "#f59e0b"; // amber-500
|
|
2
|
+
const COLOR_STREAMING = "#f59e0b"; // amber-500 — high-attention bg for typing state
|
|
3
|
+
const COLOR_BADGE = "#ef4444";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
/** Idle favicon — "PPM" text on primary background, optional red badge dot. */
|
|
6
|
+
function buildIdleSvg(badgeDot: boolean): string {
|
|
5
7
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
6
|
-
<rect width="32" height="32" rx="6" fill="${
|
|
8
|
+
<rect width="32" height="32" rx="6" fill="${COLOR_PRIMARY}"/>
|
|
7
9
|
<text x="16" y="22" text-anchor="middle" font-family="system-ui,sans-serif" font-weight="700" font-size="11" fill="white">PPM</text>
|
|
8
|
-
${badgeDot ?
|
|
10
|
+
${badgeDot ? `<circle cx="26" cy="6" r="5" fill="${COLOR_BADGE}"/>` : ""}
|
|
9
11
|
</svg>`;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
14
|
+
/** Streaming favicon — 3 dots (Messenger typing style). `activeDot` (0-2) is raised, -1 = rest. */
|
|
15
|
+
function buildStreamingSvg(activeDot: number, badgeDot: boolean): string {
|
|
16
|
+
const dots = [10, 16, 22].map((cx, i) => {
|
|
17
|
+
const cy = i === activeDot ? 13 : 17;
|
|
18
|
+
return `<circle cx="${cx}" cy="${cy}" r="3" fill="white"/>`;
|
|
19
|
+
}).join("");
|
|
20
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
21
|
+
<rect width="32" height="32" rx="6" fill="${COLOR_STREAMING}"/>
|
|
22
|
+
${dots}
|
|
23
|
+
${badgeDot ? `<circle cx="26" cy="6" r="5" fill="${COLOR_BADGE}"/>` : ""}
|
|
24
|
+
</svg>`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const encode = (svg: string) => `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
|
28
|
+
|
|
29
|
+
// Pre-encode all variants: [no-badge, with-badge]
|
|
30
|
+
const FAVICON_IDLE: [string, string] = [encode(buildIdleSvg(false)), encode(buildIdleSvg(true))];
|
|
31
|
+
// 4 frames: dot0 up, dot1 up, dot2 up, all rest (-1) — rest frame makes cycle boundary perceptible
|
|
32
|
+
const FAVICON_STREAM: [string, string][] = [0, 1, 2, -1].map((f) =>
|
|
33
|
+
[encode(buildStreamingSvg(f, false)), encode(buildStreamingSvg(f, true))] as [string, string],
|
|
34
|
+
);
|
|
35
|
+
export const STREAM_FRAME_COUNT = FAVICON_STREAM.length;
|
|
17
36
|
|
|
18
37
|
/**
|
|
19
|
-
* Swap favicon.
|
|
20
|
-
*
|
|
38
|
+
* Swap favicon.
|
|
39
|
+
* @param hasBadge — true if unread notifications exist
|
|
40
|
+
* @param streamingFrame — 0..STREAM_FRAME_COUNT-1 for typing-dots animation, null for idle
|
|
21
41
|
*/
|
|
22
|
-
export function setFavicon(hasBadge: boolean,
|
|
42
|
+
export function setFavicon(hasBadge: boolean, streamingFrame: number | null = null): void {
|
|
23
43
|
const el = document.getElementById("ppm-favicon") as HTMLLinkElement | null;
|
|
24
44
|
if (!el) return;
|
|
25
|
-
|
|
26
|
-
|
|
45
|
+
const badgeIdx = hasBadge ? 1 : 0;
|
|
46
|
+
if (streamingFrame === null) {
|
|
47
|
+
el.href = FAVICON_IDLE[badgeIdx];
|
|
27
48
|
} else {
|
|
28
|
-
|
|
49
|
+
const frame = ((streamingFrame % STREAM_FRAME_COUNT) + STREAM_FRAME_COUNT) % STREAM_FRAME_COUNT;
|
|
50
|
+
el.href = FAVICON_STREAM[frame]![badgeIdx];
|
|
29
51
|
}
|
|
30
52
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format an ISO timestamp as a short English relative time string.
|
|
3
|
+
* Examples: "just now", "5m ago", "3h ago", "2d ago", "Apr 21".
|
|
4
|
+
*/
|
|
5
|
+
export function formatRelativeDate(iso: string): string {
|
|
6
|
+
try {
|
|
7
|
+
const date = new Date(iso);
|
|
8
|
+
const now = new Date();
|
|
9
|
+
const diffMs = now.getTime() - date.getTime();
|
|
10
|
+
const diffMin = Math.floor(diffMs / 60_000);
|
|
11
|
+
if (diffMin < 1) return "just now";
|
|
12
|
+
if (diffMin < 60) return `${diffMin}m ago`;
|
|
13
|
+
const diffHr = Math.floor(diffMin / 60);
|
|
14
|
+
if (diffHr < 24) return `${diffHr}h ago`;
|
|
15
|
+
const diffDay = Math.floor(diffHr / 24);
|
|
16
|
+
if (diffDay < 7) return `${diffDay}d ago`;
|
|
17
|
+
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
18
|
+
} catch {
|
|
19
|
+
return "";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -294,3 +294,15 @@ html, body {
|
|
|
294
294
|
@media (prefers-reduced-motion: reduce) {
|
|
295
295
|
.markdown-content.is-streaming > *:last-child { animation: none; }
|
|
296
296
|
}
|
|
297
|
+
|
|
298
|
+
/* Tab streaming indicator — 3 dots bouncing inside chat bubble icon (Messenger typing style) */
|
|
299
|
+
@keyframes tabTypingBounce {
|
|
300
|
+
0%, 60%, 100% { transform: translateY(0); }
|
|
301
|
+
30% { transform: translateY(-1.5px); }
|
|
302
|
+
}
|
|
303
|
+
.tab-typing-dot {
|
|
304
|
+
animation: tabTypingBounce 1s ease-in-out infinite;
|
|
305
|
+
}
|
|
306
|
+
@media (prefers-reduced-motion: reduce) {
|
|
307
|
+
.tab-typing-dot { animation: none; }
|
|
308
|
+
}
|