diff --git a/app-constructor/feedback-action.js b/app-constructor/feedback-action.js
index 52763ee..820d0de 100644
--- a/app-constructor/feedback-action.js
+++ b/app-constructor/feedback-action.js
@@ -1,9 +1,24 @@
-const { eval_expression } = require("@saltcorn/data/models/expression");
const MetaData = require("@saltcorn/data/models/metadata");
+const Table = require("@saltcorn/data/models/table");
+const View = require("@saltcorn/data/models/view");
+const Trigger = require("@saltcorn/data/models/trigger");
+const Page = require("@saltcorn/data/models/page");
+const Plugin = require("@saltcorn/data/models/plugin");
const { interpolate } = require("@saltcorn/data/utils");
const { getState } = require("@saltcorn/data/db/state");
const { requirements_tool, task_tool } = require("./tools");
const { tool_choice } = require("./common");
+const { getResearchAnswersText } = require("./research");
+const {
+ saltcorn_description,
+ existing_tables_list,
+ existing_entities_list,
+ installed_plugins_list,
+ available_plugins_list,
+ task_planning_rules,
+ task_planning_closing,
+ research_answers_section,
+} = require("./prompts");
module.exports = {
description: "Provide user feedback to the AppConstructor",
@@ -71,6 +86,7 @@ module.exports = {
title_field,
description_field,
url_field,
+ research_context,
},
}) => {
const use_title =
@@ -81,32 +97,46 @@ module.exports = {
: row[description_field];
const use_url =
mode === "workflow" ? interpolate(url, row, user) : row[url_field];
- await MetaData.create({
- type: "CopilotConstructMgr",
- name: "feedback",
- body: { title: use_title, description: use_description, url: use_url },
- user_id: user?.id,
- });
const spec = await MetaData.findOne({
type: "CopilotConstructMgr",
name: "spec",
});
if (!spec) return;
+
+ const researchText = await getResearchAnswersText();
+ const feedbackResearchSection = research_context
+ ? `\nThe user also answered clarifying questions about this feedback:\n\n${research_context}\n`
+ : "";
+
+ let urlSection = "";
+ if (use_url) {
+ const mView = use_url.match(/\/view\/([^/?#]+)/);
+ const mPage = use_url.match(/\/page\/([^/?#]+)/);
+ if (mView)
+ urlSection =
+ `\nThe feedback was submitted from the Saltcorn view named "${mView[1]}"` +
+ ` (URL: ${use_url}).\n`;
+ else if (mPage)
+ urlSection =
+ `\nThe feedback was submitted from the Saltcorn page named "${mPage[1]}"` +
+ ` (URL: ${use_url}).\n`;
+ else
+ urlSection = `\nThe feedback was submitted from: ${use_url}\n`;
+ }
+
const reqAnswer = await getState().functions.llm_generate.run(
`The following application is being built:
-Description: ${spec.body.description}
-Audience: ${spec.body.audience}
-Core features: ${spec.body.core_features}
-Out of scope: ${spec.body.out_of_scope}
-Visual style: ${spec.body.visual_style}
-
+${spec.body.specification}
+${research_answers_section(researchText)}
A new piece of feedback has come in from a user:
Title: ${use_title}
Description: ${use_description}
+${urlSection}${feedbackResearchSection}
+Now use the make_requirements tool to create a single or several (a single is preferred) new requirements that captures this new piece of feedback.
-Now use the make_requirements tool to create a single or several (a single is prefered) new requirements that captures this new piece of feedback.
+* Priority reflects how central the feature is to the core purpose of the application. Assign 5 to features without which the application cannot function at all, 3-4 to features that are important but not blocking, 1-2 to minor convenience features. Do not assign 5 to everything.
`,
{
tools: [requirements_tool],
@@ -116,43 +146,74 @@ Now use the make_requirements tool to create a single or several (a single is pr
},
);
const tc = reqAnswer.getToolCalls()[0];
- console.log("gotr new requiremenrts", tc.input.requirements);
+ console.log("got new requiremenrts", tc.input.requirements);
+
+ const allReqs = await MetaData.find({
+ type: "CopilotConstructMgr",
+ name: "requirement",
+ });
for (const reqm of tc.input.requirements)
await MetaData.create({
type: "CopilotConstructMgr",
name: "requirement",
- body: reqm,
+ body: { ...reqm, source: "feedback", feedback_title: use_title },
user_id: req.user?.id,
});
- const taskAnswer = await getState().functions.llm_generate.run(
- `The following application is being built:
-
-Description: ${spec.body.description}
-Audience: ${spec.body.audience}
-Core features: ${spec.body.core_features}
-Out of scope: ${spec.body.out_of_scope}
-Visual style: ${spec.body.visual_style}
+ const tables = await Table.find({});
+ const tableById = Object.fromEntries(tables.map((t) => [t.id, t.name]));
+ const views = await View.find({});
+ const triggers = await Trigger.find({});
+ const pages = await Page.find({});
+ const entitiesSection = existing_entities_list({
+ views,
+ triggers,
+ pages,
+ tableById,
+ });
+ const installedPlugins = await Plugin.find({});
+ const installedNames = new Set(installedPlugins.map((p) => p.name));
+ let storePlugins = [];
+ try {
+ storePlugins = await Plugin.store_plugins_available();
+ } catch (_) {}
+ const installedPluginsSection = installed_plugins_list(installedNames);
+ const pluginsSection = available_plugins_list(storePlugins, installedNames);
-This application will be implemented in Saltcorn, a database application development
-environment.
+ const taskAnswer = await getState().functions.llm_generate.run(
+ `Generate implementation tasks for a new piece of feedback for this application:
+${spec.body.specification}
+${research_answers_section(researchText)}
A new piece of feedback has come in from a user:
Title: ${use_title}
Description: ${use_description}
+${urlSection}${feedbackResearchSection}
+The existing application requirements are:
-A product manager has determined that the following requirements should be added to the list of application requirements:
+${allReqs.map((r) => `* ${r.body.requirement}`).join("\n")}
+
+A product manager has determined that the following new requirements should be added to implement this feedback:
${tc.input.requirements.map((r) => " * " + r.requirement).join("\n")}
-Your plan for implementing this new fedback and requirements should not include any clarification or questions to the product owner. The
-information you have been given so far is all that is available. Every step in the plan
-should be immediately implementable in Saltcorn. You are writing the steps in the plan
-for a person who is competent in using saltcorn but has no other business knowledge.
+${saltcorn_description}
+
+The database has already been built. The following tables are now present in the database:
+
+${existing_tables_list(tables)}
+
+The plan should outline continued development of the application on top of this database.
+Your plan can add additional tables if needed or adjust the table fields, but normally the tables
+should be designed optimally for this application.
+
+${entitiesSection ? entitiesSection + "\n\n" : ""}${installedPluginsSection ? installedPluginsSection + "\n\n" : ""}${pluginsSection ? pluginsSection + "\n\n" : ""}${task_planning_rules}
-Now use the plan_tasks tool to create the tasks to implement this new feedback
+${task_planning_closing}
+
+Now use the plan_tasks tool to create the tasks to implement this new feedback.
`,
{
tools: [task_tool],
@@ -168,8 +229,15 @@ Now use the plan_tasks tool to create the tasks to implement this new feedback
await MetaData.create({
type: "CopilotConstructMgr",
name: "task",
- body: task,
+ body: { ...task, source: "feedback", feedback_title: use_title },
user_id: req.user?.id,
});
+
+ await MetaData.create({
+ type: "CopilotConstructMgr",
+ name: "feedback",
+ body: { title: use_title, description: use_description, url: use_url, research_context },
+ user_id: user?.id,
+ });
},
};
diff --git a/app-constructor/feedback.js b/app-constructor/feedback.js
index 275f933..81c8c7e 100644
--- a/app-constructor/feedback.js
+++ b/app-constructor/feedback.js
@@ -1,112 +1,1361 @@
+const feedbackAction = require("./feedback-action.js");
const Field = require("@saltcorn/data/models/field");
const Table = require("@saltcorn/data/models/table");
-const Form = require("@saltcorn/data/models/form");
const MetaData = require("@saltcorn/data/models/metadata");
const View = require("@saltcorn/data/models/view");
-const Trigger = require("@saltcorn/data/models/trigger");
-const { findType } = require("@saltcorn/data/models/discovery");
+const Page = require("@saltcorn/data/models/page");
const { save_menu_items } = require("@saltcorn/data/models/config");
-const db = require("@saltcorn/data/db");
-const WorkflowRun = require("@saltcorn/data/models/workflow_run");
-const {
- localeDateTime,
- renderForm,
- mkTable,
- post_delete_btn,
-} = require("@saltcorn/markup");
+const { mkTable } = require("@saltcorn/markup");
const {
div,
script,
domReady,
- pre,
- code,
- input,
- h4,
- style,
h5,
button,
- text_attr,
i,
p,
- span,
- small,
- form,
+ a,
+ hr,
+ input,
+ label,
textarea,
+ small,
} = require("@saltcorn/markup/tags");
const { getState } = require("@saltcorn/data/db/state");
-const renderLayout = require("@saltcorn/markup/layout");
const { viewname } = require("./common");
+const { questions_tool } = require("./research");
-const feedbackList = async (req) => {
- const errs = await MetaData.find({
- type: "CopilotConstructMgr",
- name: "feedback",
- });
- if (errs.length) {
- return div(
- { class: "mt-2" },
- mkTable(
- [
+const FEEDBACK_TABLE = "app_constructor_feedback";
+
+/**
+ * Returns the Bootstrap modal HTML that prompts the user to analyse or skip.
+ * Embedded once inside feedbackFormContent's layout.
+ * @returns {string}
+ */
+const feedbackClarifyModal = () =>
+ `
+
+
+
+
Analyse feedback?
+
+
+
+
Before saving, I can analyse your feedback and ask a few short questions to help clarify the requirements. Answering them produces more accurate tasks — but you can also save right away.
' +
+ 'Feedback system ready.' +
+ 'Add a Feedback button to the navigation menu?' +
+ '' +
+ '' +
+ '
';
+ } else {
+ area.innerHTML = '';
+ }
+ });
+};
+window.copilotAddFeedbackToMenu = () => {
+ view_post(safeViewName, 'add_feedback_to_menu', {}, () => refreshFeedbackViews());
+};
+window.copilotApprove = (id) => {
+ const btn = document.getElementById('approve-btn-' + id);
+ if (btn) { btn.disabled = true; btn.innerHTML = ''; }
+ _pollingIds.add(id);
+ view_post(safeViewName, 'start_approve_feedback', { id }, () => {
+ const poll = () => {
+ view_post(safeViewName, 'approval_status', { id }, (resp) => {
+ if (resp && !resp.approving) {
+ _pollingIds.delete(id);
+ refreshFeedbackViews();
+ refreshReqTaskAreas();
+ }
+ else setTimeout(poll, 3000);
+ });
+ };
+ setTimeout(poll, 3000);
+ });
+};
+window.copilotDeleteFeedback = (id) => {
+ view_post(safeViewName, 'delete_feedback_row', { id }, (resp) => {
+ if (resp && !resp.error) refreshFeedbackViews();
+ });
+};
+window.copilotOpenFeedbackEdit = (id) => {
+ view_post(safeViewName, 'get_feedback_edit_html', { id }, (resp) => {
+ if (!resp || !resp.html) return;
+ document.getElementById('fb-edit-modal-body').innerHTML = resp.html;
+ document.getElementById('fb-edit-modal').dataset.feedbackId = id;
+ new bootstrap.Modal(document.getElementById('fb-edit-modal')).show();
+ });
+};
+window.copilotSaveFeedbackEdit = () => {
+ const id = document.getElementById('fb-edit-modal').dataset.feedbackId;
+ const payload = {
+ id,
+ title: document.getElementById('fbed-title').value,
+ description: document.getElementById('fbed-desc').value,
+ url: document.getElementById('fbed-url').value,
+ };
+ document.querySelectorAll('.fbed-answer').forEach(el => {
+ payload[el.dataset.q] = el.value;
+ });
+ view_post(safeViewName, 'save_feedback_edit', payload, (resp) => {
+ if (resp && !resp.error) {
+ bootstrap.Modal.getInstance(document.getElementById('fb-edit-modal')).hide();
+ refreshFeedbackViews();
+ }
+ });
+};
+window.copilotShowProcessedFeedback = (id) => {
+ view_post(safeViewName, 'show_processed_feedback', { id }, (resp) => {
+ if (!resp || !resp.html) return;
+ document.getElementById('fb-details-modal-body').innerHTML = resp.html;
+ new bootstrap.Modal(document.getElementById('fb-details-modal')).show();
+ });
+};
+window.copilotShowFeedbackModal = () => {
+ ajax_modal('/page/app_constructor_feedback_form');
+};
+document.addEventListener('hidden.bs.modal', (e) => {
+ if (e.target.id !== 'fb-edit-modal' && e.target.id !== 'fb-clarify-modal') refreshFeedbackViews();
+});
+document.addEventListener('shown.bs.tab', () => startApprovalPolling());
+startApprovalPolling();
+`)
+ );
+
+ const editModal = div(
+ {
+ class: "modal fade",
+ id: "fb-edit-modal",
+ tabindex: "-1",
+ "aria-hidden": "true",
+ },
+ div(
+ { class: "modal-dialog modal-lg" },
+ div(
+ { class: "modal-content" },
+ div(
+ { class: "modal-header" },
+ h5({ class: "modal-title" }, "Edit feedback"),
+ button({
+ type: "button",
+ class: "btn-close",
+ "data-bs-dismiss": "modal",
+ "aria-label": "Close",
+ })
+ ),
+ div({ class: "modal-body", id: "fb-edit-modal-body" }),
+ div(
+ { class: "modal-footer" },
+ button(
+ {
+ type: "button",
+ class: "btn btn-outline-secondary",
+ "data-bs-dismiss": "modal",
+ },
+ "Cancel"
+ ),
+ button(
+ {
+ type: "button",
+ class: "btn btn-primary",
+ onclick: "copilotSaveFeedbackEdit()",
+ },
+ "Save"
+ )
+ )
+ )
+ )
+ );
+
+ const detailsModal = div(
+ {
+ class: "modal fade",
+ id: "fb-details-modal",
+ tabindex: "-1",
+ "aria-hidden": "true",
+ },
+ div(
+ { class: "modal-dialog modal-lg" },
+ div(
+ { class: "modal-content" },
+ div(
+ { class: "modal-header" },
+ h5({ class: "modal-title" }, "Feedback details"),
+ button({
+ type: "button",
+ class: "btn-close",
+ "data-bs-dismiss": "modal",
+ "aria-label": "Close",
+ })
+ ),
+ div({ class: "modal-body", id: "fb-details-modal-body" }),
+ div(
+ { class: "modal-footer" },
+ button(
+ {
+ type: "button",
+ class: "btn btn-secondary",
+ "data-bs-dismiss": "modal",
+ },
+ "Close"
+ )
+ )
+ )
+ )
+ );
+
+ return div(
+ { class: "mt-2" },
+ topSection,
+ editModal,
+ detailsModal,
+ clientScript
+ );
+};
+
+/**
+ * Route: returns the rendered feedbackViewsContent HTML for in-place ajax refresh.
+ */
+const feedback_views_html = async (
+ table_id,
+ vn,
+ config,
+ body,
+ { req, res }
+) => {
+ const html = await feedbackViewsContent();
+ return { json: { html } };
+};
+
+/**
+ * Route: starts async approval of a pending feedback row.
+ * Runs feedbackAction in the background, then deletes the row and its research metadata.
+ */
+const start_approve_feedback = async (
+ table_id,
+ vn,
+ config,
+ body,
+ { req, res }
+) => {
+ const id = parseInt(body.id);
+ const table = Table.findOne({ name: FEEDBACK_TABLE });
+ const rows = await table.getRows({ id });
+ const row = rows[0];
+ if (!row) return { json: { error: "Not found" } };
+
+ const mdName = `approving_feedback_${id}`;
+ await MetaData.create({
+ type: "CopilotConstructMgr",
+ name: mdName,
+ body: { id },
+ });
+
+ const researchMd = await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: `feedback_research_${id}`,
+ });
+ let research_context = null;
+ if (researchMd) {
+ const questions = researchMd.body.questions || [];
+ const answers = researchMd.body.answers || {};
+ const pairs = questions.map((q, idx) => {
+ const a = answers[`q${idx + 1}`];
+ return `Q: ${q}\nA: ${a && a.trim() ? a.trim() : "(no answer)"}`;
+ });
+ if (pairs.length) research_context = pairs.join("\n\n");
+ }
+
+ feedbackAction
+ .run({
+ row,
+ table,
+ user: req.user,
+ mode: "table",
+ req,
+ configuration: {
+ title_field: "title",
+ description_field: "description",
+ url_field: "url",
+ research_context,
+ },
+ })
+ .then(async () => {
+ const md = await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: mdName,
+ });
+ if (md) await md.delete();
+ await table.deleteRows({ id });
+ const rmd = await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: `feedback_research_${id}`,
+ });
+ if (rmd) await rmd.delete();
+ })
+ .catch(async (e) => {
+ console.error("approve_feedback error", e);
+ const md = await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: mdName,
+ });
+ if (md) await md.delete();
+ });
+
+ return { json: { success: true } };
+};
+
+/**
+ * Route: polls whether a given feedback row is still being approved.
+ */
+const approval_status = async (table_id, vn, config, body, { req, res }) => {
+ const id = parseInt(body.id);
+ const md = await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: `approving_feedback_${id}`,
+ });
+ return { json: { approving: !!md } };
+};
+
+/**
+ * Route: deletes a pending feedback row and its associated research metadata.
+ */
+const delete_feedback_row = async (
+ table_id,
+ vn,
+ config,
+ body,
+ { req, res }
+) => {
+ const id = parseInt(body.id);
+ const table = Table.findOne({ name: FEEDBACK_TABLE });
+ await table.deleteRows({ id });
+ const rmd = await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: `feedback_research_${id}`,
+ });
+ if (rmd) await rmd.delete();
+ return { json: { success: true } };
+};
+
+/** Route: returns the show HTML for a processed feedback entry, displayed in the details modal. */
+const show_processed_feedback = async (
+ table_id,
+ vn,
+ config,
+ body,
+ { req, res }
+) => {
+ const id = parseInt(body.id);
+ const md = await MetaData.findOne({ id });
+ if (!md) return { json: { error: "Not found" } };
+
+ const { title, description, url, research_context } = md.body;
+
+ const field = (lbl, val) =>
+ val
+ ? div(
+ { class: "mb-3" },
+ small({ class: "text-muted fw-semibold d-block" }, lbl),
+ p({ class: "mb-0" }, val)
+ )
+ : "";
+
+ const qaHtml = research_context
+ ? hr() +
+ p({ class: "fw-semibold mb-3" }, "Clarifying questions") +
+ research_context
+ .split("\n\n")
+ .map((pair) => {
+ const [qLine, aLine] = pair.split("\n");
+ const q = qLine?.replace(/^Q:\s*/, "") || "";
+ const a = aLine?.replace(/^A:\s*/, "") || "";
+ return div(
+ { class: "mb-3" },
+ small({ class: "text-muted fw-semibold d-block" }, q),
+ p({ class: "mb-0" }, a || "—")
+ );
+ })
+ .join("")
+ : "";
+
+ return {
+ json: {
+ html:
+ field("Title", title) +
+ field("Description", description) +
+ field("URL", url) +
+ qaHtml,
+ },
+ };
+};
+
+/**
+ * Route: returns editable HTML for a pending feedback row including all fields
+ * and any associated Q&A answers, shown in the edit modal.
+ */
+const get_feedback_edit_html = async (
+ table_id,
+ vn,
+ config,
+ body,
+ { req, res }
+) => {
+ const id = parseInt(body.id);
+ const table = Table.findOne({ name: FEEDBACK_TABLE });
+ const rows = await table.getRows({ id });
+ const row = rows[0];
+ if (!row) return { json: { error: "Not found" } };
+
+ const rmd = await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: `feedback_research_${id}`,
+ });
+ const rmdValid = parseInt(rmd?.body?.feedback_id) === id;
+ const questions = rmdValid ? rmd.body.questions || [] : [];
+ const answers = rmdValid ? rmd.body.answers || {} : {};
+
+ const fieldsHtml =
+ div(
+ { class: "mb-3" },
+ label({ class: "form-label fw-semibold", for: "fbed-title" }, "Title"),
+ input({
+ type: "text",
+ class: "form-control",
+ id: "fbed-title",
+ value: row.title || "",
+ })
+ ) +
+ div(
+ { class: "mb-3" },
+ label(
+ { class: "form-label fw-semibold", for: "fbed-desc" },
+ "Description"
),
+ textarea(
+ { class: "form-control", id: "fbed-desc", rows: 3 },
+ row.description || ""
+ )
+ ) +
+ div(
+ { class: "mb-3" },
+ label({ class: "form-label fw-semibold", for: "fbed-url" }, "URL"),
+ input({
+ type: "text",
+ class: "form-control",
+ id: "fbed-url",
+ value: row.url || "",
+ })
);
- } else {
- return div({ class: "mt-2" }, p("No feedback"));
+
+ const questionsHtml = questions.length
+ ? hr() +
+ p({ class: "fw-semibold mb-3" }, "Clarifying questions") +
+ questions
+ .map((q, idx) => {
+ const key = `q${idx + 1}`;
+ return div(
+ { class: "mb-3" },
+ label({ class: "form-label small fw-semibold" }, q),
+ textarea(
+ {
+ class: "form-control form-control-sm fbed-answer",
+ "data-q": key,
+ rows: 2,
+ },
+ answers[key] || ""
+ )
+ );
+ })
+ .join("")
+ : "";
+
+ return { json: { html: fieldsHtml + questionsHtml } };
+};
+
+/**
+ * Route: saves edits to a pending feedback row and updates its Q&A answers metadata.
+ */
+const save_feedback_edit = async (table_id, vn, config, body, { req, res }) => {
+ const { _csrf, id: rawId, title, description, url, ...rest } = body;
+ const id = parseInt(rawId);
+ const table = Table.findOne({ name: FEEDBACK_TABLE });
+ await table.updateRow({ title, description, url }, id);
+
+ const rmd = await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: `feedback_research_${id}`,
+ });
+ if (rmd) {
+ const answers = { ...rmd.body.answers };
+ for (const [k, v] of Object.entries(rest)) {
+ if (/^q\d+$/.test(k)) answers[k] = v;
+ }
+ await rmd.update({ body: { ...rmd.body, answers } });
}
+
+ return { json: { success: true, notify_success: "Feedback saved" } };
};
-const del_feedback = async (table_id, viewname, config, body, { req, res }) => {
- const r = await MetaData.findOne({
- id: body.id,
+/**
+ * Route: adds a Feedback link to the navigation menu if not already present.
+ */
+const add_feedback_to_menu = async (
+ table_id,
+ vn,
+ config,
+ body,
+ { req, res }
+) => {
+ const current = getState().getConfig("menu_items", []);
+ const alreadyAdded = current.some(
+ (mi) =>
+ mi.type === "Link" &&
+ mi.url === "javascript:ajax_modal('/page/app_constructor_feedback_form')"
+ );
+ if (!alreadyAdded) {
+ await save_menu_items([
+ ...current,
+ {
+ type: "Link",
+ label: "Feedback",
+ text: "Feedback",
+ icon: "fas fa-comment-alt",
+ url: "javascript:ajax_modal('/page/app_constructor_feedback_form')",
+ min_role: 80,
+ },
+ ]);
+ }
+ return { json: { success: true } };
+};
+
+/**
+ * Route: one-time setup that creates the feedback table, fields, submission page
+ * and edit view. Safe to call only once — will fail if table already exists.
+ */
+const setup_feedback_system = async (
+ table_id,
+ vn,
+ config,
+ body,
+ { req, res }
+) => {
+ const table = await Table.create(FEEDBACK_TABLE);
+
+ await Field.create({
+ table_id: table.id,
+ name: "title",
+ label: "Title",
+ type: "String",
+ required: true,
+ });
+ await Field.create({
+ table_id: table.id,
+ name: "description",
+ label: "Description",
+ type: "String",
+ });
+ await Field.create({
+ table_id: table.id,
+ name: "url",
+ label: "URL",
+ type: "String",
+ });
+ await Field.create({
+ table_id: table.id,
+ name: "status",
+ label: "Status",
+ type: "String",
+ attributes: { options: "Pending,Approved,Rejected" },
+ });
+
+ const labelFieldRow = (labelText, fieldName, fieldview = "edit") => ({
+ style: { "margin-bottom": "1.5rem" },
+ aligns: ["end", "start"],
+ widths: [2, 10],
+ breakpoints: ["md", "md"],
+ mobileAligns: ["start"],
+ setting_col_n: 0,
+ besides: [
+ {
+ type: "blank",
+ block: false,
+ inline: false,
+ font: "",
+ style: {},
+ textStyle: "",
+ customClass: "",
+ isFormula: {},
+ contents: labelText,
+ labelFor: fieldName,
+ },
+ {
+ type: "field",
+ block: false,
+ fieldview,
+ textStyle: "",
+ field_name: fieldName,
+ configuration: {},
+ },
+ ],
+ });
+
+ const saveButtonRow = (label = "") => ({
+ style: { "margin-bottom": "1.5rem" },
+ aligns: ["end", "start"],
+ widths: [2, 10],
+ breakpoints: ["", ""],
+ setting_col_n: 0,
+ besides: [
+ null,
+ {
+ type: "action",
+ block: false,
+ rndid: "a1b2c3",
+ nsteps: "",
+ minRole: 100,
+ isFormula: {},
+ run_async: false,
+ action_icon: "",
+ action_name: "Save",
+ action_size: "",
+ action_bgcol: "",
+ action_class: "",
+ action_label: label,
+ action_style: "btn-primary",
+ action_title: "",
+ configuration: {},
+ step_only_ifs: "",
+ action_textcol: "",
+ action_bordercol: "",
+ step_action_names: "",
+ },
+ ],
+ });
+
+ // User-facing feedback submission page (two-step: generate questions then save)
+ await Page.create({
+ name: "app_constructor_feedback_form",
+ title: "Submit feedback",
+ description: "",
+ min_role: 80,
+ layout: {
+ above: [
+ {
+ type: "blank",
+ contents: feedbackFormContent(),
+ block: false,
+ },
+ ],
+ },
+ fixed_states: {},
});
+ // Admin edit view — opened as popup from the feedback tab
+ await View.create({
+ name: "app_constructor_feedback_edit",
+ viewtemplate: "Edit",
+ table_id: table.id,
+ min_role: 1,
+ configuration: {
+ layout: {
+ above: [
+ labelFieldRow("Title", "title"),
+ labelFieldRow("Description", "description", "textarea"),
+ labelFieldRow("URL", "url"),
+ labelFieldRow("Status", "status"),
+ saveButtonRow(),
+ ],
+ },
+ columns: [
+ {
+ type: "Field",
+ block: false,
+ fieldview: "edit",
+ textStyle: "",
+ field_name: "title",
+ configuration: {},
+ },
+ {
+ type: "Field",
+ block: false,
+ fieldview: "textarea",
+ textStyle: "",
+ field_name: "description",
+ configuration: {},
+ },
+ {
+ type: "Field",
+ block: false,
+ fieldview: "edit",
+ textStyle: "",
+ field_name: "url",
+ configuration: {},
+ },
+ {
+ type: "Field",
+ block: false,
+ fieldview: "edit",
+ textStyle: "",
+ field_name: "status",
+ configuration: {},
+ },
+ ],
+ },
+ });
+
+ return { json: { success: true, notify_success: "Feedback system created" } };
+};
+
+/**
+ * Route: deletes a single processed feedback metadata entry.
+ */
+const del_feedback = async (table_id, vn, config, body, { req, res }) => {
+ const r = await MetaData.findOne({ id: body.id });
if (!r) throw new Error("Feedback not found");
await r.delete();
- return { json: { reload_page: true } };
+
+ // just to be sure
+ // feedback_research_${body.id} should already be cleaned up
+ const stale = await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: `feedback_research_${body.id}`,
+ });
+ if (stale) {
+ getState().log(
+ 5,
+ `del_feedback: found stale feedback_research_${body.id}, deleting`
+ );
+ await stale.delete();
+ }
+ return { json: { success: true } };
};
-const del_all_feedback = async (
+
+/**
+ * Route: deletes all processed feedback metadata entries.
+ */
+const del_all_feedback = async (table_id, vn, config, body, { req, res }) => {
+ const rs = await MetaData.find({
+ type: "CopilotConstructMgr",
+ name: "feedback",
+ });
+ for (const r of rs) {
+ await r.delete();
+ const stale = await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: `feedback_research_${r.id}`,
+ });
+ if (stale) {
+ getState().log(
+ 5,
+ `del_all_feedback: found stale feedback_research_${r.id}, deleting`
+ );
+ await stale.delete();
+ }
+ }
+ return { json: { success: true } };
+};
+
+/**
+ * Route: asks the LLM whether the feedback needs clarification.
+ * Returns an array of questions, or an empty array if the feedback is clear.
+ */
+const analyse_feedback = async (table_id, vn, config, body, { req, res }) => {
+ const { title, description, url = "" } = body;
+ const spec = await MetaData.findOne({
+ type: "CopilotConstructMgr",
+ name: "spec",
+ });
+
+ let knownContext = null;
+ if (url) {
+ const mView = url.match(/\/view\/([^/?#]+)/);
+ const mPage = url.match(/\/page\/([^/?#]+)/);
+ const entityType = mView ? "view" : mPage ? "page" : null;
+ const entityName = mView?.[1] ?? mPage?.[1] ?? null;
+ if (entityType) {
+ knownContext = {
+ section:
+ "Known context (do NOT ask about these — they are already established facts):\n" +
+ `- The feedback targets the Saltcorn ${entityType} named "${entityName}"\n` +
+ `- URL: ${url}\n`,
+ doNotAsk:
+ `- Which ${entityType}, screen, or part of the application this concerns` +
+ ` — it is the ${entityType} "${entityName}" as stated above`,
+ };
+ } else {
+ knownContext = {
+ section: `Known context:\n- URL: ${url}\n`,
+ doNotAsk: null,
+ };
+ }
+ }
+
+ const specSection = spec?.body?.specification
+ ? "The following application is being built:\n\n" +
+ spec.body.specification +
+ "\n\n"
+ : "";
+ const contextSection = knownContext ? knownContext.section + "\n" : "";
+ const feedbackSection =
+ "User feedback:\n" +
+ `- Title: ${title}` +
+ (description ? `\n- Description: ${description}` : "");
+ const doNotAskSection = knownContext?.doNotAsk
+ ? "Facts already known — do NOT ask about these:\n" +
+ knownContext.doNotAsk +
+ "\n\n"
+ : "";
+ const decisionSection =
+ "Do you have important questions about this feedback,\n" +
+ "or do you already know what needs to be done?\n\n" +
+ "- If you know what to do — no need to call any tool, just respond with nothing.\n" +
+ "- If you have questions that are truly blocking —\n" +
+ " call ask_questions with only those. 3 is a hard maximum.\n" +
+ " Each question must be short, clear, and easy to understand.\n" +
+ " Write for a non-technical user: plain language, no jargon, one idea per question.";
+
+ const answer = await getState().functions.llm_generate.run(
+ specSection +
+ contextSection +
+ feedbackSection +
+ "\n\n" +
+ doNotAskSection +
+ decisionSection,
+ {
+ tools: [questions_tool],
+ systemPrompt:
+ "You are a requirements analyst reviewing user feedback. " +
+ "Your default is to ask NO questions — only use the tool when something\n" +
+ "is genuinely too ambiguous to act on without clarification.\n" +
+ "Never fish for detail that a competent developer could infer or decide themselves.",
+ }
+ );
+ const tc =
+ typeof answer.getToolCalls === "function"
+ ? answer.getToolCalls()[0]
+ : undefined;
+ return { json: { questions: tc?.input?.questions ?? [] } };
+};
+
+/**
+ * Route: inserts a new pending feedback row and stores any Q&A answers
+ * as a separate metadata record keyed by the row id.
+ */
+const submit_feedback_with_answers = async (
table_id,
- viewname,
+ vn,
config,
body,
- { req, res },
+ { req, res }
) => {
- const rs = await MetaData.find({
+ const { _csrf, title, description, url, ...rest } = body;
+ const table = Table.findOne({ name: FEEDBACK_TABLE });
+ if (!table) return { json: { error: "Feedback table not found" } };
+
+ const rowId = await table.insertRow({
+ title,
+ description: description || null,
+ url: url || null,
+ status: "Pending",
+ });
+
+ const questions = [];
+ const answers = {};
+ let idx = 1;
+ while (rest[`question_${idx}`] !== undefined) {
+ questions.push(rest[`question_${idx}`]);
+ answers[`q${idx}`] = rest[`a${idx}`] || "";
+ idx++;
+ }
+
+ const staleRmd = await MetaData.findOne({
type: "CopilotConstructMgr",
- name: "feedback",
+ name: `feedback_research_${rowId}`,
});
- for (const r of rs) await r.delete();
- return { json: { reload_page: true } };
+ if (staleRmd) await staleRmd.delete();
+
+ if (questions.length) {
+ await MetaData.create({
+ type: "CopilotConstructMgr",
+ name: `feedback_research_${rowId}`,
+ body: {
+ feedback_id: rowId,
+ title,
+ questions,
+ answers,
+ },
+ });
+ }
+
+ return { json: { success: true } };
};
-const feedback_routes = { del_feedback, del_all_feedback };
+const feedback_routes = {
+ del_feedback,
+ del_all_feedback,
+ setup_feedback_system,
+ feedback_views_html,
+ start_approve_feedback,
+ approval_status,
+ delete_feedback_row,
+ analyse_feedback,
+ submit_feedback_with_answers,
+ get_feedback_edit_html,
+ save_feedback_edit,
+ show_processed_feedback,
+ add_feedback_to_menu,
+};
module.exports = { feedbackList, feedback_routes };
diff --git a/app-constructor/prompts.js b/app-constructor/prompts.js
index a755963..47cf4b9 100644
--- a/app-constructor/prompts.js
+++ b/app-constructor/prompts.js
@@ -145,10 +145,97 @@ const available_plugins_list = (storePlugins, installedNames) => {
);
};
+const research_answers_section = (text) =>
+ text
+ ? `\nThe user was asked clarifying questions about the application. Here are the questions and their answers:\n\n${text}\n`
+ : "";
+
+const task_planning_rules = `The plan should focus on building views, triggers (including workflows) and pages.
+
+Important trigger planning rules:
+* When a task involves a simple field update (e.g. marking an item complete or incomplete), plan it as a trigger using modify_row — NOT a workflow. Use a workflow only when multiple steps, branching, or looping are genuinely required.
+* If multiple independent single-step actions are needed (e.g. "mark complete" and "mark incomplete"), describe them as separate triggers in the task description — do not describe them as one combined workflow.
+* Do NOT mention "navigate back" or "return to context" in trigger task descriptions. Navigation is configured at the view level (GoBack button), not inside a trigger.
+* If a trigger should be accessible as a button in a view, the task description must name the target view and say to add an action segment with action_name set to the trigger's name. If the view already exists, combine trigger creation and view update in the same task. If the view is created in a later task, that task's description must mention adding the trigger button, and it must depend on the trigger task.
+* Do NOT plan any task that writes to a virtual (read-only) calculated field. Virtual fields are computed automatically and cannot be stored — any trigger or workflow that tries to update them will be refused. If you find yourself planning a trigger to keep a calculated field "current", delete that task — the field already updates itself.
+
+Important existing-entity rules:
+* Before planning any view or page task, check the list of already-implemented views and pages above. If an existing view or page already covers the required functionality — even under a slightly different name — do NOT create a new one. Reference the existing entity by its exact name in dependent tasks.
+* Never create a new view that is a renamed variant of an existing one (e.g. prefixing with "my_", "user_", "filtered_"). If the existing view needs filtering for a specific context, embed it as-is and describe the filtering in the embedding page or view task.
+* For every role's required dashboard or key page, verify it is either in the existing pages list or has a task planned for it. A requirement that mentions a dashboard or home screen for a role and has no corresponding existing page MUST have a task.
+* If a page was previously created under one name and a requirement refers to the same concept under a different name, use the existing page's actual name — do not plan a second page for the same purpose.
+
+Important view planning rules:
+* Each task must create exactly one view. Never put two or more views in the same task. Edit, Show, and List for the same table are always three separate tasks with three separate names, descriptions, and dependencies.
+* Do NOT plan separate tasks for "create" and "edit" on the same table. In Saltcorn, a single Edit view handles both (no id = create, id present = edit). One task, one Edit view, description says "create and edit".
+* Edit, Show, and List views for a table always go together as three separate tasks. Whenever you plan a List view AND a Show view for the same table, you MUST also plan an Edit view for that table — a List without an Edit leaves users unable to create or modify records. Only omit the Edit view when the requirements explicitly say the data is read-only.
+* The three tasks must be ordered: Edit and Show first (independent of each other, in any order), List last. The List task MUST list both the Edit task and the Show task in its depends_on — without exception. If you plan a List that depends on neither, that is a bug in the plan.
+* Before finalising the plan, for every List view task, verify that its depends_on includes the corresponding Edit task and the corresponding Show task (if they exist). If either is missing, add it.
+* When a List view links to a Show view or Edit view, the task description must say: "Add a viewlink column to [view_name] for the current row" — not just "link each row". This wording makes it unambiguous that a viewlink column must be added to the list for each target view.
+* Every List view task description must include a delete action column unless the table is explicitly read-only. State it explicitly: "Add a delete action column."
+* In general, if a view embeds or links to another view, the linked view's task must be listed as a dependency.
+* When a table has foreign key fields referencing the users table, the task description must explicitly state for each one whether it is an ownership field (automatically set from the logged-in user, omit from the form) or a selector field (the user picks a value, include a selector in the form). Example: "user_id records the owner and is set automatically; shared_with_user_id must have a user selector."
+* For FK fields that represent a parent context (e.g. trip_id on packing_items), always include the field as a normal selector in the Edit view form. Do NOT say to omit it. Saltcorn automatically pre-fills the selector from the URL query parameter when the view is opened from a parent context, and the user can select it manually when the view is used standalone.
+* For every task that creates a view, include the exact view name in the task description. View names must be lowercase, snake_case, unique across all tasks in the plan, and descriptive enough to identify the table and purpose — for example 'packing_items_edit' rather than just 'edit'.
+* Do NOT plan an Edit view for any table whose description says it is auto-populated or not editable by users (e.g. audit logs, import/export job tracking tables). These tables may have List and Show views for read-only visibility, but never an Edit view.
+Important user account rules:
+* The platform (Saltcorn) provides a built-in user account system with login, registration, and session management. Do NOT plan any tasks for user registration, login pages, password management, authentication flows, or email verification — these are already handled by the platform. Users register at /auth/signup and log in at /auth/login.
+* User identity is always available as the logged-in user. Ownership fields (FK to users) are set automatically from the session; no custom logic is needed.
+* If a requirement mentions "user accounts", "secure login", "saving data per user", "user-specific data", or "sharing between users", treat it as already satisfied by the platform's built-in user system. Do not generate any task in response to such a requirement.
+
+Important role rules:
+* Every view and page task description MUST state the min_role explicitly, e.g. "Set min_role to admin (1)." or "Set min_role to user (80).". Never omit it.
+* Role values: admin=1, staff=40, user=80, public=100. Use the value that matches who will use the view or page — admin for management, staff for staff-only, user for logged-in users (clients, members, etc.), public only when the view or page must be accessible without login.
+
+Important dashboard rules:
+* A dashboard page that shows aggregate statistics (totals, counts, revenue, etc.) must NEVER use client-side JavaScript fetch stubs or placeholder values. Every stat card must be backed by a real Saltcorn Statistic view embedded with an embed-view tag.
+* For each statistic shown on a dashboard, plan a separate Statistic view task (e.g. "total_billable_hours_stat", "revenue_by_client_stat"). The dashboard page task must list all these Statistic view tasks in its depends_on.
+* Statistic view tasks must be planned before the dashboard page task and have descriptive names that make their metric clear.
+
+Important home page rules:
+* Every role should land on the right page after visiting /. Plan a single task "Set home pages by role" that depends on all relevant page tasks and configures home_page_by_role for every role in one step.
+* Role IDs: public=100, user=80, staff=40, admin=1.
+* Landing/marketing page (public-facing intro): min_role must be 100 (public). It MUST include visible links to /auth/login (Log in) and /auth/signup (Create an account). Set as home for role 100 (public).
+* If there is an admin dashboard page, set it as home for role 1 (admin).
+* If there is a dashboard or main page for regular users or staff, set it as home for role 80 (user) and/or role 40 (staff) as appropriate.
+* The "Set home pages by role" task description must list every role→page mapping explicitly using the exact page names planned in this task list, e.g.: "Set home_page_by_role: public (100) → landing, user (80) → client_dashboard, staff (40) → staff_dashboard, admin (1) → app_admin_dashboard." Never use "admin_dashboard" as a page name — it is reserved by the platform.
+
+Important bulk import/export rules:
+* A plain Edit view creates or edits a single record — it is NOT a bulk import tool. Never plan an Edit view as a solution for bulk data import.
+* List views have no built-in export feature — do not plan an export button or column as part of a list view.
+* Bulk import and export functionality (e.g. CSV) must always be placed on a dedicated management or admin page as embedded views, using whatever import/export viewtemplate is available from an installed plugin.
+* Bulk import and bulk export for the same table are always two separate tasks with two separate view names. Never combine them into a single task.
+
+Important plugin rules:
+* If multiple plugins need to be installed, combine them ALL into a single task named "Install plugins" that lists every required plugin name. Do NOT create a separate task per plugin.
+
+Important dependency rules:
+* Every name in a task's depends_on MUST exactly match the name field of another task in the same plan_tasks call. Never reference a name that is not present in the tasks array — not a concept, not a table name, not a made-up label. If you find yourself writing a depends_on entry whose name does not appear as a task name in the list, either add the missing task or remove the dependency.
+* Before calling plan_tasks, mentally verify: for every task, every name in its depends_on array appears as the name of another task in the array.
+* Before calling plan_tasks, check for circular dependencies. A circular dependency means task A depends on B, and B depends on A (directly or transitively). A circular dependency causes a deadlock — neither task can ever start. To fix it: identify which dependency in the cycle is the weakest (i.e. view A only needs to embed view B, but B does not strictly require A to exist). Remove that dependency from A's depends_on so A can be created first. Then decide whether B's content is still useful without being embedded in A at creation time. If the embed is important, add a separate update task (e.g. "update_A_embed_B") whose description says to update view A to embed view B, and whose depends_on lists both A and B. Only add this extra update task when the embed is genuinely important for the finished product — do not create update tasks for minor or optional embeds, as each extra task is expensive. A good rule of thumb: add an update task only if omitting the embed from the final view would visibly break a user workflow.
+
+Important schema/table rules:
+* The database schema is already fully designed and implemented before task planning begins. ALL tables and fields needed by the application already exist. Do NOT plan any tasks that create tables, add fields, modify fields, or change the schema in any way. If you find yourself writing a task whose output is a table or a field, delete it — that work is already done.
+* Ownership behaviour (auto-setting a FK-to-users field from the logged-in user) is configured in the Edit view, not in the database. Do not create tasks for it at the schema level.
+* Do NOT plan tasks to add uniqueness constraints or validation to existing fields — those are already in the schema.
+* Do NOT plan a standalone task for "access control", "row-level security", "permissions", or "roles". These are schema-level concerns already handled during schema design, or view-level concerns handled when building each view. The ownership field and sharing logic are already in the schema — there is nothing extra to configure as a separate task.`;
+
+const task_planning_closing = `Your plan should not include any clarification or questions to the product owner. The
+information you have been given so far is all that is available. Every step in the plan
+should be immediately implementable in Saltcorn. You are writing the steps in the plan
+for a person who is competent in using saltcorn but has no other business knowledge.
+
+Do not include any steps that contain planning, design or review instructions. You are only writing a
+plan for the engineer building the application. Every step in the plan should have the construction or the modification
+of one or several application entity types.`;
+
module.exports = {
saltcorn_description,
existing_tables_list,
existing_entities_list,
installed_plugins_list,
available_plugins_list,
+ research_answers_section,
+ task_planning_rules,
+ task_planning_closing,
};
diff --git a/app-constructor/requirements.js b/app-constructor/requirements.js
index 9450bfd..fb3cc83 100644
--- a/app-constructor/requirements.js
+++ b/app-constructor/requirements.js
@@ -38,6 +38,7 @@ const renderLayout = require("@saltcorn/markup/layout");
const { viewname, tool_choice } = require("./common");
const { requirements_tool } = require("./tools");
const { getResearchAnswersText } = require("./research");
+const { research_answers_section } = require("./prompts");
const requirementsList = async (req) => {
const rs = await MetaData.find(
@@ -54,7 +55,21 @@ const requirementsList = async (req) => {
{ class: "mt-2" },
mkTable(
[
- { label: "Requirement", key: (m) => m.body.requirement },
+ {
+ label: "Requirement",
+ key: (m) =>
+ m.body.requirement +
+ (m.body.source === "feedback"
+ ? span(
+ {
+ class: "badge bg-warning text-dark ms-2 fw-normal",
+ title: `From feedback: ${m.body.feedback_title || ""}`,
+ },
+ i({ class: "fas fa-comment-alt me-1" }),
+ "feedback"
+ )
+ : ""),
+ },
{
label: "Priority",
key: (m) =>
@@ -97,15 +112,13 @@ const requirementsList = async (req) => {
),
script(
domReady(`
-(function() {
- const poll = () => {
- view_post(${JSON.stringify(viewname)}, 'req_status', {}, (resp) => {
- if (resp && !resp.generating) location.reload();
- else setTimeout(poll, 3000);
- });
- };
- setTimeout(poll, 3000);
-})();
+const poll = () => {
+ view_post(${JSON.stringify(viewname)}, 'req_status', {}, (resp) => {
+ if (resp && !resp.generating) location.reload();
+ else setTimeout(poll, 3000);
+ });
+};
+setTimeout(poll, 3000);
`)
)
);
@@ -150,7 +163,7 @@ const doGenReqs = async (spec, userId) => {
`Generate the requirements for this application:
${spec.body.specification}
-${researchText ? `\nThe user was asked clarifying questions about the application. Here are the questions and their answers:\n\n${researchText}\n` : ""}
+${research_answers_section(researchText)}
Important rules for generating requirements:
* Every requirement must be directly traceable to something stated in the description, audience, or core features above. Do not infer, invent, or add features that are not explicitly mentioned — even if they seem like an obvious addition.
* Do not generate any requirement that falls under the Out of scope section above.
@@ -219,6 +232,24 @@ const del_all_reqs = async (table_id, viewname, config, body, { req, res }) => {
return { json: { reload_page: true } };
};
-const req_routes = { gen_reqs, req_status, del_req, del_all_reqs };
+/** Route: returns the rendered requirements list HTML for AJAX refresh. */
+const req_list_html = async (
+ table_id,
+ viewname,
+ config,
+ body,
+ { req, res }
+) => {
+ const html = await requirementsList(req);
+ return { json: { html } };
+};
+
+const req_routes = {
+ gen_reqs,
+ req_status,
+ del_req,
+ del_all_reqs,
+ req_list_html,
+};
module.exports = { requirementsList, req_routes };
diff --git a/app-constructor/research.js b/app-constructor/research.js
index 714e11f..cd84c85 100644
--- a/app-constructor/research.js
+++ b/app-constructor/research.js
@@ -77,7 +77,7 @@ const researchPanelHtml = async (req) => {
.join("");
return (
- h5("Clarifying questions") +
+ h5({ class: "mb-2" }, "Specification questions") +
small(
{ class: "text-muted d-block mb-3" },
"Answer these questions to help generate more accurate requirements and tasks. " +
@@ -307,4 +307,9 @@ const research_routes = {
submit_research,
};
-module.exports = { researchPanel, research_routes, getResearchAnswersText };
+module.exports = {
+ researchPanel,
+ research_routes,
+ getResearchAnswersText,
+ questions_tool,
+};
diff --git a/app-constructor/tasks.js b/app-constructor/tasks.js
index 07afa0d..afe885e 100644
--- a/app-constructor/tasks.js
+++ b/app-constructor/tasks.js
@@ -49,12 +49,27 @@ const {
existing_entities_list,
installed_plugins_list,
available_plugins_list,
+ task_planning_rules,
+ task_planning_closing,
+ research_answers_section,
} = require("./prompts");
+const feedbackBadge = (body) =>
+ body.source === "feedback"
+ ? span(
+ {
+ class: "badge bg-warning text-dark ms-2 fw-normal",
+ title: `From feedback: ${body.feedback_title || ""}`,
+ },
+ i({ class: "fas fa-comment-alt me-1" }),
+ "feedback"
+ )
+ : "";
+
const doneTaskRowHtml = (task) =>
tr(
{ "data-row-id": task.id },
- td(task.body.name || ""),
+ td((task.body.name || "") + feedbackBadge(task.body)),
td(task.body.description || ""),
td((task.body.depends_on || []).join(", ")),
td(task.body.priority || ""),
@@ -166,7 +181,10 @@ const makeTaskList = async (req) => {
status,
mkTable(
[
- { label: "Name", key: (m) => m.body.name },
+ {
+ label: "Name",
+ key: (m) => (m.body.name || "") + feedbackBadge(m.body),
+ },
{ label: "Description", key: (m) => m.body.description },
{
label: "Depends on",
@@ -175,13 +193,27 @@ const makeTaskList = async (req) => {
.map((dep) =>
doneNames.has(dep)
? span(
- { class: "dep-indicator text-success me-2", style: "white-space:nowrap", title: dep },
- i({ class: "fas fa-check-circle me-1", style: "font-size:0.75em" }),
+ {
+ class: "dep-indicator text-success me-2",
+ style: "white-space:nowrap",
+ title: dep,
+ },
+ i({
+ class: "fas fa-check-circle me-1",
+ style: "font-size:0.75em",
+ }),
dep
)
: span(
- { class: "dep-indicator text-danger me-2", style: "white-space:nowrap", title: dep },
- i({ class: "fas fa-circle me-1", style: "font-size:0.75em" }),
+ {
+ class: "dep-indicator text-danger me-2",
+ style: "white-space:nowrap",
+ title: dep,
+ },
+ i({
+ class: "fas fa-circle me-1",
+ style: "font-size:0.75em",
+ }),
dep
)
)
@@ -675,7 +707,7 @@ const doGenTasks = async (spec, rs, schema, userId) => {
`Generate a plan for building this application:
${spec.body.specification}
-${researchText ? `\nThe user was asked clarifying questions about the application. Here are the questions and their answers:\n\n${researchText}\n` : ""}
+${research_answers_section(researchText)}
These are the requirements of the application:
${rs.map((r) => `* ${r.body.requirement}`).join("\n")}
@@ -690,86 +722,11 @@ The plan should outline the continued development of the application on top of t
Your plan can add additional tables if needed or adjust the table fields, but normally the tables
should be designed optimally for this application.
-${entitiesSection ? entitiesSection + "\n\n" : ""}${installedPluginsSection ? installedPluginsSection + "\n\n" : ""}${
- pluginsSection ? pluginsSection + "\n\n" : ""
- }The plan should focus on building views, triggers (including workflows) and pages.
-
-Important trigger planning rules:
-* When a task involves a simple field update (e.g. marking an item complete or incomplete), plan it as a trigger using modify_row — NOT a workflow. Use a workflow only when multiple steps, branching, or looping are genuinely required.
-* If multiple independent single-step actions are needed (e.g. "mark complete" and "mark incomplete"), describe them as separate triggers in the task description — do not describe them as one combined workflow.
-* Do NOT mention "navigate back" or "return to context" in trigger task descriptions. Navigation is configured at the view level (GoBack button), not inside a trigger.
-* If a trigger should be accessible as a button in a view, the task description must name the target view and say to add an action segment with action_name set to the trigger's name. If the view already exists, combine trigger creation and view update in the same task. If the view is created in a later task, that task's description must mention adding the trigger button, and it must depend on the trigger task.
-* Do NOT plan any task that writes to a virtual (read-only) calculated field. Virtual fields are computed automatically and cannot be stored — any trigger or workflow that tries to update them will be refused. If you find yourself planning a trigger to keep a calculated field "current", delete that task — the field already updates itself.
-
-Important existing-entity rules:
-* Before planning any view or page task, check the list of already-implemented views and pages above. If an existing view or page already covers the required functionality — even under a slightly different name — do NOT create a new one. Reference the existing entity by its exact name in dependent tasks.
-* Never create a new view that is a renamed variant of an existing one (e.g. prefixing with "my_", "user_", "filtered_"). If the existing view needs filtering for a specific context, embed it as-is and describe the filtering in the embedding page or view task.
-* For every role's required dashboard or key page, verify it is either in the existing pages list or has a task planned for it. A requirement that mentions a dashboard or home screen for a role and has no corresponding existing page MUST have a task.
-* If a page was previously created under one name and a requirement refers to the same concept under a different name, use the existing page's actual name — do not plan a second page for the same purpose.
-
-Important view planning rules:
-* Each task must create exactly one view. Never put two or more views in the same task. Edit, Show, and List for the same table are always three separate tasks with three separate names, descriptions, and dependencies.
-* Do NOT plan separate tasks for "create" and "edit" on the same table. In Saltcorn, a single Edit view handles both (no id = create, id present = edit). One task, one Edit view, description says "create and edit".
-* Edit, Show, and List views for a table always go together as three separate tasks. Whenever you plan a List view AND a Show view for the same table, you MUST also plan an Edit view for that table — a List without an Edit leaves users unable to create or modify records. Only omit the Edit view when the requirements explicitly say the data is read-only.
-* The three tasks must be ordered: Edit and Show first (independent of each other, in any order), List last. The List task MUST list both the Edit task and the Show task in its depends_on — without exception. If you plan a List that depends on neither, that is a bug in the plan.
-* Before finalising the plan, for every List view task, verify that its depends_on includes the corresponding Edit task and the corresponding Show task (if they exist). If either is missing, add it.
-* When a List view links to a Show view or Edit view, the task description must say: "Add a viewlink column to [view_name] for the current row" — not just "link each row". This wording makes it unambiguous that a viewlink column must be added to the list for each target view.
-* Every List view task description must include a delete action column unless the table is explicitly read-only. State it explicitly: "Add a delete action column."
-* In general, if a view embeds or links to another view, the linked view's task must be listed as a dependency.
-* When a table has foreign key fields referencing the users table, the task description must explicitly state for each one whether it is an ownership field (automatically set from the logged-in user, omit from the form) or a selector field (the user picks a value, include a selector in the form). Example: "user_id records the owner and is set automatically; shared_with_user_id must have a user selector."
-* For FK fields that represent a parent context (e.g. trip_id on packing_items), always include the field as a normal selector in the Edit view form. Do NOT say to omit it. Saltcorn automatically pre-fills the selector from the URL query parameter when the view is opened from a parent context, and the user can select it manually when the view is used standalone.
-* For every task that creates a view, include the exact view name in the task description. View names must be lowercase, snake_case, unique across all tasks in the plan, and descriptive enough to identify the table and purpose — for example 'packing_items_edit' rather than just 'edit'.
-* Do NOT plan an Edit view for any table whose description says it is auto-populated or not editable by users (e.g. audit logs, import/export job tracking tables). These tables may have List and Show views for read-only visibility, but never an Edit view.
-Important user account rules:
-* The platform (Saltcorn) provides a built-in user account system with login, registration, and session management. Do NOT plan any tasks for user registration, login pages, password management, authentication flows, or email verification — these are already handled by the platform. Users register at /auth/signup and log in at /auth/login.
-* User identity is always available as the logged-in user. Ownership fields (FK to users) are set automatically from the session; no custom logic is needed.
-* If a requirement mentions "user accounts", "secure login", "saving data per user", "user-specific data", or "sharing between users", treat it as already satisfied by the platform's built-in user system. Do not generate any task in response to such a requirement.
-
-Important role rules:
-* Every view and page task description MUST state the min_role explicitly, e.g. "Set min_role to admin (1)." or "Set min_role to user (80).". Never omit it.
-* Role values: admin=1, staff=40, user=80, public=100. Use the value that matches who will use the view or page — admin for management, staff for staff-only, user for logged-in users (clients, members, etc.), public only when the view or page must be accessible without login.
-
-Important dashboard rules:
-* A dashboard page that shows aggregate statistics (totals, counts, revenue, etc.) must NEVER use client-side JavaScript fetch stubs or placeholder values. Every stat card must be backed by a real Saltcorn Statistic view embedded with an embed-view tag.
-* For each statistic shown on a dashboard, plan a separate Statistic view task (e.g. "total_billable_hours_stat", "revenue_by_client_stat"). The dashboard page task must list all these Statistic view tasks in its depends_on.
-* Statistic view tasks must be planned before the dashboard page task and have descriptive names that make their metric clear.
-
-Important home page rules:
-* Every role should land on the right page after visiting /. Plan a single task "Set home pages by role" that depends on all relevant page tasks and configures home_page_by_role for every role in one step.
-* Role IDs: public=100, user=80, staff=40, admin=1.
-* Landing/marketing page (public-facing intro): min_role must be 100 (public). It MUST include visible links to /auth/login (Log in) and /auth/signup (Create an account). Set as home for role 100 (public).
-* If there is an admin dashboard page, set it as home for role 1 (admin).
-* If there is a dashboard or main page for regular users or staff, set it as home for role 80 (user) and/or role 40 (staff) as appropriate.
-* The "Set home pages by role" task description must list every role→page mapping explicitly using the exact page names planned in this task list, e.g.: "Set home_page_by_role: public (100) → landing, user (80) → client_dashboard, staff (40) → staff_dashboard, admin (1) → app_admin_dashboard." Never use "admin_dashboard" as a page name — it is reserved by the platform.
+${entitiesSection ? entitiesSection + "\n\n" : ""}${
+ installedPluginsSection ? installedPluginsSection + "\n\n" : ""
+ }${pluginsSection ? pluginsSection + "\n\n" : ""}${task_planning_rules}
-Important bulk import/export rules:
-* A plain Edit view creates or edits a single record — it is NOT a bulk import tool. Never plan an Edit view as a solution for bulk data import.
-* List views have no built-in export feature — do not plan an export button or column as part of a list view.
-* Bulk import and export functionality (e.g. CSV) must always be placed on a dedicated management or admin page as embedded views, using whatever import/export viewtemplate is available from an installed plugin.
-* Bulk import and bulk export for the same table are always two separate tasks with two separate view names. Never combine them into a single task.
-
-Important plugin rules:
-* If multiple plugins need to be installed, combine them ALL into a single task named "Install plugins" that lists every required plugin name. Do NOT create a separate task per plugin.
-
-Important dependency rules:
-* Every name in a task's depends_on MUST exactly match the name field of another task in the same plan_tasks call. Never reference a name that is not present in the tasks array — not a concept, not a table name, not a made-up label. If you find yourself writing a depends_on entry whose name does not appear as a task name in the list, either add the missing task or remove the dependency.
-* Before calling plan_tasks, mentally verify: for every task, every name in its depends_on array appears as the name of another task in the array.
-* Before calling plan_tasks, check for circular dependencies. A circular dependency means task A depends on B, and B depends on A (directly or transitively). A circular dependency causes a deadlock — neither task can ever start. To fix it: identify which dependency in the cycle is the weakest (i.e. view A only needs to embed view B, but B does not strictly require A to exist). Remove that dependency from A's depends_on so A can be created first. Then decide whether B's content is still useful without being embedded in A at creation time. If the embed is important, add a separate update task (e.g. "update_A_embed_B") whose description says to update view A to embed view B, and whose depends_on lists both A and B. Only add this extra update task when the embed is genuinely important for the finished product — do not create update tasks for minor or optional embeds, as each extra task is expensive. A good rule of thumb: add an update task only if omitting the embed from the final view would visibly break a user workflow.
-
-Important schema/table rules:
-* The database schema is already fully designed and implemented before task planning begins. ALL tables and fields needed by the application already exist. Do NOT plan any tasks that create tables, add fields, modify fields, or change the schema in any way. If you find yourself writing a task whose output is a table or a field, delete it — that work is already done.
-* Ownership behaviour (auto-setting a FK-to-users field from the logged-in user) is configured in the Edit view, not in the database. Do not create tasks for it at the schema level.
-* Do NOT plan tasks to add uniqueness constraints or validation to existing fields — those are already in the schema.
-* Do NOT plan a standalone task for "access control", "row-level security", "permissions", or "roles". These are schema-level concerns already handled during schema design, or view-level concerns handled when building each view. The ownership field and sharing logic are already in the schema — there is nothing extra to configure as a separate task.
-
-Your plan should not include any clarification or questions to the product owner. The
-information you have been given so far is all that is available. Every step in the plan
-should be immediately implementable in Saltcorn. You are writing the steps in the plan
-for a person who is competent in using saltcorn but has no other business knowledge.
-
-Do not include any steps that contain planning, design or review instructions. You are only writing a
-plan for the engineer building the application. Every step in the plan should have the construction or the modification
-of one or several application entity types.
+${task_planning_closing}
Before finalising the plan, you may call get_view_config for any existing view you are unsure about — to inspect its configuration and decide whether a task should reuse it (updating it) or create a new one. Once you have gathered all necessary information, call plan_tasks to submit the complete task list.
`,
@@ -1081,6 +1038,18 @@ const del_all_tasks = async (
return { json: { reload_page: true } };
};
+/** Route: returns the rendered task list HTML for AJAX refresh. */
+const tasks_list_html = async (
+ table_id,
+ viewname,
+ config,
+ body,
+ { req, res }
+) => {
+ const html = await makeTaskList(req);
+ return { json: { html } };
+};
+
const task_routes = {
gen_tasks,
del_task,
@@ -1094,6 +1063,7 @@ const task_routes = {
task_row_done,
start,
stop,
+ tasks_list_html,
};
module.exports = { makeTaskList, task_routes };
diff --git a/app-constructor/view.js b/app-constructor/view.js
index 3652d22..9d73682 100644
--- a/app-constructor/view.js
+++ b/app-constructor/view.js
@@ -73,6 +73,88 @@ const makeSpecForm = async (req) => {
});
};
+const specDepsModal = `
+
+
+
+
+
Specification changed
+
+
+
+
The following items were generated from the previous specification
+ and may now be outdated. Select any you want to clear: