Watch
1
0
Fork
You've already forked souveraine
0

shell: carry the message control row into the owned delegate

Vendor AiMessage exposed seven controls; the owned delegate had none, which
made patch 0008 a parity regression rather than a swap. Five are carried, two
are deliberately dropped:

  regenerate — the conversation is forward-only and Ai.regenerate() is already
    a no-op returning advice. A button whose only behaviour is to explain that
    it does nothing is worse than no button.
  edit — there is no in-place edit. The vendor's wrote to a local array the
    server never sees, so the message read back was not the message held.

delete is kept but armed: removeMessage() splices two local arrays and leaves
the server transcript untouched, so it is a view filter wearing a delete icon.
The armed row says so in words and replaces the controls in place rather than
opening a modal, which on a layer-shell surface would fight for focus.

speak and copy are separated at the source. copy takes the whole content;
speak takes text segments only, so the synthesizer no longer reads reasoning
and tool payloads aloud. Legacy messages without segments fall back to full
content rather than to silence.

re-synthesize prefers Speech.resynthesize() when present and degrades to the
old stop+speak otherwise, because speak() opens with a cache check on the text
and re-synthesis is by definition the same text — the legacy path replays the
identical broken audio.
This commit is contained in:
Fimeg 2026-08-12 13:50:07 -04:00
commit df11bbab96

View file

@ -8,6 +8,7 @@ import qs.modules.ii.sidebarLeft.aiChat
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
/*
* One message, drawn from typed segments.
@ -55,6 +56,36 @@ Rectangle {
readonly property bool isAssistant: (root.messageData?.role ?? "") === "assistant"
readonly property bool done: root.messageData?.done ?? false
// Delete is armed rather than immediate see the control row.
property bool deleteArmed: false
onMessageIndexChanged: root.deleteArmed = false
readonly property bool isSpeakingThis: Speech.speaking
&& Ai.speakingMessageIndex === root.messageIndex
// What `copy` puts on the clipboard: everything the message actually says.
readonly property string plainText: root.messageData?.content ?? ""
// What `speak` sends to TTS. NOT the same thing, deliberately.
//
// The vendor spoke messageData.content verbatim, which on a segmented
// message means the synthesizer reads reasoning and tool payloads aloud.
// Typed segments let us say what a voice should say: prose only. Code is
// excluded because reading a diff aloud is noise, not speech; `think` and
// `tool` are excluded because they are not addressed to anyone.
//
// Falls back to the whole content when a legacy message carries no
// segments, which is the pre-existing behaviour rather than silence.
readonly property string spokenText: {
const segments = root.messageData?.segments;
if (!segments || segments.length < 1) return root.plainText;
return segments
.filter(s => (s?.kind ?? "text") === "text")
.map(s => s?.text ?? "")
.join("\n\n")
.trim();
}
anchors.left: parent?.left
anchors.right: parent?.right
@ -106,6 +137,158 @@ Rectangle {
color: Appearance.colors.colOnSecondaryContainer
elide: Text.ElideRight
}
// Controls
// Deliberately NOT carried over from the vendor row:
//
// regenerate the conversation is forward-only. Ai.qml's
// regenerate() is already a no-op that returns advice.
// A button that only ever explains it does nothing is
// worse than no button. Audio re-synthesis is the real
// verb, and it lives on `replay` below.
// edit there is no in-place edit. The vendor's saved to
// a local array the server never sees, so the message
// you read back was not the message the agent holds.
//
// `delete` is kept but gated, because it is a *view filter*
// wearing a delete icon: removeMessage() splices two local
// arrays and the server transcript is untouched. /resume
// brings it straight back.
ButtonGroup {
id: controlRow
spacing: 5
visible: !root.deleteArmed
AiMessageControlButton {
id: speakButton
// stop icon only while THIS message is the speaker
buttonIcon: root.isSpeakingThis ? "stop_circle" : "volume_up"
visible: Speech.enabled && root.isAssistant
enabled: visible
onClicked: {
if (root.isSpeakingThis) {
Speech.stop()
} else {
Ai.speakingMessageIndex = root.messageIndex
Speech.speak(StringUtils.ttsClean(root.spokenText))
}
}
StyledToolTip {
text: root.isSpeakingThis ? Translation.tr("Stop") : Translation.tr("Speak")
}
}
AiMessageControlButton {
id: respeakButton
buttonIcon: "replay"
visible: Speech.enabled && root.isAssistant
enabled: visible
onClicked: {
// Re-synthesize when the previous synth came out
// wrong. Does NOT re-run the agent.
//
// The legacy path below cannot actually do this:
// speak() opens with a cache check on the text,
// and re-synthesis is by definition the same
// text so it replays the identical broken file.
// Speech.resynthesize() is the honest verb
// (cancel live job, bypass cache, re-request);
// until it lands we degrade rather than lie.
Ai.speakingMessageIndex = root.messageIndex
const text = StringUtils.ttsClean(root.spokenText)
if (typeof Speech.resynthesize === "function") {
Speech.resynthesize(text)
} else {
Speech.stop()
Speech.speak(text)
}
}
StyledToolTip {
text: Translation.tr("Re-synthesize audio")
}
}
AiMessageControlButton {
id: copyButton
buttonIcon: activated ? "inventory" : "content_copy"
onClicked: {
Quickshell.clipboardText = root.plainText
copyButton.activated = true
copyIconTimer.restart()
}
Timer {
id: copyIconTimer
interval: 1500
repeat: false
onTriggered: copyButton.activated = false
}
StyledToolTip {
text: Translation.tr("Copy")
}
}
AiMessageControlButton {
id: toggleMarkdownButton
activated: !root.renderMarkdown
buttonIcon: "code"
onClicked: root.renderMarkdown = !root.renderMarkdown
StyledToolTip {
text: Translation.tr("View Markdown source")
}
}
AiMessageControlButton {
id: deleteButton
buttonIcon: "close"
onClicked: root.deleteArmed = true
StyledToolTip {
text: Translation.tr("Hide from view")
}
}
}
// Armed state replaces the row in place rather than opening a
// modal: this panel is a layer-shell surface and a grabbing
// popup here fights the compositor for focus. The words are
// the point, not the chrome.
RowLayout {
id: deleteConfirmRow
visible: root.deleteArmed
spacing: 6
MaterialSymbol {
text: "visibility_off"
iconSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colOnSecondaryContainer
}
StyledText {
text: Translation.tr("Hides it here only — the transcript keeps it.")
font.pixelSize: Appearance.font.pixelSize.smaller
color: Appearance.colors.colOnSecondaryContainer
elide: Text.ElideRight
}
DialogButton {
buttonText: Translation.tr("Cancel")
onClicked: root.deleteArmed = false
}
DialogButton {
buttonText: Translation.tr("Hide")
onClicked: {
root.deleteArmed = false
Ai.removeMessage(root.messageIndex)
}
}
}
}
}