Notice broken sessions in the bar, and offer to repair them there

Nothing tells you a session is unresumable until you try to resume it, and by
then you have usually forgotten which one it was. The widget now scans every
project on a timer and whenever the panel opens, marks its icon when something
needs fixing, and lists the affected sessions with a repair button that says
what it will drop and what it will keep before doing anything.

The scan had to get roughly eighty times cheaper first. Classifying a transcript
needs two facts - what its last message id is, and whether any Anthropic id
exists at all - and the first settles the common case alone. Reading the tail of
each file and only opening the whole thing when the tail already looks wrong
takes the sweep from ~5s to ~60ms across 59 transcripts, which is the difference
between something that can sit on a timer and something that cannot. `--all`
uses the same path, and `--json` exposes it.

The badge is a dot beside the mark rather than a recolouring of it: this widget's
job is to report which provider is active, and tinting it red to mean something
else entirely would be a lie about that.

Verified by planting a genuinely corrupted transcript, watching the scan find it
and the dot appear, then removing it and watching both clear.
This commit is contained in:
smoido
2026-08-31 02:28:24 +03:00
parent 43cef6bf9e
commit 4c36f4d00b
5 changed files with 393 additions and 32 deletions
+163
View File
@@ -142,6 +142,47 @@ Panel {
}
}
// ---- Broken transcripts
//
// Repair truncates a transcript back to its last resumable point. That is
// destructive enough to confirm, and reversible enough to offer: the original
// is backed up, the cut turns are written out as Markdown, and the same text
// is handed back to the session so it still knows what it did.
readonly property var broken: widget && widget.brokenSessions ? widget.brokenSessions : []
property var repairTarget: null
function askRepair(entry) {
root.repairTarget = entry
root.stage = "repair"
}
function doRepair() {
if (root.busy || !root.repairTarget) return
root.busy = true
repairProc.command = root.cli(["repair-session", String(root.repairTarget.sessionId), "--apply"])
repairProc.running = true
}
Process {
id: repairProc
running: false
stderr: StdioCollector {
waitForEnd: true
onStreamFinished: {
var msg = String(text || "").replace(/\x1b\[[0-9;]*m/g, "").trim()
if (msg !== "") root.lastError = msg.split("\n").pop().trim()
}
}
onExited: function (code) {
root.busy = false
root.repairTarget = null
root.stage = "list"
// The scan is what drives the bar's warning dot, so re-run it rather than
// trusting this side to have guessed the new state.
if (widget) widget.scanSessions()
}
}
function storeServerKey() {
remedyProc.command = root.cli(["set-key", root.serverKeyRef, "--terminal"])
remedyProc.running = true
@@ -154,6 +195,7 @@ Panel {
root.pendingMode = ""
root.pendingPreset = ""
root.serverPreset = ""
root.repairTarget = null
}
function switchTo(mode, presetName) {
@@ -967,6 +1009,127 @@ Panel {
}
}
// ---- Sessions that a switch cut short. Shown in the list, because the
// point is to be noticed without going looking.
Column {
width: parent.width
visible: root.stage === "list" && root.broken.length > 0
spacing: Style.space(4)
PanelSeparator { width: parent.width }
Text {
width: parent.width
text: root.broken.length === 1
? "1 session cannot be resumed"
: root.broken.length + " sessions cannot be resumed"
color: root.urgentColor
wrapMode: Text.WordWrap
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(11)
font.bold: true
}
Repeater {
model: root.stage === "list" ? root.broken : []
Item {
required property var modelData
width: column.width
height: Style.space(26)
Column {
anchors.left: parent.left
anchors.right: repairBtn.left
anchors.rightMargin: Style.space(6)
anchors.verticalCenter: parent.verticalCenter
spacing: 0
Text {
width: parent.width
text: String(parent.parent.modelData.sessionId).substring(0, 8)
+ " · " + String(parent.parent.modelData.project).replace(/^-/, "").replace(/-/g, "/")
color: Color.popups.text
elide: Text.ElideMiddle
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
Text {
width: parent.width
text: parent.parent.modelData.dropLines + " turn lines after the last good message"
color: Color.muted
elide: Text.ElideRight
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(9)
}
}
PillButton {
id: repairBtn
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
label: "Repair"
onTriggered: root.askRepair(parent.modelData)
}
}
}
}
// ---- Confirming one repair.
Column {
width: parent.width
visible: root.stage === "repair" && root.repairTarget !== null
spacing: Style.space(7)
Text {
width: parent.width
text: "Repair " + (root.repairTarget ? String(root.repairTarget.sessionId).substring(0, 8) : "")
color: Color.popups.text
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(13)
font.bold: true
}
Text {
width: parent.width
text: {
if (!root.repairTarget) return ""
var p = (root.repairTarget.providers || []).join(", ")
return "This session took " + (p !== "" ? p + "'s" : "another provider's")
+ " output while the mode was switched under it, and Anthropic will not "
+ "resume it. Rolling it back to its last good message drops "
+ root.repairTarget.dropLines + " lines."
}
color: Color.muted
wrapMode: Text.WordWrap
lineHeight: 1.2
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
Text {
width: parent.width
text: "The original is backed up, the dropped turns are saved as Markdown, "
+ "and handed back to the session so it still knows what it did."
color: Color.muted
opacity: 0.85
wrapMode: Text.WordWrap
lineHeight: 1.2
font.family: root.bar ? root.bar.fontFamily : Style.font.family
font.pixelSize: Style.space(10)
}
Flow {
width: parent.width
spacing: Style.space(7)
PillButton { label: "Repair it"; primary: true; onTriggered: root.doRepair() }
PillButton { label: "Cancel"; onTriggered: root.resetFlow() }
}
}
PanelSeparator { width: parent.width; visible: root.modelRows.length > 0 && root.stage === "list" }
Column {