@raingor/pi-web-switch 0.4.2 → 0.4.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.
@@ -0,0 +1,18 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
+ <title>pi-switch — pi Configuration Manager</title>
7
+ <link rel="icon" type="image/svg+xml" href="./pi.svg" />
8
+ <link rel="apple-touch-icon" href="./apple-touch-icon.png" />
9
+ <link rel="manifest" href="./manifest.webmanifest" />
10
+ <meta name="theme-color" content="#2563eb" />
11
+ <meta name="description" content="Web UI for pi coding agent — configuration management, session browser, and usage dashboard" />
12
+ <script type="module" crossorigin src="./assets/main-eom_4fyA.js"></script>
13
+ <link rel="stylesheet" crossorigin href="./assets/main-M_eVNxKU.css">
14
+ </head>
15
+ <body class="bg-gray-950 text-gray-100 antialiased">
16
+ <div id="root"></div>
17
+ </body>
18
+ </html>
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@raingor/pi-web-switch",
3
3
  "private": false,
4
- "version": "0.4.2",
4
+ "version": "0.4.3",
5
5
  "type": "module",
6
- "main": "dist-electron/main/main.cjs",
6
+ "main": "dist/index.html",
7
7
  "description": "Web UI for pi coding agent — live configuration management, session browser, and memory viewer",
8
8
  "keywords": [
9
9
  "pi-package",
@@ -26,11 +26,7 @@
26
26
  "scripts": {
27
27
  "dev": "vite",
28
28
  "build": "tsc -b && vite build",
29
- "preview": "vite preview",
30
- "electron:dev": "node scripts/electron-dev.mjs",
31
- "electron:build": "tsc -b && vite build && vite build --config vite.electron.config.ts && vite build --config vite.preload.config.ts && electron-builder",
32
- "electron:preview": "vite build && vite build --config vite.electron.config.ts && vite build --config vite.preload.config.ts && env -u ELECTRON_RUN_AS_NODE npx electron .",
33
- "tray:icon": "node scripts/generate-tray-icon.mjs"
29
+ "preview": "vite preview"
34
30
  },
35
31
  "files": [
36
32
  "pi-package",
@@ -69,14 +65,10 @@
69
65
  "@types/react": "^19.1.2",
70
66
  "@types/react-dom": "^19.1.2",
71
67
  "@vitejs/plugin-react": "^4.4.1",
72
- "electron": "^43.2.0",
73
- "electron-builder": "^26.15.3",
74
- "electron-vite": "^5.0.0",
75
68
  "tailwindcss": "^4.1.4",
76
69
  "typescript": "~5.8.3",
77
70
  "undici": "^8.9.0",
78
71
  "vite": "^6.3.2",
79
- "vite-plugin-electron": "^1.1.0",
80
72
  "vite-plugin-pwa": "^1.3.0"
81
73
  },
82
74
  "build": {
@@ -86,32 +78,7 @@
86
78
  "output": "release"
87
79
  },
88
80
  "files": [
89
- "dist/**/*",
90
- "dist-electron/**/*"
91
- ],
92
- "mac": {
93
- "target": [
94
- {
95
- "target": "dmg"
96
- },
97
- {
98
- "target": "zip"
99
- }
100
- ],
101
- "icon": "build/icon.icns",
102
- "extendInfo": {
103
- "LSMinimumSystemVersion": "11.0.0",
104
- "CFBundleName": "pi-web-switch"
105
- },
106
- "category": "public.app-category.developer-tools"
107
- },
108
- "win": {
109
- "target": "nsis",
110
- "icon": "build/icon.ico"
111
- },
112
- "linux": {
113
- "target": "AppImage",
114
- "icon": "build/icon.png"
115
- }
81
+ "dist/**/*"
82
+ ]
116
83
  }
117
84
  }
Binary file
Binary file
Binary file
package/public/pi.svg CHANGED
@@ -1,12 +1,47 @@
1
1
  <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" fill="none">
2
2
  <defs>
