@hienlh/ppm 0.12.12 → 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 +31 -1
- package/README.md +11 -0
- package/assets/skills/ppm/SKILL.md +74 -0
- package/assets/skills/ppm/references/cli-reference.md +728 -0
- package/assets/skills/ppm/references/common-tasks.md +139 -0
- package/assets/skills/ppm/references/http-api.md +204 -0
- package/dist/web/assets/ai-settings-section-QE6nBNgN.js +1 -0
- package/dist/web/assets/{api-settings-C3T95dWg.js → api-settings-DAk7D-NP.js} +1 -1
- package/dist/web/assets/architecture-PBZL5I3N-DvZbltvY.js +1 -0
- package/dist/web/assets/{audio-preview-BkbgGtDH.js → audio-preview--hRMnXRZ.js} +1 -1
- package/dist/web/assets/chat-tab-4kL3DNxf.js +12 -0
- package/dist/web/assets/{code-editor-BtspASkW.js → code-editor-Caq5_BaF.js} +4 -4
- package/dist/web/assets/{conflict-editor-Dgsu6fmj.js → conflict-editor-Dlo25nmt.js} +1 -1
- package/dist/web/assets/{csv-preview-DcWCjQkZ.js → csv-preview-HMSavgBb.js} +1 -1
- package/dist/web/assets/{database-viewer-C85RxdMV.js → database-viewer-DcBl6OkV.js} +2 -2
- package/dist/web/assets/{diff-viewer-2pPy97Tl.js → diff-viewer-CCzPq1o-.js} +1 -1
- package/dist/web/assets/{esm-_CLpyLJ_.js → esm-K1XIK4vc.js} +1 -1
- package/dist/web/assets/{extension-store-BZDZ9QRc.js → extension-store-3yZYn07W.js} +1 -1
- package/dist/web/assets/{extension-webview-U1lMYZ0p.js → extension-webview-D7bGVSEd.js} +1 -1
- package/dist/web/assets/{file-store-4BpOJthN.js → file-store-BrbCNyLm.js} +1 -1
- package/dist/web/assets/gitGraph-HDMCJU4V-BxhdxFgj.js +1 -0
- package/dist/web/assets/{image-preview-BcT1SbY2.js → image-preview-CfkqnhXJ.js} +1 -1
- package/dist/web/assets/index-BGFG66Gh.js +27 -0
- package/dist/web/assets/index-Bce0weeW.css +2 -0
- package/dist/web/assets/info-3K5VOQVL-BwAZ2zd8.js +1 -0
- package/dist/web/assets/{input-2eDVjcRZ.js → input-Dk49gO8E.js} +1 -1
- package/dist/web/assets/{keybindings-store-BOG1yviy.js → keybindings-store-B-zET-0o.js} +1 -1
- package/dist/web/assets/keybindings-store-DaBV6qhz.js +1 -0
- package/dist/web/assets/{markdown-renderer-Dbam_-04.js → markdown-renderer-DyAm7zuA.js} +3 -3
- package/dist/web/assets/packet-RMMSAZCW-tx2n5Qry.js +1 -0
- package/dist/web/assets/{pdf-preview-BmHVGx32.js → pdf-preview-CZPcuy5c.js} +1 -1
- package/dist/web/assets/pie-UPGHQEXC-D6S2MqVT.js +1 -0
- package/dist/web/assets/plus-51UQ45rf.js +1 -0
- package/dist/web/assets/{port-forwarding-tab-Dkq1upWC.js → port-forwarding-tab-3RNozlZ5.js} +1 -1
- package/dist/web/assets/{postgres-viewer-BgBJAJ9q.js → postgres-viewer-CXJv4TXc.js} +3 -3
- package/dist/web/assets/radar-KQ55EAFF-BviZcL-b.js +1 -0
- package/dist/web/assets/{scroll-area-CdxNNnN-.js → scroll-area-BEllam7_.js} +1 -1
- package/dist/web/assets/{settings-store-CMAssqyb.js → settings-store-BLLR7ed8.js} +2 -2
- package/dist/web/assets/settings-tab-Cnav4g2u.js +1 -0
- package/dist/web/assets/{sql-query-editor-b7zJ8XPp.js → sql-query-editor-CVAnRFbi.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-4lLAz1es.js → sqlite-viewer-C8WUEFhA.js} +1 -1
- package/dist/web/assets/{tab-store-DNBsLdPn.js → tab-store-B3M9hjho.js} +1 -1
- package/dist/web/assets/{terminal-tab-BtnqkN1H.js → terminal-tab-CaEsMxp8.js} +1 -1
- package/dist/web/assets/treemap-KZPCXAKY-CM54VdaB.js +1 -0
- package/dist/web/assets/{use-blob-url-QX-XajU8.js → use-blob-url-e9uTXjv5.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-D68oX3XU.js → use-monaco-theme-BkZDwoVd.js} +1 -1
- package/dist/web/assets/{vendor-mermaid-sQS4C_iL.js → vendor-mermaid-Dx86tuVP.js} +2 -2
- package/dist/web/assets/{video-preview-CkOKvVLt.js → video-preview-Dfz71RGb.js} +1 -1
- package/dist/web/index.html +18 -18
- package/dist/web/sw.js +1 -1
- package/docs/project-changelog.md +15 -1
- package/package.json +3 -3
- package/scripts/generate-ppm-skill.ts +23 -0
- package/scripts/lib/generate-cli-reference.ts +81 -0
- package/scripts/lib/generate-common-tasks.ts +14 -0
- package/scripts/lib/generate-http-api.ts +145 -0
- package/scripts/lib/generate-skill-md.ts +28 -0
- package/scripts/lib/write-output.ts +17 -0
- package/src/cli/commands/export-cmd.ts +85 -0
- package/src/index.ts +167 -153
- 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/skill-export/backup-existing.ts +33 -0
- package/src/services/skill-export/copy-bundled-skill.ts +36 -0
- package/src/services/skill-export/generate-db-schema.ts +66 -0
- package/src/services/skill-export/index.ts +6 -0
- package/src/services/skill-export/resolve-assets-dir.ts +31 -0
- package/src/services/skill-export/resolve-target-dir.ts +17 -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-tab.tsx +6 -1
- package/src/web/components/chat/chat-welcome.tsx +1 -18
- package/src/web/components/chat/message-list.tsx +96 -43
- package/src/web/components/layout/draggable-tab.tsx +12 -5
- package/src/web/hooks/use-chat.ts +37 -1
- package/src/web/hooks/use-notification-badge.ts +7 -7
- package/src/web/lib/favicon.ts +37 -15
- package/src/web/lib/flatten-expansions.ts +36 -0
- package/src/web/lib/format-date.ts +21 -0
- package/src/web/styles/globals.css +12 -0
- package/templates/skill/SKILL.md.tmpl +74 -0
- package/templates/skill/common-tasks.md +139 -0
- package/assets/skills/ppm-guide/SKILL.md +0 -61
- package/bun.lock +0 -2062
- package/bunfig.toml +0 -2
- package/dist/web/assets/ai-settings-section-NNWp6nw7.js +0 -1
- package/dist/web/assets/architecture-PBZL5I3N-DDuzYaUV.js +0 -1
- package/dist/web/assets/chat-tab-BZlP1qjX.js +0 -12
- package/dist/web/assets/chevron-up-BWBvMZkp.js +0 -1
- package/dist/web/assets/gitGraph-HDMCJU4V-BURAevTc.js +0 -1
- package/dist/web/assets/index-BWSRKVZn.js +0 -23
- package/dist/web/assets/index-b6tIZImC.css +0 -2
- package/dist/web/assets/info-3K5VOQVL-tSD4Fpi3.js +0 -1
- package/dist/web/assets/keybindings-store-BvdUoEC7.js +0 -1
- package/dist/web/assets/packet-RMMSAZCW-DmDLZUrV.js +0 -1
- package/dist/web/assets/pie-UPGHQEXC-w03Pc9ZR.js +0 -1
- package/dist/web/assets/pre-compact-button-Dp7Hs49L.js +0 -1
- package/dist/web/assets/pre-compact-section-DnM5fGSR.js +0 -1
- package/dist/web/assets/radar-KQ55EAFF-C9XQvoey.js +0 -1
- package/dist/web/assets/settings-tab-zYWKTq5z.js +0 -1
- package/dist/web/assets/treemap-KZPCXAKY-lmftxSky.js +0 -1
- package/scripts/generate-ppm-guide.ts +0 -92
- package/src/web/components/chat/pre-compact-section.tsx +0 -69
- /package/dist/web/assets/{api-client-DIhJ5qVW.js → api-client-Dvzcc_EO.js} +0 -0
- /package/dist/web/assets/{csv-parser-B5QW8pZ6.js → csv-parser--2WJNgS7.js} +0 -0
- /package/dist/web/assets/{dist-GtkSekuX.js → dist-im4ynINo.js} +0 -0
- /package/dist/web/assets/{katex-C3cZrCvP.js → katex-CKoArbIw.js} +0 -0
- /package/dist/web/assets/{lib-Bu71-TFS.js → lib-DQHnkzGy.js} +0 -0
- /package/dist/web/assets/{react-DMIOAtcX.js → react-GqWghJ-L.js} +0 -0
- /package/dist/web/assets/{refresh-cw-BjrAbUJe.js → refresh-cw-LlbZDJpO.js} +0 -0
- /package/dist/web/assets/{sql-completion-provider-CULTsCqR.js → sql-completion-provider-C3cq9j99.js} +0 -0
- /package/dist/web/assets/{table-tf7pRkME.js → table-Dq575bPF.js} +0 -0
- /package/dist/web/assets/{text-wrap-BV-R4Vvy.js → text-wrap-Cn6BNQfq.js} +0 -0
- /package/dist/web/assets/{trash-2-DjQOpgUV.js → trash-2-CJYoLw7Q.js} +0 -0
- /package/dist/web/assets/{utils-CQux7CsO.js → utils-CTg5uAYR.js} +0 -0
- /package/dist/web/assets/{vendor-xterm-K3_Xwigj.js → vendor-xterm-CU2c3f0A.js} +0 -0
|
@@ -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>
|
|
@@ -88,6 +88,9 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
88
88
|
|
|
89
89
|
const {
|
|
90
90
|
messages,
|
|
91
|
+
renderedMessages,
|
|
92
|
+
expandCompact,
|
|
93
|
+
isCompactExpanded,
|
|
91
94
|
messagesLoading,
|
|
92
95
|
isStreaming,
|
|
93
96
|
phase,
|
|
@@ -384,7 +387,9 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
384
387
|
|
|
385
388
|
{/* Messages */}
|
|
386
389
|
<MessageList
|
|
387
|
-
messages={
|
|
390
|
+
messages={renderedMessages}
|
|
391
|
+
onExpandCompact={expandCompact}
|
|
392
|
+
isCompactExpanded={isCompactExpanded}
|
|
388
393
|
messagesLoading={messagesLoading}
|
|
389
394
|
pendingApproval={pendingApproval}
|
|
390
395
|
onApprovalResponse={respondToApproval}
|
|
@@ -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;
|
|
@@ -5,10 +5,7 @@ import type { ChatMessage, ChatEvent } from "../../../types/chat";
|
|
|
5
5
|
import type { SessionPhase } from "../../../types/api";
|
|
6
6
|
import type { BashPartialEntry } from "../../hooks/use-chat";
|
|
7
7
|
import { ToolCard } from "./tool-cards";
|
|
8
|
-
import { extractJsonlPath } from "./pre-compact-button";
|
|
9
|
-
const PreCompactSection = lazy(() =>
|
|
10
|
-
import("./pre-compact-section").then((m) => ({ default: m.PreCompactSection }))
|
|
11
|
-
);
|
|
8
|
+
import { extractJsonlPath, PreCompactButton, type PreCompactStatus } from "./pre-compact-button";
|
|
12
9
|
const MarkdownRenderer = lazy(() =>
|
|
13
10
|
import("@/components/shared/markdown-renderer").then((m) => ({ default: m.MarkdownRenderer }))
|
|
14
11
|
);
|
|
@@ -59,6 +56,10 @@ interface MessageListProps {
|
|
|
59
56
|
onSelectSession?: (session: import("../../../types/chat").SessionInfo) => void;
|
|
60
57
|
/** Partial bash output ref from useChat for real-time streaming */
|
|
61
58
|
bashPartialOutput?: React.RefObject<Map<string, BashPartialEntry>>;
|
|
59
|
+
/** Fetches pre-compact transcript and prepends messages. Returns loaded count. */
|
|
60
|
+
onExpandCompact?: (compactMessageId: string, jsonlPath: string) => Promise<number>;
|
|
61
|
+
/** Whether a given compact message has already been expanded. */
|
|
62
|
+
isCompactExpanded?: (compactMessageId: string) => boolean;
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
export function MessageList({
|
|
@@ -75,6 +76,8 @@ export function MessageList({
|
|
|
75
76
|
projectName,
|
|
76
77
|
onFork,
|
|
77
78
|
bashPartialOutput,
|
|
79
|
+
onExpandCompact,
|
|
80
|
+
isCompactExpanded,
|
|
78
81
|
}: MessageListProps) {
|
|
79
82
|
// Scroll handled by StickToBottom wrapper — no manual scroll logic needed
|
|
80
83
|
|
|
@@ -106,6 +109,15 @@ export function MessageList({
|
|
|
106
109
|
onFork?.(msgContent, msgId);
|
|
107
110
|
}, [onFork]);
|
|
108
111
|
|
|
112
|
+
// Wrap expandCompact: bump visibleCount by loaded count so expansion is immediately visible
|
|
113
|
+
// in the paginated view (pre-compact messages land at top of flattened array, above pagination window).
|
|
114
|
+
const handleExpandCompact = useCallback(async (compactId: string, jsonlPath: string): Promise<number> => {
|
|
115
|
+
if (!onExpandCompact) throw new Error("Expansion not wired");
|
|
116
|
+
const count = await onExpandCompact(compactId, jsonlPath);
|
|
117
|
+
setVisibleCount((c) => c + count);
|
|
118
|
+
return count;
|
|
119
|
+
}, [onExpandCompact]);
|
|
120
|
+
|
|
109
121
|
if (messagesLoading) {
|
|
110
122
|
return (
|
|
111
123
|
<div className="flex flex-col items-center justify-center h-full gap-3 text-text-secondary">
|
|
@@ -126,8 +138,8 @@ export function MessageList({
|
|
|
126
138
|
|
|
127
139
|
return (
|
|
128
140
|
<div className="relative flex-1 overflow-hidden flex flex-col min-h-0">
|
|
129
|
-
<StickToBottom className="flex-1 overflow-y-auto overflow-x-hidden [contain:strict]" resize="smooth" initial="instant">
|
|
130
|
-
<StickToBottom.Content className="p-4 space-y-4 select-none">
|
|
141
|
+
<StickToBottom className="flex-1 overflow-y-auto overflow-x-hidden [contain:strict] [overflow-anchor:auto]" resize="smooth" initial="instant">
|
|
142
|
+
<StickToBottom.Content className="p-4 space-y-4 select-none [&>*]:[overflow-anchor:auto]">
|
|
131
143
|
{hasMore && (
|
|
132
144
|
<button onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
|
|
133
145
|
className="w-full py-2 text-xs text-text-secondary hover:text-text-primary bg-surface-elevated/50 hover:bg-surface-elevated rounded-md border border-border/50 transition-colors">
|
|
@@ -146,6 +158,8 @@ export function MessageList({
|
|
|
146
158
|
onFork={msg.role === "user" && onFork ? handleFork : undefined}
|
|
147
159
|
prevMsgId={prevMsg?.sdkUuid ?? prevMsg?.id}
|
|
148
160
|
bashPartialOutput={bashPartialOutput}
|
|
161
|
+
onExpandCompact={handleExpandCompact}
|
|
162
|
+
isCompactExpanded={isCompactExpanded}
|
|
149
163
|
/>
|
|
150
164
|
);
|
|
151
165
|
})}
|
|
@@ -180,16 +194,25 @@ function ScrollToBottomButton() {
|
|
|
180
194
|
);
|
|
181
195
|
}
|
|
182
196
|
|
|
183
|
-
const MessageBubble = memo(function MessageBubble({ message, isStreaming, projectName, onFork, prevMsgId, bashPartialOutput }: {
|
|
197
|
+
const MessageBubble = memo(function MessageBubble({ message, isStreaming, projectName, onFork, prevMsgId, bashPartialOutput, onExpandCompact, isCompactExpanded }: {
|
|
184
198
|
message: ChatMessage; isStreaming: boolean; projectName?: string;
|
|
185
199
|
onFork?: (content: string, messageId: string | undefined) => void;
|
|
186
200
|
prevMsgId?: string;
|
|
187
201
|
bashPartialOutput?: React.RefObject<Map<string, BashPartialEntry>>;
|
|
202
|
+
onExpandCompact?: (compactMessageId: string, jsonlPath: string) => Promise<number>;
|
|
203
|
+
isCompactExpanded?: (compactMessageId: string) => boolean;
|
|
188
204
|
}) {
|
|
189
205
|
if (message.role === "user") {
|
|
190
206
|
const handleFork = onFork ? () => onFork(message.content, prevMsgId) : undefined;
|
|
191
207
|
return (
|
|
192
|
-
<UserBubble
|
|
208
|
+
<UserBubble
|
|
209
|
+
content={message.content}
|
|
210
|
+
messageId={message.id}
|
|
211
|
+
projectName={projectName}
|
|
212
|
+
onFork={handleFork}
|
|
213
|
+
onExpandCompact={onExpandCompact}
|
|
214
|
+
isCompactExpanded={isCompactExpanded}
|
|
215
|
+
/>
|
|
193
216
|
);
|
|
194
217
|
}
|
|
195
218
|
|
|
@@ -206,7 +229,7 @@ const MessageBubble = memo(function MessageBubble({ message, isStreaming, projec
|
|
|
206
229
|
return (
|
|
207
230
|
<div className="flex flex-col gap-2">
|
|
208
231
|
{message.events && message.events.length > 0
|
|
209
|
-
? <InterleavedEvents events={message.events} isStreaming={isStreaming} projectName={projectName} bashPartialOutput={bashPartialOutput} />
|
|
232
|
+
? <InterleavedEvents events={message.events} isStreaming={isStreaming} projectName={projectName} bashPartialOutput={bashPartialOutput} messageId={message.id} onExpandCompact={onExpandCompact} isCompactExpanded={isCompactExpanded} />
|
|
210
233
|
: message.content && (
|
|
211
234
|
<div className="text-sm text-text-primary select-text">
|
|
212
235
|
<MarkdownContent content={message.content} projectName={projectName} />
|
|
@@ -313,7 +336,14 @@ function isPdfPath(path: string): boolean {
|
|
|
313
336
|
const SYSTEM_TAG_NAMES = new Set(["task-notification", "environment_details"]);
|
|
314
337
|
|
|
315
338
|
/** User message bubble — full width, collapsible, with system tag badges */
|
|
316
|
-
function UserBubble({ content,
|
|
339
|
+
function UserBubble({ content, messageId, projectName, onFork, onExpandCompact, isCompactExpanded }: {
|
|
340
|
+
content: string;
|
|
341
|
+
messageId?: string;
|
|
342
|
+
projectName?: string;
|
|
343
|
+
onFork?: () => void;
|
|
344
|
+
onExpandCompact?: (compactMessageId: string, jsonlPath: string) => Promise<number>;
|
|
345
|
+
isCompactExpanded?: (compactMessageId: string) => boolean;
|
|
346
|
+
}) {
|
|
317
347
|
const { files, text, tags, command, jsonlPath } = useMemo(() => {
|
|
318
348
|
const parsed = parseUserAttachments(content);
|
|
319
349
|
const { cleanText: noSysTags, tags } = extractSystemTags(parsed.text);
|
|
@@ -321,6 +351,23 @@ function UserBubble({ content, projectName, onFork }: { content: string; project
|
|
|
321
351
|
return { files: parsed.files, text: cleanText, tags, command, jsonlPath: extractJsonlPath(cleanText) };
|
|
322
352
|
}, [content]);
|
|
323
353
|
|
|
354
|
+
// Pre-compact expansion state — local per button instance
|
|
355
|
+
const [preCompactStatus, setPreCompactStatus] = useState<PreCompactStatus>(() =>
|
|
356
|
+
messageId && isCompactExpanded?.(messageId) ? "loaded" : "idle",
|
|
357
|
+
);
|
|
358
|
+
const [preCompactCount, setPreCompactCount] = useState<number | undefined>();
|
|
359
|
+
const handleExpand = useCallback(async () => {
|
|
360
|
+
if (!jsonlPath || !messageId || !onExpandCompact) return;
|
|
361
|
+
setPreCompactStatus("loading");
|
|
362
|
+
try {
|
|
363
|
+
const count = await onExpandCompact(messageId, jsonlPath);
|
|
364
|
+
setPreCompactCount(count);
|
|
365
|
+
setPreCompactStatus("loaded");
|
|
366
|
+
} catch {
|
|
367
|
+
setPreCompactStatus("error");
|
|
368
|
+
}
|
|
369
|
+
}, [jsonlPath, messageId, onExpandCompact]);
|
|
370
|
+
|
|
324
371
|
const isSystemContext = tags.some((t) => SYSTEM_TAG_NAMES.has(t.name));
|
|
325
372
|
|
|
326
373
|
const [expanded, setExpanded] = useState(false);
|
|
@@ -403,22 +450,14 @@ function UserBubble({ content, projectName, onFork }: { content: string; project
|
|
|
403
450
|
{expanded ? <><ChevronUp className="size-3" />Show less</> : <><ChevronDown className="size-3" />Show more</>}
|
|
404
451
|
</button>
|
|
405
452
|
)}
|
|
406
|
-
{/* Expand compacted conversation: detect JSONL path in compact summary user message
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
key={msg.id ?? `pc-${idx}`}
|
|
415
|
-
message={msg}
|
|
416
|
-
isStreaming={false}
|
|
417
|
-
projectName={projectName}
|
|
418
|
-
/>
|
|
419
|
-
)}
|
|
420
|
-
/>
|
|
421
|
-
</Suspense>
|
|
453
|
+
{/* Expand compacted conversation: detect JSONL path in compact summary user message.
|
|
454
|
+
Prepends pre-compact messages into main flattened list (see useChat.expandCompact). */}
|
|
455
|
+
{jsonlPath && messageId && onExpandCompact && (
|
|
456
|
+
<PreCompactButton
|
|
457
|
+
status={preCompactStatus}
|
|
458
|
+
onLoad={preCompactStatus === "idle" || preCompactStatus === "error" ? handleExpand : undefined}
|
|
459
|
+
count={preCompactCount}
|
|
460
|
+
/>
|
|
422
461
|
)}
|
|
423
462
|
{/* Fork/Rewind button — only for real user messages */}
|
|
424
463
|
{!isSystemContext && onFork && (
|
|
@@ -694,7 +733,31 @@ type EventGroup =
|
|
|
694
733
|
| { kind: "thinking"; content: string }
|
|
695
734
|
| { kind: "tool"; tool: ChatEvent; result?: ChatEvent; completed?: boolean };
|
|
696
735
|
|
|
697
|
-
function InterleavedEvents({ events, isStreaming, projectName, bashPartialOutput
|
|
736
|
+
function InterleavedEvents({ events, isStreaming, projectName, bashPartialOutput, messageId, onExpandCompact, isCompactExpanded }: {
|
|
737
|
+
events: ChatEvent[];
|
|
738
|
+
isStreaming: boolean;
|
|
739
|
+
projectName?: string;
|
|
740
|
+
bashPartialOutput?: React.RefObject<Map<string, BashPartialEntry>>;
|
|
741
|
+
messageId?: string;
|
|
742
|
+
onExpandCompact?: (compactMessageId: string, jsonlPath: string) => Promise<number>;
|
|
743
|
+
isCompactExpanded?: (compactMessageId: string) => boolean;
|
|
744
|
+
}) {
|
|
745
|
+
// Local state for the /compact slash-command path (assistant-authored summary)
|
|
746
|
+
const [preCompactStatus, setPreCompactStatus] = useState<PreCompactStatus>(() =>
|
|
747
|
+
messageId && isCompactExpanded?.(messageId) ? "loaded" : "idle",
|
|
748
|
+
);
|
|
749
|
+
const [preCompactCount, setPreCompactCount] = useState<number | undefined>();
|
|
750
|
+
const handleExpand = useCallback(async (jsonlPath: string) => {
|
|
751
|
+
if (!messageId || !onExpandCompact) return;
|
|
752
|
+
setPreCompactStatus("loading");
|
|
753
|
+
try {
|
|
754
|
+
const count = await onExpandCompact(messageId, jsonlPath);
|
|
755
|
+
setPreCompactCount(count);
|
|
756
|
+
setPreCompactStatus("loaded");
|
|
757
|
+
} catch {
|
|
758
|
+
setPreCompactStatus("error");
|
|
759
|
+
}
|
|
760
|
+
}, [messageId, onExpandCompact]);
|
|
698
761
|
// Group: consecutive text → merged text block; tool_use + tool_result paired by toolUseId
|
|
699
762
|
const groups: EventGroup[] = [];
|
|
700
763
|
let textBuffer = "";
|
|
@@ -813,22 +876,12 @@ function InterleavedEvents({ events, isStreaming, projectName, bashPartialOutput
|
|
|
813
876
|
return (
|
|
814
877
|
<div key={`text-${i}`} className="text-sm text-text-primary select-text">
|
|
815
878
|
<StreamingText content={group.content} animate={isLast} projectName={projectName} />
|
|
816
|
-
{jsonlPath && (
|
|
817
|
-
<
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
<MessageBubble
|
|
823
|
-
key={msg.id ?? `pc-${idx}`}
|
|
824
|
-
message={msg}
|
|
825
|
-
isStreaming={false}
|
|
826
|
-
projectName={projectName}
|
|
827
|
-
bashPartialOutput={bashPartialOutput}
|
|
828
|
-
/>
|
|
829
|
-
)}
|
|
830
|
-
/>
|
|
831
|
-
</Suspense>
|
|
879
|
+
{jsonlPath && messageId && onExpandCompact && (
|
|
880
|
+
<PreCompactButton
|
|
881
|
+
status={preCompactStatus}
|
|
882
|
+
onLoad={preCompactStatus === "idle" || preCompactStatus === "error" ? () => handleExpand(jsonlPath) : undefined}
|
|
883
|
+
count={preCompactCount}
|
|
884
|
+
/>
|
|
832
885
|
)}
|
|
833
886
|
</div>
|
|
834
887
|
);
|
|
@@ -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}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { useState, useCallback, useRef, useEffect } from "react";
|
|
1
|
+
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
|
2
2
|
import { useWebSocket } from "./use-websocket";
|
|
3
3
|
import { api, getAuthToken, projectUrl } from "@/lib/api-client";
|
|
4
|
+
import { flattenWithExpansions, prefixPreCompactIds } from "@/lib/flatten-expansions";
|
|
4
5
|
import { useNotificationStore } from "@/stores/notification-store";
|
|
5
6
|
import { useStreamingStore } from "@/stores/streaming-store";
|
|
6
7
|
import { usePanelStore } from "@/stores/panel-store";
|
|
@@ -41,6 +42,12 @@ export interface BashPartialEntry {
|
|
|
41
42
|
|
|
42
43
|
interface UseChatReturn {
|
|
43
44
|
messages: ChatMessage[];
|
|
45
|
+
/** Messages flattened with pre-compact expansions prepended before their compact cards. */
|
|
46
|
+
renderedMessages: ChatMessage[];
|
|
47
|
+
/** Fetch pre-compact transcript and store expansion. Returns loaded message count. */
|
|
48
|
+
expandCompact: (compactMessageId: string, jsonlPath: string) => Promise<number>;
|
|
49
|
+
/** Whether a given compactMessageId has been expanded. */
|
|
50
|
+
isCompactExpanded: (compactMessageId: string) => boolean;
|
|
44
51
|
messagesLoading: boolean;
|
|
45
52
|
isStreaming: boolean;
|
|
46
53
|
phase: SessionPhase;
|
|
@@ -79,6 +86,8 @@ function isSessionTabActive(sid: string): boolean {
|
|
|
79
86
|
|
|
80
87
|
export function useChat(sessionId: string | null, providerId = "claude", projectName = ""): UseChatReturn {
|
|
81
88
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
89
|
+
/** Map of compactMessageId → pre-compact messages (already ID-prefixed). Ephemeral. */
|
|
90
|
+
const [expansions, setExpansions] = useState<Map<string, ChatMessage[]>>(new Map());
|
|
82
91
|
const [messagesLoading, setMessagesLoading] = useState(false);
|
|
83
92
|
const [phase, setPhase] = useState<SessionPhase>("idle");
|
|
84
93
|
const [isReconnecting, setIsReconnecting] = useState(false);
|
|
@@ -604,6 +613,8 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
604
613
|
setPendingApproval(null);
|
|
605
614
|
if (approvalToastRef.current != null) { toast.dismiss(approvalToastRef.current); approvalToastRef.current = null; }
|
|
606
615
|
setCompactStatus(null);
|
|
616
|
+
// Clear ephemeral pre-compact expansions on session change
|
|
617
|
+
setExpansions(new Map());
|
|
607
618
|
streamingContentRef.current = "";
|
|
608
619
|
streamingEventsRef.current = [];
|
|
609
620
|
bashOutputRef.current.clear();
|
|
@@ -796,8 +807,33 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
796
807
|
// Keep refetchRef in sync
|
|
797
808
|
refetchRef.current = refetchMessages;
|
|
798
809
|
|
|
810
|
+
/** Fetch pre-compact transcript. Idempotent: re-expanding same id replaces entry. */
|
|
811
|
+
const expandCompact = useCallback(async (compactMessageId: string, jsonlPath: string): Promise<number> => {
|
|
812
|
+
if (!projectName) throw new Error("No project context available");
|
|
813
|
+
const url = `${projectUrl(projectName)}/chat/pre-compact-messages?jsonlPath=${encodeURIComponent(jsonlPath)}`;
|
|
814
|
+
const loaded = await api.get<ChatMessage[]>(url);
|
|
815
|
+
const prefixed = prefixPreCompactIds(loaded, jsonlPath);
|
|
816
|
+
setExpansions((prev) => {
|
|
817
|
+
const next = new Map(prev);
|
|
818
|
+
next.set(compactMessageId, prefixed);
|
|
819
|
+
return next;
|
|
820
|
+
});
|
|
821
|
+
return prefixed.length;
|
|
822
|
+
}, [projectName]);
|
|
823
|
+
|
|
824
|
+
const isCompactExpanded = useCallback((id: string) => expansions.has(id), [expansions]);
|
|
825
|
+
|
|
826
|
+
/** Flattened view: expansions prepended before their compact cards. */
|
|
827
|
+
const renderedMessages = useMemo(
|
|
828
|
+
() => flattenWithExpansions(messages, expansions),
|
|
829
|
+
[messages, expansions],
|
|
830
|
+
);
|
|
831
|
+
|
|
799
832
|
return {
|
|
800
833
|
messages,
|
|
834
|
+
renderedMessages,
|
|
835
|
+
expandCompact,
|
|
836
|
+
isCompactExpanded,
|
|
801
837
|
messagesLoading,
|
|
802
838
|
isStreaming,
|
|
803
839
|
phase,
|
|
@@ -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
|
}
|