Cookbook scheduler + serve: schedule via Tasks, Stop verifies kill, Ollama auto port-pick

- Schedule cookbook serves through the existing ScheduledTask system: the
  serve preset gets a ^ button next to Launch that opens a daily/hourly/
  weekly form mirroring the admin-switch style; the schedule action runs
  action_cookbook_serve, which delegates to /api/model/serve and stamps
  the resulting task with _scheduledStopAtMs. A background
  cookbook_serve_lifecycle loop ticks every 60s and kills any serve
  whose window has ended, also dropping the auto-registered endpoint
  so the model picker doesn't keep pointing at a dead server.
- Stop and remove on a Running serve now awaits the SSH/tmux kill,
  re-checks tmux has-session, and surfaces an error toast (leaving the
  row) when the kill failed. Previously fire-and-forget, so a failed
  SSH/tmux call silently left the live serve running while the row
  vanished from the UI.
- Cookbook tasks/status orphan-adoption sweep no longer requires the
  serve-/cookbook- session-id prefix; any tmux session whose pane is
  running a known model-server process gets auto-pulled into Running.
  Without this loosening, a cookbook-launched serve whose tmux id
  fell back to a bare number was invisible — you couldn't see it,
  let alone stop it.
- Ollama serve always launches a fresh process under cookbook's tmux
  (no more monitor-mode reattach to a systemd/Docker ollama Stop can't
  reach). The handler pre-picks a free port by probing the target
  host over SSH and mutates req.cmd's OLLAMA_HOST so the runner script
  AND the auto-registered endpoint agree on the same bind port.
- Auto-register uses host.docker.internal (when running inside Docker)
  instead of localhost, matching the URL /setup adds for Ollama by
  hand. Local cookbook serves now produce a chat-reachable endpoint
  on first launch.
- Cascade-delete: removing a scheduled cookbook task also deletes any
  linked calendar event (cookbook_task_id marker in the description).
- Tasks list groups cookbook_serve under a "Cookbook" category that
  sorts above the rest, so scheduler-launched serves are easy to find.
This commit is contained in:
pewdiepie-archdaemon
2026-06-05 14:41:43 +09:00
parent f8aaeab245
commit e2f449f4ef
12 changed files with 1434 additions and 67 deletions

View File

@@ -2283,6 +2283,7 @@
<script type="module" src="/static/js/chatStream.js"></script>
<script type="module" src="/static/js/chat.js?v=20260604s"></script>
<script type="module" src="/static/js/cookbook.js"></script>
<script src="/static/js/cookbookSchedule.js"></script>
<script type="module" src="/static/js/search-chat.js"></script>
<script type="module" src="/static/js/compare/index.js"></script>
<script type="module" src="/static/js/theme.js"></script>

View File

