From 080d3d9aaffa8848d6de8546511f096d8e7c30f5 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sat, 17 Oct 2026 11:34:07 +0200 Subject: settings: pages edit a draft, and Save writes it The qtmaildir, Assistant and Ask presets pages wrote a field on Enter, and a switch or chip the moment it was clicked, so a half-finished change reached the file and a slip could not be taken back. They now edit a draft as fields are typed, and a Save and Discard pair in the page header (SaveBar, through Page.actions) writes or drops it. Save waits while a field holds a value that cannot be written, and a draft over a file that changed on disk meanwhile is refused at Save rather than written over it. A dot in the sidebar marks a page with unsaved edits; drafts survive moving between pages. The four Mail pages share one draft, since they are one file. Tool switches are staged too and renamed on Save; model Load, Prefer and Restart stay immediate, being actions rather than settings. Live editing needed two rules. A field is no longer bound to its value, which would reset the cursor and eat a typed trailing space; it follows the value only when the draft moves under it. And a Repeater's model is now the set of row keys (or a count), assigned only when that set changes: an array rebuilt from the draft on each keystroke recreates every row, including the one being typed in, which loses its focus. Git, autostart's rows, HyprSunset, HyprIdle and User keep their own buttons. Co-Authored-By: Claude Opus 5.5 --- desktop/modules/assistant/AssistantModule.qml | 96 ++++++++++++++++++++++----- 1 file changed, 80 insertions(+), 16 deletions(-) (limited to 'desktop/modules/assistant/AssistantModule.qml') diff --git a/desktop/modules/assistant/AssistantModule.qml b/desktop/modules/assistant/AssistantModule.qml index 8253cc6..f738ea6 100644 --- a/desktop/modules/assistant/AssistantModule.qml +++ b/desktop/modules/assistant/AssistantModule.qml @@ -42,10 +42,29 @@ Module { readonly property var labels: ({ llm: "Server", system: "System prompt", tool_rounds: "Tool rounds", temperature: "Temperature", thinking: "Thinking" }) - // config.toml as last read or written; "" when there is none. + // config.toml as last read or written; "" when there is none. The page + // edits draft, which save() writes; base is the file the draft began + // from, so a save over a file changed meanwhile is refused. property string text: "" - readonly property var values: C.scalars(am.text, am.keys) - readonly property var envList: C.env(am.text) + property string base: "" + property string draft: "" + readonly property var values: C.scalars(am.draft, am.keys) + readonly property var envList: C.env(am.draft) + // The [env] keys, assigned only when the set changes: a model bound to the + // draft would rebuild every row on each keystroke and drop the focus of + // the one being typed in. Rows read their values from envValue(). + property var envKeys: [] + onDraftChanged: { + const k = C.env(am.draft).map(e => e.key); + if (JSON.stringify(k) !== JSON.stringify(am.envKeys)) am.envKeys = k; + } + function envValue(key) { return (am.envList.find(e => e.key === key) ?? { value: "" }).value; } + // Tool switches flipped but not saved: name -> wanted on. + property var toolsWanted: ({}) + // Fields holding a value that cannot be written; Save waits for none. + property var invalid: ({}) + readonly property bool valid: Object.keys(am.invalid).length === 0 + dirty: am.draft !== am.base || Object.keys(am.toolsWanted).length > 0 // The daemon's built-in values, from assistant.py --defaults. property var defaults: ({}) // [{name, on, description}] @@ -87,8 +106,12 @@ Module { watchChanges: true printErrors: false onFileChanged: reload() - onLoaded: am.text = text() - onLoadFailed: am.text = "" + // A clean draft follows the file; a dirty one is kept for save(). + onLoaded: { + am.text = text(); + if (am.draft === am.base) { am.base = am.text; am.draft = am.text; } + } + onLoadFailed: if (am.draft === am.base) { am.text = ""; am.base = ""; am.draft = ""; } } // One Process per call, so calls never share one (a running Process @@ -130,15 +153,40 @@ Module { + "with open(p + '.tmp', 'w') as f:\n f.write(t)\n" + "os.replace(p + '.tmp', p)\n" - function save(t) { + // The config first, then the tool renames, each only when there is + // something to do; any failure stops there and says why. + function save() { + if (am.text !== am.base) { + am.error = "config.toml changed on disk since these edits began. Discard to load it, then redo them."; + return; + } + am.error = ""; + const t = am.draft; + const tools = () => am.applyTools(Object.keys(am.toolsWanted), () => { am.pending = true; am.listTools(); }); + if (t === am.text) { tools(); return; } am.run([am.python, "-c", am.writer, am.cfgPath], t, (code, out, err) => { - if (code === 0) { am.text = t; am.pending = true; am.error = ""; } - else am.error = err.trim() || "config.toml could not be written"; + if (code !== 0) { am.error = err.trim() || "config.toml could not be written"; return; } + am.text = t; + am.base = t; + tools(); }); } + function discard() { + am.error = ""; + am.invalid = ({}); + am.toolsWanted = ({}); + am.base = am.text; + am.draft = am.text; + } + function markInvalid(id, bad) { + if (!!am.invalid[id] === bad) return; + const next = Object.assign({}, am.invalid); + if (bad) next[id] = true; else delete next[id]; + am.invalid = next; + } // toml is the value written as TOML, or null to fall back to the default. - function setKey(key, toml) { am.save(C.setScalar(am.text, key, toml)); } - function setEnv(key, value) { am.save(C.setEnv(am.text, key, value)); } + function setKey(key, toml) { am.draft = C.setScalar(am.draft, key, toml); } + function setEnv(key, value) { am.draft = C.setEnv(am.draft, key, value); } function quote(s) { return C.quote(s); } // Every tools/*.toml is loaded; *.toml.off is the same file switched off. @@ -158,14 +206,27 @@ Module { }); } + // Whether a tool is on in the draft: its wanted state, else the file's. + function toolOn(t) { return am.toolsWanted[t.name] ?? t.on; } function toggleTool(t) { - const on = am.toolsDir + "/" + t.name + ".toml"; + const next = Object.assign({}, am.toolsWanted); + if (!am.toolOn(t) === t.on) delete next[t.name]; else next[t.name] = !am.toolOn(t); + am.toolsWanted = next; + } + // One rename at a time, then done(). -n never overwrites a file that + // already holds the other name. + function applyTools(names, done) { + if (names.length === 0) { done(); return; } + const name = names[0]; + const on = am.toolsDir + "/" + name + ".toml"; const off = on + ".off"; - // -n: never overwrite a file that already holds the other name. - am.run(["mv", "-n", t.on ? on : off, t.on ? off : on], null, (code, out, err) => { - if (code === 0) { am.pending = true; am.error = ""; } - else am.error = err.trim() || "could not switch " + t.name; - am.listTools(); + const want = am.toolsWanted[name]; + am.run(["mv", "-n", want ? off : on, want ? on : off], null, (code, out, err) => { + if (code !== 0) { am.error = err.trim() || "could not switch " + name; am.listTools(); return; } + const next = Object.assign({}, am.toolsWanted); + delete next[name]; + am.toolsWanted = next; + am.applyTools(names.slice(1), done); }); } @@ -240,6 +301,9 @@ Module { page: Component { Page { title: "Assistant" + actions: Component { + SaveBar { dirty: am.dirty; valid: am.valid; onSave: am.save(); onDiscard: am.discard() } + } AssistantPage { width: parent.width; asst: am } } } -- cgit v1.2.3