3
- <linearGradient id="bg" x1="0" y1="0" x2="0.3" y2="1">
4
- <stop offset="0" stop-color="#3b82f6"/>
5
- <stop offset="1" stop-color="#1d4ed8"/>
3
+ <!-- Brand blue diagonal gradient -->
4
+ <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
5
+ <stop offset="0" stop-color="#6ba6fb"/>
6
+ <stop offset="0.5" stop-color="#2563eb"/>
7
+ <stop offset="1" stop-color="#1e40af"/>
6
8
  </linearGradient>
9
+ <!-- Soft top gloss for a tactile, premium feel -->
10
+ <linearGradient id="gloss" x1="0" y1="0" x2="0" y2="1">
11
+ <stop offset="0" stop-color="#ffffff" stop-opacity="0.28"/>
12
+ <stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
13
+ </linearGradient>
14
+ <!-- Subtle vertical shading on the pi glyph -->
15
+ <linearGradient id="piGrad" x1="0" y1="0" x2="0" y2="1">
16
+ <stop offset="0" stop-color="#ffffff"/>
17
+ <stop offset="1" stop-color="#d9e6ff"/>
18
+ </linearGradient>
19
+ <!-- Toggle knob highlight -->
20
+ <radialGradient id="knob" cx="0.35" cy="0.3" r="0.85">
21
+ <stop offset="0" stop-color="#ffffff"/>
22
+ <stop offset="1" stop-color="#cdd9f2"/>
23
+ </radialGradient>
24
+ <filter id="softShadow" x="-30%" y="-30%" width="160%" height="160%">
25
+ <feDropShadow dx="0" dy="7" stdDeviation="11" flood-color="#0a2a6b" flood-opacity="0.38"/>
26
+ </filter>
27
+ <filter id="knobShadow" x="-60%" y="-60%" width="220%" height="220%">
28
+ <feDropShadow dx="0" dy="3" stdDeviation="5" flood-color="#0a2a6b" flood-opacity="0.35"/>
29
+ </filter>
7
30
  </defs>
8
- <rect x="0" y="0" width="512" height="512" rx="113" fill="url(#bg)"/>
9
- <text x="256" y="270" text-anchor="middle" dominant-baseline="central"
31
+
32
+ <!-- Squircle background -->
33
+ <rect x="0" y="0" width="512" height="512" rx="115" fill="url(#bg)"/>
34
+ <!-- Top gloss highlight -->
35
+ <rect x="0" y="0" width="512" height="250" rx="115" fill="url(#gloss)"/>
36
+
37
+ <!-- Toggle switch track (behind the pi) -->
38
+ <rect x="92" y="217" width="328" height="78" rx="39" fill="#ffffff" opacity="0.20"/>
39
+ <rect x="92" y="217" width="328" height="78" rx="39" fill="none" stroke="#ffffff" stroke-opacity="0.25" stroke-width="2"/>
40
+ <!-- Toggle knob in the "on" position -->
41
+ <circle cx="378" cy="256" r="30" fill="url(#knob)" filter="url(#knobShadow)"/>
42
+
43
+ <!-- Pi glyph -->
44
+ <text x="256" y="256" text-anchor="middle" dominant-baseline="central"
10
45
  font-family="-apple-system, 'SF Pro Display', 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
11
- font-size="240" font-weight="500" fill="#ffffff">π</text>
46
+ font-size="258" font-weight="700" fill="url(#piGrad)" filter="url(#softShadow)">π</text>
12
47
  </svg>
@@ -1,4 +1,4 @@
1
- import { readFileSync, readdirSync, existsSync, statSync, unlinkSync, writeFileSync, mkdirSync, renameSync, realpathSync } from "fs";
1
+ import { readFileSync, readdirSync, existsSync, statSync, unlinkSync, writeFileSync, mkdirSync, renameSync } from "fs";
2
2
  import { homedir, platform } from "os";
3
3
  import { join, resolve, dirname, relative, sep } from "path";
4
4
  import { spawnSync } from "child_process";