@@ -2094,7 +2094,7 @@ export function _renderRunningTab() {
// Edit serve — open the full serve panel (same as the edit icon),
// switching to this task's server first so the model is found.
if (task.type === 'serve' && task.payload?.repo_id) {
items.push({ label: 'Edit serve', action: 'edit-panel', custom: () => _openEdit() });
items.push({ label: 'Edit in serve panel', action: 'edit-panel', tooltip: 'Open the full Serve config panel pre-filled with this task — pick a different backend, change GPUs, edit env vars, then Launch from there', custom: () => _openEdit() });
}
// Save serve — save current launch config as a preset.
if (task.type === 'serve' && task.payload?._cmd) {
@@ -2107,7 +2107,7 @@ export function _renderRunningTab() {
// Edit command — only meaningful for serve tasks that aren't running.
// Lets the user tweak flags after a crash/error and relaunch.
if (task.type === 'serve' && task.status !== 'running' && task.payload?._cmd) {
items.push({ label: 'Edit command', action: 'edit', custom: async () => {
items.push({ label: 'Edit cmd & relaunch', action: 'edit', tooltip: 'Edit the raw vllm/llama-server cmd string in a dialog and relaunch immediately on the same host', custom: async () => {
const newCmd = await _promptEditServeCmd(task.payload._cmd);
if (newCmd == null) return; // cancelled
try {
@@ -2201,7 +2201,19 @@ export function _renderRunningTab() {
_copyText(last);
uiModule.showToast('Copied last 50 lines');
}});
items.push({ label: 'Remove', action: 'kill', danger: true });
// Label matches behavior — the kill handler ALWAYS first kills
// the live tmux session and (for serve tasks) deletes the
// matching model-endpoint, THEN animates the task card out.
// Just "Remove" hid that it stops the live serve too.
const _isLive = task.type === 'serve' && ['running', 'ready', 'loading', 'warming', 'starting'].includes(task.status || '');
items.push({
label: _isLive ? 'Stop and remove' : 'Remove',
action: 'kill',
tooltip: _isLive
? 'Kill the live tmux session, deregister the chat endpoint, and remove this row'
: 'Remove this row',
danger: true,
});
// Cancel = mobile-only dismiss item. Same pattern as the email kebab:
// the `dropdown-cancel-mobile` class is hidden on desktop and styled
// as a separated bottom row on mobile (border-top + extra padding).
@@ -2228,6 +2240,7 @@ export function _renderRunningTab() {
+ (item.danger ? ' cookbook-dropdown-danger' : '')
+ (item.mobileOnly ? ' dropdown-cancel-mobile' : '');
div.style.cssText = 'display:flex;align-items:center;gap:8px;';
if (item.tooltip) div.title = item.tooltip;
const ic = _MENU_ICONS[item.action] || '';
div.innerHTML = `<span style="display:inline-flex;flex-shrink:0;opacity:0.7;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${ic}</svg></span><span>${item.label}</span>`;
div.addEventListener('click', () => {
@@ -2347,22 +2360,57 @@ export function _renderRunningTab() {
_animateOutThenRemove(el, task.sessionId);
});
// Wire kill
el.querySelector('.cookbook-task-action-kill').addEventListener('click', () => {
// Wire kill — awaits the SSH/tmux kill and verifies the session is
// actually gone before removing the row. Previously fire-and-forget,
// which meant a failed kill (wrong remoteHost, SSH error, tmux server
// already exited) silently left the live serve running while the
// row disappeared from the UI.
el.querySelector('.cookbook-task-action-kill').addEventListener('click', async () => {
const outputText = el.querySelector('.cookbook-output-pre')?.textContent || task.output || '';
const isLive = task.type === 'serve' && ['running', 'ready', 'loading', 'warming', 'starting'].includes(task.status || '');
const ollamaUnload = _ollamaUnloadCommand(task, outputText);
if (ollamaUnload) {
fetch('/api/shell/exec', {
try {
await fetch('/api/shell/exec', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: ollamaUnload }),
});
} catch (_) { /* unload best-effort */ }
}
let killOk = true;
try {
const r = await fetch('/api/shell/exec', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: ollamaUnload }),
}).catch(() => {});
body: JSON.stringify({ command: _tmuxGracefulKill(task) }),
});
if (r.ok) {
const out = await r.json();
// Don't trust exit_code alone — tmux kill returns 0 even when
// there was nothing to kill. Verify the session is actually gone.
if (task.sessionId && isLive) {
try {
const probe = await fetch('/api/shell/exec', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: _tmuxCmd(task, `has-session -t ${task.sessionId}`) }),
});
if (probe.ok) {
const pj = await probe.json();
// has-session exits 0 when session STILL exists; non-zero = gone.
if ((pj.exit_code || 0) === 0) killOk = false;
}
} catch (_) { /* probe best-effort; trust kill */ }
}
} else {
killOk = false;
}
} catch (_) { killOk = false; }
if (!killOk) {
try { uiModule.showToast('Kill failed — session may still be running. Check `tmux ls` on the server.', 'error'); } catch (_) {}
return; // leave the row so the user can retry
}
fetch('/api/shell/exec', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: _tmuxGracefulKill(task) }),
}).catch(() => {});
if (task.type === 'serve' && task.payload) {
const endpointUrl = _endpointUrlForTask(task, outputText);
_removeEndpointByUrl(endpointUrl);
@@ -2401,7 +2449,13 @@ export function _renderRunningTab() {
if (targetBody) targetBody.appendChild(el);
else group.appendChild(el);
if (task.status === 'running') {
// Auto-attach the tmux output stream for any task whose underlying
// session could still be alive — not just 'running'. Scheduler-
// launched serves transition to 'ready' as soon as /v1/models
// responds; without this, the user opens the Running tab and sees
// only the placeholder ("Launched by scheduled task …") because
// _reconnectTask never fires for status 'ready'/'loading'/'warming'.
if (['running', 'ready', 'loading', 'warming', 'starting'].includes(task.status)) {
_reconnectTask(el, task);
}
}

View File

@@ -0,0 +1,386 @@
// Cookbook Schedule — opens a small inline form (styled with the app's
// existing .cookbook-* classes) that creates a ScheduledTask with
// action=cookbook_serve. Mounted from two places:
//
// 1. The ^ button next to Launch in a serve panel.
// 2. The "Schedule…" entry in the cached-model ⋯ dropdown menu (which
// programmatically clicks the ^ button so this module owns the
// single source of truth).
//
// Feedback uses uiModule.showToast() — the same toast the rest of the
// app uses for "Saved", "Favorited", etc. — so the success message
// doesn't introduce a parallel notification style.
//
// To remove: delete this file + the <script> tag in index.html + the
// ^ button in cookbookServe.js + the "cookbook_serve" entry in
// BUILTIN_ACTIONS + src/cookbook_serve_lifecycle.py + its
// registration line in app.py.
try { (function () {
function _safe(fn) {
return function () {
try { return fn.apply(this, arguments); }
catch (e) { try { console.warn("[cookbookSchedule]", e); } catch (_) {} }
};
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
// Cached handle to the ui.js showToast function. Bound lazily on
// first use because ui.js is an ES module — it's not on `window`
// unless something else has explicitly exposed it.
let _toastFn = null;
async function _getToast() {
if (_toastFn) return _toastFn;
try {
const m = await import("/static/js/ui.js");
_toastFn = m.default?.showToast || m.showToast || null;
} catch (_) { _toastFn = null; }
return _toastFn;
}
// Optional opts: {action, onAction, duration, leadingIcon}
async function toast(msg, opts) {
const fn = await _getToast();
if (fn) {
try { fn(msg, opts); return; } catch (_) {}
}
try { console.log("[toast]", msg); } catch (_) {}
}
// Cached handle to the tasks module so the success toast's "Open"
// action can jump straight to the new task in the Tasks tab.
let _tasksMod = null;
async function _getTasksMod() {
if (_tasksMod) return _tasksMod;
try { _tasksMod = await import("/static/js/tasks.js"); } catch (_) {}
return _tasksMod;
}
async function openTaskInTasksTab(taskId) {
const m = await _getTasksMod();
if (m && typeof m.openTasks === "function") {
try { m.openTasks(taskId); return; } catch (_) {}
}
// Last-resort fallback: click the sidebar Tasks button.
document.getElementById("tool-tasks-btn")?.click();
}
const DAYS = [
{ k: "MO", l: "Mon", idx: 0 },
{ k: "TU", l: "Tue", idx: 1 },
{ k: "WE", l: "Wed", idx: 2 },
{ k: "TH", l: "Thu", idx: 3 },
{ k: "FR", l: "Fri", idx: 4 },
{ k: "SA", l: "Sat", idx: 5 },
{ k: "SU", l: "Sun", idx: 6 },
];
const WEEKDAYS = new Set(["MO","TU","WE","TH","FR"]);
// Resolve the model identity from the closest .memory-item card —
// that's the canonical container the cookbook serve UI uses, with
// the model repo on data-repo. We do NOT grab the title via
// textContent, because the title row also contains inline status
// pills ("running", "downloading") and an "HF ↗" link — pulling all
// of it in turns a clean preset name like "Qwen3.5-397B-A17B-AWQ"
// into "Qwen3.5-397B-A17B-AWQ running HF ↗", which then fails the
// preset lookup in action_cookbook_serve.
function readPanelConfig(arrowBtn) {
const item = arrowBtn.closest(".memory-item") || arrowBtn.closest(".hwfit-cached-item");
const panel = arrowBtn.closest(".hwfit-serve-panel");
const repo = item?.dataset?.repo
|| arrowBtn.closest(".hwfit-serve-panel")?.dataset?.repo
|| "";
// Title = last segment of the repo (after the final /), which is
// exactly what the cookbook UI renders in the card title and what
// the preset registry uses as its short name. e.g.
// cyankiwi/Qwen3.5-397B-A17B-AWQ → Qwen3.5-397B-A17B-AWQ
// Falls back to data-modelName or the bare repo for ollama-style
// entries that don't have a slash.
let title = "";
if (repo) {
title = repo.includes("/") ? repo.split("/").pop() : repo;
}
if (!title) {
title = item?.dataset?.modelName || "model";
}
return { panel, item, title, repo_id: repo, host: item?.dataset?.host || "" };
}
function buildFormHtml(cfg) {
return `
<div class="hwfit-schedule-form cookbook-panel">
<div class="hwfit-schedule-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
<span class="hwfit-schedule-title-text">Schedule serve: <strong>${esc(cfg.title)}</strong></span>
<span class="hwfit-schedule-title-spacer"></span>
<label class="hwfit-schedule-mirror-toggle" title="Also create a calendar event on the Cookbook calendar">
<span class="hwfit-schedule-mirror-label">Create event in calendar</span>
<span class="admin-switch hwfit-schedule-mirror-switch">
<input type="checkbox" class="hwfit-sched-calendar-mirror" />
<span class="admin-slider"></span>
</span>
</label>
</div>
<div class="hwfit-schedule-row">
<label class="hwfit-schedule-field">
<span>From</span>
<input type="time" class="hwfit-sched-start cookbook-field-input" value="09:00" />
</label>
<label class="hwfit-schedule-field">
<span>Until</span>
<input type="time" class="hwfit-sched-end cookbook-field-input" value="17:00" />
</label>
</div>
<div class="hwfit-schedule-row hwfit-schedule-days-row">
<span class="hwfit-schedule-label">Days</span>
<div class="hwfit-sched-days">
${DAYS.map(d => `
<button type="button" class="hwfit-sched-day-chip${WEEKDAYS.has(d.k) ? " is-on" : ""}" data-day="${d.k}">${d.l}</button>
`).join("")}
</div>
<span class="hwfit-schedule-actions-spacer"></span>
<button type="button" class="cookbook-btn hwfit-sched-cancel" title="Cancel">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;flex-shrink:0;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<span>Cancel</span>
</button>
<button type="button" class="cookbook-btn hwfit-sched-save" title="Save schedule" aria-label="Save schedule">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;flex-shrink:0;"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
<span>Save</span>
</button>
</div>
<div class="hwfit-sched-err"></div>
</div>`;
}
function openForm(arrowBtn) {
const cfg = readPanelConfig(arrowBtn);
const anchor = cfg.panel
|| cfg.item
|| arrowBtn.closest(".cookbook-saved-item")
|| arrowBtn.parentElement?.parentElement
|| arrowBtn.parentElement;
if (!anchor) {
toast("Couldn't find a panel to mount the schedule form");
return;
}
// Toggle.
const existing = anchor.querySelector(".hwfit-schedule-form");
if (existing) { existing.remove(); return; }
const tmp = document.createElement("div");
tmp.innerHTML = buildFormHtml(cfg);
const form = tmp.firstElementChild;
anchor.appendChild(form);
setTimeout(() => {
try { form.scrollIntoView({ behavior: "smooth", block: "nearest" }); } catch (_) {}
}, 50);
wireForm(form, cfg);
}
function wireForm(form, cfg) {
form.querySelectorAll(".hwfit-sched-day-chip").forEach(chip => {
chip.addEventListener("click", () => chip.classList.toggle("is-on"));
});
form.querySelector(".hwfit-sched-cancel").addEventListener("click", () => form.remove());
form.querySelector(".hwfit-sched-save").addEventListener("click", _safe(async () => {
const startTime = form.querySelector(".hwfit-sched-start").value;
const endTime = form.querySelector(".hwfit-sched-end").value;
const days = Array.from(form.querySelectorAll(".hwfit-sched-day-chip.is-on")).map(c => c.dataset.day);
const mirrorToCalendar = !!form.querySelector(".hwfit-sched-calendar-mirror")?.checked;
const errEl = form.querySelector(".hwfit-sched-err");
errEl.textContent = "";
errEl.classList.remove("is-visible");
function fail(msg) {
errEl.textContent = msg;
errEl.classList.add("is-visible");
}
if (!/^\d\d:\d\d$/.test(startTime) || !/^\d\d:\d\d$/.test(endTime)) {
return fail("Start and end must be HH:MM");
}
if (!days.length) {
return fail("Pick at least one day");
}
const [sh, sm] = startTime.split(":").map(Number);
const [eh, em] = endTime.split(":").map(Number);
let dur = (eh * 60 + em) - (sh * 60 + sm);
if (dur <= 0) dur += 24 * 60;
// The backend stores scheduled_time as UTC. The user picks
// wall-clock LOCAL time. Without converting, "09:55" in a UTC+9
// timezone gets stored as 09:55 UTC = 18:55 local → next-run
// shows ~9 hours later instead of "in 5 min". Mirror what
// tasks.js does via its _localTimeToUtc helper.
const _localHHMMToUtc = (hhmm) => {
const [h, m] = hhmm.split(":").map(Number);
const d = new Date();
d.setHours(h, m, 0, 0);
return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
};
const startUtc = _localHHMMToUtc(startTime);
const [shUtc, smUtc] = startUtc.split(":").map(Number);
const allDays = days.length === 7;
const weekdaysOnly = days.length === 5 && ["MO","TU","WE","TH","FR"].every(d => days.includes(d));
const sched = {};
if (allDays) {
sched.schedule = "daily";
sched.scheduled_time = startUtc;
} else if (weekdaysOnly) {
sched.schedule = "cron";
sched.cron_expression = `${smUtc} ${shUtc} * * 1-5`;
} else if (days.length === 1) {
const dayIdx = DAYS.find(d => d.k === days[0]).idx;
sched.schedule = "weekly";
sched.scheduled_time = startUtc;
sched.scheduled_day = dayIdx;
} else {
const dayNum = days.map(k => {
const i = DAYS.find(d => d.k === k).idx;
return i === 6 ? 0 : i + 1;
});
sched.schedule = "cron";
sched.cron_expression = `${smUtc} ${shUtc} * * ${dayNum.join(",")}`;
}
// Name: "Serve: <full model name>" — pulled from .memory-item-title
// so it's the user's display name (e.g. "Qwen3.5-397B-A17B-AWQ")
// not a placeholder like "model".
const fullName = (cfg.title || cfg.repo_id || "").trim() || "model";
const payload = {
name: `Serve: ${fullName}`,
task_type: "action",
action: "cookbook_serve",
trigger_type: "schedule",
prompt: JSON.stringify({
preset: fullName,
repo_id: cfg.repo_id || "",
host: cfg.host || "",
end_after_min: dur,
}),
...sched,
};
const saveBtn = form.querySelector(".hwfit-sched-save");
saveBtn.disabled = true;
saveBtn.textContent = "Saving…";
try {
const r = await fetch("/api/tasks", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await r.json();
if (!r.ok || data.error) {
fail(data.error || data.detail || `HTTP ${r.status}`);
saveBtn.disabled = false;
saveBtn.textContent = "Save schedule";
toast(`Schedule save failed: ${data.error || data.detail || r.status}`);
return;
}
if (mirrorToCalendar) {
// Mirror onto a dedicated "Cookbook" calendar so the user can
// toggle the whole set on/off as a unit in the calendar UI.
// Best-effort: if anything here fails, we still consider the
// task creation a success (the task itself works regardless).
try {
const calsRes = await fetch("/api/calendar/calendars", { credentials: "same-origin" });
const calsBody = calsRes.ok ? await calsRes.json() : {};
let cookbookCal = (calsBody.calendars || []).find(c => (c.name || "").toLowerCase() === "cookbook");
if (!cookbookCal) {
const mk = await fetch("/api/calendar/calendars?name=Cookbook&color=%233b82f6", {
method: "POST", credentials: "same-origin",
});
if (mk.ok) {
const mkData = await mk.json();
// The create endpoint returns {ok, id, name, color}; the
// list endpoint returns {href, name, color}. The two map
// 1:1 (href === id) so we synthesize the same shape.
cookbookCal = { href: mkData.id, name: mkData.name, color: mkData.color };
}
}
// The `cookbook_task_id:` marker on its own line lets
// calendar.js's event-form code detect that this event was
// created from a Cookbook schedule and render an
// "Open task" button alongside the description, so the user
// can jump straight to the source task from the calendar UI.
const evBody = {
summary: payload.name,
dtstart: new Date().toISOString(),
dtend: new Date(Date.now() + dur * 60 * 1000).toISOString(),
all_day: false,
description: `Auto-mirrored from Cookbook schedule task ${data.id || ""}.\n`
+ `Edit/delete the task in the Tasks tab — this event will follow.\n`
+ `cookbook_task_id: ${data.id || ""}`,
rrule: weekdaysOnly
? "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"
: (sched.schedule === "weekly" ? `FREQ=WEEKLY;BYDAY=${days.join(",")}`
: (sched.schedule === "daily" ? "FREQ=DAILY" : "FREQ=WEEKLY")),
color: "#3b82f6",
};
if (cookbookCal?.href) evBody.calendar_href = cookbookCal.href;
const evRes = await fetch("/api/calendar/events", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(evBody),
});
const evData = evRes.ok ? await evRes.json() : null;
// Stash the event uid + calendar href on the task's prompt
// JSON so the task-delete hook can cascade the calendar
// cleanup. PATCH the task with an updated prompt.
if (evData && (evData.uid || evData.id)) {
const eventUid = evData.uid || evData.id;
try {
const updatedPrompt = JSON.stringify({
...JSON.parse(payload.prompt),
cookbook_event_uid: eventUid,
cookbook_event_calendar: cookbookCal?.href || "",
});
// /api/tasks/{id} accepts PUT, not PATCH — sending PATCH
// here silently failed (no such method on that route), so
// the task never got the cookbook_event_uid marker and the
// server-side delete-cascade had nothing to follow when the
// user later deleted the task.
await fetch(`/api/tasks/${encodeURIComponent(data.id)}`, {
method: "PUT", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: updatedPrompt }),
});
} catch (_) {}
}
} catch (_) {}
}
form.remove();
const newTaskId = data.id || data.task_id || "";
toast(`Created task: Serve: ${fullName}`, {
leadingIcon: "check",
action: "Open",
duration: 5000,
onAction: () => openTaskInTasksTab(newTaskId),
});
} catch (e) {
fail(String(e));
saveBtn.disabled = false;
saveBtn.textContent = "Save schedule";
toast(`Schedule save failed: ${e}`);
}
}));
}
document.addEventListener("click", _safe((e) => {
const arrow = e.target.closest && e.target.closest(".hwfit-serve-schedule-arrow");
if (!arrow) return;
e.preventDefault();
e.stopPropagation();
openForm(arrow);
}));
})(); } catch (e) { try { console.warn("[cookbookSchedule] top-level error:", e); } catch (_) {} }

View File

@@ -294,19 +294,23 @@ function _rerenderCachedModels() {
}
const ggufCount = _runnableGgufFiles(m).length;
if (ggufCount > 1) metaParts.push(`${ggufCount} GGUFs`);
if (m.status === 'downloading') {
const _active = _isActivelyDownloading(m.repo_id);
metaParts.push(`<span class="cookbook-dl-status" style="color:var(--accent,var(--red));">${_active ? 'downloading' : 'download stalled'}</span>`);
}
// "downloading" status now renders as a title-row pill instead of
// a meta-row text label, matching the "running" pill style and
// living on the same line as the model name.
const _isDownloading = m.status === 'downloading';
const _isDlActive = _isDownloading ? _isActivelyDownloading(m.repo_id) : false;
const isSelectMode = document.getElementById('hwfit-cache-select')?.classList.contains('active');
html += `<div class="doclib-card memory-item" data-repo="${esc(m.repo_id)}" data-tag="${m._tag || ''}" data-family="${m._family || ''}" style="cursor:pointer;">`;
html += `<span class="serve-select-cb memory-select-dot" style="display:${isSelectMode ? 'inline-block' : 'none'};cursor:pointer;"></span>`;
html += `<div style="flex:1;min-width:0;">`;
const _mc = modelColor(m.repo_id) || '';
const _runningPill = _isActivelyServing(m.repo_id)
? ' <span class="cookbook-serve-running-pill" title="This model is currently being served">running</span>'
? ` <span class="cookbook-serve-running-pill is-clickable" title="This model is currently being served — click to open in Running" data-repo="${esc(m.repo_id)}" role="button" tabindex="0">running</span>`
: '';
html += `<div class="memory-item-title"${_mc ? ` style="color:${_mc}"` : ''}>${modelLogo(m.repo_id)}${esc(shortName)}${hfLink ? ` <a href="${esc(hfLink)}" target="_blank" rel="noopener" class="cookbook-hf-link">HF ↗</a>` : ''}${_runningPill}</div>`;
const _downloadingPill = _isDownloading
? ` <span class="cookbook-serve-downloading-pill${_isDlActive ? '' : ' is-stalled'}" title="${_isDlActive ? 'Download in progress' : 'Download stalled — retry to resume'}">${_isDlActive ? 'downloading' : 'stalled'}</span>`
: '';
html += `<div class="memory-item-title"${_mc ? ` style="color:${_mc}"` : ''}>${modelLogo(m.repo_id)}${esc(shortName)}${hfLink ? ` <a href="${esc(hfLink)}" target="_blank" rel="noopener" class="cookbook-hf-link">HF ↗</a>` : ''}${_runningPill}${_downloadingPill}</div>`;
html += `<div class="memory-item-meta" style="font-size:10px;opacity:0.4;margin-top:2px;">${metaParts.join(' \u00b7 ')}</div>`;
html += `</div>`;
const _bk = _detectBackend(m).backend;
@@ -388,9 +392,11 @@ function _rerenderCachedModels() {
const _retryIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>';
const _deleteIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>';
const _selectIco = '<span style="font-size:16px;line-height:1;position:relative;top:-2px;">●</span>';
const _schedIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
const items = [];
if (m && m.status === 'ready') items.push({ label: 'Serve', icon: _serveIco, action: 'serve' });
if (m && m.status === 'downloading') items.push({ label: 'Retry', icon: _retryIco, action: 'retry' });
if (m && m.status === 'ready') items.push({ label: 'Schedule…', icon: _schedIco, action: 'schedule' });
items.push({ label: 'Select', icon: _selectIco, action: 'select' });
items.push({ label: 'Delete', icon: _deleteIco, action: 'delete', danger: true });
for (const opt of items) {
@@ -402,6 +408,16 @@ function _rerenderCachedModels() {
if (opt.action === 'serve') item.click();
else if (opt.action === 'delete') _deleteCachedModel(repo, item, false, m);
else if (opt.action === 'retry') _retryCachedModel(repo, m);
else if (opt.action === 'schedule') {
// Same entry point as the ^ button next to Launch — let
// cookbookSchedule.js handle it. Expand the panel first
// so the form has somewhere to mount.
if (!item.querySelector('.hwfit-serve-panel')) item.click();
setTimeout(() => {
const arrow = item.querySelector('.hwfit-serve-schedule-arrow');
if (arrow) arrow.click();
}, 120);
}
else if (opt.action === 'select') {
const selectBtn = document.getElementById('hwfit-cache-select');
const bulkBar = document.getElementById('serve-bulk-bar');
@@ -743,8 +759,16 @@ function _rerenderCachedModels() {
// Copy moved inside the command textarea (top-right). Spacer then
// pushes Cancel + Launch to the right.
panelHtml += `<span class="hwfit-serve-actions-spacer"></span>`;
panelHtml += `<button class="cookbook-btn hwfit-serve-cancel" type="button" title="Close this configuration panel">Cancel</button>`;
panelHtml += `<button class="cookbook-btn hwfit-serve-cancel" type="button" title="Close this configuration panel"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;flex-shrink:0;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>Cancel</button>`;
// Launch + a small ^ that opens an inline schedule form. The form
// creates a ScheduledTask (action=cookbook_serve), so the schedule
// ends up in the existing Tasks UI for edit/delete/pause.
panelHtml += `<span class="hwfit-serve-launch-group">`;
panelHtml += `<button class="cookbook-btn hwfit-serve-launch"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:4px;flex-shrink:0;"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>Launch</button>`;
// Chevron points DOWN because the schedule form opens beneath the
// panel — the arrow signals the direction of motion, not menu state.
panelHtml += `<button class="cookbook-btn hwfit-serve-schedule-arrow" type="button" aria-haspopup="true" aria-label="Schedule this serve on a recurring window" title="Schedule this serve as a recurring task"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>`;
panelHtml += `</span>`;
panelHtml += `</div>`;
panelHtml += `</div>`;
@@ -1748,6 +1772,56 @@ function _rerenderCachedModels() {
// hiccuped (the user can read the real error in the task output).
}
}
// Pre-launch PORT probe — second most common failure pattern is
// collision with an already-running server (vllm crashing with
// "Address already in use" because Ollama owns 11434, or a
// previous vllm on the same port wasn't killed). The post-mortem
// "Suggested action: Kill existing vLLM" came AFTER the failed
// launch — user wants to know BEFORE clicking Launch. Parse the
// port out of the cmd, ssh-check who owns it on the target host,
// and offer to abort or proceed.
try {
const _portMatch = launchCmd.match(/(?:^|\s)(?:--port|-p|--host\s+\S+\s+--port)\s+(\d{2,5})\b/)
|| launchCmd.match(/(?:^|\s)--port=(\d{2,5})\b/)
|| launchCmd.match(/OLLAMA_HOST=[^:\s]+:(\d{2,5})\b/);
const _port = _portMatch ? _portMatch[1] : '';
if (_port) {
const _portHost = (_envState.remoteHost || '').trim();
const _checkInner = `ss -tlnp 2>/dev/null | awk '$4 ~ /:${_port}$/ {print; exit}' || netstat -tlnp 2>/dev/null | awk '$4 ~ /:${_port}$/ {print; exit}'`;
const _cmd = _portHost
? `ss h ${_portHost} <<<"" 2>/dev/null; ssh -o ConnectTimeout=4 -o StrictHostKeyChecking=no ${_portHost} ${JSON.stringify(_checkInner)}`
: _checkInner;
const _res = await fetch('/api/shell/exec', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: _cmd }),
});
const _data = await _res.json().catch(() => ({}));
const _stdout = (_data.stdout || '').trim();
if (_stdout) {
// Try to surface the process name from `users:(("name",pid=...,...))`.
const _procMatch = _stdout.match(/users:\(\("([^"]+)",pid=(\d+)/);
const _procDesc = _procMatch
? `${_procMatch[1]} (PID ${_procMatch[2]})`
: 'another process';
const _hostLabel = _portHost ? _portHost : 'this host';
const _proceed = await window.styledConfirm(
`Port ${_port} on ${_hostLabel} is already in use by ${_procDesc}. Launching ${serveState.backend.toUpperCase()} now will fail with "Address already in use".\n\nStop the existing process first, OR change the --port in the command above, OR launch anyway and watch it crash.`,
{
title: `Port ${_port} taken`,
confirmText: 'Launch anyway',
cancelText: 'Cancel',
danger: true,
},
);
if (!_proceed) { _restoreLaunchBtn(); return; }
}
}
} catch {
// Probe failure — don't block. If the port check can't run we'd
// rather let the launch try than silently refuse.
}
// Save in the { _byRepo, _lastUsed } schema — no legacy flat keys at
// the root so per-model state doesn't leak between models.
try {
@@ -1801,7 +1875,12 @@ function _rerenderCachedModels() {
// Copy button — now icon-only, so flash a green checkmark on success
// instead of swapping to text (which would also break the width).
panel.querySelector('.hwfit-serve-copy').addEventListener('click', () => {
panel.querySelector('.hwfit-serve-copy').addEventListener('click', (e) => {
// Without stopPropagation the click bubbles up to the
// .doclib-card click handler that toggles the expand state →
// copying collapses the whole serve panel mid-flight.
e.preventDefault();
e.stopPropagation();
const cmd = panel.querySelector('.hwfit-serve-cmd').value;
_copyText(cmd).then(() => {
const btn = panel.querySelector('.hwfit-serve-copy');
@@ -2177,3 +2256,39 @@ export function initServe(shared) {
}
export { _cachedAllModels, _filterCachedList, _rerenderCachedModels, _deleteCachedModel };
// Click the "running" pill on a serve-card → switch to Cookbook → Running
// tab and scroll the matching task into view, with a brief flash so the
// user can find it among a long list. Tracks the click via event
// delegation so it survives every _rerenderCachedModels() pass.
function _openRunningTabForRepo(repo) {
const body = document.querySelector('#cookbook-modal .cookbook-body');
if (!body) return;
const runTab = body.querySelector('.cookbook-tab[data-backend="Running"]');
if (runTab) runTab.click();
// The Running tab needs a tick to mount/render before we can find
// task cards inside it.
setTimeout(() => {
const candidates = Array.from(body.querySelectorAll('.cookbook-task'));
const match = candidates.find(c => {
// task cards expose modelId or name via dataset / inner title
const dsRepo = c.dataset?.modelId || c.dataset?.repoId || '';
if (dsRepo === repo) return true;
const title = c.querySelector('.cookbook-task-title, .memory-item-title')?.textContent?.trim() || '';
return title === repo || title === (repo.split('/').pop() || '');
});
if (match) {
try { match.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (_) {}
match.classList.add('cookbook-task-flash');
setTimeout(() => match.classList.remove('cookbook-task-flash'), 1600);
}
}, 180);
}
document.addEventListener('click', (e) => {
const pill = e.target.closest && e.target.closest('.cookbook-serve-running-pill.is-clickable');
if (!pill) return;
e.preventDefault();
e.stopPropagation();
const repo = pill.dataset.repo || '';
if (repo) _openRunningTabForRepo(repo);
});

View File

@@ -504,8 +504,13 @@ const _CATEGORY_MAP = {
ssh_command: 'System',
run_script: 'System',
run_local: 'System',
cookbook_serve: 'Cookbook',
};
const _CATEGORY_ORDER = ['Other', 'Calendar', 'Email', 'Chats', 'Documents', 'Memory', 'Research', 'Skills', 'Assistant', 'System'];
// Cookbook serves listed FIRST so a just-saved schedule shows at the
// top instead of scrolling off the bottom of the list. The remaining
// order is preserved for backwards-compatibility with users who've
// learned where things are.
const _CATEGORY_ORDER = ['Cookbook', 'Other', 'Calendar', 'Email', 'Chats', 'Documents', 'Memory', 'Research', 'Skills', 'Assistant', 'System'];
const _CATEGORY_ICONS = {
Calendar: '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
Email: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
@@ -516,6 +521,8 @@ const _CATEGORY_ICONS = {
Skills: '<path d="M9 11l3 3L22 4"/><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M4 4.5A2.5 2.5 0 0 1 6.5 2H20v15H6.5A2.5 2.5 0 0 0 4 19.5z"/>',
Assistant: '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 18a5 5 0 0 1 10 0"/>',
System: '<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>',
// Cookbook icon — matches the recipe-book glyph used on the sidebar.
Cookbook: '<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>',
Other: '<circle cx="12" cy="12" r="3"/>',
};
@@ -955,9 +962,13 @@ function _showPresetPicker() {
let html = '<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">';
html += '<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;"><h2 style="margin:0;padding:0;line-height:1;">Add Task</h2></div>';
html += '<p class="memory-desc" style="position:relative;top:4px;">Describe a task for the AI to draft, or pick a type below to set one up manually.</p>';
html += '<div class="task-ai-compose" style="display:flex;gap:6px;margin:6px 0 10px;">'
+ '<input type="text" id="task-ai-input" class="memory-search-input" style="flex:1;" placeholder="Describe a task — e.g. &quot;every weekday 7am summarize my unread email&quot;" />'
+ '<button class="memory-toolbar-btn active" id="task-ai-btn" title="Draft a task with AI" style="white-space:nowrap;height:28px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:3px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Draft with AI</button>'
// flex-wrap + min-width:0 on the input lets the row collapse cleanly
// on narrow modal widths instead of pushing the AI button past the
// right edge. margin-left:-4px nudges the compose row 4px into the
// description bar above so the input lines up with it visually.
html += '<div class="task-ai-compose" style="display:flex;gap:6px;margin:6px 0 10px -4px;flex-wrap:wrap;align-items:center;">'
+ '<input type="text" id="task-ai-input" class="memory-search-input" style="flex:1 1 220px;min-width:0;" placeholder="Describe a task — e.g. &quot;every weekday 7am summarize my unread email&quot;" />'
+ '<button class="memory-toolbar-btn active" id="task-ai-btn" title="Draft a task with AI" style="white-space:nowrap;height:28px;flex:0 0 auto;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:3px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Draft with AI</button>'
+ '</div>';
html += '<div class="memory-list" style="max-height:none;flex:1;gap:0px;margin-top:2px;padding-right:8px;">';
_TASK_PRESETS.forEach((p, i) => {

View File

@@ -14059,8 +14059,28 @@ body:has(.doc-version-panel:not(.hidden)) .hamburger-btn {
.admin-model-form-row {
display: flex;
gap: 6px;
flex-wrap: wrap; /* let buttons drop to a new row on narrow widths
instead of overflowing the modal */
align-items: center;
}
.admin-model-form-row input {
flex: 1 1 180px; /* api-key input takes the long axis but allows
dropping below the buttons at small widths */
min-width: 0; /* don't refuse to shrink past content width */
}
.admin-model-form-row select {
flex: 0 0 auto;
}
.admin-model-form-row button {
flex: 0 0 auto;
white-space: nowrap;
}
/* On narrow screens, give buttons their own row + push the Add button
to the right so it remains the obvious primary action. */
@media (max-width: 540px) {
.admin-model-form-row input { flex-basis: 100%; }
.admin-model-form-row .admin-btn-add { margin-left: auto; }
}
.admin-model-form-row input { flex: 1; }
.adm-ep-inline-msg {
min-height: 16px;
margin-top: 5px;
@@ -18219,8 +18239,12 @@ body.gallery-selecting .gallery-dl-btn,
color: var(--fg-muted);
letter-spacing: 0.2px;
}
/* "running" pill on a Serve-tab card when the model has a live serve task. */
.cookbook-serve-running-pill {
/* Status pills shown inline in a Serve-tab card title (next to the model
name) when the model has a live serve / download task. Shared base
class so "running" and "downloading" sit on the same row with the
same chrome; only color varies. */
.cookbook-serve-running-pill,
.cookbook-serve-downloading-pill {
display: inline-block;
margin-left: 6px;
padding: 1px 7px;
@@ -18231,11 +18255,50 @@ body.gallery-selecting .gallery-dl-btn,
letter-spacing: 0.3px;
vertical-align: 2px;
position: relative;
top: -1px;
top: 1px; /* nudged down 2px from the old -1px so it sits
flush with the cap-height of the title text */
}
.cookbook-serve-running-pill {
color: var(--accent, var(--red));
background: color-mix(in srgb, var(--accent, var(--red)) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--accent, var(--red)) 35%, transparent);
}
.cookbook-serve-downloading-pill {
color: var(--accent, var(--red));
background: color-mix(in srgb, var(--accent, var(--red)) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--accent, var(--red)) 35%, transparent);
opacity: 0.85;
}
.cookbook-serve-downloading-pill.is-stalled {
/* Stalled downloads stay visible but read as warning, not progress. */
color: var(--fg-muted, #888);
background: color-mix(in srgb, var(--fg-muted, #888) 10%, transparent);
border-color: color-mix(in srgb, var(--fg-muted, #888) 30%, transparent);
opacity: 1;
}
.cookbook-serve-running-pill.is-clickable {
cursor: pointer;
transition: background 0.12s, border-color 0.12s;
}
.cookbook-serve-running-pill.is-clickable:hover {
background: color-mix(in srgb, var(--accent, var(--red)) 22%, transparent);
border-color: color-mix(in srgb, var(--accent, var(--red)) 55%, transparent);
}
/* Brief highlight on the matched task card when jumping from the
running pill, so the user can spot it among a long list. */
.cookbook-task-flash {
animation: cookbook-task-flash-anim 1.6s ease-out;
}
@keyframes cookbook-task-flash-anim {
0% { box-shadow: 0 0 0 2px var(--accent, var(--red)); }
100% { box-shadow: 0 0 0 2px transparent; }
}
/* Cookbook header "downloading" status label sits 2px too far left
against the rest of the cookbook chrome nudge it right. */
#cookbook-bg-status {
left: 2px;
}
.cookbook-serve-dir-edit {
font-size: 9px;
color: var(--fg-muted);
@@ -33535,8 +33598,15 @@ button.cal-view-btn {
of days it covers within the row, --slot stacks parallel bars. */
.cal-multiday {
position: absolute;
left: calc(var(--col, 0) * (100% / 7));
width: calc(var(--span, 1) * (100% / 7));
/* Fractional offsets let timed events that cross midnight render at
true proportional width. start-frac shifts the left edge into the
first day; end-frac trims the right edge inside the last day.
All-day events default to (0, 1) and still fill whole columns. */
left: calc((var(--col, 0) + var(--start-frac, 0)) * (100% / 7));
width: calc(
(var(--span, 1) - var(--start-frac, 0) - (1 - var(--end-frac, 1)))
* (100% / 7)
);
top: calc(2px + var(--slot, 0) * 12px);
height: 11px;
font-size: 8px;
@@ -35925,3 +35995,149 @@ body.theme-frosted .modal {
padding: 10px 18px;
border-top: 1px solid var(--border);
}
/* Cookbook serve panel: Launch + ^ split button pair */
.hwfit-serve-launch-group {
display: inline-flex;
align-items: stretch;
vertical-align: middle;
}
.hwfit-serve-launch-group .hwfit-serve-launch {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
margin-right: 0 !important;
}
.hwfit-serve-launch-group .hwfit-serve-schedule-arrow {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
border-left: 1px solid var(--border) !important;
padding: 0 8px !important;
min-width: 26px;
display: inline-flex !important;
align-items: center;
justify-content: center;
}
/* Schedule form mounted inside the cookbook serve panel. Uses the
theme tokens (--bg, --panel, --border, --accent, --red) so it
matches the rest of the cookbook chrome instead of inline whites. */
.hwfit-schedule-form {
margin: 10px 0 4px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
display: flex;
flex-direction: column;
gap: 10px;
}
.hwfit-schedule-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
opacity: 0.95;
flex-wrap: wrap;
}
.hwfit-schedule-title svg { opacity: 0.7; flex-shrink: 0; }
.hwfit-schedule-title-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hwfit-schedule-title-spacer { flex: 1; min-width: 8px; }
.hwfit-schedule-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.hwfit-schedule-field {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 11px;
opacity: 0.75;
}
.hwfit-schedule-field input[type="time"] {
font: inherit;
min-width: 90px;
}
.hwfit-schedule-label {
font-size: 11px;
opacity: 0.75;
}
.hwfit-sched-days {
display: inline-flex;
flex-wrap: wrap;
gap: 5px;
}
.hwfit-sched-day-chip {
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid var(--border);
background: transparent;
color: inherit;
font: inherit;
font-size: 10px;
line-height: 1;
padding: 0;
cursor: pointer;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.hwfit-sched-day-chip:hover { border-color: var(--accent, var(--red)); }
.hwfit-sched-day-chip.is-on {
background: var(--accent, var(--red));
color: var(--panel, #fff);
border-color: var(--accent, var(--red));
}
.hwfit-schedule-actions-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.hwfit-schedule-actions-spacer { flex: 1; }
/* Days row hosts the day-chip strip on the left and the Cancel / Save
action buttons on the right. The spacer between them pushes the
actions to the right edge. */
.hwfit-schedule-days-row {
align-items: center;
}
/* Cancel + Save sit on the right side of the days row with matching
icon-plus-label chrome. */
.hwfit-sched-cancel,
.hwfit-sched-save {
display: inline-flex !important;
align-items: center;
}
.hwfit-schedule-mirror-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
opacity: 0.9;
cursor: pointer;
user-select: none;
}
.hwfit-schedule-mirror-label { white-space: nowrap; }
.hwfit-schedule-mirror-switch { transform: scale(0.85); transform-origin: left center; }
.hwfit-sched-err {
font-size: 12px;
color: var(--red, #ff6b6b);
display: none;
}
.hwfit-sched-err.is-visible { display: block; }
@media (max-width: 600px) {
.hwfit-schedule-row { gap: 8px; }
.hwfit-sched-day-chip { width: 36px; height: 36px; font-size: 11px; }
}
/* Brief outline flash on a note card when it's the target of a
#note-<id> link click from chat same pattern as the cookbook
task flash, just scoped to .note-card. */
.note-card-flash {
animation: note-card-flash-anim 1.6s ease-out;
}
@keyframes note-card-flash-anim {
0% { box-shadow: 0 0 0 2px var(--accent, var(--red)); }
100% { box-shadow: 0 0 0 2px transparent; }
}