viberoom 0.4.0 → 0.4.1

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/README.md CHANGED
@@ -130,6 +130,10 @@ it in a browser tab. Later, `npm install -g viberoom@latest` and the next start
130
130
  - **Nothing leaves your machine** except what each agent sends to its own provider. viberoom never
131
131
  sees your keys; every agent keeps its own login.
132
132
  - **Edit a message.** Fix what you said; the vibemates get the memo, or the conversation rewinds.
133
+ - **Your messages on a timeline.** A thin strip on the chat's right edge, one mark per message of yours:
134
+ hover for the message with its neighbours, click to jump there.
135
+ - **Pick a folder from a tree.** Browse the machine's folders when a room needs one; make a new one on the spot.
136
+ - **Settings save themselves.** Change a setting and it is saved: on Enter, on leaving the field, on a pick.
133
137
  - **Search the room.** Everything anyone said, one search box.
134
138
  - **For geeks.** Every panel folds its technical settings behind a toggle. You never have to open it.
135
139
 
@@ -0,0 +1,65 @@
1
+ // viberoom - Copyright (c) 2026 Todor Rusev - AGPL-3.0-or-later; see LICENSE
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir, readdir } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
6
+ export function listRoots(platform = process.platform, exists = existsSync) {
7
+ if (platform !== "win32")
8
+ return [{ name: "/", path: "/", hidden: false }];
9
+ const roots = [];
10
+ for (let code = "C".charCodeAt(0); code <= "Z".charCodeAt(0); code++) {
11
+ const drive = `${String.fromCharCode(code)}:\\`;
12
+ if (exists(drive))
13
+ roots.push({ name: drive.slice(0, 2), path: drive, hidden: false });
14
+ }
15
+ return roots;
16
+ }
17
+ export function homeFolder() {
18
+ return homedir();
19
+ }
20
+ export function normalizeFolder(input) {
21
+ let trimmed = input.trim().replace(/^["']|["']$/g, "");
22
+ if (/^[A-Za-z]:$/.test(trimmed))
23
+ trimmed += "\\";
24
+ if (!trimmed || !isAbsolute(trimmed))
25
+ throw new Error("an absolute folder path is needed");
26
+ const full = resolve(trimmed);
27
+ return /^[A-Za-z]:\\?$/.test(full) ? `${full.slice(0, 2)}\\` : full.replace(new RegExp(`\\${sep}+$`), "") || sep;
28
+ }
29
+ function isRoot(path) {
30
+ return path === sep || /^[A-Za-z]:\\?$/.test(path);
31
+ }
32
+ export async function listFolders(input) {
33
+ const path = normalizeFolder(input);
34
+ const entries = await readdir(path, { withFileTypes: true });
35
+ const dirs = [];
36
+ for (const entry of entries) {
37
+ let isDir = entry.isDirectory();
38
+ if (!isDir && entry.isSymbolicLink()) {
39
+ try {
40
+ isDir = (await readdir(join(path, entry.name), { withFileTypes: true })) !== undefined;
41
+ }
42
+ catch {
43
+ isDir = false;
44
+ }
45
+ }
46
+ if (!isDir)
47
+ continue;
48
+ dirs.push({ name: entry.name, path: join(path, entry.name), hidden: entry.name.startsWith(".") || entry.name.startsWith("$") });
49
+ }
50
+ dirs.sort((a, b) => (a.hidden === b.hidden ? a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) : a.hidden ? 1 : -1));
51
+ return { path, parent: isRoot(path) ? null : dirname(path), dirs };
52
+ }
53
+ export function validFolderName(name) {
54
+ const n = name.trim();
55
+ return n.length > 0 && n.length <= 120 && n !== "." && n !== ".." && !/[\\/:*?"<>|\u0000-\u001f]/.test(n) && !/[. ]$/.test(n);
56
+ }
57
+ export async function createFolder(parent, name) {
58
+ if (!validFolderName(name))
59
+ throw new Error("a folder name cannot contain \\ / : * ? \" < > | and cannot end with a dot or a space");
60
+ const path = join(normalizeFolder(parent), name.trim());
61
+ if (existsSync(path))
62
+ throw new Error(`"${basename(path)}" already exists here`);
63
+ await mkdir(path);
64
+ return path;
65
+ }
package/dist/server.js CHANGED
@@ -8,6 +8,7 @@ import { dirname, join } from "node:path";
8
8
  import { existsSync as fileExists } from "node:fs";
9
9
  import { classifyOpenTarget, describeOpen, detectEditor, editorCommand, isExecutablePath, openCommand } from "./open.js";
10
10
  import { parseCsv, viewerKind, VIEWER_MAX_BYTES } from "./viewer.js";
11
+ import { createFolder, homeFolder, listFolders, listRoots } from "./fsbrowse.js";
11
12
  let editorFound;
12
13
  function currentEditor() {
13
14
  if (editorFound === undefined)
@@ -106,6 +107,26 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
106
107
  req.on("close", () => clients.delete(res));
107
108
  return;
108
109
  }
110
+ if (req.method === "GET" && path === "/api/fs/dirs") {
111
+ const at = url.searchParams.get("path");
112
+ if (!at) {
113
+ sendJson(res, 200, { ok: true, roots: listRoots(), home: homeFolder() });
114
+ return;
115
+ }
116
+ try {
117
+ sendJson(res, 200, { ok: true, ...(await listFolders(at)) });
118
+ }
119
+ catch (error) {
120
+ const code = error.code;
121
+ if (code === "ENOENT" || code === "ENOTDIR")
122
+ sendJson(res, 404, { error: `no such folder: ${at}` });
123
+ else if (code === "EACCES" || code === "EPERM")
124
+ sendJson(res, 403, { error: `no access to ${at}` });
125
+ else
126
+ throw error;
127
+ }
128
+ return;
129
+ }
109
130
  if (req.method === "GET" && path === "/api/file") {
110
131
  const target = classifyOpenTarget(url.searchParams.get("path") ?? "");
111
132
  if (!target || target.kind !== "path")
@@ -202,6 +223,10 @@ export function startServer(hub, port, log, info, onShutdownRequest) {
202
223
  sendJson(res, 200, { ok: true, settings: hub.updateSettings(body) });
203
224
  return;
204
225
  }
226
+ if (path === "/api/fs/mkdir") {
227
+ sendJson(res, 200, { ok: true, path: await createFolder(String(body.parent ?? ""), String(body.name ?? "")) });
228
+ return;
229
+ }
205
230
  if (path === "/api/window") {
206
231
  hub.saveWindowPlacement(body);
207
232
  sendJson(res, 200, { ok: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viberoom",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "viberoom: group chat rooms for a human and several coding agents over the Agent Client Protocol",
5
5
  "type": "module",
6
6
  "engines": {
package/ui/app.css CHANGED
@@ -177,7 +177,7 @@
177
177
  .chat-sub .dir-chip { cursor: help; }
178
178
  .chat-actions { display: flex; align-items: center; gap: 8px; }
179
179
  .chat-actions .search { margin: 0; width: 250px; }
180
- .messages { flex: 1; overflow-y: auto; padding: 12px 22px; display: flex; flex-direction: column; gap: 14px; background: var(--canvas-pattern) content-box, var(--grad-canvas); background-size: var(--canvas-pattern-size), auto; }
180
+ .messages { position: relative; flex: 1; overflow-y: auto; padding: 12px 22px; display: flex; flex-direction: column; gap: 14px; background: var(--canvas-pattern) content-box, var(--grad-canvas); background-size: var(--canvas-pattern-size), auto; }
181
181
  .day { align-self: center; color: var(--faint); font-size: 11px; font-weight: 800; padding: 3px 12px; margin: 2px 0; }
182
182
  .msg { display: flex; gap: 12px; align-items: flex-start; max-width: 100%; animation: bubble-in var(--t-base) var(--ease-out); }
183
183
  .msg.agent { padding-right: 64px; }
@@ -189,6 +189,20 @@
189
189
  .bubble-col { display: flex; flex-direction: column; gap: 6px; min-width: 0; max-width: min(1040px, 100%); }
190
190
  .msg.mine .bubble-col { align-items: flex-end; }
191
191
  .shell.side-collapsed .bubble-col { max-width: min(1480px, 100%); }
192
+ .timeline { position: absolute; right: 6px; top: 78px; bottom: 96px; width: 18px; z-index: 4; }
193
+ .tl-ticks { position: absolute; inset: 0; }
194
+ .tl-view { position: absolute; left: 2px; right: 2px; border-radius: 6px; background: rgba(91, 91, 240, 0.07); pointer-events: none; transition: top 80ms linear, height 80ms linear; }
195
+ .tl-tick { position: absolute; left: 3px; width: 12px; height: 5px; border-radius: 3px; background: #cdcdf9; cursor: pointer; transition: background var(--t-fast), transform var(--t-fast); }
196
+ .tl-tick:hover, .tl-tick.active { background: var(--primary); transform: scaleX(1.25); }
197
+ .tl-tick.in-view { background: #a9a9f5; }
198
+ .tl-pop { position: absolute; right: 26px; width: 340px; background: var(--card); border-radius: 14px; box-shadow: var(--shadow-pop); padding: 6px; z-index: 30; animation: rise var(--t-base) var(--ease-out); }
199
+ .tl-row { padding: 6px 10px; border-radius: 9px; font-size: 12.5px; font-weight: 600; line-height: 1.4; color: var(--ink-2); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; cursor: pointer; }
200
+ .tl-row.faded { color: var(--muted); opacity: 0.8; }
201
+ .tl-row.far { opacity: 0.5; }
202
+ .tl-row.current { background: var(--lav); color: var(--primary); font-weight: 800; }
203
+ .tl-row:hover { background: var(--lav-2); }
204
+ .msg.flash .bubble { animation: tl-flash 1.4s var(--ease-out); }
205
+ @keyframes tl-flash { 0% { box-shadow: 0 0 0 0 rgba(91, 91, 240, 0.55); } 100% { box-shadow: 0 0 0 14px rgba(91, 91, 240, 0); } }
192
206
  .jump-latest { position: absolute; right: 30px; bottom: 96px; z-index: 5; display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 14px 0 10px; border: 0; border-radius: var(--r-pill); background: var(--card); color: var(--primary); font: inherit; font-size: 12px; font-weight: 800; box-shadow: var(--shadow-pop); cursor: pointer; animation: rise var(--t-base) var(--ease-out); }
193
207
  .jump-latest:hover { background: var(--soft); }
194
208
  .jump-latest svg { width: 16px; height: 16px; }
@@ -223,6 +237,37 @@
223
237
  .msg.mine .bubble .text th { background: rgba(255, 255, 255, 0.18); }
224
238
  .msg.mine .bubble .text th, .msg.mine .bubble .text td { border-color: rgba(255, 255, 255, 0.35); }
225
239
  .msg.mine .bubble .text blockquote { border-color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.85); }
240
+ .dir-row { display: flex; gap: 8px; align-items: stretch; }
241
+ .dir-row input { flex: 1; min-width: 0; }
242
+ .dir-row .browse-btn { flex: 0 0 auto; white-space: nowrap; }
243
+ .folder-dialog { width: 760px; height: min(80vh, 720px); flex-direction: column; }
244
+ .folder-dialog[open] { display: flex; }
245
+ .folder-dialog .file-head { flex: 0 0 auto; }
246
+ .fp-bar { display: flex; gap: 8px; align-items: stretch; margin: 6px 0 8px; }
247
+ .fp-bar input { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12.5px; }
248
+ .fp-bar .btn { white-space: nowrap; }
249
+ .fp-recent { display: flex; flex-wrap: wrap; gap: 6px; min-height: 0; margin-bottom: 8px; }
250
+ .fp-recent:empty { display: none; }
251
+ .fp-recent .chip-btn { max-width: 260px; }
252
+ .fp-body { flex: 1; min-height: 0; overflow: auto; border: 2px solid var(--lav); border-radius: var(--r-sm); background: var(--soft); padding: 6px 4px; }
253
+ .tree, .tree ul { list-style: none; margin: 0; padding: 0; }
254
+ .tree ul { padding-left: 18px; }
255
+ .tree li { margin: 0; }
256
+ .tn { display: flex; align-items: center; gap: 4px; height: 30px; padding: 0 8px 0 2px; border-radius: 8px; cursor: pointer; font-size: 13.5px; font-weight: 700; color: var(--ink-2); white-space: nowrap; user-select: none; }
257
+ .tn:hover { background: var(--lav); }
258
+ .tn.selected { background: var(--lav-2); color: var(--primary); }
259
+ .tn.hidden-dir { color: var(--muted); font-weight: 600; }
260
+ .tn-tw { flex: 0 0 18px; width: 18px; height: 18px; border: 0; background: transparent; color: var(--muted); padding: 0; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; border-radius: 5px; transition: transform var(--t-fast); }
261
+ .tn-tw svg { width: 14px; height: 14px; }
262
+ .tn-tw:hover { background: var(--card); color: var(--primary); }
263
+ .tn.open .tn-tw { transform: rotate(90deg); }
264
+ .tn.leaf .tn-tw { visibility: hidden; }
265
+ .tn-ico { flex: 0 0 auto; font-size: 15px; line-height: 1; }
266
+ .tn-name { overflow: hidden; text-overflow: ellipsis; }
267
+ .tn-more { color: var(--faint); font-size: 12px; font-weight: 700; padding: 4px 26px; }
268
+ .tn-new { display: flex; gap: 6px; align-items: center; padding: 2px 0 4px 26px; }
269
+ .tn-new input { height: 28px; font-size: 13px; padding: 0 10px; }
270
+ .fp-selected { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; align-self: center; }
226
271
  .file-dialog { width: 900px; flex-direction: column; }
227
272
  .file-dialog[open] { display: flex; }
228
273
  .file-dialog .file-head { flex: 0 0 auto; }
package/ui/app.js CHANGED
@@ -578,16 +578,26 @@
578
578
  function geek(id, bodyHtml, hint) {
579
579
  return `<details class="geek" id="${id}"><summary>${ic("geek")}for geeks${hint ? `<span class="g-hint">${hint}</span>` : ""}<span class="chev">${ic("down")}</span></summary><div class="geek-body">${bodyHtml}</div></details>`;
580
580
  }
581
+ const recentlySaved = new Map();
581
582
  function bindSave(container, button, onSave) {
582
583
  if (!container || !button) return;
584
+ let dirty = false;
585
+ let saving = false;
586
+ let again = false;
583
587
  const arm = () => {
588
+ dirty = true;
584
589
  button.disabled = false;
585
590
  button.classList.remove("saved");
586
591
  button.textContent = "Save";
587
592
  };
588
- container.addEventListener("input", arm);
589
- container.addEventListener("change", arm);
590
- button.addEventListener("click", async () => {
593
+ const save = async () => {
594
+ if (!dirty) return;
595
+ if (saving) {
596
+ again = true;
597
+ return;
598
+ }
599
+ saving = true;
600
+ dirty = false;
591
601
  button.disabled = true;
592
602
  button.classList.add("loading");
593
603
  try {
@@ -595,11 +605,43 @@
595
605
  button.classList.remove("loading");
596
606
  button.classList.add("saved");
597
607
  button.innerHTML = `${ic("check")} Saved`;
608
+ if (button.id) recentlySaved.set(button.id, Date.now());
598
609
  } catch (e) {
610
+ dirty = true;
599
611
  button.classList.remove("loading");
600
612
  button.disabled = false;
601
613
  showError(e);
602
614
  }
615
+ saving = false;
616
+ if (again) {
617
+ again = false;
618
+ save();
619
+ }
620
+ };
621
+ if (button.id && Date.now() - (recentlySaved.get(button.id) || 0) < 5000) {
622
+ button.disabled = true;
623
+ button.classList.add("saved");
624
+ button.innerHTML = `${ic("check")} Saved`;
625
+ }
626
+ container.addEventListener("input", arm);
627
+ container.addEventListener("change", () => {
628
+ arm();
629
+ save();
630
+ });
631
+ container.addEventListener("keydown", (e) => {
632
+ if (e.key !== "Enter") return;
633
+ const t = e.target;
634
+ if (t.tagName === "INPUT" || (e.ctrlKey && (t.tagName === "TEXTAREA" || t.isContentEditable))) {
635
+ e.preventDefault();
636
+ t.blur();
637
+ }
638
+ });
639
+ container.addEventListener("focusout", (e) => {
640
+ if (e.target.isContentEditable) save();
641
+ });
642
+ button.addEventListener("click", () => {
643
+ dirty = true;
644
+ save();
603
645
  });
604
646
  }
605
647
  function roomHue(room) {
@@ -1261,6 +1303,7 @@
1261
1303
  for (const perm of room.permissions) renderPermission(room, perm);
1262
1304
  refreshSeen(room);
1263
1305
  scrollToBottom();
1306
+ renderTimeline();
1264
1307
  }
1265
1308
 
1266
1309
  function upsertMessage(roomId, m) {
@@ -1289,6 +1332,7 @@
1289
1332
  if (m.from === "human") refreshSeen(room);
1290
1333
  }
1291
1334
  if (stick) scrollToBottom();
1335
+ if (m.from === "human") renderTimeline();
1292
1336
  }
1293
1337
 
1294
1338
  function removeMessage(roomId, id) {
@@ -1575,7 +1619,7 @@
1575
1619
  $("#pp-avatar-picker").appendChild(
1576
1620
  window.Avatars.pickerElement(p.avatar || "", (emoji) => {
1577
1621
  $("#pp-avatar").value = emoji;
1578
- $("#pp-avatar").dispatchEvent(new Event("input", { bubbles: true }));
1622
+ $("#pp-avatar").dispatchEvent(new Event("change", { bubbles: true }));
1579
1623
  }),
1580
1624
  );
1581
1625
  renderSkillChecks($("#pp-skills"), p.skills || []);
@@ -1664,7 +1708,7 @@
1664
1708
  $("#me-avatar-picker").appendChild(
1665
1709
  window.Avatars.pickerElement(s.humanAvatar || "", (emoji) => {
1666
1710
  $("#me-avatar").value = emoji;
1667
- $("#me-avatar").dispatchEvent(new Event("input", { bubbles: true }));
1711
+ $("#me-avatar").dispatchEvent(new Event("change", { bubbles: true }));
1668
1712
  }),
1669
1713
  );
1670
1714
  bindSave($("#me-vibe"), $("#me-save"), () => post("/api/settings", { humanName: $("#me-name").value, humanAvatar: $("#me-avatar").value, humanDescription: $("#me-desc").value }));
@@ -1683,7 +1727,7 @@
1683
1727
  ${field("Name", `<input type="text" id="rp-name" maxlength="60" value="${esc(room.name)}">`)}
1684
1728
  ${field("Emoji", `<div id="rp-emoji-picker"></div><input type="text" id="rp-emoji" maxlength="8" value="${esc(rs.emoji || "")}" placeholder="custom emoji (optional)">`, "A face for the room, next to its name.")}
1685
1729
  ${field("Topic", `<input type="text" id="rp-topic" maxlength="2000" value="${esc(rs.topic || "")}" placeholder="what this room is about (optional)">`)}
1686
- ${field("Folder", `<input type="text" id="rp-dir" maxlength="1000" value="${esc(room.dir)}" spellcheck="false">`, "Where the vibemates read and write. Changing it restarts them in the new folder; they replay the last messages.")}
1730
+ ${field("Folder", `<span class="dir-row"><input type="text" id="rp-dir" maxlength="1000" value="${esc(room.dir)}" spellcheck="false"><button type="button" class="btn ghost browse-btn" id="rp-dir-browse" title="Choose a folder">${ic("folder")}Browse</button></span>`, "Where the vibemates read and write. Changing it restarts them in the new folder; they replay the last messages.")}
1687
1731
  <label class="field mention-host"><span class="label">Room rules${geekTip("References follow renames and note when a participant has left. Rules go into every vibemate's brief as instructions, not as routing.")}</span><div id="rp-rules" class="rules-editor" contenteditable="true" spellcheck="true" data-placeholder="e.g. Everyone listens to @Pesho, he is the manager. Keep answers under 3 sentences."></div><span class="hint">One rule per line; type @ to reference a participant.</span><div class="mention-menu inline" id="rp-rules-menu" hidden></div></label>
1688
1732
  ${field("Language", `<input type="text" id="rp-lang" value="${esc(lang)}" placeholder="follow the human (default), or e.g. English">`)}
1689
1733
  </div>
@@ -1739,6 +1783,10 @@
1739
1783
  $("#rp-emoji").dispatchEvent(new Event("input", { bubbles: true }));
1740
1784
  }),
1741
1785
  );
1786
+ $("#rp-dir-browse").addEventListener("click", () => openFolderPicker($("#rp-dir").value, (dir) => {
1787
+ $("#rp-dir").value = dir;
1788
+ $("#rp-dir").dispatchEvent(new Event("change", { bubbles: true }));
1789
+ }));
1742
1790
  bindSave($("#rp-form"), $("#rp-save"), async () => {
1743
1791
  const name = $("#rp-name").value;
1744
1792
  if (name.trim() !== room.name) await post(roomApi("/rename"), { name });
@@ -3062,6 +3110,7 @@
3062
3110
  els.sideToggle.addEventListener("click", () => setSideOpen(els.app.classList.contains("side-collapsed")));
3063
3111
  els.messages.addEventListener("scroll", () => {
3064
3112
  els.jumpLatest.hidden = nearBottom();
3113
+ updateTimelineView();
3065
3114
  });
3066
3115
  els.jumpLatest.addEventListener("click", () => els.messages.scrollTo({ top: els.messages.scrollHeight, behavior: "smooth" }));
3067
3116
  els.detailsHandle.addEventListener("click", closeDetails);
@@ -3178,6 +3227,264 @@
3178
3227
  });
3179
3228
  window.addEventListener("beforeunload", () => remember("view", state.view));
3180
3229
 
3230
+ const tl = { el: $("#timeline"), ticks: $("#timeline .tl-ticks"), view: $("#timeline .tl-view"), pop: $("#timeline .tl-pop"), items: [] };
3231
+ const TICK_H = 5;
3232
+ function renderTimeline() {
3233
+ const room = currentRoom();
3234
+ const nodes = room && state.view === "room" ? [...els.messages.querySelectorAll(".msg.mine:not(.hidden-by-search)")] : [];
3235
+ tl.items = nodes;
3236
+ tl.el.hidden = nodes.length === 0;
3237
+ tl.pop.hidden = true;
3238
+ if (!nodes.length) return;
3239
+ const total = els.messages.scrollHeight || 1;
3240
+ const h = Math.max(0, tl.ticks.clientHeight - TICK_H);
3241
+ tl.ticks.innerHTML = "";
3242
+ nodes.forEach((el, i) => {
3243
+ const t = document.createElement("div");
3244
+ t.className = "tl-tick";
3245
+ t.dataset.i = i;
3246
+ t.title = "";
3247
+ t.style.top = `${Math.round((el.offsetTop / total) * h)}px`;
3248
+ tl.ticks.appendChild(t);
3249
+ });
3250
+ updateTimelineView();
3251
+ }
3252
+ function updateTimelineView() {
3253
+ if (tl.el.hidden) return;
3254
+ const m = els.messages;
3255
+ const total = m.scrollHeight || 1;
3256
+ const h = tl.ticks.clientHeight;
3257
+ tl.view.style.top = `${(m.scrollTop / total) * h}px`;
3258
+ tl.view.style.height = `${Math.max(8, (m.clientHeight / total) * h)}px`;
3259
+ const top = m.scrollTop;
3260
+ const bottom = m.scrollTop + m.clientHeight;
3261
+ tl.items.forEach((el, i) => {
3262
+ const tick = tl.ticks.children[i];
3263
+ if (tick) tick.classList.toggle("in-view", el.offsetTop + el.offsetHeight > top && el.offsetTop < bottom);
3264
+ });
3265
+ }
3266
+ function timelineText(el) {
3267
+ const t = el.querySelector(".text");
3268
+ return (t ? t.innerText : el.innerText).trim().replace(/\s+/g, " ").slice(0, 240);
3269
+ }
3270
+ function showTimelinePop(i) {
3271
+ const rows = [[i - 2, "faded far"], [i - 1, "faded"], [i, "current"], [i + 1, "faded"], [i + 2, "faded far"]].filter(([k]) => tl.items[k]);
3272
+ tl.pop.innerHTML = rows.map(([k, c]) => `<div class="tl-row ${c}" data-i="${k}">${esc(timelineText(tl.items[k]))}</div>`).join("");
3273
+ tl.pop.hidden = false;
3274
+ tl.ticks.querySelectorAll(".tl-tick.active").forEach((t) => t.classList.remove("active"));
3275
+ const tick = tl.ticks.children[i];
3276
+ if (tick) tick.classList.add("active");
3277
+ const current = tl.pop.querySelector(".tl-row.current");
3278
+ let top = (tick ? tick.offsetTop : 0) - (current ? current.offsetTop + current.offsetHeight / 2 : 20) + TICK_H / 2;
3279
+ top = Math.max(0, Math.min(top, tl.el.clientHeight - tl.pop.offsetHeight));
3280
+ tl.pop.style.top = `${top}px`;
3281
+ }
3282
+ function hideTimelinePop() {
3283
+ tl.pop.hidden = true;
3284
+ tl.ticks.querySelectorAll(".tl-tick.active").forEach((t) => t.classList.remove("active"));
3285
+ }
3286
+ function jumpToMessage(el) {
3287
+ if (!el) return;
3288
+ el.scrollIntoView({ block: "center", behavior: "smooth" });
3289
+ el.classList.remove("flash");
3290
+ void el.offsetWidth;
3291
+ el.classList.add("flash");
3292
+ }
3293
+ tl.ticks.addEventListener("mouseover", (e) => {
3294
+ const tick = e.target.closest(".tl-tick");
3295
+ if (tick) showTimelinePop(Number(tick.dataset.i));
3296
+ });
3297
+ tl.el.addEventListener("mouseleave", hideTimelinePop);
3298
+ tl.ticks.addEventListener("click", (e) => {
3299
+ const tick = e.target.closest(".tl-tick");
3300
+ if (tick) jumpToMessage(tl.items[Number(tick.dataset.i)]);
3301
+ });
3302
+ tl.pop.addEventListener("click", (e) => {
3303
+ const row = e.target.closest(".tl-row");
3304
+ if (row) jumpToMessage(tl.items[Number(row.dataset.i)]);
3305
+ });
3306
+ new ResizeObserver(() => renderTimeline()).observe(els.messages);
3307
+
3308
+ const fp = { onChoose: null, selected: "", roots: [], home: "" };
3309
+ const fpEls = { dialog: $("#folder-dialog"), path: $("#fp-path"), tree: $("#fp-tree"), recent: $("#fp-recent"), error: $("#fp-error"), selected: $("#fp-selected"), choose: $("#fp-choose"), home: $("#fp-home"), newBtn: $("#fp-new") };
3310
+ const sepOf = (p) => (p.includes("\\") || /^[A-Za-z]:/.test(p) ? "\\" : "/");
3311
+ const sameFolder = (a, b) => a.replace(/[\\/]+$/, "").toLowerCase() === b.replace(/[\\/]+$/, "").toLowerCase();
3312
+ const isUnder = (child, parent) => {
3313
+ const c = child.replace(/[\\/]+$/, "").toLowerCase();
3314
+ const p = parent.replace(/[\\/]+$/, "").toLowerCase();
3315
+ return c === p || c.startsWith(p + sepOf(parent)) || (parent.endsWith(sepOf(parent)) && c.startsWith(p + sepOf(parent)));
3316
+ };
3317
+ function fpFail(error) {
3318
+ fpEls.error.textContent = error.message || String(error);
3319
+ fpEls.error.hidden = false;
3320
+ }
3321
+ function fpNode(entry) {
3322
+ const li = document.createElement("li");
3323
+ li.dataset.path = entry.path;
3324
+ li.innerHTML = `<div class="tn${entry.hidden ? " hidden-dir" : ""}"><button type="button" class="tn-tw" title="Expand">${ic("forward")}</button><span class="tn-ico">📁</span><span class="tn-name">${esc(entry.name)}</span></div><ul hidden></ul>`;
3325
+ return li;
3326
+ }
3327
+ function fpSelect(path, li) {
3328
+ fp.selected = path;
3329
+ fpEls.tree.querySelectorAll(".tn.selected").forEach((el) => el.classList.remove("selected"));
3330
+ if (li) li.querySelector(":scope > .tn").classList.add("selected");
3331
+ fpEls.path.value = path;
3332
+ fpEls.selected.textContent = path;
3333
+ fpEls.error.hidden = true;
3334
+ }
3335
+ async function fpLoad(li) {
3336
+ const ul = li.querySelector(":scope > ul");
3337
+ if (li.dataset.loaded) return ul;
3338
+ const data = await get(`/api/fs/dirs?path=${encodeURIComponent(li.dataset.path)}`);
3339
+ ul.innerHTML = "";
3340
+ for (const d of data.dirs) ul.appendChild(fpNode(d));
3341
+ if (!data.dirs.length) ul.innerHTML = `<li class="tn-more">no sub-folders</li>`;
3342
+ li.dataset.loaded = "1";
3343
+ li.querySelector(":scope > .tn").classList.toggle("leaf", !data.dirs.length);
3344
+ return ul;
3345
+ }
3346
+ async function fpExpand(li, open) {
3347
+ const tn = li.querySelector(":scope > .tn");
3348
+ const ul = li.querySelector(":scope > ul");
3349
+ const want = open === undefined ? ul.hidden : open;
3350
+ if (!want) {
3351
+ ul.hidden = true;
3352
+ tn.classList.remove("open");
3353
+ return;
3354
+ }
3355
+ tn.classList.add("open");
3356
+ try {
3357
+ await fpLoad(li);
3358
+ ul.hidden = false;
3359
+ } catch (e) {
3360
+ tn.classList.remove("open");
3361
+ fpFail(e);
3362
+ }
3363
+ }
3364
+ async function fpReveal(path) {
3365
+ const target = path.replace(/[\\/]+$/, "") || path;
3366
+ let level = fpEls.tree;
3367
+ let found = null;
3368
+ for (let guard = 0; guard < 64; guard++) {
3369
+ const li = [...level.children].find((el) => el.dataset && el.dataset.path && isUnder(target, el.dataset.path));
3370
+ if (!li) break;
3371
+ found = li;
3372
+ if (sameFolder(li.dataset.path, target)) break;
3373
+ await fpExpand(li, true);
3374
+ level = li.querySelector(":scope > ul");
3375
+ }
3376
+ if (found && sameFolder(found.dataset.path, target)) {
3377
+ fpSelect(found.dataset.path, found);
3378
+ found.scrollIntoView({ block: "center" });
3379
+ return true;
3380
+ }
3381
+ return false;
3382
+ }
3383
+ async function fpGoTo(typed) {
3384
+ const p = typed.trim();
3385
+ if (!p) return;
3386
+ try {
3387
+ const data = await get(`/api/fs/dirs?path=${encodeURIComponent(p)}`);
3388
+ if (!(await fpReveal(data.path))) fpSelect(data.path, null);
3389
+ } catch (e) {
3390
+ fpFail(e);
3391
+ }
3392
+ }
3393
+ function fpRecent() {
3394
+ const dirs = [];
3395
+ for (const room of state.rooms.values()) if (room.dir && !dirs.some((d) => sameFolder(d, room.dir))) dirs.push(room.dir);
3396
+ fpEls.recent.innerHTML = "";
3397
+ for (const d of dirs.slice(0, 6)) {
3398
+ const b = document.createElement("button");
3399
+ b.type = "button";
3400
+ b.className = "chip-btn";
3401
+ b.title = d;
3402
+ b.textContent = d.split(/[\\/]/).filter(Boolean).slice(-1)[0] || d;
3403
+ b.addEventListener("click", () => fpGoTo(d));
3404
+ fpEls.recent.appendChild(b);
3405
+ }
3406
+ }
3407
+ async function openFolderPicker(initial, onChoose) {
3408
+ fp.onChoose = onChoose;
3409
+ fpEls.error.hidden = true;
3410
+ fpEls.tree.innerHTML = `<li class="tn-more">loading…</li>`;
3411
+ fpSelect("", null);
3412
+ openDialog(fpEls.dialog);
3413
+ try {
3414
+ const data = await get("/api/fs/dirs");
3415
+ fp.roots = data.roots;
3416
+ fp.home = data.home;
3417
+ fpEls.tree.innerHTML = "";
3418
+ for (const r of data.roots) fpEls.tree.appendChild(fpNode(r));
3419
+ fpRecent();
3420
+ const start = (initial || "").trim() || data.home;
3421
+ await fpGoTo(start);
3422
+ fpEls.path.focus();
3423
+ } catch (e) {
3424
+ fpFail(e);
3425
+ }
3426
+ }
3427
+ fpEls.tree.addEventListener("click", (e) => {
3428
+ const li = e.target.closest("li[data-path]");
3429
+ if (!li) return;
3430
+ if (e.target.closest(".tn-tw")) return void fpExpand(li);
3431
+ fpSelect(li.dataset.path, li);
3432
+ });
3433
+ fpEls.tree.addEventListener("dblclick", (e) => {
3434
+ const li = e.target.closest("li[data-path]");
3435
+ if (li && !e.target.closest(".tn-tw")) fpExpand(li);
3436
+ });
3437
+ fpEls.path.addEventListener("keydown", (e) => {
3438
+ if (e.key === "Enter") {
3439
+ e.preventDefault();
3440
+ fpGoTo(fpEls.path.value);
3441
+ }
3442
+ });
3443
+ fpEls.home.addEventListener("click", () => fpGoTo(fp.home));
3444
+ fpEls.newBtn.addEventListener("click", async () => {
3445
+ const li = fpEls.tree.querySelector(".tn.selected")?.closest("li[data-path]");
3446
+ if (!li) return fpFail(new Error("select the folder to create it in first"));
3447
+ await fpExpand(li, true);
3448
+ const ul = li.querySelector(":scope > ul");
3449
+ if (ul.querySelector(".tn-new")) return;
3450
+ const row = document.createElement("li");
3451
+ row.className = "tn-new";
3452
+ row.innerHTML = `<span class="tn-ico">📁</span><input type="text" placeholder="folder name" maxlength="120">`;
3453
+ ul.prepend(row);
3454
+ const input = row.querySelector("input");
3455
+ input.focus();
3456
+ const done = async () => {
3457
+ const name = input.value.trim();
3458
+ row.remove();
3459
+ if (!name) return;
3460
+ try {
3461
+ const r = await post("/api/fs/mkdir", { parent: li.dataset.path, name });
3462
+ delete li.dataset.loaded;
3463
+ await fpExpand(li, true);
3464
+ await fpReveal(r.path);
3465
+ } catch (err) {
3466
+ fpFail(err);
3467
+ }
3468
+ };
3469
+ input.addEventListener("keydown", (e) => {
3470
+ if (e.key === "Enter") {
3471
+ e.preventDefault();
3472
+ done();
3473
+ } else if (e.key === "Escape") {
3474
+ e.preventDefault();
3475
+ row.remove();
3476
+ }
3477
+ });
3478
+ input.addEventListener("blur", () => setTimeout(() => row.isConnected && done(), 120));
3479
+ });
3480
+ fpEls.choose.addEventListener("click", () => {
3481
+ const chosen = fp.selected || fpEls.path.value.trim();
3482
+ if (!chosen) return fpFail(new Error("pick a folder first"));
3483
+ closeDialog(fpEls.dialog);
3484
+ if (fp.onChoose) fp.onChoose(chosen);
3485
+ });
3486
+ $("#room-dir-browse").addEventListener("click", () => openFolderPicker(els.roomDir.value, (dir) => (els.roomDir.value = dir)));
3487
+
3181
3488
  if (window.matchMedia("(display-mode: standalone)").matches) {
3182
3489
  const placement = () => ({
3183
3490
  left: window.screenX,
package/ui/index.html CHANGED
@@ -79,6 +79,11 @@
79
79
  </header>
80
80
  <div id="messages" class="messages"></div>
81
81
  <button type="button" id="jump-latest" class="jump-latest" title="Back to the latest messages" hidden><span data-icon="arrow-down"></span>Latest</button>
82
+ <div id="timeline" class="timeline" hidden>
83
+ <div class="tl-view"></div>
84
+ <div class="tl-ticks"></div>
85
+ <div class="tl-pop" hidden></div>
86
+ </div>
82
87
  <div id="mention-menu" class="mention-menu" hidden></div>
83
88
  <div id="emoji-menu" class="mention-menu emoji-menu" hidden></div>
84
89
  <form id="composer" class="composer">
@@ -127,7 +132,7 @@
127
132
  <p class="lead">A room is a shared space for you and the vibemates you summon. Give it a name.</p>
128
133
  <label>Room name<input id="room-name" type="text" maxlength="60" required placeholder="e.g. Architecture review"></label>
129
134
  <label>Working directory <span class="hint">(optional)</span><button type="button" class="geek-tip" title="For geeks"><span data-icon="geek"></span>for geeks</button><span class="geek-text" hidden>The folder the vibemates work in (their cwd). Leave it empty and the hub keeps a folder per room. Instruction files in it (CLAUDE.md, AGENTS.md, GEMINI.md, .cursor/rules) are read by the vibemates by their own conventions; the hub tells you if it finds any.</span>
130
- <input id="room-dir" type="text" placeholder="C:\projects\my-app"></label>
135
+ <span class="dir-row"><input id="room-dir" type="text" placeholder="C:\projects\my-app"><button type="button" class="btn ghost browse-btn" id="room-dir-browse" title="Choose a folder"><span data-icon="folder"></span>Browse</button></span></label>
131
136
  <p class="error" id="room-error" hidden></p>
132
137
  <div class="actions"><button type="button" class="btn ghost" data-close>Cancel</button><button type="submit" class="btn primary">Open the room</button></div>
133
138
  </form>
@@ -212,6 +217,26 @@
212
217
  <div class="actions"><button type="button" class="btn ghost" data-close>Keep my vibe</button><button type="submit" id="erase-submit" class="btn danger solid" disabled>Erase everything</button></div>
213
218
  </form>
214
219
  </dialog>
220
+ <dialog id="folder-dialog" class="dialog wide folder-dialog">
221
+ <div class="file-head">
222
+ <h3><span class="h-ico" data-icon="folder"></span>Choose a folder</h3>
223
+ <div class="fp-bar">
224
+ <input id="fp-path" type="text" spellcheck="false" placeholder="Type a path and press Enter, or pick one below">
225
+ <button type="button" class="btn ghost" id="fp-home" title="Your home folder"><span data-icon="user"></span>Home</button>
226
+ <button type="button" class="btn ghost" id="fp-new" title="New folder inside the selected one"><span data-icon="plus"></span>New folder</button>
227
+ </div>
228
+ <div class="fp-recent" id="fp-recent"></div>
229
+ </div>
230
+ <div class="fp-body">
231
+ <ul class="tree" id="fp-tree"></ul>
232
+ </div>
233
+ <p class="error" id="fp-error" hidden></p>
234
+ <div class="actions">
235
+ <span class="fp-selected" id="fp-selected"></span>
236
+ <button type="button" class="btn ghost" data-close>Cancel</button>
237
+ <button type="button" class="btn primary" id="fp-choose">Choose this folder</button>
238
+ </div>
239
+ </dialog>
215
240
  <dialog id="file-dialog" class="dialog wide file-dialog">
216
241
  <div class="file-head">
217
242
  <h3><span class="h-ico" data-icon="link"></span><span id="fv-title">File</span></h3>