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:
@@ -63,6 +63,15 @@ BarWidget {
|
||||
// bar stays quiet exactly when nothing unusual is configured.
|
||||
readonly property bool gateway: known && mode !== "anthropic"
|
||||
|
||||
readonly property color urgentColor: {
|
||||
var c = Color.urgent
|
||||
var mx = Math.max(c.r, c.g, c.b)
|
||||
var mn = Math.min(c.r, c.g, c.b)
|
||||
var l = (mx + mn) / 2
|
||||
var d = 1 - Math.abs(2 * l - 1)
|
||||
return (d > 0.0001 ? (mx - mn) / d : 0) >= 0.15 ? c : "#d2685f"
|
||||
}
|
||||
|
||||
readonly property color activeColor: !known
|
||||
? Color.muted
|
||||
: (gateway ? Color.accent : (bar ? bar.barForeground : Color.foreground))
|
||||
@@ -111,10 +120,48 @@ BarWidget {
|
||||
running: false
|
||||
}
|
||||
|
||||
// ---- Broken transcripts
|
||||
//
|
||||
// A session cut short by a mode switch cannot be resumed, and nothing tells
|
||||
// you until you try - by which time you have usually forgotten which session
|
||||
// it was. So the bar checks periodically and marks itself when there is
|
||||
// something to fix.
|
||||
//
|
||||
// The scan reads the tail of each transcript first and only opens the whole
|
||||
// file when the tail already looks wrong, which is what makes it cheap enough
|
||||
// to sit on a timer: ~60ms for 59 transcripts here, against 5s for the naive
|
||||
// version that read every byte of every one.
|
||||
property var brokenSessions: []
|
||||
|
||||
Process {
|
||||
id: scanProc
|
||||
running: false
|
||||
command: [root.cmRoot + "/bin/claude-mode", "repair-session", "--json"]
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: {
|
||||
var d = null
|
||||
try { d = JSON.parse(String(text || "")) } catch (e) { d = null }
|
||||
root.brokenSessions = (d && d.broken) ? d.broken : []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scanSessions() { if (!scanProc.running) scanProc.running = true }
|
||||
|
||||
Timer {
|
||||
interval: 20 * 60 * 1000
|
||||
running: true
|
||||
repeat: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: root.scanSessions()
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
healthFile.reload()
|
||||
stateFile.reload()
|
||||
if (!root.health) seedProc.running = true
|
||||
root.scanSessions()
|
||||
}
|
||||
|
||||
Component.onCompleted: Qt.callLater(root.refresh)
|
||||
@@ -134,14 +181,34 @@ BarWidget {
|
||||
anchors.centerIn: parent
|
||||
spacing: root.gap
|
||||
|
||||
BrandIcon {
|
||||
id: icon
|
||||
Item {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: icon.width
|
||||
height: icon.height
|
||||
visible: root.logo !== ""
|
||||
pathData: root.logo
|
||||
opticalScale: Modes.logoScale(root.mode)
|
||||
color: root.activeColor
|
||||
iconSize: root.iconPx
|
||||
|
||||
BrandIcon {
|
||||
id: icon
|
||||
anchors.fill: parent
|
||||
pathData: root.logo
|
||||
opticalScale: Modes.logoScale(root.mode)
|
||||
color: root.activeColor
|
||||
iconSize: root.iconPx
|
||||
}
|
||||
|
||||
// Recolouring the mark would misreport the mode, which is this widget's
|
||||
// whole job, so the warning gets its own dot instead.
|
||||
Rectangle {
|
||||
visible: root.brokenSessions.length > 0
|
||||
width: Math.max(4, Math.round(root.iconPx / 3.2))
|
||||
height: width
|
||||
radius: width / 2
|
||||
color: root.urgentColor
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.rightMargin: -Math.round(width / 3)
|
||||
anchors.topMargin: -Math.round(width / 4)
|
||||
}
|
||||
}
|
||||
|
||||
// Only reached when claude-mode is not installed - there is no brand to
|
||||
@@ -185,6 +252,11 @@ BarWidget {
|
||||
var models = root.health && root.health.models ? root.health.models : null
|
||||
if (models && models.opus) lines.push("opus " + models.opus)
|
||||
if (models && models.sonnet) lines.push("sonnet " + models.sonnet)
|
||||
if (root.brokenSessions.length > 0) {
|
||||
lines.push(root.brokenSessions.length === 1
|
||||
? "1 session needs repair"
|
||||
: root.brokenSessions.length + " sessions need repair")
|
||||
}
|
||||
return lines.join(" · ")
|
||||
}
|
||||
|
||||
@@ -199,6 +271,7 @@ BarWidget {
|
||||
}
|
||||
|
||||
onHealthChanged: syncTooltip()
|
||||
onBrokenSessionsChanged: syncTooltip()
|
||||
|
||||
// Hover must come from a HoverHandler: once triggerPress() exists the bar's
|
||||
// own slot MouseArea accepts hover events and swallows them before any
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user