@hienlh/ppm 0.13.0 → 0.13.3
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 +30 -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-R7cq1uhJ.js} +1 -1
- package/dist/web/assets/chat-tab-umei1UkV.js +12 -0
- package/dist/web/assets/{code-editor-tMfcFaQ5.js → code-editor-BTosKXkr.js} +2 -2
- package/dist/web/assets/{conflict-editor-FydCxWTC.js → conflict-editor-dzofjxab.js} +1 -1
- package/dist/web/assets/{database-viewer-Celi1puH.js → database-viewer-5Uf8Rrls.js} +1 -1
- package/dist/web/assets/{diff-viewer-NgDJLTk9.js → diff-viewer-DKLeIBkK.js} +1 -1
- package/dist/web/assets/{extension-webview-xWAdCj3q.js → extension-webview-HILvTnnn.js} +1 -1
- package/dist/web/assets/{image-preview-C6bFkdZD.js → image-preview-0cJMnFZK.js} +1 -1
- package/dist/web/assets/index-Bce0weeW.css +2 -0
- package/dist/web/assets/index-DDBvHVVr.js +27 -0
- package/dist/web/assets/{markdown-renderer-BAnnk1pI.js → markdown-renderer-D0MrsVJB.js} +1 -1
- package/dist/web/assets/{pdf-preview-BNuFTSOL.js → pdf-preview-BBVDS-z5.js} +1 -1
- package/dist/web/assets/{port-forwarding-tab-BbDlGxAs.js → port-forwarding-tab-ByKzBs-R.js} +1 -1
- package/dist/web/assets/{postgres-viewer-Cman1YRO.js → postgres-viewer-BnCbdR7g.js} +1 -1
- package/dist/web/assets/{settings-tab-n5X_Dbu4.js → settings-tab-BPdzUw3v.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-D6JT11uu.js → sqlite-viewer-D6mSIIx2.js} +1 -1
- package/dist/web/assets/{terminal-tab-B4kMthYo.js → terminal-tab-BLIA53mt.js} +1 -1
- package/dist/web/assets/{video-preview-BftQOOzF.js → video-preview-CKaht6nI.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/providers/claude-agent-sdk.ts +5 -2
- package/src/server/index.ts +12 -4
- package/src/server/routes/chat.ts +10 -1
- package/src/server/ws/chat.ts +29 -2
- 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/types/chat.ts +1 -1
- 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/chat/message-list.tsx +6 -5
- package/src/web/components/layout/command-palette.tsx +35 -12
- package/src/web/components/layout/draggable-tab.tsx +12 -5
- package/src/web/hooks/use-chat.ts +6 -0
- 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/lib/score-file-search.ts +41 -21
- 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
- package/dist/web/assets/index-DtbAoxyy.js +0 -23
package/src/server/ws/chat.ts
CHANGED
|
@@ -50,6 +50,8 @@ interface SessionEntry {
|
|
|
50
50
|
teamNames: Set<string>;
|
|
51
51
|
/** toolUseId of a pending TeamCreate call */
|
|
52
52
|
pendingTeamCreate?: string;
|
|
53
|
+
/** Compact indicator state — sticky until turn ends or boundary received, synced on reconnect */
|
|
54
|
+
compactStatus?: "compacting" | null;
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
/** Tracks active sessions — persists even when FE disconnects */
|
|
@@ -262,8 +264,12 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
|
|
|
262
264
|
if (evType === "system") {
|
|
263
265
|
const sub = (ev as any).subtype;
|
|
264
266
|
if (sub === "compacting") {
|
|
267
|
+
entry.compactStatus = "compacting";
|
|
268
|
+
console.log(`[chat] session=${sessionId} compact_status=compacting (persisted on entry)`);
|
|
265
269
|
broadcast(sessionId, { type: "compact_status", status: "compacting" });
|
|
266
270
|
} else if (sub === "compact_done") {
|
|
271
|
+
entry.compactStatus = null;
|
|
272
|
+
console.log(`[chat] session=${sessionId} compact_status=done (via compact_boundary)`);
|
|
267
273
|
broadcast(sessionId, { type: "compact_status", status: "done" });
|
|
268
274
|
}
|
|
269
275
|
if (!firstEventReceived) {
|
|
@@ -415,6 +421,14 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
|
|
|
415
421
|
if (evType === "done") {
|
|
416
422
|
entry.turnEvents = [];
|
|
417
423
|
entry.pendingApprovalEvent = undefined;
|
|
424
|
+
// Clear stale compact status if turn ended without compact_boundary.
|
|
425
|
+
// SDK may emit `status: compacting` without a matching boundary (deferred,
|
|
426
|
+
// resolved, or errored); without this clear, UI shows stuck "Compacting…".
|
|
427
|
+
if (entry.compactStatus === "compacting") {
|
|
428
|
+
entry.compactStatus = null;
|
|
429
|
+
console.log(`[chat] session=${sessionId} compact_status=done (cleared on turn done without boundary)`);
|
|
430
|
+
broadcast(sessionId, { type: "compact_status", status: "done" });
|
|
431
|
+
}
|
|
418
432
|
setPhase(sessionId, "idle");
|
|
419
433
|
// Reset heartbeat tracking for next turn
|
|
420
434
|
firstEventReceived = false;
|
|
@@ -432,6 +446,12 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
|
|
|
432
446
|
if (heartbeat) clearInterval(heartbeat);
|
|
433
447
|
entry.isStreamingActive = false;
|
|
434
448
|
entry.turnEvents = [];
|
|
449
|
+
// Force-clear compact status on stream teardown (error, close, etc.)
|
|
450
|
+
if (entry.compactStatus === "compacting") {
|
|
451
|
+
entry.compactStatus = null;
|
|
452
|
+
console.log(`[chat] session=${sessionId} compact_status=done (cleared on stream teardown)`);
|
|
453
|
+
broadcast(sessionId, { type: "compact_status", status: "done" });
|
|
454
|
+
}
|
|
435
455
|
setPhase(sessionId, "idle");
|
|
436
456
|
entry.pendingApprovalEvent = undefined;
|
|
437
457
|
// Cleanup bash output spies
|
|
@@ -488,6 +508,7 @@ export const chatWebSocket = {
|
|
|
488
508
|
phase: existing.phase,
|
|
489
509
|
pendingApproval: existing.pendingApprovalEvent ?? null,
|
|
490
510
|
sessionTitle: session?.title || null,
|
|
511
|
+
compactStatus: existing.compactStatus ?? null,
|
|
491
512
|
}));
|
|
492
513
|
|
|
493
514
|
// If actively streaming, send buffered turn events for reconnect sync
|
|
@@ -528,6 +549,7 @@ export const chatWebSocket = {
|
|
|
528
549
|
isStreamingActive: false,
|
|
529
550
|
teamWatchers: new Map(),
|
|
530
551
|
teamNames: new Set(),
|
|
552
|
+
compactStatus: null,
|
|
531
553
|
};
|
|
532
554
|
activeSessions.set(sessionId, newEntry);
|
|
533
555
|
setupClientPing(newEntry, ws);
|
|
@@ -539,6 +561,7 @@ export const chatWebSocket = {
|
|
|
539
561
|
phase: "idle",
|
|
540
562
|
pendingApproval: null,
|
|
541
563
|
sessionTitle: session?.title || null,
|
|
564
|
+
compactStatus: null,
|
|
542
565
|
}));
|
|
543
566
|
|
|
544
567
|
// Async: resolve title from SDK if in-memory title is generic (DB title takes priority)
|
|
@@ -580,7 +603,7 @@ export const chatWebSocket = {
|
|
|
580
603
|
const newEntry: SessionEntry = {
|
|
581
604
|
providerId: pid, clients: new Set([ws]), projectPath: pp, projectName: pn,
|
|
582
605
|
pingIntervals: new Map(), phase: "idle", turnEvents: [], isStreamingActive: false,
|
|
583
|
-
teamWatchers: new Map(), teamNames: new Set(),
|
|
606
|
+
teamWatchers: new Map(), teamNames: new Set(), compactStatus: null,
|
|
584
607
|
};
|
|
585
608
|
activeSessions.set(sessionId, newEntry);
|
|
586
609
|
setupClientPing(newEntry, ws);
|
|
@@ -605,6 +628,7 @@ export const chatWebSocket = {
|
|
|
605
628
|
phase: entry.phase,
|
|
606
629
|
pendingApproval: entry.pendingApprovalEvent ?? null,
|
|
607
630
|
sessionTitle: chatService.getSession(sessionId)?.title || null,
|
|
631
|
+
compactStatus: entry.compactStatus ?? null,
|
|
608
632
|
}));
|
|
609
633
|
if (entry.phase !== "idle") {
|
|
610
634
|
sendTurnEvents(sessionId, ws);
|
|
@@ -705,7 +729,10 @@ export const chatWebSocket = {
|
|
|
705
729
|
} else if (parsed.type === "cancel") {
|
|
706
730
|
// Fully teardown streaming session — user must resume to continue
|
|
707
731
|
const provider = providerRegistry.get(providerId);
|
|
708
|
-
|
|
732
|
+
const phase = entry?.phase ?? "unknown";
|
|
733
|
+
console.log(`[chat] session=${sessionId} WS cancel received from FE (phase=${phase})`);
|
|
734
|
+
logSessionEvent(sessionId, "CANCEL", `WS cancel from FE (phase=${phase})`);
|
|
735
|
+
provider?.abortQuery?.(sessionId, "ws_cancel");
|
|
709
736
|
} else if (parsed.type === "approval_response") {
|
|
710
737
|
const provider = providerRegistry.get(providerId);
|
|
711
738
|
if (provider && typeof provider.resolveApproval === "function") {
|
|
@@ -125,10 +125,12 @@ After=network-online.target
|
|
|
125
125
|
Wants=network-online.target
|
|
126
126
|
|
|
127
127
|
[Service]
|
|
128
|
-
Type=
|
|
128
|
+
Type=notify
|
|
129
|
+
NotifyAccess=all
|
|
129
130
|
ExecStart=${execStart}
|
|
130
131
|
Restart=on-failure
|
|
131
132
|
RestartSec=5
|
|
133
|
+
TimeoutStartSec=60
|
|
132
134
|
TimeoutStopSec=10
|
|
133
135
|
KillMode=mixed
|
|
134
136
|
${envPath}
|
|
@@ -350,4 +350,21 @@ export function getAutoStartStatus(): AutoStartStatus {
|
|
|
350
350
|
};
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Detect whether an existing systemd unit file is outdated and needs
|
|
355
|
+
* regeneration. Currently flags units missing Type=notify (introduced to fix
|
|
356
|
+
* the WSL/systemd upgrade-kill bug). Linux-only; returns false elsewhere.
|
|
357
|
+
*/
|
|
358
|
+
export function isAutoStartUnitStale(): boolean {
|
|
359
|
+
if (process.platform !== "linux") return false;
|
|
360
|
+
try {
|
|
361
|
+
const path = getServicePath();
|
|
362
|
+
if (!existsSync(path)) return false;
|
|
363
|
+
const content = readFileSync(path, "utf-8");
|
|
364
|
+
return !content.includes("Type=notify");
|
|
365
|
+
} catch {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
353
370
|
export { loadMetadata, METADATA_FILE };
|
|
@@ -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
|
|
package/src/types/chat.ts
CHANGED
|
@@ -24,7 +24,7 @@ export interface AIProvider {
|
|
|
24
24
|
// Optional capabilities — providers implement what they support
|
|
25
25
|
resolveApproval?(requestId: string, approved: boolean, data?: unknown): void;
|
|
26
26
|
onToolApproval?: (callback: ToolApprovalHandler) => void;
|
|
27
|
-
abortQuery?(sessionId: string): void;
|
|
27
|
+
abortQuery?(sessionId: string, source?: string): void;
|
|
28
28
|
getMessages?(sessionId: string): Promise<ChatMessage[]>;
|
|
29
29
|
listSessionsByDir?(dir: string, opts?: { limit?: number; offset?: number }): Promise<SessionInfo[]>;
|
|
30
30
|
ensureProjectPath?(sessionId: string, path: string): void;
|
|
@@ -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;
|
|
@@ -348,7 +348,11 @@ function UserBubble({ content, messageId, projectName, onFork, onExpandCompact,
|
|
|
348
348
|
const parsed = parseUserAttachments(content);
|
|
349
349
|
const { cleanText: noSysTags, tags } = extractSystemTags(parsed.text);
|
|
350
350
|
const { command, cleanText } = parseCommandTags(noSysTags);
|
|
351
|
-
|
|
351
|
+
// Merge command args into body text so line-clamp + Show more applies uniformly
|
|
352
|
+
const bodyText = command?.args
|
|
353
|
+
? (cleanText ? `${command.args}\n\n${cleanText}` : command.args)
|
|
354
|
+
: cleanText;
|
|
355
|
+
return { files: parsed.files, text: bodyText, tags, command, jsonlPath: extractJsonlPath(cleanText) };
|
|
352
356
|
}, [content]);
|
|
353
357
|
|
|
354
358
|
// Pre-compact expansion state — local per button instance
|
|
@@ -394,16 +398,13 @@ function UserBubble({ content, messageId, projectName, onFork, onExpandCompact,
|
|
|
394
398
|
{/* System tags as badges */}
|
|
395
399
|
{tags.length > 0 && <SystemTagBadges tags={tags} />}
|
|
396
400
|
|
|
397
|
-
{/* Slash command chip */}
|
|
401
|
+
{/* Slash command chip — args rendered in body for expand/collapse support */}
|
|
398
402
|
{command && (
|
|
399
403
|
<div className="flex items-center gap-1.5 mb-0.5">
|
|
400
404
|
<span className="inline-flex items-center gap-1 rounded-md bg-primary/15 border border-primary/20 px-2 py-0.5 text-xs font-medium text-primary">
|
|
401
405
|
<Slash className="size-3 shrink-0" />
|
|
402
406
|
{command.name}
|
|
403
407
|
</span>
|
|
404
|
-
{command.args && (
|
|
405
|
-
<span className="text-xs text-text-secondary truncate max-w-80">{command.args}</span>
|
|
406
|
-
)}
|
|
407
408
|
</div>
|
|
408
409
|
)}
|
|
409
410
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
|
1
|
+
import { useState, useEffect, useRef, useMemo, useCallback, useDeferredValue } from "react";
|
|
2
2
|
import {
|
|
3
3
|
Terminal,
|
|
4
4
|
MessageSquare,
|
|
@@ -25,7 +25,10 @@ import { useFileStore, type FileNode } from "@/stores/file-store";
|
|
|
25
25
|
import { useExtensionStore } from "@/stores/extension-store";
|
|
26
26
|
import { api } from "@/lib/api-client";
|
|
27
27
|
import { basename } from "@/lib/utils";
|
|
28
|
-
import {
|
|
28
|
+
import { scoreFileSearchFast, compareScores, getFilename, type FileSearchScore } from "@/lib/score-file-search";
|
|
29
|
+
|
|
30
|
+
/** Max results to display — prevents rendering thousands of matches */
|
|
31
|
+
const MAX_RESULTS = 100;
|
|
29
32
|
|
|
30
33
|
interface CommandItem {
|
|
31
34
|
id: string;
|
|
@@ -108,6 +111,7 @@ const fsCache = new Map<string, string[]>();
|
|
|
108
111
|
|
|
109
112
|
export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boolean; onClose: () => void; initialQuery?: string }) {
|
|
110
113
|
const [query, setQuery] = useState("");
|
|
114
|
+
const deferredQuery = useDeferredValue(query);
|
|
111
115
|
const [selectedIdx, setSelectedIdx] = useState(0);
|
|
112
116
|
const [fsFiles, setFsFiles] = useState<string[]>([]);
|
|
113
117
|
const [fsLoading, setFsLoading] = useState(false);
|
|
@@ -305,11 +309,29 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
|
|
|
305
309
|
[actionCommands, fileCommands],
|
|
306
310
|
);
|
|
307
311
|
|
|
312
|
+
/**
|
|
313
|
+
* Precomputed lowercase search index — avoids re-allocating thousands of
|
|
314
|
+
* lowercased strings per keystroke. Recomputed only when allCommands changes.
|
|
315
|
+
*/
|
|
316
|
+
const searchIndex = useMemo(() => {
|
|
317
|
+
return allCommands.map((cmd) => {
|
|
318
|
+
const path = cmd.keywords ?? cmd.label;
|
|
319
|
+
const pathLower = path.toLowerCase();
|
|
320
|
+
return {
|
|
321
|
+
cmd,
|
|
322
|
+
filenameLower: getFilename(pathLower),
|
|
323
|
+
pathLower,
|
|
324
|
+
labelLen: cmd.label.length,
|
|
325
|
+
depth: path.split("/").length,
|
|
326
|
+
};
|
|
327
|
+
});
|
|
328
|
+
}, [allCommands]);
|
|
329
|
+
|
|
308
330
|
const filtered = useMemo(() => {
|
|
309
331
|
// Path mode — search filesystem results using filename portion only
|
|
310
|
-
if (isPathQuery(
|
|
311
|
-
const lastSlash =
|
|
312
|
-
const fileFilter = lastSlash >= 0 ?
|
|
332
|
+
if (isPathQuery(deferredQuery)) {
|
|
333
|
+
const lastSlash = deferredQuery.lastIndexOf("/");
|
|
334
|
+
const fileFilter = lastSlash >= 0 ? deferredQuery.slice(lastSlash + 1).toLowerCase() : "";
|
|
313
335
|
if (!fileFilter) return fsCommands.slice(0, 50);
|
|
314
336
|
return fsCommands.filter((c) => {
|
|
315
337
|
const name = c.label.toLowerCase();
|
|
@@ -319,17 +341,18 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
|
|
|
319
341
|
}
|
|
320
342
|
|
|
321
343
|
// Normal mode
|
|
322
|
-
if (!
|
|
344
|
+
if (!deferredQuery.trim()) return actionCommands;
|
|
345
|
+
const qLower = deferredQuery.toLowerCase();
|
|
323
346
|
const scored: Array<{ cmd: CommandItem; score: FileSearchScore }> = [];
|
|
324
|
-
for (const
|
|
325
|
-
const s =
|
|
326
|
-
if (s) scored.push({ cmd:
|
|
347
|
+
for (const entry of searchIndex) {
|
|
348
|
+
const s = scoreFileSearchFast(qLower, entry.filenameLower, entry.pathLower, entry.labelLen, entry.depth);
|
|
349
|
+
if (s) scored.push({ cmd: entry.cmd, score: s });
|
|
327
350
|
}
|
|
328
351
|
scored.sort((a, b) => compareScores(a.score, b.score));
|
|
329
|
-
const matched = scored.map((s) => s.cmd);
|
|
352
|
+
const matched = scored.slice(0, MAX_RESULTS).map((s) => s.cmd);
|
|
330
353
|
// Prepend DB results (already filtered server-side) when query is 2+ chars
|
|
331
|
-
return
|
|
332
|
-
}, [
|
|
354
|
+
return deferredQuery.trim().length >= 2 ? [...dbCommands, ...matched] : matched;
|
|
355
|
+
}, [searchIndex, actionCommands, fsCommands, dbCommands, deferredQuery]);
|
|
333
356
|
|
|
334
357
|
// Reset state when opening
|
|
335
358
|
useEffect(() => {
|
|
@@ -100,13 +100,20 @@ export function DraggableTab({
|
|
|
100
100
|
colorStyle && "border-transparent",
|
|
101
101
|
)}
|
|
102
102
|
>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
103
|
+
<span
|
|
104
|
+
// Streaming: force amber (matches favicon streaming bg) so typing state is unmistakable
|
|
105
|
+
// regardless of tab active state. Otherwise inherits parent button's color (primary/text-secondary).
|
|
106
|
+
// Tag identity is now shown as a separate left-edge bar (see wrapper div below), not icon color.
|
|
107
|
+
className={cn("relative", isStreaming && "text-amber-500")}
|
|
108
|
+
>
|
|
107
109
|
<Icon className="size-4" />
|
|
108
110
|
{isStreaming ? (
|
|
109
|
-
|
|
111
|
+
// Messenger-style typing dots inside chat bubble — inherits current icon color (amber while streaming)
|
|
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}
|
|
@@ -505,6 +505,9 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
505
505
|
setPhase(p);
|
|
506
506
|
phaseRef.current = p;
|
|
507
507
|
setConnectingElapsed(p === "connecting" ? ((data as any).elapsed ?? 0) : 0);
|
|
508
|
+
// Safety: idle phase means no turn running — ensure compact indicator does not linger.
|
|
509
|
+
// BE should broadcast compact_status=done too, but this is a belt-and-braces clear.
|
|
510
|
+
if (p === "idle") setCompactStatus(null);
|
|
508
511
|
return;
|
|
509
512
|
}
|
|
510
513
|
|
|
@@ -523,6 +526,9 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
523
526
|
input: state.pendingApproval.input,
|
|
524
527
|
});
|
|
525
528
|
}
|
|
529
|
+
// Sync compact indicator from authoritative server state (covers reconnect).
|
|
530
|
+
// state.compactStatus is "compacting" | null — treat undefined as null for back-compat.
|
|
531
|
+
setCompactStatus(state.compactStatus === "compacting" ? "compacting" : null);
|
|
526
532
|
// If idle, refetch history (completed turns) and hide overlay
|
|
527
533
|
if (p === "idle") {
|
|
528
534
|
refetchRef.current?.();
|
|
@@ -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
|
+
}
|