agent-zero/plugins/_goal/webui/goal-store.js
Alessandro 298cb2c05d
Some checks are pending
Build And Publish Docker Images / plan (push) Waiting to run
Build And Publish Docker Images / build (push) Blocked by required conditions
Speed up chat loading and guard selector refresh races
Run model/profile, context-usage and goal display refreshes independently
of snapshot rendering so chat switches do not wait for their API calls.

Guard refreshes and save completions by request sequence and selected chat.
Pending saves still finish for their original chat, while late responses
cannot replace the current selector state or finish a newer loading cycle.
Add deterministic pending-request and save-race regressions and update
the owning plugin contracts.

Validation: 94 focused tests passed for the loading change, followed by
47 focused tests after the model/profile race fixes. Live checks at
localhost:32081 covered delayed API responses, rapid chat switching,
late profile/preset/clear saves, and desktop/mobile behavior.
2026-10-10 14:10:35 +02:00

212 lines
6.2 KiB
JavaScript

import { createStore } from "/js/AlpineStore.js";
import { callJsonApi } from "/js/api.js";
import { formatDuration } from "/js/time-utils.js";
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
import {
toastFrontendError,
toastFrontendSuccess,
} from "/components/notifications/notification-store.js";
const GOAL_API_PATH = "/plugins/_goal/goal";
const model = {
goal: null,
loading: false,
loadSeq: 0,
saving: false,
editing: false,
draft: "",
lastContextId: "",
clockIntervalId: null,
goalChangedHandler: null,
now: Date.now(),
get visible() {
return Boolean(this.goal?.objective && this.goal.status !== "complete");
},
get contextId() {
return chatsStore?.getSelectedChatId?.() || globalThis.getContext?.() || "";
},
get statusLabel() {
const status = this.goal?.status || "active";
if (status === "paused") return "Goal paused";
if (status === "complete") return "Goal complete";
if (status === "blocked") return "Goal blocked";
return "Pursuing goal";
},
get statusIcon() {
const status = this.goal?.status || "active";
if (status === "paused") return "pause_circle";
if (status === "complete") return "check_circle";
if (status === "blocked") return "error";
return "track_changes";
},
get elapsedSeconds() {
if (!this.goal) return 0;
const status = this.goal.status || "active";
const storedSeconds = Number.parseInt(this.goal.elapsed_seconds, 10);
let seconds = Number.isNaN(storedSeconds) ? 0 : storedSeconds;
if (Number.isNaN(storedSeconds) && status !== "active") {
seconds = this.secondsBetween(this.goal.created_at, this.goal.updated_at);
}
if (status === "active") {
seconds += this.secondsBetween(
this.goal.active_since || this.goal.created_at || this.goal.updated_at,
this.now,
);
}
return Math.max(0, seconds);
},
secondsBetween(start, end) {
const startMs = Date.parse(start || "");
const endMs = typeof end === "number" ? end : Date.parse(end || "");
if (Number.isNaN(startMs) || Number.isNaN(endMs)) return 0;
return Math.max(0, Math.floor((endMs - startMs) / 1000));
},
get elapsedLabel() {
return formatDuration(this.elapsedSeconds * 1000);
},
onMount() {
document.getElementById("progress-bar-box")?.classList.add("has-goal-bar");
this.goalChangedHandler = (event) => {
const detail = event?.detail || {};
if (detail.context_id && detail.context_id !== this.contextId) return;
this.goal = detail.goal || null;
this.now = Date.now();
if (!this.goal) this.editing = false;
};
window.addEventListener("goal:changed", this.goalChangedHandler);
this.clockIntervalId = window.setInterval(() => {
this.now = Date.now();
}, 1000);
},
cleanup() {
document.getElementById("progress-bar-box")?.classList.remove("has-goal-bar");
if (this.goalChangedHandler) {
window.removeEventListener("goal:changed", this.goalChangedHandler);
}
if (this.clockIntervalId) {
window.clearInterval(this.clockIntervalId);
}
this.goalChangedHandler = null;
this.clockIntervalId = null;
},
async refresh(force = false) {
const contextId = this.contextId;
if (!force && this.loading && contextId === this.lastContextId) return;
const requestSeq = ++this.loadSeq;
if (contextId !== this.lastContextId) {
this.goal = null;
this.editing = false;
}
this.lastContextId = contextId;
if (!contextId) {
this.goal = null;
this.loading = false;
return;
}
this.loading = true;
try {
const response = await callJsonApi(GOAL_API_PATH, {
action: "get",
context_id: contextId,
});
if (requestSeq !== this.loadSeq || contextId !== this.contextId) return;
this.goal = response?.goal || null;
this.now = Date.now();
if (!this.goal) this.editing = false;
} catch (error) {
if (requestSeq !== this.loadSeq || contextId !== this.contextId) return;
console.error("Failed to load goal:", error);
this.goal = null;
} finally {
if (requestSeq === this.loadSeq) this.loading = false;
}
},
startEdit() {
if (!this.goal) return;
this.draft = this.goal.objective || "";
this.editing = true;
requestAnimationFrame(() => {
document.querySelector(".goal-strip-input")?.focus?.();
document.querySelector(".goal-strip-input")?.select?.();
});
},
cancelEdit() {
this.editing = false;
this.draft = "";
},
async saveEdit() {
const objective = (this.draft || "").trim();
if (!objective) {
void toastFrontendError("Goal objective is required.", "Goal");
return;
}
const response = await this.update(
{ action: "update", objective, status: "active" },
"Goal updated.",
);
this.editing = false;
if (response?.reactivated) {
await globalThis.sendMessage?.({
message: response.goal?.objective || objective,
context: response.goal?.context_id || this.contextId,
});
}
},
async pauseOrResume() {
if (!this.goal) return;
const action = this.goal.status === "active" ? "pause" : "resume";
await this.update(
{ action },
action === "pause" ? "Goal paused." : "Goal resumed.",
);
},
async deleteGoal() {
await this.update({ action: "delete" }, "Goal deleted.");
},
async update(payload, successMessage) {
const contextId = this.contextId;
if (!contextId || this.saving) return;
this.saving = true;
try {
const response = await callJsonApi(GOAL_API_PATH, {
...payload,
context_id: contextId,
});
this.goal = response?.goal || null;
this.now = Date.now();
this.lastContextId = contextId;
window.dispatchEvent(new CustomEvent("goal:changed", {
detail: { goal: this.goal, context_id: contextId },
}));
void toastFrontendSuccess(successMessage, "Goal");
return response;
} catch (error) {
console.error("Failed to update goal:", error);
void toastFrontendError(error?.message || "Failed to update goal.", "Goal");
return null;
} finally {
this.saving = false;
}
},
};
export const store = createStore("goalBar", model);