@@ -125,47 +125,6 @@ function getSessionDirs(): string[] {
125
125
  .filter((dir) => statSync(dir).isDirectory());
126
126
  }
127
127
 
128
- /**
129
- * Recursively walk a project directory to find all session JSONL files.
130
- *
131
- * Supports both the legacy flat layout and the current nested layout:
132
- * Legacy: --project--/session.jsonl (any .jsonl file at the project root)
133
- * Current: --project--/{sessionId}/{hash}/run-0/session.jsonl
134
- */
135
- function walkSessionJsonl(dir: string, out: string[]): void {
136
- let entries: string[];
137
- try {
138
- entries = readdirSync(dir);
139
- } catch {
140
- return;
141
- }
142
- for (const name of entries) {
143
- const p = join(dir, name);
144
- try {
145
- const stat = statSync(p);
146
- if (stat.isDirectory()) {
147
- walkSessionJsonl(p, out);
148
- } else if (name.endsWith(".jsonl")) {
149
- out.push(p);
150
- }
151
- } catch {
152
- // skip unreadable entries
153
- }
154
- }
155
- }
156
-
157
- /**
158
- * Return every session JSONL file across all project directories, handling
159
- * both the legacy flat layout and the current nested run-0 layout.
160
- */
161
- function getAllSessionFiles(): string[] {
162
- const files: string[] = [];
163
- for (const dir of getSessionDirs()) {
164
- walkSessionJsonl(dir, files);
165
- }
166
- return files;
167
- }
168
-
169
128
  // Usage stats are bucketed in China time (UTC+8) regardless of the machine's
170
129
  // system timezone, so daily totals stay consistent for a Beijing-based user.
171
130
  const CN_TZ = "Asia/Shanghai";
@@ -254,13 +213,22 @@ export function readAllUsage(): UsageRecord[] {
254
213
  return usageCache.records;
255
214
  }
256
215
  const allRecords: UsageRecord[] = [];
257
- const files = getAllSessionFiles();
216
+ const dirs = getSessionDirs();
258
217
 
