This commit is contained in:
+322
-44
@@ -26,7 +26,10 @@ const ollamaDownloadButton = document.getElementById("ollama-download");
|
||||
const ollamaInstallButton = document.getElementById("ollama-install");
|
||||
const ollamaLaunchButton = document.getElementById("ollama-launch");
|
||||
const ollamaPullButton = document.getElementById("ollama-pull");
|
||||
const codexLoginButton = document.getElementById("codex-login");
|
||||
const openaiModelsRefreshButton = document.getElementById("openai-models-refresh");
|
||||
const providerModelSelect = document.getElementById("provider-model-select");
|
||||
const modelReasoningEffortSelect = document.getElementById("model-reasoning-effort");
|
||||
const ollamaStatusEl = document.getElementById("ollama-status");
|
||||
const ollamaMessageEl = document.getElementById("ollama-message");
|
||||
const updateCheckButton = document.getElementById("update-check");
|
||||
@@ -57,6 +60,7 @@ const planForm = document.getElementById("plan-form");
|
||||
const plansStatusEl = document.getElementById("plans-status");
|
||||
const plansDashboardEl = document.getElementById("plans-dashboard");
|
||||
const plansRailListEl = document.getElementById("plans-rail-list");
|
||||
const providerScopedFields = Array.from(document.querySelectorAll("[data-provider-scope]"));
|
||||
|
||||
let ollamaOnline = true;
|
||||
let latestUpdate = null;
|
||||
@@ -599,6 +603,9 @@ const ollamaFieldIds = {
|
||||
openai_base_url: "openai-base-url",
|
||||
openai_api_key: "openai-api-key",
|
||||
openai_model: "openai-model",
|
||||
model_reasoning_effort: "model-reasoning-effort",
|
||||
codex_command: "codex-command",
|
||||
codex_model: "codex-model",
|
||||
};
|
||||
|
||||
async function refreshConfig() {
|
||||
@@ -636,6 +643,8 @@ function renderConfig(config) {
|
||||
field.value = values[key] ?? "";
|
||||
}
|
||||
}
|
||||
renderReasoningEffortOptions(["none", "minimal", "low", "medium", "high", "xhigh"], values.model_reasoning_effort || "medium");
|
||||
updateProviderFieldVisibility(values.model_provider || "ollama");
|
||||
configPathsEl.textContent = `App data: ${config.app_data_dir}\nConfig: ${config.config_path}\nLog: ${config.log_path}\nEdge profile: ${config.edge_profile_dir}`;
|
||||
configStatusEl.textContent = "";
|
||||
}
|
||||
@@ -703,18 +712,21 @@ async function refreshOllamaStatus() {
|
||||
function renderOllamaStatus(status) {
|
||||
if (!ollamaStatusEl) return;
|
||||
latestOllamaStatus = status;
|
||||
const provider = status.provider === "openai" ? "OpenAI" : "Ollama";
|
||||
updateProviderFieldVisibility(status.provider || "ollama");
|
||||
const provider = providerDisplayName(status.provider);
|
||||
const models = status.models?.length ? status.models.join(", ") : "None detected";
|
||||
const ready = status.provider === "openai"
|
||||
const isOpenAIProvider = status.provider === "openai";
|
||||
const isCodexProvider = status.provider === "codex";
|
||||
const ready = isOpenAIProvider
|
||||
? Boolean(status.online && status.model_available)
|
||||
: Boolean(status.installed && status.running && status.model_available);
|
||||
const pillClass = ready ? "status-pill" : "status-pill warning";
|
||||
const detailItems = [
|
||||
ollamaStatusItem("Provider", provider),
|
||||
ollamaStatusItem("Model", status.configured_model || ""),
|
||||
ollamaStatusItem("URL", status.base_url || ""),
|
||||
ollamaStatusItem(isCodexProvider ? "Command" : "URL", status.base_url || ""),
|
||||
];
|
||||
if (status.provider !== "openai") {
|
||||
if (!isOpenAIProvider && !isCodexProvider) {
|
||||
detailItems.splice(1, 0, ollamaStatusItem("Installed", status.installed ? "Yes" : "No"));
|
||||
detailItems.splice(2, 0, ollamaStatusItem("Running", status.running ? "Yes" : "No"));
|
||||
detailItems.push(ollamaStatusItem("Pulled", status.model_available ? "Yes" : "No"));
|
||||
@@ -728,27 +740,32 @@ function renderOllamaStatus(status) {
|
||||
<div class="ollama-status-grid">
|
||||
${detailItems.join("")}
|
||||
</div>
|
||||
${ollamaStatusItem(status.provider === "openai" ? "Available Models" : "Installed Models", models)}
|
||||
${ollamaStatusItem(isOpenAIProvider || isCodexProvider ? "Available Models" : "Installed Models", models)}
|
||||
${status.detail ? ollamaStatusItem("Detail", status.detail) : ""}
|
||||
`;
|
||||
if (ollamaDownloadButton) ollamaDownloadButton.hidden = status.provider === "openai";
|
||||
if (ollamaDownloadButton) ollamaDownloadButton.hidden = isOpenAIProvider || isCodexProvider;
|
||||
if (ollamaInstallButton) {
|
||||
ollamaInstallButton.hidden = status.provider === "openai" || !status.can_auto_install;
|
||||
ollamaInstallButton.hidden = isOpenAIProvider || isCodexProvider || !status.can_auto_install;
|
||||
ollamaInstallButton.disabled = Boolean(status.installed) || !status.can_auto_install;
|
||||
}
|
||||
if (ollamaLaunchButton) {
|
||||
ollamaLaunchButton.hidden = status.provider === "openai";
|
||||
ollamaLaunchButton.hidden = isOpenAIProvider || isCodexProvider;
|
||||
ollamaLaunchButton.disabled = !status.installed || Boolean(status.running);
|
||||
}
|
||||
if (ollamaPullButton) {
|
||||
ollamaPullButton.hidden = status.provider === "openai";
|
||||
ollamaPullButton.hidden = isOpenAIProvider || isCodexProvider;
|
||||
ollamaPullButton.disabled = !status.running || Boolean(status.model_available);
|
||||
}
|
||||
if (codexLoginButton) {
|
||||
codexLoginButton.hidden = !isCodexProvider;
|
||||
codexLoginButton.disabled = Boolean(status.online);
|
||||
}
|
||||
if (openaiModelsRefreshButton) {
|
||||
openaiModelsRefreshButton.hidden = status.provider !== "openai";
|
||||
openaiModelsRefreshButton.hidden = false;
|
||||
openaiModelsRefreshButton.disabled = false;
|
||||
}
|
||||
renderProviderModelOptions(status.models || []);
|
||||
renderProviderModelOptions(status.models || [], status);
|
||||
renderReasoningEffortOptions(status.reasoning_efforts || [], status.configured_reasoning_effort || "medium");
|
||||
updateOllamaAttention(status);
|
||||
}
|
||||
|
||||
@@ -791,15 +808,18 @@ function setOllamaButtonAttention(button, action, active) {
|
||||
function updateOllamaAttention(status = null) {
|
||||
const currentStatus = status || latestOllamaStatus;
|
||||
if (!currentStatus) return;
|
||||
const ready = currentStatus.provider === "openai"
|
||||
const isOpenAIProvider = currentStatus.provider === "openai";
|
||||
const isCodexProvider = currentStatus.provider === "codex";
|
||||
const ready = isOpenAIProvider
|
||||
? Boolean(currentStatus.online && currentStatus.model_available)
|
||||
: Boolean(currentStatus.installed && currentStatus.running && currentStatus.model_available);
|
||||
ollamaToggle?.classList.toggle("attention-pulse", !ready);
|
||||
setOllamaButtonAttention(ollamaDownloadButton, "download", currentStatus.provider !== "openai" && !currentStatus.installed);
|
||||
setOllamaButtonAttention(ollamaInstallButton, "install", currentStatus.provider !== "openai" && !currentStatus.installed && currentStatus.can_auto_install);
|
||||
setOllamaButtonAttention(ollamaLaunchButton, "launch", currentStatus.provider !== "openai" && currentStatus.installed && !currentStatus.running);
|
||||
setOllamaButtonAttention(ollamaPullButton, "pull", currentStatus.provider !== "openai" && currentStatus.running && !currentStatus.model_available);
|
||||
setOllamaButtonAttention(openaiModelsRefreshButton, "openai-models", currentStatus.provider === "openai" && !currentStatus.model_available);
|
||||
setOllamaButtonAttention(ollamaDownloadButton, "download", !isOpenAIProvider && !isCodexProvider && !currentStatus.installed);
|
||||
setOllamaButtonAttention(ollamaInstallButton, "install", !isOpenAIProvider && !isCodexProvider && !currentStatus.installed && currentStatus.can_auto_install);
|
||||
setOllamaButtonAttention(ollamaLaunchButton, "launch", !isOpenAIProvider && !isCodexProvider && currentStatus.installed && !currentStatus.running);
|
||||
setOllamaButtonAttention(ollamaPullButton, "pull", !isOpenAIProvider && !isCodexProvider && currentStatus.running && !currentStatus.model_available);
|
||||
setOllamaButtonAttention(codexLoginButton, "codex-login", isCodexProvider && !currentStatus.online);
|
||||
setOllamaButtonAttention(openaiModelsRefreshButton, "openai-models", isOpenAIProvider && !currentStatus.model_available);
|
||||
if (ready) clickedOllamaActions.clear();
|
||||
}
|
||||
|
||||
@@ -807,31 +827,137 @@ function configuredOllamaModel() {
|
||||
return document.getElementById("ollama-model")?.value || "";
|
||||
}
|
||||
|
||||
function renderProviderModelOptions(models) {
|
||||
function updateProviderFieldVisibility(provider) {
|
||||
for (const field of providerScopedFields) {
|
||||
const scope = field.dataset.providerScope;
|
||||
field.hidden = scope !== provider;
|
||||
}
|
||||
}
|
||||
|
||||
function renderProviderModelOptions(models, status = latestOllamaStatus) {
|
||||
const datalist = document.getElementById("provider-models");
|
||||
if (!datalist) return;
|
||||
datalist.innerHTML = "";
|
||||
if (datalist) datalist.innerHTML = "";
|
||||
for (const model of models) {
|
||||
if (datalist) {
|
||||
const option = document.createElement("option");
|
||||
option.value = model;
|
||||
datalist.appendChild(option);
|
||||
}
|
||||
}
|
||||
if (!providerModelSelect) return;
|
||||
const provider = status?.provider || document.getElementById("model-provider")?.value || "ollama";
|
||||
const configuredModel = configuredProviderModel(provider);
|
||||
providerModelSelect.innerHTML = "";
|
||||
const allModels = [...new Set([configuredModel, ...models].filter(Boolean))];
|
||||
if (!allModels.length) {
|
||||
const option = document.createElement("option");
|
||||
option.value = "";
|
||||
option.textContent = "No models detected";
|
||||
providerModelSelect.appendChild(option);
|
||||
providerModelSelect.disabled = true;
|
||||
return;
|
||||
}
|
||||
providerModelSelect.disabled = false;
|
||||
for (const model of allModels) {
|
||||
const option = document.createElement("option");
|
||||
option.value = model;
|
||||
datalist.appendChild(option);
|
||||
option.textContent = model;
|
||||
if (model === configuredModel) option.selected = true;
|
||||
providerModelSelect.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshOpenAIModels() {
|
||||
setOllamaMessage("Loading OpenAI models");
|
||||
setOllamaMessage("Loading provider models");
|
||||
try {
|
||||
const response = await fetch("/api/openai/models");
|
||||
const provider = document.getElementById("model-provider")?.value || latestOllamaStatus?.provider || "openai";
|
||||
const response = await fetch(`/api/provider/models?provider=${encodeURIComponent(provider)}`);
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
|
||||
renderProviderModelOptions(result.models || []);
|
||||
setOllamaMessage(result.message || "Loaded OpenAI models");
|
||||
renderProviderModelOptions(result.models || [], {
|
||||
provider: result.provider || provider,
|
||||
configured_model: configuredProviderModel(result.provider || provider),
|
||||
});
|
||||
setOllamaMessage(result.message || "Loaded provider models");
|
||||
await refreshOllamaStatus();
|
||||
} catch (error) {
|
||||
setOllamaMessage(`OpenAI models failed: ${fetchErrorMessage(error)}`);
|
||||
setOllamaMessage(`Provider model load failed: ${fetchErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function launchCodexLogin() {
|
||||
markOllamaActionClicked("codex-login");
|
||||
setOllamaMessage("Starting Codex sign-in");
|
||||
try {
|
||||
const response = await fetch("/api/codex/login", { method: "POST" });
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
|
||||
if (result.auth_url) {
|
||||
window.open(result.auth_url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
setOllamaMessage(result.message || "Opened Codex sign-in in your browser. Waiting for completion...");
|
||||
await waitForCodexLogin();
|
||||
} catch (error) {
|
||||
setOllamaMessage(`Codex sign-in failed: ${fetchErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCodexLogin() {
|
||||
for (let attempt = 0; attempt < 80; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, attempt < 8 ? 1500 : 3000));
|
||||
await refreshOllamaStatus();
|
||||
const provider = latestOllamaStatus?.provider || "";
|
||||
if (provider === "codex" && latestOllamaStatus?.online) {
|
||||
setOllamaMessage("Codex sign-in complete.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
setOllamaMessage("Codex sign-in opened. If you completed it, click Load Provider Models or refresh provider status.");
|
||||
}
|
||||
|
||||
function renderReasoningEffortOptions(efforts, configured) {
|
||||
if (!modelReasoningEffortSelect) return;
|
||||
const options = [...new Set([...(efforts || []), configured || "medium"].filter(Boolean))];
|
||||
modelReasoningEffortSelect.innerHTML = "";
|
||||
for (const effort of options) {
|
||||
const option = document.createElement("option");
|
||||
option.value = effort;
|
||||
option.textContent = effort;
|
||||
if (effort === configured) option.selected = true;
|
||||
modelReasoningEffortSelect.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
function configuredProviderModel(provider) {
|
||||
if (provider === "openai") return document.getElementById("openai-model")?.value || "";
|
||||
if (provider === "codex") return document.getElementById("codex-model")?.value || "";
|
||||
return document.getElementById("ollama-model")?.value || "";
|
||||
}
|
||||
|
||||
function syncSelectedProviderModel() {
|
||||
const provider = document.getElementById("model-provider")?.value || "ollama";
|
||||
const selectedModel = providerModelSelect?.value || "";
|
||||
if (!selectedModel) return;
|
||||
if (provider === "openai") {
|
||||
const field = document.getElementById("openai-model");
|
||||
if (field) field.value = selectedModel;
|
||||
return;
|
||||
}
|
||||
if (provider === "codex") {
|
||||
const field = document.getElementById("codex-model");
|
||||
if (field) field.value = selectedModel;
|
||||
return;
|
||||
}
|
||||
const field = document.getElementById("ollama-model");
|
||||
if (field) field.value = selectedModel;
|
||||
}
|
||||
|
||||
function providerDisplayName(provider) {
|
||||
if (provider === "openai") return "OpenAI";
|
||||
if (provider === "codex") return "Codex";
|
||||
return "Local Ollama";
|
||||
}
|
||||
|
||||
async function checkForUpdate(promptUser = false) {
|
||||
if (!updateStatusEl) return;
|
||||
updateStatusEl.textContent = "Checking releases";
|
||||
@@ -1282,34 +1408,81 @@ function renderPlansRail(plans) {
|
||||
async function renderPlans(plans, openPlanId = null) {
|
||||
plansDashboardEl.innerHTML = "";
|
||||
if (!plans.length) {
|
||||
plansDashboardEl.innerHTML = '<div class="pending-empty">No continual plans</div>';
|
||||
plansDashboardEl.innerHTML = `
|
||||
<section class="plans-overview">
|
||||
<div>
|
||||
<p class="eyebrow">Plan board</p>
|
||||
<h3>No plans yet</h3>
|
||||
<p class="plan-overview-copy">Create a buying watchlist or a custom follow-up routine to start tracking work over time.</p>
|
||||
</div>
|
||||
</section>
|
||||
<div class="plan-empty-state">
|
||||
<h4>Nothing is running</h4>
|
||||
<p>Your continual plans will appear here with status, timing, and recent activity.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
const activeCount = plans.filter((plan) => plan.status === "active").length;
|
||||
const attentionCount = plans.filter((plan) => plan.status === "needs_input" || plan.status === "paused").length;
|
||||
const overview = document.createElement("section");
|
||||
overview.className = "plans-overview";
|
||||
overview.innerHTML = `
|
||||
<div>
|
||||
<p class="eyebrow">Plan board</p>
|
||||
<h3>${plans.length} continual ${plans.length === 1 ? "plan" : "plans"}</h3>
|
||||
<p class="plan-overview-copy">Monitor recurring work, keep candidate leads in view, and jump into details when something needs attention.</p>
|
||||
</div>
|
||||
<div class="plan-overview-stats">
|
||||
<div class="plan-overview-stat">
|
||||
<span class="plan-overview-stat-value">${activeCount}</span>
|
||||
<span class="plan-overview-stat-label">active</span>
|
||||
</div>
|
||||
<div class="plan-overview-stat">
|
||||
<span class="plan-overview-stat-value">${attentionCount}</span>
|
||||
<span class="plan-overview-stat-label">needs eyes</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
plansDashboardEl.appendChild(overview);
|
||||
for (const plan of plans) {
|
||||
const card = document.createElement("article");
|
||||
card.className = `plan-card${plan.status === "active" ? " active" : ""}`;
|
||||
card.className = `plan-card plan-status-${slugifyPlanValue(plan.status)}${plan.status === "active" ? " active" : ""}`;
|
||||
const heading = document.createElement("div");
|
||||
heading.className = "plan-card-heading";
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = plan.title || "Untitled plan";
|
||||
const statusBadge = document.createElement("span");
|
||||
statusBadge.className = `plan-status-badge plan-status-${slugifyPlanValue(plan.status)}`;
|
||||
statusBadge.textContent = humanizePlanValue(plan.status || "unknown");
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "plan-meta";
|
||||
meta.textContent = plan.objective || "";
|
||||
const pills = document.createElement("div");
|
||||
pills.className = "plan-pill-row";
|
||||
for (const value of [plan.status, plan.kind, plan.next_run_at ? `next ${formatShortDate(plan.next_run_at)}` : "not scheduled"]) {
|
||||
for (const value of [plan.kind, plan.next_run_at ? `next ${formatShortDate(plan.next_run_at)}` : "not scheduled"]) {
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "plan-pill";
|
||||
pill.textContent = value;
|
||||
pill.textContent = humanizePlanValue(value);
|
||||
pills.appendChild(pill);
|
||||
}
|
||||
const metrics = document.createElement("div");
|
||||
metrics.className = "plan-metrics";
|
||||
metrics.append(
|
||||
planMetric("Checklist", String((plan.items || []).length)),
|
||||
planMetric("Cadence", summarizeCadence(plan.cadence)),
|
||||
planMetric("Updated", formatShortDate(plan.updated_at || plan.created_at))
|
||||
);
|
||||
const controls = document.createElement("div");
|
||||
controls.className = "plan-controls";
|
||||
controls.append(
|
||||
planButton("Details", () => loadPlanDetail(plan.id, card)),
|
||||
planButton("Run", () => postPlanAction(plan.id, "run")),
|
||||
planButton(plan.status === "active" ? "Pause" : "Resume", () => postPlanAction(plan.id, plan.status === "active" ? "pause" : "resume")),
|
||||
planButton("Cancel", () => postPlanAction(plan.id, "cancel"), "secondary small-button")
|
||||
planButton("Delete", () => deletePlan(plan.id), "secondary small-button")
|
||||
);
|
||||
card.append(title, meta, pills, controls);
|
||||
heading.append(title, statusBadge);
|
||||
card.append(heading, meta, pills, metrics, controls);
|
||||
plansDashboardEl.appendChild(card);
|
||||
if (openPlanId && plan.id === openPlanId) await loadPlanDetail(plan.id, card);
|
||||
}
|
||||
@@ -1330,41 +1503,113 @@ async function loadPlanDetail(planId, card) {
|
||||
existing.remove();
|
||||
return;
|
||||
}
|
||||
const loading = document.createElement("div");
|
||||
loading.className = "plan-detail plan-detail-loading";
|
||||
loading.textContent = "Loading plan details...";
|
||||
card.appendChild(loading);
|
||||
const response = await fetch(`/api/plans/${encodeURIComponent(planId)}`);
|
||||
const result = await response.json();
|
||||
const plan = result.plan;
|
||||
loading.remove();
|
||||
const detail = document.createElement("div");
|
||||
detail.className = "plan-detail";
|
||||
detail.append(
|
||||
planSection("Checklist", (plan.items || []).map((item) => `${item.item_name}: ${item.acquired_quantity || 0}/${item.desired_quantity || 1}${item.max_unit_price ? `, max ${Number(item.max_unit_price).toLocaleString()} UEC` : ""} (${item.status})`)),
|
||||
planSection("Best Candidates", bestCandidateLines(plan)),
|
||||
planSection("Recent Events", (plan.events || []).slice(0, 5).map((event) => `${formatShortDate(event.created_at)} ${event.kind}: ${event.message}`))
|
||||
planSection("Checklist", checklistLines(plan), "checklist"),
|
||||
planSection("Best Candidates", bestCandidateLines(plan), "candidates"),
|
||||
planSection("Recent Events", recentEventLines(plan), "events")
|
||||
);
|
||||
card.appendChild(detail);
|
||||
}
|
||||
|
||||
function planSection(title, lines) {
|
||||
function planSection(title, lines, sectionClass = "") {
|
||||
const wrapper = document.createElement("section");
|
||||
wrapper.className = `plan-section${sectionClass ? ` plan-section-${sectionClass}` : ""}`;
|
||||
const heading = document.createElement("h4");
|
||||
heading.textContent = title;
|
||||
const list = document.createElement("ul");
|
||||
list.className = "plan-list";
|
||||
const items = lines.length ? lines : ["Empty"];
|
||||
const items = lines.length ? lines : [planListItemData("Empty", "Nothing to show right now.")];
|
||||
for (const line of items) {
|
||||
const item = document.createElement("li");
|
||||
item.textContent = line;
|
||||
if (typeof line === "string") {
|
||||
item.textContent = line;
|
||||
} else {
|
||||
item.className = line.className || "";
|
||||
const titleEl = document.createElement("div");
|
||||
titleEl.className = "plan-list-title";
|
||||
titleEl.textContent = line.title;
|
||||
const bodyEl = document.createElement("div");
|
||||
bodyEl.className = "plan-list-body";
|
||||
bodyEl.textContent = line.body;
|
||||
item.append(titleEl, bodyEl);
|
||||
}
|
||||
list.appendChild(item);
|
||||
}
|
||||
wrapper.append(heading, list);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function planMetric(label, value) {
|
||||
const metric = document.createElement("div");
|
||||
metric.className = "plan-metric";
|
||||
const metricLabel = document.createElement("span");
|
||||
metricLabel.className = "plan-metric-label";
|
||||
metricLabel.textContent = label;
|
||||
const metricValue = document.createElement("span");
|
||||
metricValue.className = "plan-metric-value";
|
||||
metricValue.textContent = value;
|
||||
metric.append(metricLabel, metricValue);
|
||||
return metric;
|
||||
}
|
||||
|
||||
function summarizeCadence(cadence) {
|
||||
if (!cadence) return "manual";
|
||||
return cadence.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function slugifyPlanValue(value) {
|
||||
return String(value || "unknown")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
}
|
||||
|
||||
function humanizePlanValue(value) {
|
||||
return String(value || "")
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function planListItemData(title, body, className = "") {
|
||||
return { title, body, className };
|
||||
}
|
||||
|
||||
function checklistLines(plan) {
|
||||
return (plan.items || []).map((item) => {
|
||||
const quantity = `${item.acquired_quantity || 0}/${item.desired_quantity || 1}`;
|
||||
const price = item.max_unit_price ? `Max ${Number(item.max_unit_price).toLocaleString()} UEC` : "No price cap";
|
||||
return planListItemData(item.item_name, `${quantity} acquired • ${price} • ${humanizePlanValue(item.status || "pending")}`);
|
||||
});
|
||||
}
|
||||
|
||||
function bestCandidateLines(plan) {
|
||||
const byItem = new Map((plan.items || []).map((item) => [item.id, item.item_name]));
|
||||
return (plan.candidates || [])
|
||||
.filter((candidate) => candidate.status === "current" || candidate.status === "drafted")
|
||||
.slice(0, 6)
|
||||
.map((candidate) => `${byItem.get(candidate.plan_item_id) || "Item"}: ${candidate.title || candidate.listing_slug || candidate.listing_id} at ${Number(candidate.price || 0).toLocaleString()} ${candidate.currency || "UEC"} from ${candidate.seller || "unknown"} (${candidate.status})`);
|
||||
.map((candidate) => {
|
||||
const title = byItem.get(candidate.plan_item_id) || "Item";
|
||||
const listing = candidate.title || candidate.listing_slug || candidate.listing_id || "Unnamed listing";
|
||||
const price = `${Number(candidate.price || 0).toLocaleString()} ${candidate.currency || "UEC"}`;
|
||||
const seller = candidate.seller || "unknown seller";
|
||||
return planListItemData(title, `${listing} • ${price} • ${seller} • ${humanizePlanValue(candidate.status || "current")}`);
|
||||
});
|
||||
}
|
||||
|
||||
function recentEventLines(plan) {
|
||||
return (plan.events || [])
|
||||
.slice(0, 5)
|
||||
.map((event) => planListItemData(`${formatShortDate(event.created_at)} • ${humanizePlanValue(event.kind || "event")}`, event.message || "No details."));
|
||||
}
|
||||
|
||||
async function postPlanAction(planId, action) {
|
||||
@@ -1382,6 +1627,22 @@ async function postPlanAction(planId, action) {
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePlan(planId) {
|
||||
if (!window.confirm("Delete this plan and its stored history?")) return;
|
||||
plansStatusEl.textContent = "delete requested";
|
||||
try {
|
||||
const response = await fetch(`/api/plans/${encodeURIComponent(planId)}`, { method: "DELETE" });
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
|
||||
plansStatusEl.textContent = result.summary || "Plan deleted";
|
||||
await refreshPlans();
|
||||
await refreshPending();
|
||||
await refreshInbox();
|
||||
} catch (error) {
|
||||
plansStatusEl.textContent = `Plan delete failed: ${fetchErrorMessage(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatShortDate(value) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
@@ -1391,10 +1652,20 @@ function formatShortDate(value) {
|
||||
|
||||
async function checkHealth() {
|
||||
try {
|
||||
const response = await fetch("/api/health");
|
||||
const result = await response.json();
|
||||
const health = result.ollama || {};
|
||||
const provider = health.provider === "openai" ? "OpenAI" : "Ollama";
|
||||
let health = {};
|
||||
try {
|
||||
const response = await fetch("/api/health");
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const result = await response.json();
|
||||
health = result.inference || result.ollama || {};
|
||||
} catch (primaryError) {
|
||||
const fallbackResponse = await fetch("/api/ollama/status");
|
||||
if (!fallbackResponse.ok) throw primaryError;
|
||||
health = await fallbackResponse.json();
|
||||
}
|
||||
const provider = providerDisplayName(health.provider);
|
||||
const isOpenAIProvider = health.provider === "openai";
|
||||
const isCodexProvider = health.provider === "codex";
|
||||
ollamaOnline = Boolean(health.online);
|
||||
if (!ollamaOnline) {
|
||||
statusEl.textContent = "Offline";
|
||||
@@ -1403,7 +1674,7 @@ async function checkHealth() {
|
||||
return false;
|
||||
}
|
||||
if (health.model_available === false) {
|
||||
const action = health.provider === "openai" ? "Load OpenAI Models." : "Install Model.";
|
||||
const action = isOpenAIProvider ? "Load Provider Models." : isCodexProvider ? "Sign In to Codex." : "Install Model.";
|
||||
setWarning(`${provider} needs the configured model "${health.model}". Open the model provider tab and use ${action}`);
|
||||
ollamaToggle?.classList.add("attention-pulse");
|
||||
} else {
|
||||
@@ -1637,6 +1908,13 @@ ollamaPullButton?.addEventListener("click", () => {
|
||||
markOllamaActionClicked("pull");
|
||||
postOllamaAction("/api/ollama/pull", { body: { model: configuredOllamaModel() } });
|
||||
});
|
||||
codexLoginButton?.addEventListener("click", launchCodexLogin);
|
||||
providerModelSelect?.addEventListener("change", syncSelectedProviderModel);
|
||||
document.getElementById("model-provider")?.addEventListener("change", () => {
|
||||
const provider = document.getElementById("model-provider")?.value || "ollama";
|
||||
updateProviderFieldVisibility(provider);
|
||||
renderProviderModelOptions(latestOllamaStatus?.models || [], { ...latestOllamaStatus, provider });
|
||||
});
|
||||
openaiModelsRefreshButton?.addEventListener("click", () => {
|
||||
markOllamaActionClicked("openai-models");
|
||||
refreshOpenAIModels();
|
||||
|
||||
+53
-28
@@ -119,22 +119,27 @@
|
||||
</div>
|
||||
<div class="sidebar-panel" id="ollama-panel" hidden>
|
||||
<div class="section-title-row">
|
||||
<h2>Model Provider</h2>
|
||||
<h2>Inference</h2>
|
||||
<button class="secondary small-button" id="ollama-refresh" type="button">Refresh</button>
|
||||
</div>
|
||||
<form class="config-form" id="ollama-config-form">
|
||||
<label>Provider
|
||||
<select id="model-provider" name="model_provider">
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="ollama">Local Ollama</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="codex">Codex</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Ollama URL<input id="ollama-base-url" name="ollama_base_url" type="text"></label>
|
||||
<label>Ollama Model<input id="ollama-model" name="ollama_model" type="text" list="provider-models"></label>
|
||||
<label>Context Tokens<input id="ollama-num-ctx" name="ollama_num_ctx" type="number" min="1024" step="1024"></label>
|
||||
<label>OpenAI URL<input id="openai-base-url" name="openai_base_url" type="text"></label>
|
||||
<label>OpenAI API Key<input id="openai-api-key" name="openai_api_key" type="password" autocomplete="off"></label>
|
||||
<label>OpenAI Model<input id="openai-model" name="openai_model" type="text" list="provider-models"></label>
|
||||
<label data-provider-scope="ollama">Ollama URL<input id="ollama-base-url" name="ollama_base_url" type="text"></label>
|
||||
<label data-provider-scope="ollama">Ollama Model<input id="ollama-model" name="ollama_model" type="text" list="provider-models"></label>
|
||||
<label data-provider-scope="ollama">Context Tokens<input id="ollama-num-ctx" name="ollama_num_ctx" type="number" min="1024" step="1024"></label>
|
||||
<label data-provider-scope="openai">OpenAI URL<input id="openai-base-url" name="openai_base_url" type="text"></label>
|
||||
<label data-provider-scope="openai">OpenAI API Key<input id="openai-api-key" name="openai_api_key" type="password" autocomplete="off"></label>
|
||||
<label data-provider-scope="openai">OpenAI Model<input id="openai-model" name="openai_model" type="text" list="provider-models"></label>
|
||||
<label data-provider-scope="codex">Codex Command<input id="codex-command" name="codex_command" type="text"></label>
|
||||
<label data-provider-scope="codex">Codex Model<input id="codex-model" name="codex_model" type="text" list="provider-models"></label>
|
||||
<label>Available Models<select id="provider-model-select"></select></label>
|
||||
<label>Reasoning Effort<select id="model-reasoning-effort" name="model_reasoning_effort"></select></label>
|
||||
<datalist id="provider-models"></datalist>
|
||||
<button type="submit">Save Provider Config</button>
|
||||
</form>
|
||||
@@ -144,7 +149,8 @@
|
||||
<button class="secondary small-button" id="ollama-install" type="button">Auto Install</button>
|
||||
<button class="secondary small-button" id="ollama-launch" type="button">Launch</button>
|
||||
<button class="small-button" id="ollama-pull" type="button">Install Model</button>
|
||||
<button class="secondary small-button" id="openai-models-refresh" type="button">Load OpenAI Models</button>
|
||||
<button class="secondary small-button" id="codex-login" type="button">Sign In to Codex</button>
|
||||
<button class="secondary small-button" id="openai-models-refresh" type="button">Load Provider Models</button>
|
||||
</div>
|
||||
<div class="config-status" id="ollama-message"></div>
|
||||
</div>
|
||||
@@ -157,10 +163,10 @@
|
||||
<i data-lucide="brain" aria-hidden="true"></i>
|
||||
<span>Memory</span>
|
||||
</button>
|
||||
<button class="sidebar-tool-button" id="ollama-toggle" type="button" aria-expanded="false" aria-controls="ollama-panel" title="Ollama">
|
||||
<button class="sidebar-tool-button" id="ollama-toggle" type="button" aria-expanded="false" aria-controls="ollama-panel" title="Inference">
|
||||
<img class="sidebar-tool-image" src="/static/art/ollama-icon.svg" alt="" onerror="this.remove();">
|
||||
<i data-lucide="bot" aria-hidden="true"></i>
|
||||
<span>Ollama</span>
|
||||
<span>Inference</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -199,23 +205,42 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="plans-panel-body">
|
||||
<form class="config-form" id="plan-form">
|
||||
<label>Title<input id="plan-title" type="text" placeholder="Wikelo Idris parts"></label>
|
||||
<label>Objective<input id="plan-objective" type="text" placeholder="Find and draft deals for the parts I list"></label>
|
||||
<label>Kind
|
||||
<select id="plan-kind">
|
||||
<option value="buying">Buying</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Items<textarea id="plan-items" rows="4" placeholder="One item per line, optionally: name | quantity | max unit price"></textarea></label>
|
||||
<label>Instructions<textarea id="plan-instructions" rows="3" placeholder="Extra guidance for custom or buying plans"></textarea></label>
|
||||
<label>Cron Cadence<input id="plan-cadence" type="text" placeholder="0 */6 * * *"></label>
|
||||
<label>Message Tone<input id="plan-tone" type="text" placeholder="polite and concise"></label>
|
||||
<button type="submit">Create Plan</button>
|
||||
<div class="config-status" id="plans-status"></div>
|
||||
</form>
|
||||
<div class="plans-dashboard" id="plans-dashboard"></div>
|
||||
<aside class="plan-creator-shell">
|
||||
<div class="plan-creator-card">
|
||||
<div class="plan-creator-copy">
|
||||
<p class="eyebrow">New continual plan</p>
|
||||
<h3>Set the watch once</h3>
|
||||
<p>Spin up buying runs or custom follow-up work with a title, a goal, and just enough guardrails to keep it on track.</p>
|
||||
</div>
|
||||
<form class="config-form plan-form-grid" id="plan-form">
|
||||
<label>Title<input id="plan-title" type="text" placeholder="Wikelo Idris parts"></label>
|
||||
<label>Objective<input id="plan-objective" type="text" placeholder="Find and draft deals for the parts I list"></label>
|
||||
<div class="plan-form-split">
|
||||
<label>Kind
|
||||
<select id="plan-kind">
|
||||
<option value="buying">Buying</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Message Tone<input id="plan-tone" type="text" placeholder="polite and concise"></label>
|
||||
</div>
|
||||
<label>Items<textarea id="plan-items" rows="5" placeholder="One item per line, optionally: name | quantity | max unit price"></textarea></label>
|
||||
<label>Instructions<textarea id="plan-instructions" rows="4" placeholder="Extra guidance for custom or buying plans"></textarea></label>
|
||||
<div class="plan-form-split">
|
||||
<label>Cron Cadence<input id="plan-cadence" type="text" placeholder="0 */6 * * *"></label>
|
||||
<div class="plan-form-hint">
|
||||
<strong>Tip</strong>
|
||||
<span>Buying plans work best with item lines. Custom plans can run with just instructions.</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit">Create Plan</button>
|
||||
<div class="config-status" id="plans-status"></div>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="plans-dashboard-shell">
|
||||
<div class="plans-dashboard" id="plans-dashboard"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop" id="update-modal" hidden>
|
||||
|
||||
+344
-30
@@ -1039,15 +1039,15 @@ button {
|
||||
|
||||
.plans-floating-panel {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
width: min(680px, calc(100vw - 28px));
|
||||
width: min(980px, calc(100vw - 28px));
|
||||
}
|
||||
|
||||
.plans-panel-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -1106,10 +1106,8 @@ button.secondary {
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin-top: auto;
|
||||
position: sticky;
|
||||
bottom: -28px;
|
||||
padding-bottom: 28px;
|
||||
background: linear-gradient(180deg, rgba(247, 241, 220, 0) 0%, var(--cream) 22%, var(--cream) 100%);
|
||||
padding-top: 24px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar-tool-buttons {
|
||||
@@ -1119,6 +1117,12 @@ button.secondary {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
padding-top: 14px;
|
||||
padding-bottom: 2px;
|
||||
background: linear-gradient(180deg, var(--ivory) 0%, var(--cream) 100%);
|
||||
}
|
||||
|
||||
.sidebar-tool-button {
|
||||
@@ -1208,6 +1212,11 @@ button.secondary {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.sidebar-panel .section-title-row {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.config-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -1248,6 +1257,77 @@ button.secondary {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.plan-creator-shell,
|
||||
.plans-dashboard-shell {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.plan-creator-card {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 20px;
|
||||
border: 1px solid rgba(212, 175, 55, 0.28);
|
||||
border-radius: 22px;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(240, 214, 129, 0.18), transparent 34%),
|
||||
linear-gradient(180deg, rgba(255, 253, 247, 0.98), rgba(247, 241, 220, 0.94));
|
||||
box-shadow: 0 20px 40px rgba(38, 58, 27, 0.08);
|
||||
}
|
||||
|
||||
.plan-creator-copy {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plan-creator-copy h3 {
|
||||
margin: 0;
|
||||
color: var(--forest);
|
||||
font-family: "Playfair Display", Georgia, serif;
|
||||
font-size: 28px;
|
||||
line-height: 1.02;
|
||||
}
|
||||
|
||||
.plan-creator-copy p:last-child {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.plan-form-grid {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plan-form-grid textarea {
|
||||
min-height: 96px;
|
||||
}
|
||||
|
||||
.plan-form-split {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.plan-form-hint {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 4px;
|
||||
padding: 12px 13px;
|
||||
border: 1px dashed rgba(52, 83, 38, 0.24);
|
||||
border-radius: 14px;
|
||||
background: rgba(237, 243, 223, 0.68);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.plan-form-hint strong {
|
||||
color: var(--forest);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ollama-status {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -1482,36 +1562,175 @@ pre {
|
||||
|
||||
.plans-dashboard {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.plans-overview {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 6px 2px 10px;
|
||||
}
|
||||
|
||||
.plans-overview h3 {
|
||||
margin: 4px 0 0;
|
||||
color: var(--forest);
|
||||
font-family: "Playfair Display", Georgia, serif;
|
||||
font-size: 31px;
|
||||
line-height: 1.04;
|
||||
}
|
||||
|
||||
.plan-overview-copy {
|
||||
max-width: 48ch;
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.plan-overview-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.plan-overview-stat {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 110px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(212, 175, 55, 0.28);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 250, 240, 0.78);
|
||||
box-shadow: 0 12px 24px rgba(38, 58, 27, 0.06);
|
||||
}
|
||||
|
||||
.plan-overview-stat-value {
|
||||
color: var(--forest);
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.plan-overview-stat-label {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.plan-empty-state {
|
||||
padding: 22px;
|
||||
border: 1px dashed rgba(52, 83, 38, 0.24);
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 253, 247, 0.72);
|
||||
}
|
||||
|
||||
.plan-empty-state h4 {
|
||||
margin: 0 0 6px;
|
||||
color: var(--forest);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.plan-empty-state p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.plan-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 13px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 250, 240, 0.82);
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(221, 206, 176, 0.92);
|
||||
border-radius: 20px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 252, 246, 0.96), rgba(251, 244, 223, 0.88));
|
||||
box-shadow: 0 16px 30px rgba(38, 58, 27, 0.06);
|
||||
}
|
||||
|
||||
.plan-card.active {
|
||||
border-color: rgba(52, 83, 38, 0.42);
|
||||
background: #edf3df;
|
||||
border-color: rgba(52, 83, 38, 0.32);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(190, 212, 144, 0.22), transparent 26%),
|
||||
linear-gradient(180deg, rgba(247, 250, 238, 0.98), rgba(237, 243, 223, 0.96));
|
||||
}
|
||||
|
||||
.plan-card-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plan-card h3 {
|
||||
margin: 0;
|
||||
color: var(--forest);
|
||||
font-size: 16px;
|
||||
line-height: 1.25;
|
||||
font-size: 19px;
|
||||
line-height: 1.18;
|
||||
}
|
||||
|
||||
.plan-status-badge {
|
||||
flex: 0 0 auto;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.plan-status-active {
|
||||
border-color: rgba(52, 83, 38, 0.28);
|
||||
background: rgba(237, 243, 223, 0.9);
|
||||
color: var(--forest);
|
||||
}
|
||||
|
||||
.plan-status-badge.plan-status-active {
|
||||
border: 1px solid rgba(52, 83, 38, 0.24);
|
||||
}
|
||||
|
||||
.plan-status-paused {
|
||||
border-color: rgba(196, 170, 115, 0.42);
|
||||
background: rgba(255, 246, 220, 0.86);
|
||||
color: #7a5a18;
|
||||
}
|
||||
|
||||
.plan-status-badge.plan-status-paused {
|
||||
border: 1px solid rgba(196, 170, 115, 0.34);
|
||||
}
|
||||
|
||||
.plan-status-needs-input {
|
||||
border-color: rgba(159, 60, 50, 0.24);
|
||||
background: rgba(255, 241, 237, 0.88);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.plan-status-badge.plan-status-needs-input {
|
||||
border: 1px solid rgba(159, 60, 50, 0.22);
|
||||
}
|
||||
|
||||
.plan-status-canceled,
|
||||
.plan-status-completed {
|
||||
opacity: 0.84;
|
||||
}
|
||||
|
||||
.plan-status-badge.plan-status-canceled,
|
||||
.plan-status-badge.plan-status-completed {
|
||||
border: 1px solid rgba(111, 91, 80, 0.18);
|
||||
background: rgba(255, 250, 240, 0.82);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.plan-meta,
|
||||
.plan-line {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -1525,33 +1744,82 @@ pre {
|
||||
.plan-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid rgba(52, 83, 38, 0.24);
|
||||
min-height: 26px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid rgba(52, 83, 38, 0.14);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 250, 240, 0.8);
|
||||
background: rgba(255, 250, 240, 0.88);
|
||||
color: var(--forest);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.plan-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.plan-metric {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid rgba(221, 206, 176, 0.78);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 253, 247, 0.76);
|
||||
}
|
||||
|
||||
.plan-metric-label {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.plan-metric-value {
|
||||
color: var(--brown);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.plan-controls button {
|
||||
flex: 1 1 80px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plan-detail {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(221, 206, 176, 0.92);
|
||||
}
|
||||
|
||||
.plan-detail-loading {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 16px;
|
||||
border: 1px dashed rgba(52, 83, 38, 0.2);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 253, 247, 0.72);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.plan-section {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.plan-detail h4 {
|
||||
margin: 0;
|
||||
color: var(--forest);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.plan-list {
|
||||
@@ -1563,15 +1831,29 @@ pre {
|
||||
}
|
||||
|
||||
.plan-list li {
|
||||
padding: 8px;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 10px 11px;
|
||||
border: 1px solid rgba(221, 206, 176, 0.72);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 253, 247, 0.72);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 253, 247, 0.8);
|
||||
color: var(--brown);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.plan-list-title {
|
||||
color: var(--forest);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.plan-list-body {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.decline-button {
|
||||
border: 1px solid var(--line-strong);
|
||||
background: #fff9e9;
|
||||
@@ -1751,6 +2033,38 @@ pre {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.plans-overview {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.plan-detail {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.plan-form-split,
|
||||
.plan-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.plan-card-heading {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.plan-status-badge {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.plans-floating-panel {
|
||||
width: min(100vw - 18px, 980px);
|
||||
right: 9px;
|
||||
bottom: 9px;
|
||||
}
|
||||
|
||||
.plans-panel-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user