feat(maestro): tile-first web spawn form — host/command tiles, recents, worktree-by-branch, generated name (#27)
ci / rust (push) Successful in 4m29s
ci / rust (push) Successful in 4m29s
Rework web/src/spawn.ts from raw text boxes into a low-friction, tile-first flow: pick host tile → pick command tile → pick recent dir → Spawn, name already filled. - Command tiles from the daemon's advertised presets (GET /api/web/info, #26), with built-in fallback; `custom` reveals the free-text command input. - Host tiles replace the <select> (label hostname, sub daemon_id); most- recently-spawned-on host sorts first + pre-selected; overflow behind "More…". - Recent-dirs picker per host (GET /api/daemons/:id/recent-dirs, #26), rows show dir + last command, re-queried on host change; picking a dir can pre-select its last command. Persistent "Other directory…" drops to the raw input + NAP-329 autocomplete; empty history prefills raw with home_dir. - Worktree by branch alone: toggle infers base_repo via /gitroot from the chosen dir (#26); non-repo dir disables the toggle with an inline hint; WorktreeSpec.base_repo filled client-side (wire unchanged). - Name field prefilled with a generated cyberpunk handle (names.ts mirrors the maestro-names wordlists); ↻ rerolls. Adds api.ts getWebInfo/recentDirs/gitRoot, the WebInfo/SpawnPreset/RecentDirs/ GitRoot types, and stubs the new endpoints in the `?mock&spawn` review fixture. Closes #27 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,8 +3,11 @@ import type {
|
||||
CreateSessionRequest,
|
||||
DaemonInfo,
|
||||
DirListing,
|
||||
GitRoot,
|
||||
RecentDirs,
|
||||
Session,
|
||||
Uuid,
|
||||
WebInfo,
|
||||
} from "./types.js";
|
||||
|
||||
async function jsonOrError<T>(res: Response): Promise<T> {
|
||||
@@ -58,6 +61,26 @@ export async function listDir(daemonId: string, path: string): Promise<DirListin
|
||||
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
/** Identity + spawn presets of the daemon this web app is connected to (#26). */
|
||||
export async function getWebInfo(): Promise<WebInfo> {
|
||||
return jsonOrError(await fetch("/api/web/info", { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
/** Recently-used working dirs on `daemonId`'s host, newest-first and deduped by
|
||||
* dir (#26). Drives the spawn form's recents picker. */
|
||||
export async function recentDirs(daemonId: string): Promise<RecentDirs> {
|
||||
const url = `/api/daemons/${encodeURIComponent(daemonId)}/recent-dirs`;
|
||||
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
/** Probe the enclosing git repo of `path` on `daemonId`'s host (#26). `root` is
|
||||
* absent when the path isn't inside a repo. `path` may be `~`/relative/absolute;
|
||||
* the daemon resolves it against its home dir. */
|
||||
export async function gitRoot(daemonId: string, path: string): Promise<GitRoot> {
|
||||
const url = `/api/daemons/${encodeURIComponent(daemonId)}/gitroot?path=${encodeURIComponent(path)}`;
|
||||
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
export function eventsWsUrl(): string {
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${proto}//${location.host}/api/events`;
|
||||
|
||||
+18
-11
@@ -273,7 +273,8 @@ export function renderTopbar(store: CatalogStore): HTMLElement {
|
||||
spawnBtn.className = "btn btn-primary spawn-btn";
|
||||
spawnBtn.textContent = "+ Spawn";
|
||||
spawnBtn.addEventListener("click", () => {
|
||||
openSpawnModal(store.snapshot().daemons);
|
||||
const snap = store.snapshot();
|
||||
openSpawnModal(snap.daemons, undefined, snap.sessions);
|
||||
});
|
||||
actions.appendChild(spawnBtn);
|
||||
return header;
|
||||
@@ -387,9 +388,10 @@ export function renderSidebar(store: CatalogStore): SidebarHandle {
|
||||
treeHost.replaceChildren();
|
||||
if (sessions.length === 0) {
|
||||
treeHost.appendChild(
|
||||
renderEmpty(state.daemons.size === 0, filter.trim().length > 0, () =>
|
||||
openSpawnModal(store.snapshot().daemons),
|
||||
),
|
||||
renderEmpty(state.daemons.size === 0, filter.trim().length > 0, () => {
|
||||
const snap = store.snapshot();
|
||||
openSpawnModal(snap.daemons, undefined, snap.sessions);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -406,13 +408,18 @@ export function renderSidebar(store: CatalogStore): SidebarHandle {
|
||||
const cloneHandler = (ev: Event) => {
|
||||
const s = (ev as CustomEvent<Session>).detail;
|
||||
const rawName = typeof s.metadata?.name === "string" ? s.metadata.name : "";
|
||||
openSpawnModal(store.snapshot().daemons, {
|
||||
daemon_id: s.daemon_id,
|
||||
cwd: s.cwd,
|
||||
command: s.command,
|
||||
name: rawName,
|
||||
label: sessionLabel(s) ?? "",
|
||||
});
|
||||
const snap = store.snapshot();
|
||||
openSpawnModal(
|
||||
snap.daemons,
|
||||
{
|
||||
daemon_id: s.daemon_id,
|
||||
cwd: s.cwd,
|
||||
command: s.command,
|
||||
name: rawName,
|
||||
label: sessionLabel(s) ?? "",
|
||||
},
|
||||
snap.sessions,
|
||||
);
|
||||
};
|
||||
document.addEventListener("catalog-clone", cloneHandler);
|
||||
|
||||
|
||||
+4
-1
@@ -70,7 +70,10 @@ function renderWelcome(): HTMLElement {
|
||||
spawn.type = "button";
|
||||
spawn.className = "btn btn-primary";
|
||||
spawn.textContent = "+ Spawn session";
|
||||
spawn.addEventListener("click", () => openSpawnModal(store.snapshot().daemons));
|
||||
spawn.addEventListener("click", () => {
|
||||
const snap = store.snapshot();
|
||||
openSpawnModal(snap.daemons, undefined, snap.sessions);
|
||||
});
|
||||
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
|
||||
return el;
|
||||
}
|
||||
|
||||
+59
-2
@@ -23,6 +23,57 @@ function sess(
|
||||
};
|
||||
}
|
||||
|
||||
/** Stub the spawn-form backend endpoints (#26) so `?mock&spawn` renders the full
|
||||
* tile-first flow — presets, recents, gitroot — with no daemon running. Dev-only;
|
||||
* delegates every other request to the real `fetch`. */
|
||||
function stubSpawnApi(): void {
|
||||
const real = globalThis.fetch.bind(globalThis);
|
||||
const json = (body: unknown) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
const path = url.startsWith("http") ? new URL(url).pathname + new URL(url).search : url;
|
||||
if (path.startsWith("/api/web/info")) {
|
||||
return Promise.resolve(
|
||||
json({
|
||||
daemon_id: "thinkpad",
|
||||
hostname: "thinkpad.lan",
|
||||
version: "0.1.0",
|
||||
daemon_url: "wss://thinkpad.lan/daemon",
|
||||
presets: [
|
||||
{ id: "claude", label: "Claude", command: "claude" },
|
||||
{ id: "opencode", label: "opencode", command: "opencode" },
|
||||
{ id: "zsh", label: "Shell", command: "zsh" },
|
||||
{ id: "custom", label: "Custom…", command: "" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (path.includes("/recent-dirs")) {
|
||||
return Promise.resolve(
|
||||
json({
|
||||
dirs: [
|
||||
{ cwd: "/home/dev/git/maestro", last_command: "claude", last_used: "2026-06-20T10:00:00Z" },
|
||||
{ cwd: "/home/dev/git/dotfiles", last_command: "zsh", last_used: "2026-06-19T18:00:00Z" },
|
||||
{ cwd: "/home/dev/work/acme/api", last_command: "opencode", last_used: "2026-06-18T09:00:00Z" },
|
||||
],
|
||||
truncated: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (path.includes("/gitroot")) {
|
||||
const root = path.includes("maestro") || path.includes("dotfiles")
|
||||
? { root: "/home/dev/git/maestro" }
|
||||
: {};
|
||||
return Promise.resolve(json(root));
|
||||
}
|
||||
return real(input, init);
|
||||
};
|
||||
}
|
||||
|
||||
export function seedMock(store: CatalogStore): void {
|
||||
const daemons: DaemonInfo[] = [
|
||||
{ daemon_id: "thinkpad", hostname: "thinkpad.lan", version: "0.1.0", daemon_url: "wss://thinkpad.lan/daemon", home_dir: "/home/dev" },
|
||||
@@ -57,10 +108,16 @@ export function seedMock(store: CatalogStore): void {
|
||||
sessions,
|
||||
});
|
||||
|
||||
// `?mock&spawn` auto-opens the spawn modal for visual review.
|
||||
// `?mock&spawn` auto-opens the spawn modal for visual review, with the spawn
|
||||
// backend endpoints stubbed so presets/recents/gitroot all render.
|
||||
if (location.search.includes("spawn")) {
|
||||
stubSpawnApi();
|
||||
void import("./spawn.js").then((m) =>
|
||||
m.openSpawnModal(new Map(daemons.map((d) => [d.daemon_id, d]))),
|
||||
m.openSpawnModal(
|
||||
new Map(daemons.map((d) => [d.daemon_id, d])),
|
||||
undefined,
|
||||
new Map(sessions.map((s) => [s.id, s])),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Client-side session-handle generator for the spawn form's instant reroll
|
||||
// (#23/#27). Mirrors the wordlists in `crates/maestro-names/src/lib.rs` verbatim
|
||||
// — the Rust generator is the source of truth, but the web wants a zero-round-
|
||||
// trip reroll, so the lists are duplicated here. Keep them in sync.
|
||||
|
||||
/** Musical adjectives — tempo, dynamics, and articulation markings. */
|
||||
const ADJECTIVES = [
|
||||
"allegro", "andante", "adagio", "presto", "vivace", "largo", "grave", "lento",
|
||||
"moderato", "crescendo", "staccato", "legato", "forte", "dolce", "agitato",
|
||||
"cantabile", "sostenuto", "tremolo", "rubato", "marcato", "brillante",
|
||||
"maestoso", "grazioso", "espressivo", "tranquillo", "animato", "spirito",
|
||||
"sotto", "vivo", "feroce",
|
||||
];
|
||||
|
||||
/** Musical nouns — instruments and forms from the concert hall. */
|
||||
const NOUNS = [
|
||||
"cello", "violin", "viola", "oboe", "flute", "clarinet", "bassoon", "trumpet",
|
||||
"trombone", "tuba", "horn", "timpani", "harp", "piano", "organ", "lyre",
|
||||
"lute", "fugue", "sonata", "octave", "chord", "cadence", "prelude", "rondo",
|
||||
"nocturne", "scherzo", "refrain", "coda", "motif", "overture",
|
||||
];
|
||||
|
||||
function pick<T>(list: T[]): T {
|
||||
return list[Math.floor(Math.random() * list.length)];
|
||||
}
|
||||
|
||||
/** A fresh `adjective-noun` handle, e.g. `allegro-cello`. */
|
||||
export function generateName(): string {
|
||||
return `${pick(ADJECTIVES)}-${pick(NOUNS)}`;
|
||||
}
|
||||
+442
-123
@@ -1,6 +1,13 @@
|
||||
import { createSession } from "./api.js";
|
||||
import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
||||
import type { CreateSessionRequest, DaemonInfo } from "./types.js";
|
||||
import { createSession, getWebInfo, recentDirs, gitRoot } from "./api.js";
|
||||
import { attachDirAutocomplete, type DirAutocompleteHandle } from "./dirAutocomplete.js";
|
||||
import { generateName } from "./names.js";
|
||||
import type {
|
||||
CreateSessionRequest,
|
||||
DaemonInfo,
|
||||
RecentDir,
|
||||
Session,
|
||||
SpawnPreset,
|
||||
} from "./types.js";
|
||||
|
||||
/** Seed values for the spawn form — used by "clone" to reopen the dialog with
|
||||
* the same daemon, cwd, command, name, and label as an existing session. */
|
||||
@@ -12,9 +19,22 @@ export interface SpawnPrefill {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** Built-ins used when the daemon's `/api/web/info` presets can't be fetched
|
||||
* (older daemon, transient error). Mirrors `maestro_config::builtin_presets`. */
|
||||
const FALLBACK_PRESETS: SpawnPreset[] = [
|
||||
{ id: "claude", label: "Claude", command: "claude" },
|
||||
{ id: "opencode", label: "opencode", command: "opencode" },
|
||||
{ id: "zsh", label: "Shell", command: "zsh" },
|
||||
{ id: "custom", label: "Custom…", command: "" },
|
||||
];
|
||||
|
||||
/** How many host tiles to show before collapsing the rest behind "More…". */
|
||||
const MAX_HOST_TILES = 4;
|
||||
|
||||
export function openSpawnModal(
|
||||
daemons: Map<string, DaemonInfo>,
|
||||
prefill?: SpawnPrefill,
|
||||
sessions?: Map<string, Session>,
|
||||
): void {
|
||||
const existing = document.getElementById("modal-host");
|
||||
if (existing) existing.remove();
|
||||
@@ -23,52 +43,61 @@ export function openSpawnModal(
|
||||
host.id = "modal-host";
|
||||
host.className = "modal-backdrop";
|
||||
host.innerHTML = `
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="spawn-title">
|
||||
<div class="modal spawn-modal" role="dialog" aria-modal="true" aria-labelledby="spawn-title">
|
||||
<header class="modal-header">
|
||||
<h2 id="spawn-title">Spawn session</h2>
|
||||
<button type="button" class="btn btn-icon close" aria-label="Close">✕</button>
|
||||
</header>
|
||||
<form class="modal-body" id="spawn-form">
|
||||
<label>
|
||||
Daemon
|
||||
<select name="daemon_id" required></select>
|
||||
</label>
|
||||
<label>
|
||||
Working directory
|
||||
<input name="cwd" type="text" required placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Command
|
||||
<input name="command" type="text" required value="claude" placeholder="bash" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Name (optional)
|
||||
<input name="name" type="text" placeholder="e.g. impl" autocomplete="off" />
|
||||
</label>
|
||||
<div class="field">
|
||||
<span class="field-label">Host</span>
|
||||
<div class="tile-row" id="host-tiles"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Command</span>
|
||||
<div class="tile-row" id="cmd-tiles"></div>
|
||||
<input class="raw-input" id="cmd-raw" name="command_raw" type="text" hidden
|
||||
placeholder="bash -l" autocomplete="off" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Working directory</span>
|
||||
<div class="picker" id="recents"></div>
|
||||
<div class="raw-wrap" id="cwd-raw-wrap" hidden>
|
||||
<input class="raw-input" id="cwd-raw" name="cwd_raw" type="text"
|
||||
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
<fieldset class="worktree">
|
||||
<legend>
|
||||
<label class="row-inline">
|
||||
<input type="checkbox" name="worktree_enabled" id="wt-enabled" />
|
||||
Create git worktree
|
||||
</label>
|
||||
</legend>
|
||||
<p class="wt-hint" id="wt-hint" hidden></p>
|
||||
<div class="wt-body" id="wt-body" hidden>
|
||||
<p class="wt-repo" id="wt-repo"></p>
|
||||
<label>
|
||||
Branch
|
||||
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
||||
</label>
|
||||
<label class="row-inline">
|
||||
<input type="checkbox" name="worktree_cleanup_on_kill" />
|
||||
Remove worktree when session is killed
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="field">
|
||||
<span class="field-label">Name</span>
|
||||
<div class="name-row">
|
||||
<input name="name" type="text" autocomplete="off" />
|
||||
<button type="button" class="btn btn-icon reroll" id="reroll" aria-label="Generate a new name" title="New name">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
<label>
|
||||
Label (optional)
|
||||
<input name="label" type="text" placeholder="personal/maestro" autocomplete="off" />
|
||||
</label>
|
||||
<fieldset class="worktree">
|
||||
<legend>
|
||||
<label class="row-inline">
|
||||
<input type="checkbox" name="worktree_enabled" />
|
||||
Create git worktree
|
||||
</label>
|
||||
</legend>
|
||||
<label>
|
||||
Base repo
|
||||
<input name="worktree_base_repo" type="text" placeholder="/path/to/repo" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Branch
|
||||
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
||||
</label>
|
||||
<label class="row-inline">
|
||||
<input type="checkbox" name="worktree_cleanup_on_kill" />
|
||||
Remove worktree when session is killed
|
||||
</label>
|
||||
</fieldset>
|
||||
<p class="form-error" role="alert" aria-live="polite"></p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-ghost cancel">Cancel</button>
|
||||
@@ -79,73 +108,343 @@ export function openSpawnModal(
|
||||
`;
|
||||
document.body.appendChild(host);
|
||||
|
||||
const select = host.querySelector("select[name=daemon_id]") as HTMLSelectElement;
|
||||
if (daemons.size === 0) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "";
|
||||
opt.textContent = "(no daemons connected)";
|
||||
opt.disabled = true;
|
||||
opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
} else {
|
||||
for (const d of daemons.values()) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = d.daemon_id;
|
||||
opt.textContent = `${d.daemon_id} (${d.hostname})`;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
}
|
||||
const form = host.querySelector("#spawn-form") as HTMLFormElement;
|
||||
const errEl = form.querySelector(".form-error") as HTMLElement;
|
||||
const hostTiles = host.querySelector("#host-tiles") as HTMLElement;
|
||||
const cmdTiles = host.querySelector("#cmd-tiles") as HTMLElement;
|
||||
const cmdRaw = host.querySelector("#cmd-raw") as HTMLInputElement;
|
||||
const recentsEl = host.querySelector("#recents") as HTMLElement;
|
||||
const cwdRawWrap = host.querySelector("#cwd-raw-wrap") as HTMLElement;
|
||||
const cwdRaw = host.querySelector("#cwd-raw") as HTMLInputElement;
|
||||
const wtEnabled = host.querySelector("#wt-enabled") as HTMLInputElement;
|
||||
const wtHint = host.querySelector("#wt-hint") as HTMLElement;
|
||||
const wtBody = host.querySelector("#wt-body") as HTMLElement;
|
||||
const wtRepo = host.querySelector("#wt-repo") as HTMLElement;
|
||||
const branchInput = host.querySelector("input[name=worktree_branch]") as HTMLInputElement;
|
||||
const nameInput = host.querySelector("input[name=name]") as HTMLInputElement;
|
||||
|
||||
// Prefill the working directory with the selected daemon's home dir, and keep
|
||||
// it in sync when the daemon changes — unless the user has typed their own.
|
||||
const cwdInput = host.querySelector("input[name=cwd]") as HTMLInputElement;
|
||||
let cwdEdited = false;
|
||||
cwdInput.addEventListener("input", () => {
|
||||
cwdEdited = true;
|
||||
// ---- form state ------------------------------------------------------
|
||||
let selectedHostId = "";
|
||||
let presets: SpawnPreset[] = FALLBACK_PRESETS;
|
||||
let selectedPresetId = "";
|
||||
let command = ""; // resolved command (preset's, or the raw custom input)
|
||||
let cwd = ""; // chosen working directory
|
||||
let inferredBaseRepo: string | null = null; // from the gitroot probe
|
||||
let probeToken = 0; // guards async gitroot probes against races
|
||||
let showAllHosts = false;
|
||||
// A clone's cwd, applied once the (async) recents load for the initial host
|
||||
// settles — so the fetch can't race past and clobber the seeded directory.
|
||||
let cloneCwd: string | null = prefill?.cwd ?? null;
|
||||
|
||||
// ---- name ------------------------------------------------------------
|
||||
nameInput.value = prefill?.name ?? generateName();
|
||||
(host.querySelector("#reroll") as HTMLButtonElement).addEventListener("click", () => {
|
||||
nameInput.value = generateName();
|
||||
});
|
||||
const applyHomeDir = () => {
|
||||
if (cwdEdited) return;
|
||||
const home = daemons.get(select.value)?.home_dir;
|
||||
if (home) cwdInput.value = home;
|
||||
};
|
||||
select.addEventListener("change", applyHomeDir);
|
||||
applyHomeDir();
|
||||
|
||||
// Clone: seed the form from an existing session. cwd is marked edited so the
|
||||
// home-dir sync won't clobber it on daemon change.
|
||||
if (prefill) {
|
||||
(host.querySelector("#spawn-title") as HTMLElement).textContent =
|
||||
"Clone session";
|
||||
const set = (name: string, value: string | undefined) => {
|
||||
if (value == null) return;
|
||||
const field = host.querySelector(
|
||||
`[name=${name}]`,
|
||||
) as HTMLInputElement | HTMLSelectElement | null;
|
||||
if (field) field.value = value;
|
||||
};
|
||||
if (prefill.daemon_id && daemons.has(prefill.daemon_id)) {
|
||||
select.value = prefill.daemon_id;
|
||||
// ---- command tiles ---------------------------------------------------
|
||||
function selectPreset(id: string): void {
|
||||
selectedPresetId = id;
|
||||
const preset = presets.find((p) => p.id === id);
|
||||
const isCustom = !preset || preset.command === "";
|
||||
cmdRaw.hidden = !isCustom;
|
||||
if (isCustom) {
|
||||
command = cmdRaw.value.trim();
|
||||
cmdRaw.focus();
|
||||
} else {
|
||||
command = preset!.command;
|
||||
}
|
||||
set("command", prefill.command);
|
||||
set("name", prefill.name);
|
||||
set("label", prefill.label);
|
||||
if (prefill.cwd) {
|
||||
cwdInput.value = prefill.cwd;
|
||||
cwdEdited = true;
|
||||
for (const tile of cmdTiles.querySelectorAll(".tile")) {
|
||||
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
|
||||
}
|
||||
}
|
||||
|
||||
// Live directory autocomplete (NAP-329) on the path fields, scoped to the
|
||||
// currently-selected daemon — re-reading `select.value` per query means it
|
||||
// tracks host changes for free.
|
||||
const baseRepoInput = host.querySelector(
|
||||
"input[name=worktree_base_repo]",
|
||||
) as HTMLInputElement;
|
||||
const autocompletes = [
|
||||
attachDirAutocomplete(cwdInput, () => select.value),
|
||||
attachDirAutocomplete(baseRepoInput, () => select.value),
|
||||
function renderCommandTiles(): void {
|
||||
cmdTiles.replaceChildren();
|
||||
for (const p of presets) {
|
||||
const tile = document.createElement("button");
|
||||
tile.type = "button";
|
||||
tile.className = "tile";
|
||||
tile.dataset.id = p.id;
|
||||
tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
|
||||
tile.addEventListener("click", () => selectPreset(p.id));
|
||||
cmdTiles.appendChild(tile);
|
||||
}
|
||||
}
|
||||
|
||||
cmdRaw.addEventListener("input", () => {
|
||||
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
|
||||
command = cmdRaw.value.trim();
|
||||
}
|
||||
});
|
||||
|
||||
// Presets ride `/api/web/info`; render fallbacks immediately, then refine.
|
||||
renderCommandTiles();
|
||||
applyInitialCommand();
|
||||
getWebInfo()
|
||||
.then((info) => {
|
||||
if (info.presets?.length) {
|
||||
presets = info.presets;
|
||||
renderCommandTiles();
|
||||
applyInitialCommand();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* keep fallbacks */
|
||||
});
|
||||
|
||||
// Pick a sensible starting command: a clone's command (matched to a preset or
|
||||
// dropped into custom), else the first non-custom preset.
|
||||
function applyInitialCommand(): void {
|
||||
const wanted = prefill?.command;
|
||||
if (wanted) {
|
||||
const match = presets.find((p) => p.command === wanted && p.command !== "");
|
||||
if (match) {
|
||||
selectPreset(match.id);
|
||||
} else {
|
||||
const custom = presets.find((p) => p.command === "");
|
||||
cmdRaw.value = wanted;
|
||||
selectPreset(custom ? custom.id : "custom");
|
||||
command = wanted;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const first = presets.find((p) => p.command !== "");
|
||||
if (first) selectPreset(first.id);
|
||||
}
|
||||
|
||||
// ---- working-directory picker ---------------------------------------
|
||||
function showRawCwd(prefillValue?: string): void {
|
||||
cwdRawWrap.hidden = false;
|
||||
if (prefillValue != null) cwd = prefillValue;
|
||||
cwdRaw.value = cwd;
|
||||
markActiveRecent(null);
|
||||
cwdRaw.focus();
|
||||
cwdRaw.select();
|
||||
}
|
||||
|
||||
cwdRaw.addEventListener("input", () => {
|
||||
cwd = cwdRaw.value.trim();
|
||||
if (wtEnabled.checked) probeGitRoot();
|
||||
});
|
||||
|
||||
function markActiveRecent(activeCwd: string | null): void {
|
||||
for (const row of recentsEl.querySelectorAll(".recent-row")) {
|
||||
row.classList.toggle(
|
||||
"is-active",
|
||||
(row as HTMLElement).dataset.cwd === activeCwd,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function selectRecent(dir: RecentDir): void {
|
||||
cwd = dir.cwd;
|
||||
cwdRawWrap.hidden = true;
|
||||
markActiveRecent(dir.cwd);
|
||||
// Picking a recent dir can pre-select the command it was last used with.
|
||||
const match = presets.find((p) => p.command === dir.last_command && p.command !== "");
|
||||
if (match) selectPreset(match.id);
|
||||
if (wtEnabled.checked) probeGitRoot();
|
||||
}
|
||||
|
||||
async function loadRecents(): Promise<void> {
|
||||
recentsEl.replaceChildren(loadingRow());
|
||||
let dirs: RecentDir[] = [];
|
||||
try {
|
||||
dirs = (await recentDirs(selectedHostId)).dirs ?? [];
|
||||
} catch {
|
||||
dirs = [];
|
||||
}
|
||||
recentsEl.replaceChildren();
|
||||
for (const dir of dirs) {
|
||||
recentsEl.appendChild(recentRow(dir));
|
||||
}
|
||||
// Persistent escape hatch + empty-history fallback.
|
||||
recentsEl.appendChild(otherDirRow());
|
||||
if (cloneCwd) {
|
||||
// A clone seeds the directory verbatim into the raw input, regardless of
|
||||
// this host's history.
|
||||
showRawCwd(cloneCwd);
|
||||
cloneCwd = null;
|
||||
} else if (dirs.length === 0) {
|
||||
// No history: default to raw input prefilled with the host's home dir.
|
||||
const home = daemons.get(selectedHostId)?.home_dir ?? "";
|
||||
showRawCwd(home);
|
||||
} else {
|
||||
cwdRawWrap.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function recentRow(dir: RecentDir): HTMLElement {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "recent-row";
|
||||
row.dataset.cwd = dir.cwd;
|
||||
row.innerHTML = `
|
||||
<span class="recent-dir">${escapeHtml(dir.cwd)}</span>
|
||||
<span class="recent-cmd">${escapeHtml(dir.last_command)}</span>`;
|
||||
row.addEventListener("click", () => selectRecent(dir));
|
||||
return row;
|
||||
}
|
||||
|
||||
function otherDirRow(): HTMLElement {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "recent-row recent-other";
|
||||
row.innerHTML = `<span class="recent-dir">Other directory…</span>`;
|
||||
row.addEventListener("click", () => {
|
||||
const home = daemons.get(selectedHostId)?.home_dir ?? "";
|
||||
showRawCwd(cwd || home);
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
function loadingRow(): HTMLElement {
|
||||
const p = document.createElement("p");
|
||||
p.className = "picker-loading";
|
||||
p.textContent = "Loading recent directories…";
|
||||
return p;
|
||||
}
|
||||
|
||||
// ---- worktree (branch-only; base_repo inferred via /gitroot) ----------
|
||||
async function probeGitRoot(): Promise<void> {
|
||||
const token = ++probeToken;
|
||||
if (!cwd) {
|
||||
inferredBaseRepo = null;
|
||||
wtBody.hidden = true;
|
||||
wtHint.hidden = false;
|
||||
wtHint.textContent = "Choose a working directory first.";
|
||||
return;
|
||||
}
|
||||
wtHint.hidden = false;
|
||||
wtHint.textContent = "Checking for a git repository…";
|
||||
wtBody.hidden = true;
|
||||
let root: string | undefined;
|
||||
try {
|
||||
root = (await gitRoot(selectedHostId, cwd)).root;
|
||||
} catch {
|
||||
root = undefined;
|
||||
}
|
||||
if (token !== probeToken) return; // a newer probe superseded us
|
||||
if (root) {
|
||||
inferredBaseRepo = root;
|
||||
wtHint.hidden = true;
|
||||
wtBody.hidden = false;
|
||||
wtRepo.textContent = `repo: ${root} (inferred)`;
|
||||
} else {
|
||||
inferredBaseRepo = null;
|
||||
wtEnabled.checked = false;
|
||||
wtBody.hidden = true;
|
||||
wtHint.hidden = false;
|
||||
wtHint.textContent = "Not inside a git repository — pick a dir in a repo to use a worktree.";
|
||||
}
|
||||
}
|
||||
|
||||
wtEnabled.addEventListener("change", () => {
|
||||
if (wtEnabled.checked) {
|
||||
probeGitRoot();
|
||||
} else {
|
||||
inferredBaseRepo = null;
|
||||
wtBody.hidden = true;
|
||||
wtHint.hidden = true;
|
||||
}
|
||||
});
|
||||
|
||||
// ---- host tiles ------------------------------------------------------
|
||||
// Order hosts by most-recently-spawned-on first, so the common case is the
|
||||
// pre-selected tile.
|
||||
function orderedHosts(): DaemonInfo[] {
|
||||
const list = [...daemons.values()];
|
||||
const lastUsed = new Map<string, number>();
|
||||
for (const s of sessions?.values() ?? []) {
|
||||
const t = Date.parse(s.created_at);
|
||||
if (Number.isFinite(t)) {
|
||||
lastUsed.set(s.daemon_id, Math.max(lastUsed.get(s.daemon_id) ?? 0, t));
|
||||
}
|
||||
}
|
||||
return list.sort((a, b) => (lastUsed.get(b.daemon_id) ?? 0) - (lastUsed.get(a.daemon_id) ?? 0));
|
||||
}
|
||||
|
||||
function selectHost(id: string): void {
|
||||
selectedHostId = id;
|
||||
for (const tile of hostTiles.querySelectorAll(".tile")) {
|
||||
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
|
||||
}
|
||||
// Recents + any worktree inference are host-scoped — refresh on change.
|
||||
cwd = "";
|
||||
inferredBaseRepo = null;
|
||||
if (wtEnabled.checked) {
|
||||
wtEnabled.checked = false;
|
||||
wtBody.hidden = true;
|
||||
wtHint.hidden = true;
|
||||
}
|
||||
void loadRecents();
|
||||
}
|
||||
|
||||
function renderHostTiles(): void {
|
||||
hostTiles.replaceChildren();
|
||||
const ordered = orderedHosts();
|
||||
if (ordered.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "picker-loading";
|
||||
empty.textContent = "(no daemons connected)";
|
||||
hostTiles.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
const visible = showAllHosts ? ordered : ordered.slice(0, MAX_HOST_TILES);
|
||||
for (const d of visible) {
|
||||
const tile = document.createElement("button");
|
||||
tile.type = "button";
|
||||
tile.className = "tile tile-host";
|
||||
tile.dataset.id = d.daemon_id;
|
||||
tile.innerHTML = `
|
||||
<span class="tile-label">${escapeHtml(d.hostname)}</span>
|
||||
<span class="tile-sub">${escapeHtml(d.daemon_id)}</span>`;
|
||||
tile.addEventListener("click", () => selectHost(d.daemon_id));
|
||||
hostTiles.appendChild(tile);
|
||||
}
|
||||
if (!showAllHosts && ordered.length > MAX_HOST_TILES) {
|
||||
const more = document.createElement("button");
|
||||
more.type = "button";
|
||||
more.className = "tile tile-more";
|
||||
more.innerHTML = `<span class="tile-label">More…</span>`;
|
||||
more.addEventListener("click", () => {
|
||||
showAllHosts = true;
|
||||
renderHostTiles();
|
||||
// Keep the current selection highlighted after re-render.
|
||||
if (selectedHostId) {
|
||||
hostTiles
|
||||
.querySelector(`.tile[data-id="${cssEscape(selectedHostId)}"]`)
|
||||
?.classList.add("is-active");
|
||||
}
|
||||
});
|
||||
hostTiles.appendChild(more);
|
||||
}
|
||||
}
|
||||
|
||||
renderHostTiles();
|
||||
// Pre-select: a clone's host if present & connected, else the most-recent.
|
||||
const ordered = orderedHosts();
|
||||
const initialHost =
|
||||
prefill?.daemon_id && daemons.has(prefill.daemon_id)
|
||||
? prefill.daemon_id
|
||||
: ordered[0]?.daemon_id;
|
||||
if (initialHost) selectHost(initialHost);
|
||||
|
||||
// ---- clone seed ------------------------------------------------------
|
||||
if (prefill) {
|
||||
(host.querySelector("#spawn-title") as HTMLElement).textContent = "Clone session";
|
||||
(host.querySelector("input[name=label]") as HTMLInputElement).value = prefill.label ?? "";
|
||||
// The cwd itself is applied by loadRecents once the initial host's recents
|
||||
// settle (see `cloneCwd`), so a slow fetch can't clobber it.
|
||||
}
|
||||
|
||||
// ---- live dir autocomplete on the raw cwd input (NAP-329) ------------
|
||||
const autocompletes: DirAutocompleteHandle[] = [
|
||||
attachDirAutocomplete(cwdRaw, () => selectedHostId),
|
||||
];
|
||||
|
||||
// ---- lifecycle -------------------------------------------------------
|
||||
const close = () => {
|
||||
autocompletes.forEach((a) => a.destroy());
|
||||
host.remove();
|
||||
@@ -163,49 +462,56 @@ export function openSpawnModal(
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
const form = host.querySelector("#spawn-form") as HTMLFormElement;
|
||||
const errEl = form.querySelector(".form-error") as HTMLElement;
|
||||
|
||||
// ---- submit ----------------------------------------------------------
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
errEl.textContent = "";
|
||||
const data = new FormData(form);
|
||||
const daemonId = String(data.get("daemon_id") ?? "");
|
||||
const cwd = String(data.get("cwd") ?? "").trim();
|
||||
const command = String(data.get("command") ?? "").trim();
|
||||
if (!daemonId || !cwd || !command) {
|
||||
errEl.textContent = "Daemon, cwd, and command are required.";
|
||||
if (!selectedHostId) {
|
||||
errEl.textContent = "Pick a host.";
|
||||
return;
|
||||
}
|
||||
const name = String(data.get("name") ?? "").trim();
|
||||
const label = String(data.get("label") ?? "").trim();
|
||||
if (!cwd) {
|
||||
errEl.textContent = "Pick a working directory.";
|
||||
return;
|
||||
}
|
||||
if (!command) {
|
||||
errEl.textContent = "Pick a command (or fill the custom command).";
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata: Record<string, unknown> = {};
|
||||
const name = nameInput.value.trim();
|
||||
const label = (host.querySelector("input[name=label]") as HTMLInputElement).value.trim();
|
||||
if (name) metadata.name = name;
|
||||
if (label) metadata.label = label;
|
||||
|
||||
const req: CreateSessionRequest = {
|
||||
daemon_id: daemonId,
|
||||
daemon_id: selectedHostId,
|
||||
cwd,
|
||||
command,
|
||||
metadata,
|
||||
};
|
||||
if (data.get("worktree_enabled")) {
|
||||
const baseRepo = String(data.get("worktree_base_repo") ?? "").trim();
|
||||
const branch = String(data.get("worktree_branch") ?? "").trim();
|
||||
if (!baseRepo || !branch) {
|
||||
errEl.textContent = "Worktree requires base repo and branch.";
|
||||
|
||||
if (wtEnabled.checked) {
|
||||
const branch = branchInput.value.trim();
|
||||
if (!inferredBaseRepo) {
|
||||
errEl.textContent = "Worktree needs a directory inside a git repository.";
|
||||
return;
|
||||
}
|
||||
if (!branch) {
|
||||
errEl.textContent = "Worktree requires a branch name.";
|
||||
return;
|
||||
}
|
||||
req.worktree = {
|
||||
base_repo: baseRepo,
|
||||
base_repo: inferredBaseRepo,
|
||||
branch,
|
||||
cleanup_on_kill: Boolean(data.get("worktree_cleanup_on_kill")),
|
||||
cleanup_on_kill: Boolean(
|
||||
(host.querySelector("input[name=worktree_cleanup_on_kill]") as HTMLInputElement).checked,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const submitBtn = form.querySelector(
|
||||
"button[type=submit]",
|
||||
) as HTMLButtonElement;
|
||||
const submitBtn = form.querySelector("button[type=submit]") as HTMLButtonElement;
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
const session = await createSession(req);
|
||||
@@ -216,7 +522,20 @@ export function openSpawnModal(
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
const firstInput = form.querySelector("input, select") as HTMLElement | null;
|
||||
firstInput?.focus();
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** Escape a string for use inside a CSS attribute selector. Uses the native
|
||||
* `CSS.escape` when available, with a conservative fallback. */
|
||||
function cssEscape(s: string): string {
|
||||
const cssApi = (globalThis as { CSS?: { escape?: (v: string) => string } }).CSS;
|
||||
if (cssApi?.escape) return cssApi.escape(s);
|
||||
return s.replace(/["\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
@@ -1421,6 +1421,183 @@ textarea {
|
||||
border-radius: var(--r-2);
|
||||
}
|
||||
|
||||
/* ---------- spawn form: tiles + recents picker (#27) ---------- */
|
||||
|
||||
.spawn-modal .field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: var(--fs-13);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* One-click tiles: host row + command row. */
|
||||
.tile-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.tile {
|
||||
flex: 1 1 auto;
|
||||
min-width: 96px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: flex-start;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
color: var(--ink);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color var(--dur-fast) var(--ease-out),
|
||||
background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.tile:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.tile.is-active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
|
||||
.tile-label {
|
||||
font-size: var(--fs-14);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tile-sub {
|
||||
font-size: var(--fs-12);
|
||||
color: var(--muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.tile-more {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Recent-dirs picker rows. */
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-1);
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-strong) transparent;
|
||||
}
|
||||
|
||||
.recent-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
padding: 6px var(--sp-3);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
color: var(--ink);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color var(--dur-fast) var(--ease-out),
|
||||
background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.recent-row:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.recent-row.is-active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
|
||||
.recent-dir {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-14);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recent-cmd {
|
||||
flex: 0 0 auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-12);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.recent-other .recent-dir {
|
||||
color: var(--muted);
|
||||
font-family: inherit;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.picker-loading {
|
||||
margin: 0;
|
||||
padding: 6px var(--sp-3);
|
||||
color: var(--faint);
|
||||
font-size: var(--fs-13);
|
||||
}
|
||||
|
||||
.raw-wrap {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.raw-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Worktree-by-branch: inferred repo line + inline hint. */
|
||||
.wt-hint {
|
||||
margin: 0;
|
||||
font-size: var(--fs-12);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.wt-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.wt-repo {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-12);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Generated-name field with a reroll button. */
|
||||
.name-row {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.name-row input {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.reroll {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* ---------- directory autocomplete (NAP-329) ---------- */
|
||||
|
||||
.dir-ac {
|
||||
|
||||
@@ -37,6 +37,45 @@ export interface DirListing {
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
/** A spawn-form command preset advertised by the daemon (#22/#26). The `custom`
|
||||
* preset has an empty `command` and reveals the free-text command input. */
|
||||
export interface SpawnPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
/** `GET /api/web/info` — the connected daemon's identity plus its spawn presets
|
||||
* (#26). Mirrors the daemon-side `WebInfo`. */
|
||||
export interface WebInfo {
|
||||
daemon_id: string;
|
||||
hostname: string;
|
||||
version: string;
|
||||
daemon_url: string;
|
||||
/** Command presets (claude/opencode/zsh + custom), config-resolved. */
|
||||
presets: SpawnPreset[];
|
||||
}
|
||||
|
||||
/** One deduplicated recent working dir for the spawn form's recents picker
|
||||
* (#24/#26). */
|
||||
export interface RecentDir {
|
||||
cwd: string;
|
||||
last_command: string;
|
||||
last_used: IsoTimestamp;
|
||||
}
|
||||
|
||||
/** `GET /api/daemons/:id/recent-dirs` — a host's recent working dirs (#26). */
|
||||
export interface RecentDirs {
|
||||
dirs: RecentDir[];
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
/** `GET /api/daemons/:id/gitroot?path=` — the enclosing repo toplevel of a path
|
||||
* on a host, or absent when the path isn't inside a repo (#26). */
|
||||
export interface GitRoot {
|
||||
root?: string;
|
||||
}
|
||||
|
||||
export interface WorktreeSpec {
|
||||
base_repo: string;
|
||||
branch: string;
|
||||
|
||||
Reference in New Issue
Block a user