259
- for (const filePath of files) {
260
- const records = parseSessionFile(filePath);
261
- allRecords.push(...records);
218
+ for (const dir of dirs) {
219
+ try {
220
+ const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
221
+ for (const file of files) {
222
+ const filePath = join(dir, file);
223
+ const records = parseSessionFile(filePath);
224
+ allRecords.push(...records);
225
+ }
226
+ } catch {
227
+ // skip unreadable directories
228
+ }
262
229
  }
263
230
 
231
+ // Sort by date ascending
264
232
  allRecords.sort((a, b) => a.date.localeCompare(b.date));
265
233
  usageCache = { records: allRecords, at: Date.now() };
266
234
  return allRecords;
@@ -1094,12 +1062,13 @@ export function listSessions(): ProjectGroup[] {
1094
1062
  }
1095
1063
 
1096
1064
  const group = groups.get(projectPath)!;
1097
- const files: string[] = [];
1098
- walkSessionJsonl(dir, files);
1099
- // Sort by path descending (newest session directories first as a rough proxy)
1100
- files.sort().reverse();
1065
+ const files = readdirSync(dir)
1066
+ .filter((f) => f.endsWith(".jsonl"))
1067
+ .sort()
1068
+ .reverse(); // newest first
1101
1069
 
1102
- for (const filePath of files) {
1070
+ for (const file of files) {
1071
+ const filePath = join(dir, file);
1103
1072
  const session = parseSessionFileInfo(filePath);
1104
1073
  if (session) {
1105
1074
  group.sessions.push(session);
@@ -1108,10 +1077,11 @@ export function listSessions(): ProjectGroup[] {
1108
1077
 
1109
1078
  group.totalSessions = group.sessions.length;
1110
1079
  if (group.sessions.length > 0) {
1111
- group.lastActive = group.sessions[0]?.timestamp ?? "";
1080
+ group.lastActive = group.sessions[0]?.timestamp ?? ""; // already sorted newest-first
1112
1081
  }
1113
1082
  }
1114
1083
 
1084
+ // Sort groups by lastActive descending
1115
1085
  return Array.from(groups.values())
1116
1086
  .filter((g) => g.sessions.length > 0)
1117
1087
  .sort((a, b) => b.lastActive.localeCompare(a.lastActive));
@@ -1298,74 +1268,6 @@ export function permanentlyDeleteTrash(trashPath: string): boolean {
1298
1268
  }
1299
1269
  }
1300
1270
 
1301
- // ─── Session Auto-Expiry ──────────────────────────────────
1302
-
1303
- const AUTO_EXPIRE_INTERVAL_MS = 24 * 60 * 60 * 1000; // every 24h
1304
- let autoExpireTimer: ReturnType<typeof setInterval> | null = null;
1305
-
1306
- function getSessionExpiryDays(): number {
1307
- const settings = readSettings();
1308
- const val = settings?.sessionExpiryDays;
1309
- const n = typeof val === "number" ? val : Number(val);
1310
- return Number.isFinite(n) && n > 0 ? Math.floor(n) : 7;
1311
- }
1312
-
1313
- interface ExpireResult {
1314
- expired: string[];
1315
- skipped: string[];
1316
- errors: string[];
1317
- }
1318
-
1319
- export function autoExpireSessions(): ExpireResult {
1320
- const result: ExpireResult = { expired: [], skipped: [], errors: [] };
1321
- const expiryDays = getSessionExpiryDays();
1322
- const cutoffMs = Date.now() - expiryDays * 24 * 60 * 60 * 1000;
1323
-
1324
- const dirs = getSessionDirs();
1325
- for (const dir of dirs) {
1326
- let files: string[];
1327
- try {
1328
- files = readdirSync(dir)
1329
- .filter((f) => f.endsWith(".jsonl"))
1330
- .map((f) => join(dir, f));
1331
- } catch {
1332
- continue;
1333
- }
1334
-
1335
- for (const filePath of files) {
1336
- try {
1337
- const info = parseSessionFileInfo(filePath);
1338
- if (!info) { result.skipped.push(filePath); continue; }
1339
-
1340
- const lastActiveTs = new Date(info.lastActive || info.timestamp).getTime();
1341
- if (isNaN(lastActiveTs)) { result.skipped.push(filePath); continue; }
1342
-
1343
- if (lastActiveTs < cutoffMs) {
1344
- const ok = trashSessionFile(filePath);
1345
- if (ok) {
1346
- result.expired.push(filePath);
1347
- } else {
1348
- result.errors.push(`trash failed: ${filePath}`);
1349
- }
1350
- }
1351
- } catch {
1352
- result.errors.push(`scan failed: ${filePath}`);
1353
- }
1354
- }
1355
- }
1356
-
1357
- return result;
1358
- }
1359
-
1360
- export function startAutoExpiryTimer(): void {
1361
- if (autoExpireTimer) return;
1362
- // Run once at startup (fire-and-forget, swallow errors)
1363
- try { autoExpireSessions(); } catch { /* ignore */ }
1364
- autoExpireTimer = setInterval(() => {
1365
- try { autoExpireSessions(); } catch { /* ignore */ }
1366
- }, AUTO_EXPIRE_INTERVAL_MS);
1367
- }
1368
-
1369
1271
  // ─── Session Preview ────────────────────────────────
1370
1272
 
1371
1273
  export interface SessionPreviewMessage {
@@ -1647,93 +1549,6 @@ export interface ApplyUpdateResult {
1647
1549
  message?: string;
1648
1550
  }
1649
1551
 
1650
- // ─── npm resolution ──────────────────────────────────────
1651
- // GUI-launched apps (Finder / Launchpad) inherit a minimal PATH
1652
- // (/usr/bin:/bin:/usr/sbin:/sbin) that does NOT contain the user's npm or
1653
- // node, so `spawnSync("npm", ...)` fails with ENOENT in the packaged app.
1654
- // Instead of relying on PATH we locate the real npm-cli.js + node binary
1655
- // from common install locations and run npm via that explicit node.
1656
-
1657
- function realpathOr(p: string): string | null {
1658
- try {
1659
- return realpathSync(p);
1660
- } catch {
1661
- return null;
1662
- }
1663
- }
1664
-
1665
- /** Candidate npm binaries across common layouts (PATH + known locations). */
1666
- function npmBinCandidates(): string[] {
1667
- const home = homedir();
1668
- const fromPath = (process.env.PATH || "")
1669
- .split(":")
1670
- .filter(Boolean)
1671
- .map((d) => join(d, "npm"));
1672
- return [
1673
- ...fromPath,
1674
- `${home}/.npm-global/bin/npm`,
1675
- `${home}/.npm-packages/bin/npm`,
1676
- `${home}/.local/share/pnpm/npm`,
1677
- "/usr/local/bin/npm",
1678
- "/opt/homebrew/bin/npm",
1679
- // pi-node bundles its own node/npm under ~/.local/share/pi-node/node-*/
1680
- ...(() => {
1681
- const base = join(home, ".local", "share", "pi-node");
1682
- try {
1683
- return readdirSync(base)
1684
- .filter((n) => n.startsWith("node-"))
1685
- .map((n) => join(base, n, "bin", "npm"));
1686
- } catch {
1687
- return [];
1688
- }
1689
- })(),
1690
- ];
1691
- }
1692
-
1693
- /** Resolve the npm CLI entry (npm-cli.js) — npm bins are symlinks to it. */
1694
- function resolveNpmCliJs(): string | null {
1695
- for (const bin of npmBinCandidates()) {
1696
- const real = realpathOr(bin);
1697
- if (real && existsSync(real) && /npm-cli\.js$/.test(real)) return real;
1698
- }
1699
- return null;
1700
- }
1701
-
1702
- /** Candidate node binaries (PATH + common locations + pi-node bundle). */
1703
- function resolveNodeBin(): string | null {
1704
- const home = homedir();
1705
- const fromPath = (process.env.PATH || "")
1706
- .split(":")
1707
- .filter(Boolean)
1708
- .map((d) => join(d, "node"));
1709
- const candidates = [
1710
- ...fromPath,
1711
- `${home}/.npm-global/bin/node`,
1712
- `${home}/.npm-packages/bin/node`,
1713
- "/usr/local/bin/node",
1714
- "/opt/homebrew/bin/node",
1715
- ...(() => {
1716
- const base = join(home, ".local", "share", "pi-node");
1717
- try {
1718
- return readdirSync(base)
1719
- .filter((n) => n.startsWith("node-"))
1720
- .map((n) => join(base, n, "bin", "node"));
1721
- } catch {
1722
- return [];
1723
- }
1724
- })(),
1725
- ];
1726
- for (const bin of candidates) {
1727
- try {
1728
- const out = spawnSync(bin, ["--version"], { encoding: "utf8", timeout: 5000 });
1729
- if (out.status === 0 && out.stdout) return bin;
1730
- } catch {
1731
- // try next candidate
1732
- }
1733
- }
1734
- return null;
1735
- }
1736
-
1737
1552
  /**
1738
1553
  * One-click update: npm install <name>@latest inside ~/.pi/agent/npm.
1739
1554
  * Only packages already installed there are accepted (pi core is excluded —
@@ -1742,8 +1557,6 @@ function resolveNodeBin(): string | null {
1742
1557
  export function applyExtensionUpdates(names: string[]): ApplyUpdateResult[] {
1743
1558
  const dir = join(PI_DIR, "npm");
1744
1559
  const installed = new Set(listInstalledExtensions().map((e) => e.name));
1745
- const npmCliJs = resolveNpmCliJs();
1746
- const nodeBin = resolveNodeBin();
1747
1560
 
1748
1561
  return names.map((name) => {
1749
1562
  if (!installed.has(name)) {
@@ -1752,19 +1565,15 @@ export function applyExtensionUpdates(names: string[]): ApplyUpdateResult[] {
1752
1565
  try {
1753
1566
  // --legacy-peer-deps: peer deps (e.g. pi core) are provided by the pi host,
1754
1567
  // not installed here — strict resolution would fail with ERESOLVE.
1755
- const args = ["install", `${name}@latest`, "--no-audit", "--no-fund", "--legacy-peer-deps"];
1756
- let out;
1757
- if (nodeBin && npmCliJs) {
1758
- // Explicit node + npm-cli.js — works even when PATH lacks npm
1759
- out = spawnSync(nodeBin, [npmCliJs, ...args], {
1568
+ const out = spawnSync(
1569
+ "npm",
1570
+ ["install", `${name}@latest`, "--no-audit", "--no-fund", "--legacy-peer-deps"],
1571
+ {
1760
1572
  cwd: dir,
1761
1573
  encoding: "utf8",
1762
1574
  timeout: 120000,
1763
- });
1764
- } else {
1765
- // Fall back to PATH resolution (dev / terminal environments)
1766
- out = spawnSync("npm", args, { cwd: dir, encoding: "utf8", timeout: 120000 });
1767
- }
1575
+ }
1576
+ );
1768
1577
  if (out.status === 0) return { name, success: true };
1769
1578
  const stderr = (out.stderr || "").trim().split("\n").slice(-3).join(" ");
1770
1579
  return { name, success: false, message: stderr || `npm exited with ${out.status}` };
@@ -415,35 +415,6 @@ export function DashboardPage() {
415
415
 
416
416
  return (
417
417
  <div className="space-y-5">
418
- {/* Source Selector: Pi / Cindy-Pi */}
419
- <div className="flex items-center gap-1 rounded-lg border p-0.5" style={{ borderColor: "var(--card-border)", backgroundColor: "var(--page-bg)" }}>
420
- {([
421
- { key: "all" as SourceKey, label: "dashboard.source_all", icon: "📊" },
422
- { key: "pi" as SourceKey, label: "dashboard.source_pi", icon: "🖥" },
423
- { key: "cindy-pi" as SourceKey, label: "dashboard.source_cindy_pi", icon: "🤖" },
424
- { key: "claude" as SourceKey, label: "dashboard.source_claude", icon: "🧠" },
425
- { key: "codex" as SourceKey, label: "dashboard.source_codex", icon: "⚡" },
426
- { key: "opencode" as SourceKey, label: "dashboard.source_opencode", icon: "🔷" },
427
- { key: "gemini" as SourceKey, label: "dashboard.source_gemini", icon: "✨" },
428
- { key: "grok" as SourceKey, label: "dashboard.source_grok", icon: "🌀" },
429
- { key: "atomcode" as SourceKey, label: "dashboard.source_atomcode", icon: "⚛️" },
430
- { key: "copilot" as SourceKey, label: "dashboard.source_copilot", icon: "🐙" },
431
- ]).map((s) => (
432
- <button
433
- key={s.key}
434
- onClick={() => setSource(s.key)}
435
- className={cn(
436
- "rounded-md px-3 py-1.5 text-xs font-medium transition-colors flex items-center gap-1.5",
437
- source === s.key ? "text-white" : "hover:bg-gray-800/30"
438
- )}
439
- style={source === s.key ? { backgroundColor: "#3b82f6", color: "#fff" } : { color: "var(--muted-text)" }}
440
- >
441
- <span>{s.icon}</span>
442
- <span>{t(s.label)}</span>
443
- </button>
444
- ))}
445
- </div>
446
-
447
418
  {/* Title + Time Range Selector + Currency Toggle */}
448
419
  <div className="flex items-center justify-between flex-wrap gap-3">
449
420
  <div>
@@ -33,21 +33,10 @@ export function Sidebar() {
33
33
  borderColor: "var(--sidebar-border)",
34
34
  }}
35
35
  >
36
- {/* Logo — pt-12 leaves room for the macOS traffic-light buttons in the
37
- immersive (hiddenInset) title bar so they don't overlap the logo.
38
- WebkitAppRegion: drag restores window dragging since the native
39
- title bar is hidden. */}
36
+ {/* Logo */}
40
37
  <div
41
- className="flex items-center gap-3 border-b px-6 pt-12 pb-5"
42
- style={{
43
- borderColor: "var(--sidebar-border)",
44
- userSelect: "none",
45
- WebkitUserSelect: "none",
46
- // Electron-only: lets the user drag the frameless (hiddenInset) window
47
- // from the sidebar header. TypeScript doesn't know the non-standard
48
- // -webkit-app-region property, so cast it explicitly.
49
- ...({ WebkitAppRegion: "drag" } as React.CSSProperties),
50
- }}
38
+ className="flex items-center gap-3 border-b px-6 py-5"
39
+ style={{ borderColor: "var(--sidebar-border)" }}
51
40
  >
52
41
  <img src="/pi.svg" alt="pi-switch" className="h-9 w-9 rounded-lg" />
53
42
  <div>
@@ -60,8 +60,9 @@ function parseMemoryEntries(content: string): MemoryEntry[] {
60
60
  for (const section of sections) {
61
61
  const trimmed = section.trim();
62
62
  // Match `<!-- created=DATE, last=DATE -->` at the end
63
+ // Tolerate extra fields after `last=` (e.g. `, project64=...`) before `-->`
63
64
  const markerMatch = trimmed.match(
64
- /<!--\s*created\s*=\s*([^,\s]+)\s*,\s*last\s*=\s*([^>\s]+)\s*-->\s*$/
65
+ /<!--\s*created\s*=\s*([^,\s>]+)\s*,\s*last\s*=\s*([^,\s>]+)[^>]*-->\s*$/
65
66
  );
66
67
  if (markerMatch) {
67
68
  entries.push({
@@ -3,7 +3,7 @@ import { useConfigStore } from "@/store/config-store";
3
3
  import { useTranslation } from "@/lib/i18n";
4
4
  import {
5
5
  History, MessageSquare, Clock, ChevronDown, ChevronRight, Trash2, AlertTriangle,
6
- Shield, RefreshCw, Undo2, Eye, Folder, FolderOpen, FileText, Search, ArchiveX,
6
+ Shield, RefreshCw, Undo2, Eye, Folder, FolderOpen, FileText, Search,
7
7
  } from "lucide-react";
8
8
  import { Modal } from "@/components/ui/Modal";
9
9
 
@@ -466,7 +466,7 @@ function findParentProjectPath(node: TreeNode): string {
466
466
 
467
467
  export function SessionsPage() {
468
468
  const { t } = useTranslation();
469
- const { initialized, settings } = useConfigStore();
469
+ const { initialized } = useConfigStore();
470
470
  const [tab, setTab] = useState<"sessions" | "trash">("sessions");
471
471
  const [groups, setGroups] = useState<ProjectGroup[]>([]);
472
472
  const [trash, setTrash] = useState<TrashEntry[]>([]);
@@ -480,7 +480,6 @@ export function SessionsPage() {
480
480
  const [selectedTrash, setSelectedTrash] = useState<Set<string>>(new Set());
481
481
  const [purgeTarget, setPurgeTarget] = useState<"batch" | TrashEntry | null>(null);
482
482
  const [purging, setPurging] = useState(false);
483
- const [expiring, setExpiring] = useState(false);
484
483
  // Preview modal state
485
484
  const [previewTarget, setPreviewTarget] = useState<SessionInfo | null>(null);
486
485
  const [preview, setPreview] = useState<{ messages: PreviewMessage[]; total: number } | null>(null);
@@ -576,27 +575,6 @@ export function SessionsPage() {
576
575
  loadAll();
577
576
  };
578
577
 
579
- const handleAutoExpire = async () => {
580
- setExpiring(true);
581
- try {
582
- const res = await fetch("/api/pi/session/auto-expire", { method: "POST" });
583
- const result = await res.json();
584
- if (result.success) {
585
- const count = result.expired?.length ?? 0;
586
- alert(count > 0
587
- ? t("sessions.auto_expire_success", String(count))
588
- : t("sessions.auto_expire_none"));
589
- loadAll();
590
- } else {
591
- alert(t("sessions.auto_expire_error", result.error || "unknown"));
592
- }
593
- } catch {
594
- alert(t("sessions.auto_expire_error", "network"));
595
- } finally {
596
- setExpiring(false);
597
- }
598
- };
599
-
600
578
  const openPreview = (session: SessionInfo) => {
601
579
  setPreviewTarget(session);
602
580
  setPreview(null);
@@ -723,18 +701,6 @@ function countExpandableNodes(nodes: TreeNode[]): number {
723
701
  <RefreshCw className={refreshing ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"} />
724
702
  {t("sessions.refresh")}
725
703
  </button>
726
- {tab === "sessions" && groups.length > 0 && (
727
- <button
728
- onClick={handleAutoExpire}
729
- disabled={expiring}
730
- className="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors"
731
- style={{ borderColor: "var(--card-border)", color: "var(--muted-text)", backgroundColor: "var(--card-bg)", opacity: expiring ? 0.6 : 1 }}
732
- title={t("sessions.auto_expire_tooltip", String(settings?.sessionExpiryDays ?? 7))}
733
- >
734
- <ArchiveX className={expiring ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"} />
735
- {t("sessions.auto_expire")}
736
- </button>
737
- )}
738
704
  </div>
739
705
  </div>
740
706
 
@@ -242,11 +242,6 @@ const en: Record<string, string> = {
242
242
  "sessions.delete_error": "Error deleting session",
243
243
  "sessions.load_failed": "Failed to load sessions",
244
244
  "sessions.refresh": "Refresh",
245
- "sessions.auto_expire": "Auto-Expire",
246
- "sessions.auto_expire_tooltip": "Move sessions inactive for {0} days to trash",
247
- "sessions.auto_expire_success": "Moved {0} expired sessions to trash",
248
- "sessions.auto_expire_none": "No expired sessions found",
249
- "sessions.auto_expire_error": "Auto-expire failed: {0}",
250
245
  "sessions.expand_all": "Expand All",
251
246
  "sessions.collapse_all": "Collapse All",
252
247
  "sessions.more_count": "{0} more",
@@ -236,11 +236,6 @@ const ja: Record<string, string> = {
236
236
  "sessions.delete_error": "削除中にエラーが発生しました",
237
237
  "sessions.load_failed": "セッションの読み込みに失敗しました",
238
238
  "sessions.refresh": "更新",
239
- "sessions.auto_expire": "自動期限切れ",
240
- "sessions.auto_expire_tooltip": "{0}日間非活跃のセッションをゴミ箱に移動",
241
- "sessions.auto_expire_success": "{0}件の期限切れセッションをゴミ箱に移動しました",
242
- "sessions.auto_expire_none": "期限切れのセッションはありません",
243
- "sessions.auto_expire_error": "自動期限切れに失敗:{0}",
244
239
  "sessions.expand_all": "すべて展開",
245
240
  "sessions.collapse_all": "すべて折りたたむ",
246
241
  "sessions.more_count": "あと {0} 件",
@@ -236,11 +236,6 @@ const zhCN: Record<string, string> = {
236
236
  "sessions.delete_error": "删除时发生错误",
237
237
  "sessions.load_failed": "加载会话失败",
238
238
  "sessions.refresh": "刷新",
239
- "sessions.auto_expire": "自动清理",
240
- "sessions.auto_expire_tooltip": "将 {0} 天未活跃的会话移入回收站",
241
- "sessions.auto_expire_success": "已将 {0} 个过期会话移入回收站",
242
- "sessions.auto_expire_none": "没有找到过期会话",
243
- "sessions.auto_expire_error": "自动清理失败:{0}",
244
239
  "sessions.expand_all": "全部展开",
245
240
  "sessions.collapse_all": "全部折叠",
246
241
  "sessions.more_count": "还有 {0} 条",