pi-web-ui 0.2.15 → 0.4.0

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.
@@ -10,8 +10,8 @@
10
10
  * so reconnects just re-request a snapshot.
11
11
  */
12
12
  import { spawn } from "node:child_process";
13
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
14
- import { join } from "node:path";
13
+ import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync, } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
15
  import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, getAgentDir, SessionManager, } from "@earendil-works/pi-coding-agent";
16
16
  import { serializeMessage, serializeStreamingMessage, } from "./serialize.js";
17
17
  import { loadCommands, saveCommandsFile, TerminalManager, } from "./terminals.js";
@@ -20,6 +20,163 @@ const WIDGET_REFRESH_MS = 2000;
20
20
  const WIDGET_WIDTH = 80;
21
21
  /** Preview panel cap: only the first 512KB of a file is ever read/sent. */
22
22
  const MAX_PREVIEW_BYTES = 512 * 1024;
23
+ const PREVIEW_IMAGE_EXTS = new Set([
24
+ "png",
25
+ "jpg",
26
+ "jpeg",
27
+ "gif",
28
+ "webp",
29
+ "svg",
30
+ "bmp",
31
+ "ico",
32
+ "avif",
33
+ "jfif",
34
+ "tif",
35
+ "tiff",
36
+ ]);
37
+ const PREVIEW_VIDEO_EXTS = new Set([
38
+ "mp4",
39
+ "webm",
40
+ "mov",
41
+ "mkv",
42
+ "avi",
43
+ "m4v",
44
+ "ogv",
45
+ "mpg",
46
+ "mpeg",
47
+ "wmv",
48
+ "flv",
49
+ ]);
50
+ const PREVIEW_TEXT_EXTS = new Set([
51
+ // code
52
+ "ts",
53
+ "tsx",
54
+ "js",
55
+ "jsx",
56
+ "mjs",
57
+ "cjs",
58
+ "jsm",
59
+ "es6",
60
+ "vue",
61
+ "svelte",
62
+ "py",
63
+ "pyw",
64
+ "ipynb",
65
+ "go",
66
+ "rs",
67
+ "c",
68
+ "h",
69
+ "cpp",
70
+ "hpp",
71
+ "cc",
72
+ "cxx",
73
+ "hh",
74
+ "csh",
75
+ "java",
76
+ "kt",
77
+ "kts",
78
+ "scala",
79
+ "sc",
80
+ "cs",
81
+ "fs",
82
+ "fsx",
83
+ "fsi",
84
+ "sh",
85
+ "bash",
86
+ "zsh",
87
+ "fish",
88
+ "bat",
89
+ "cmd",
90
+ "ps1",
91
+ "psd1",
92
+ "psm1",
93
+ "rb",
94
+ "php",
95
+ "pl",
96
+ "pm",
97
+ "tcl",
98
+ "lua",
99
+ "r",
100
+ "rmd",
101
+ "sql",
102
+ "swift",
103
+ "dart",
104
+ "groovy",
105
+ "gradle",
106
+ "tf",
107
+ "tfvars",
108
+ "hcl",
109
+ "nim",
110
+ "zig",
111
+ "v",
112
+ "vala",
113
+ "d",
114
+ "clj",
115
+ "cljs",
116
+ "cljc",
117
+ "edn",
118
+ "ex",
119
+ "exs",
120
+ "erl",
121
+ "hrl",
122
+ "ml",
123
+ "mli",
124
+ // markup / config / data
125
+ "json",
126
+ "jsonc",
127
+ "json5",
128
+ "md",
129
+ "mdx",
130
+ "markdown",
131
+ "html",
132
+ "htm",
133
+ "xhtml",
134
+ "css",
135
+ "scss",
136
+ "sass",
137
+ "less",
138
+ "styl",
139
+ "xml",
140
+ "dtd",
141
+ "yaml",
142
+ "yml",
143
+ "toml",
144
+ "ini",
145
+ "cfg",
146
+ "conf",
147
+ "properties",
148
+ "env",
149
+ "log",
150
+ "txt",
151
+ "text",
152
+ "csv",
153
+ "tsv",
154
+ "lock",
155
+ "sqlite",
156
+ "graphql",
157
+ "gql",
158
+ "proto",
159
+ "prisma",
160
+ "asm",
161
+ "s",
162
+ ]);
163
+ /**
164
+ * Classify a file name into its preview category. Files with no extension
165
+ * (README, Makefile, .gitignore, …) are treated as text. Everything not in an
166
+ * allowlist (exe, jar, dll, zip, …) is "none" — never previewed.
167
+ */
168
+ export function previewKind(name) {
169
+ const dot = name.lastIndexOf(".");
170
+ // A leading dot with nothing after it (.gitignore, .env) counts as no ext.
171
+ const ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
172
+ if (PREVIEW_IMAGE_EXTS.has(ext))
173
+ return "image";
174
+ if (PREVIEW_VIDEO_EXTS.has(ext))
175
+ return "video";
176
+ if (ext === "" || PREVIEW_TEXT_EXTS.has(ext))
177
+ return "text";
178
+ return "none";
179
+ }
23
180
  // ---------------------------------------------------------------------------
