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
+79 -6
View File
@@ -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