24
181
  // Web UI context adapter — bridges extension UI calls (setWidget/notify) to the
25
182
  // browser. Extensions like rpiv-todo render a TUI widget via
@@ -271,15 +428,65 @@ function extractPartialText(partial) {
271
428
  }
272
429
  return null;
273
430
  }
431
+ /**
432
+ * Persists which workspace each browser client last used + which workspaces it
433
+ * has opened, so a server restart / page reload restores the same project and
434
+ * the UI can offer a one-click recent-project list. File I/O is best-effort:
435
+ * persistence problems must never crash the server or block a session.
436
+ */
437
+ class ClientStateStore {
438
+ filePath;
439
+ cache = null;
440
+ constructor(filePath) {
441
+ this.filePath = filePath;
442
+ }
443
+ load() {
444
+ if (this.cache)
445
+ return this.cache;
446
+ try {
447
+ const parsed = JSON.parse(readFileSync(this.filePath, "utf8"));
448
+ this.cache = parsed && typeof parsed === "object" ? parsed : {};
449
+ }
450
+ catch {
451
+ this.cache = {};
452
+ }
453
+ return this.cache;
454
+ }
455
+ save() {
456
+ try {
457
+ mkdirSync(dirname(this.filePath), { recursive: true });
458
+ writeFileSync(this.filePath, JSON.stringify(this.cache, null, 2) + "\n");
459
+ }
460
+ catch {
461
+ // best effort
462
+ }
463
+ }
464
+ get(clientId) {
465
+ return this.load()[clientId] ?? { projects: [] };
466
+ }
467
+ /** Remember which workspace a client last used; bumps its project entry. */
468
+ remember(clientId, cwd) {
469
+ const all = this.load();
470
+ const state = (all[clientId] ??= { projects: [] });
471
+ state.lastCwd = cwd;
472
+ const now = Date.now();
473
+ state.projects = [
474
+ { path: cwd, lastUsed: now },
475
+ ...state.projects.filter((p) => p.path !== cwd),
476
+ ].slice(0, 30);
477
+ this.save();
478
+ }
479
+ }
274
480
  export class ClientSession {
275
481
  clientId;
276
482
  cwd;
277
- /** Immutable workspace root the commands file (.pi/commands.json) is anchored to. */
278
- workspaceRoot;
483
+ /** Absolute per-client session directory.
279
484
  /** Absolute per-client session directory. */
280
485
  sessionDir;
281
486
  /** pi config dir (auth/models/skills). */
282
487
  agentDir;
488
+ /** Persisted per-client UI state (last workspace + recent projects). */
489
+ stateStore;
283
490
  runtime;
284
491
  session;
285
492
  /** PTY terminals for this client (killed when the last socket detaches). */
@@ -313,16 +520,16 @@ export class ClientSession {
313
520
  disposed = false;
314
521
  /** pi-config readiness check, cached briefly so 60ms snapshots don't hit disk. */
315
522
  piCheckCache = null;
316
- constructor(clientId, cwd, sessionDir, agentDir, runtime) {
523
+ constructor(clientId, cwd, sessionDir, agentDir, runtime, stateStore) {
317
524
  this.clientId = clientId;
318
525
  this.cwd = cwd;
319
- this.workspaceRoot = cwd;
320
526
  this.sessionDir = sessionDir;
321
527
  this.agentDir = agentDir;
322
528
  this.runtime = runtime;
323
529
  this.session = runtime.session;
530
+ this.stateStore = stateStore;
324
531
  }
325
- static async create(clientId, cwd, sessionDir) {
532
+ static async create(clientId, cwd, sessionDir, stateStore) {
326
533
  const agentDir = process.env.PI_CODING_AGENT_DIR ?? getAgentDir();
327
534
  const runtime = await createAgentSessionRuntime(ClientSession.runtimeFactory, {
328
535
  cwd,
@@ -331,7 +538,7 @@ export class ClientSession {
331
538
  // or start a fresh one on first visit.
332
539
  sessionManager: SessionManager.continueRecent(cwd, sessionDir),
333
540
  });
334
- const cs = new ClientSession(clientId, cwd, sessionDir, agentDir, runtime);
541
+ const cs = new ClientSession(clientId, cwd, sessionDir, agentDir, runtime, stateStore);
335
542
  for (const d of runtime.diagnostics) {
336
543
  if (d.type !== "info") {
337
544
  cs.pendingNotices.push({
@@ -1322,6 +1529,118 @@ export class ClientSession {
1322
1529
  }
1323
1530
  this.flushSnapshot();
1324
1531
  }
1532
+ /**
1533
+ * Map a rendered user-message id (`u-<timestamp>-<seq>`, assigned in
1534
+ * serialize.ts) back to its append-only session entry id. The seq handles
1535
+ * two user messages sharing the same millisecond timestamp.
1536
+ */
1537
+ resolveUserMessageEntryId(messageId) {
1538
+ const m = /^u-(\d+)(?:-(\d+))?$/.exec(messageId);
1539
+ if (!m)
1540
+ return null;
1541
+ const ts = Number(m[1]);
1542
+ const seq = m[2] ? Number(m[2]) : 1;
1543
+ let count = 0;
1544
+ for (const entry of this.session.sessionManager.getEntries()) {
1545
+ if (entry.type !== "message")
1546
+ continue;
1547
+ const msg = entry.message;
1548
+ if (!msg || msg.role !== "user" || msg.timestamp !== ts)
1549
+ continue;
1550
+ count += 1;
1551
+ if (count === seq)
1552
+ return entry.id;
1553
+ }
1554
+ return null;
1555
+ }
1556
+ /**
1557
+ * Edit a past user question and re-ask it: forks a NEW session file that
1558
+ * keeps everything up to (but not including) that question, then sends the
1559
+ * edited text there. The original thread is untouched and stays in the
1560
+ * session list, so nothing is ever lost.
1561
+ */
1562
+ async editMessage(messageId, text) {
1563
+ const trimmed = text.trim();
1564
+ if (!trimmed) {
1565
+ this.emit({
1566
+ type: "notice",
1567
+ level: "warning",
1568
+ text: "编辑内容为空,已取消",
1569
+ });
1570
+ this.flushSnapshot();
1571
+ return;
1572
+ }
1573
+ const entryId = this.resolveUserMessageEntryId(messageId);
1574
+ if (!entryId) {
1575
+ this.emit({
1576
+ type: "notice",
1577
+ level: "error",
1578
+ text: "找不到要编辑的消息(可能已被压缩或不在当前分支)",
1579
+ });
1580
+ this.flushSnapshot();
1581
+ return;
1582
+ }
1583
+ try {
1584
+ const result = await this.runtime.fork(entryId);
1585
+ if (result.cancelled) {
1586
+ this.emit({
1587
+ type: "notice",
1588
+ level: "info",
1589
+ text: "已取消编辑重问",
1590
+ });
1591
+ this.flushSnapshot();
1592
+ return;
1593
+ }
1594
+ await this.bindSession();
1595
+ await this.prompt(trimmed);
1596
+ this.emit({
1597
+ type: "notice",
1598
+ level: "info",
1599
+ text: "已从该问题重新提问(原对话保留在会话列表中)",
1600
+ });
1601
+ }
1602
+ catch (err) {
1603
+ this.emit({
1604
+ type: "notice",
1605
+ level: "error",
1606
+ text: `编辑重问失败:${err.message}`,
1607
+ });
1608
+ }
1609
+ this.flushSnapshot();
1610
+ }
1611
+ /**
1612
+ * Push the recent-project list (persisted per client, merged with every cwd
1613
+ * that has persisted sessions in this client's session store — so workspaces
1614
+ * opened before the recent-list feature existed still show up).
1615
+ */
1616
+ async pushProjects() {
1617
+ try {
1618
+ const saved = this.stateStore.get(this.clientId);
1619
+ const map = new Map();
1620
+ for (const p of saved.projects)
1621
+ map.set(p.path, p.lastUsed);
1622
+ const all = await SessionManager.listAll(this.sessionDir);
1623
+ for (const s of all) {
1624
+ if (s.cwd) {
1625
+ const t = s.modified.getTime();
1626
+ const prev = map.get(s.cwd);
1627
+ if (prev === undefined || t > prev)
1628
+ map.set(s.cwd, t);
1629
+ }
1630
+ }
1631
+ // Only keep directories that still exist — a deleted/unmounted workspace
1632
+ // is useless in the picker.
1633
+ const projects = [...map.entries()]
1634
+ .filter(([path]) => existsSync(path))
1635
+ .map(([path, lastUsed]) => ({ path, lastUsed }))
1636
+ .sort((a, b) => b.lastUsed - a.lastUsed)
1637
+ .slice(0, 20);
1638
+ this.emit({ type: "projects", projects });
1639
+ }
1640
+ catch {
1641
+ this.emit({ type: "projects", projects: [] });
1642
+ }
1643
+ }
1325
1644
  /** List a workspace directory (relative to the configured cwd). */
1326
1645
  async listFiles(relPath) {
1327
1646
  try {
@@ -1341,11 +1660,16 @@ export class ClientSession {
1341
1660
  const dirents = await fs.readdir(target, { withFileTypes: true });
1342
1661
  const entries = dirents
1343
1662
  .filter((d) => !IGNORED_ENTRIES.has(d.name))
1344
- .map((d) => ({
1345
- name: d.name,
1346
- path: rel === "" ? d.name : `${rel}/${d.name}`,
1347
- type: (d.isDirectory() ? "dir" : "file"),
1348
- }))
1663
+ .map((d) => {
1664
+ const entry = {
1665
+ name: d.name,
1666
+ path: rel === "" ? d.name : `${rel}/${d.name}`,
1667
+ type: (d.isDirectory() ? "dir" : "file"),
1668
+ };
1669
+ if (!d.isDirectory())
1670
+ entry.kind = previewKind(d.name);
1671
+ return entry;
1672
+ })
1349
1673
  .sort((a, b) => a.type === b.type
1350
1674
  ? a.name.localeCompare(b.name)
1351
1675
  : a.type === "dir"
@@ -1396,14 +1720,46 @@ export class ClientSession {
1396
1720
  });
1397
1721
  return;
1398
1722
  }
1399
- // Read only the first MAX_PREVIEW_BYTES so huge files can't exhaust
1400
- // memory or flood the socket.
1723
+ const name = relPath.split("/").pop() ?? relPath;
1724
+ const kind = previewKind(name);
1725
+ // Not previewable (exe, jar, archives, …) — never read or sent.
1726
+ if (kind === "none") {
1727
+ this.emit({
1728
+ type: "file_content",
1729
+ path: rel,
1730
+ name,
1731
+ text: "",
1732
+ truncated: false,
1733
+ binary: true,
1734
+ kind: "none",
1735
+ lines: 0,
1736
+ size: stat.size,
1737
+ });
1738
+ return;
1739
+ }
1740
+ // Media previews stream over the /api/file HTTP endpoint, so only
1741
+ // metadata is sent here — the raw bytes never touch the socket.
1742
+ if (kind === "image" || kind === "video") {
1743
+ this.emit({
1744
+ type: "file_content",
1745
+ path: rel,
1746
+ name,
1747
+ text: "",
1748
+ truncated: false,
1749
+ binary: true,
1750
+ kind,
1751
+ lines: 0,
1752
+ size: stat.size,
1753
+ });
1754
+ return;
1755
+ }
1756
+ // Text: read only the first MAX_PREVIEW_BYTES so huge files can't
1757
+ // exhaust memory or flood the socket.
1401
1758
  const handle = await fs.open(abs, "r");
1402
1759
  try {
1403
1760
  const buf = Buffer.alloc(Math.min(stat.size, MAX_PREVIEW_BYTES));
1404
1761
  const { bytesRead } = await handle.read(buf, 0, buf.length, 0);
1405
1762
  const data = buf.subarray(0, bytesRead);
1406
- const name = relPath.split("/").pop() ?? relPath;
1407
1763
  if (data.includes(0)) {
1408
1764
  this.emit({
1409
1765
  type: "file_content",
@@ -1412,6 +1768,7 @@ export class ClientSession {
1412
1768
  text: "",
1413
1769
  truncated: false,
1414
1770
  binary: true,
1771
+ kind: "text",
1415
1772
  lines: 0,
1416
1773
  size: stat.size,
1417
1774
  });
@@ -1424,6 +1781,7 @@ export class ClientSession {
1424
1781
  text: data.toString("utf8"),
1425
1782
  truncated: bytesRead < stat.size,
1426
1783
  binary: false,
1784
+ kind: "text",
1427
1785
  lines: countLines(data),
1428
1786
  size: stat.size,
1429
1787
  });
@@ -1547,6 +1905,9 @@ export class ClientSession {
1547
1905
  const oldRuntime = this.runtime;
1548
1906
  this.runtime = newRuntime;
1549
1907
  this.cwd = abs;
1908
+ // Remember the new workspace (restore target + recent-project entry).
1909
+ this.stateStore.remember(this.clientId, abs);
1910
+ void this.pushProjects();
1550
1911
  this.unsubscribe?.();
1551
1912
  this.unsubscribe = undefined;
1552
1913
  await this.bindSession();
@@ -1563,6 +1924,8 @@ export class ClientSession {
1563
1924
  });
1564
1925
  void this.refreshSessions();
1565
1926
  void this.listFiles(undefined);
1927
+ // Commands are per-project (.pi/commands.json in the current cwd).
1928
+ void this.listCommands();
1566
1929
  }
1567
1930
  catch (err) {
1568
1931
  this.emit({
@@ -1647,7 +2010,7 @@ export class ClientSession {
1647
2010
  }
1648
2011
  /** Push the user command list (.pi/commands.json) to the client. */
1649
2012
  async listCommands() {
1650
- const { commands, path, warning } = await loadCommands(this.workspaceRoot);
2013
+ const { commands, path, warning } = await loadCommands(this.cwd);
1651
2014
  if (warning) {
1652
2015
  this.emit({ type: "notice", level: "warning", text: warning });
1653
2016
  }
@@ -1655,7 +2018,7 @@ export class ClientSession {
1655
2018
  }
1656
2019
  /** Persist the user command list (.pi/commands.json). */
1657
2020
  async saveCommands(commands) {
1658
- const { path, error } = await saveCommandsFile(this.workspaceRoot, commands);
2021
+ const { path, error } = await saveCommandsFile(this.cwd, commands);
1659
2022
  if (error) {
1660
2023
  this.emit({ type: "notice", level: "error", text: error });
1661
2024
  return;
@@ -1694,9 +2057,11 @@ export class AgentService {
1694
2057
  sessionDirRoot;
1695
2058
  clients = new Map();
1696
2059
  pending = new Map();
1697
- constructor(cwd, sessionDirRoot) {
2060
+ stateStore;
2061
+ constructor(cwd, sessionDirRoot, stateFile) {
1698
2062
  this.cwd = cwd;
1699
2063
  this.sessionDirRoot = sessionDirRoot;
2064
+ this.stateStore = new ClientStateStore(stateFile);
1700
2065
  }
1701
2066
  /** Get or create the session for a client, racing attach calls safely. */
1702
2067
  async attach(clientId, send) {
@@ -1707,12 +2072,34 @@ export class AgentService {
1707
2072
  cs = await inflight;
1708
2073
  }
1709
2074
  else {
1710
- const creating = ClientSession.create(clientId, this.cwd, join(this.sessionDirRoot, sanitizeId(clientId))).finally(() => {
2075
+ // Restore this client's last-used workspace when it still exists;
2076
+ // otherwise fall back to the server's configured default cwd.
2077
+ let cwd = this.cwd;
2078
+ const saved = this.stateStore.get(clientId);
2079
+ if (saved.lastCwd && saved.lastCwd !== this.cwd) {
2080
+ try {
2081
+ if (statSync(saved.lastCwd).isDirectory())
2082
+ cwd = saved.lastCwd;
2083
+ }
2084
+ catch {
2085
+ // gone (unmounted drive / deleted) — fall back to the default
2086
+ }
2087
+ }
2088
+ const creating = ClientSession.create(clientId, cwd, join(this.sessionDirRoot, sanitizeId(clientId)), this.stateStore).finally(() => {
1711
2089
  this.pending.delete(clientId);
1712
2090
  });
1713
2091
  this.pending.set(clientId, creating);
1714
2092
  cs = await creating;
1715
2093
  this.clients.set(clientId, cs);
2094
+ // Make sure the restored/default workspace appears in the project list.
2095
+ this.stateStore.remember(clientId, cwd);
2096
+ if (cwd !== this.cwd) {
2097
+ send({
2098
+ type: "notice",
2099
+ level: "info",
2100
+ text: `已恢复上次的工作目录:${cwd}`,
2101
+ });
2102
+ }
1716
2103
  }
1717
2104
  }
1718
2105
  cs.attachSink(send);
@@ -13,14 +13,15 @@
13
13
  * PI_CODING_AGENT_DIR pi config dir (auth/models/skills) — passed to the SDK
14
14
  */
15
15
  import { existsSync } from "node:fs";
16
+ import { stat } from "node:fs/promises";
16
17
  import { createServer } from "node:http";
17
- import { dirname, join, resolve } from "node:path";
18
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
18
19
  import { fileURLToPath } from "node:url";
19
20
  import { randomUUID } from "node:crypto";
20
21
  import express from "express";
21
22
  import { WebSocket, WebSocketServer } from "ws";
22
23
  import { VERSION } from "@earendil-works/pi-coding-agent";
23
- import { AgentService } from "./agent-service.js";
24
+ import { AgentService, previewKind } from "./agent-service.js";
24
25
  const PORT = Number(process.env.PORT ?? 8787);
25
26
  const CWD = resolve(process.env.PI_WEB_CWD ?? process.cwd());
26
27
  const DATA_DIR = resolve(process.env.PI_WEB_DATA_DIR ?? join(CWD, ".pi-web"));
@@ -30,6 +31,38 @@ app.use(express.json({ limit: "10mb" }));
30
31
  app.get("/api/health", (_req, res) => {
31
32
  res.json({ ok: true, piVersion: VERSION, cwd: CWD, pid: process.pid });
32
33
  });
34
+ /**
35
+ * Stream a workspace file for the media preview (image/video). Path is
36
+ * validated against the workspace root and only image/video kinds are served —
37
+ * text goes over the WebSocket, and exe/jar/etc. are never exposed here.
38
+ * express's sendFile handles Range requests, so video seeking works.
39
+ */
40
+ app.get("/api/file", async (req, res) => {
41
+ try {
42
+ const raw = typeof req.query.path === "string" ? req.query.path : "";
43
+ const abs = resolve(CWD, raw);
44
+ const rel = relative(CWD, abs);
45
+ if (rel.startsWith("..") || rel.includes(`${sep}..`)) {
46
+ res.status(400).end("path outside workspace");
47
+ return;
48
+ }
49
+ const name = basename(abs);
50
+ const kind = previewKind(name);
51
+ if (kind !== "image" && kind !== "video") {
52
+ res.status(400).end("not a previewable media file");
53
+ return;
54
+ }
55
+ const st = await stat(abs);
56
+ if (!st.isFile()) {
57
+ res.status(400).end("not a file");
58
+ return;
59
+ }
60
+ res.sendFile(abs);
61
+ }
62
+ catch {
63
+ res.status(404).end("not found");
64
+ }
65
+ });
33
66
  // Production: serve the built frontend from web/dist. Resolve relative to this
34
67
  // module so it works when installed as a package (global/npx/Docker), not just
35
68
  // from the repo root. In dev, Vite serves the UI on :5173 and proxies /ws.
@@ -54,7 +87,9 @@ const heartbeatTimer = setInterval(() => {
54
87
  }
55
88
  }
56
89
  }, 10_000);
57
- const service = new AgentService(CWD, SESSION_DIR_ROOT);
90
+ const service = new AgentService(CWD, SESSION_DIR_ROOT,
91
+ // Per-client persisted UI state: last-used workspace + recent projects.
92
+ join(DATA_DIR, "client-state.json"));
58
93
  wss.on("connection", (ws) => {
59
94
  let clientId = null;
60
95
  let closed = false;
@@ -86,6 +121,9 @@ wss.on("connection", (ws) => {
86
121
  case "new_chat":
87
122
  void cs.newChat();
88
123
  break;
124
+ case "edit_message":
125
+ void cs.editMessage(msg.messageId, msg.text);
126
+ break;
89
127
  case "cycle_model":
90
128
  void cs.cycleModel();
91
129
  break;
@@ -98,6 +136,9 @@ wss.on("connection", (ws) => {
98
136
  case "list_sessions":
99
137
  void cs.refreshSessions();
100
138
  break;
139
+ case "list_projects":
140
+ void cs.pushProjects();
141
+ break;
101
142
  case "switch_session":
102
143
  void cs.switchSession(msg.path);
103
144
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.2.15",
3
+ "version": "0.4.0",
4
4
  "description": "Web chat interface for the pi coding agent, powered by the pi SDK (@earendil-works/pi-coding-agent) — one-command run, Docker/systemd/launchd deployable",
5
5
  "license": "MIT",
6
6
  "type": "module",