Nucleic: Collapseable session transcript blocks

This commit is contained in:
2026-08-07 00:07:19 -07:00
parent 146c9400de
commit 81555a4ea1
@@ -20,11 +20,7 @@ struct ToolCallCard: View {
// Folded lock-lifecycle lines read as part of the edit card (always visible, not gated // Folded lock-lifecycle lines read as part of the edit card (always visible, not gated
// by expand), so the lock brackets its edit rather than sorting after it — as on the Mac. // by expand), so the lock brackets its edit rather than sorting after it — as on the Mac.
if !group.lockLines.isEmpty { if !group.lockLines.isEmpty {
VStack(alignment: .leading, spacing: 2) { LockLinesSection(locks: group.lockLines)
ForEach(Array(group.lockLines.enumerated()), id: \.offset) { _, lock in
LockLineRow(lock: lock)
}
}
} }
if expanded { details } if expanded { details }
} }
@@ -570,6 +566,99 @@ struct LockLineRow: View {
} }
} }
/// The whole lock trail folded onto one tool card — the phone echo of the Mac's
/// `LockLinesSection`. A short trail (an ordinary edit's waiting → acquired → released) stays
/// literal; a long one — a supervising turn holding the lock for every file its workers touch —
/// collapses to one summary line that expands into a `LockDigest`: one entry per path at its
/// latest state, grouped under its directory so a shared prefix is written once.
struct LockLinesSection: View {
let locks: [NoteLock]
/// Trails at or below this many events render as plain lines (the common edit card).
static let inlineLimit = 4
@State private var expanded = false
var body: some View {
if locks.count <= Self.inlineLimit {
VStack(alignment: .leading, spacing: 2) {
ForEach(Array(locks.enumerated()), id: \.offset) { _, lock in
LockLineRow(lock: lock)
}
}
} else {
let digest = LockDigest(locks)
VStack(alignment: .leading, spacing: 5) {
Button {
withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() }
} label: { summary(digest) }
.buttonStyle(.plain)
if expanded { LockDigestBody(digest: digest) }
}
}
}
private func summary(_ digest: LockDigest) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: LockLineStyle.icon(digest.dominantState))
.font(.caption2).foregroundStyle(.tertiary).frame(width: 15)
Text(digest.headline)
.font(.caption).foregroundStyle(.secondary)
Image(systemName: expanded ? "chevron.down" : "chevron.right")
.font(.system(size: 8, weight: .semibold)).foregroundStyle(.tertiary)
Spacer(minLength: 0)
}
.contentShape(Rectangle())
}
}
/// The expanded lock digest: a block per state, and within it one line per directory carrying
/// that directory's basenames. A directory holding a single file collapses back to its full path.
private struct LockDigestBody: View {
let digest: LockDigest
var body: some View {
let sections = digest.sections
VStack(alignment: .leading, spacing: 6) {
ForEach(sections, id: \.state) { section in
VStack(alignment: .leading, spacing: 3) {
// With one state the summary line above already names it; only a mixed
// trail needs each block labelled.
if sections.count > 1 {
HStack(spacing: 5) {
Image(systemName: LockLineStyle.icon(section.state))
.font(.system(size: 9)).foregroundStyle(.tertiary)
Text("\(LockDigest.sectionTitle(section.state)) · \(section.count)")
.font(.caption2.weight(.medium)).foregroundStyle(.secondary)
}
}
ForEach(section.directories, id: \.directory) { directory in
group(directory)
}
}
}
}
.padding(.leading, 21)
}
@ViewBuilder
private func group(_ directory: LockDigest.Directory) -> some View {
if let path = directory.singlePath {
Text(path)
.font(.caption2.monospaced()).foregroundStyle(.secondary)
.lineLimit(1).truncationMode(.middle)
} else {
VStack(alignment: .leading, spacing: 1) {
Text(directory.directory.isEmpty ? "./" : "\(directory.directory)/")
.font(.caption2.monospaced()).foregroundStyle(.tertiary)
.lineLimit(1).truncationMode(.middle)
Text(directory.files.joined(separator: ", "))
.font(.caption2).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.leading, 10)
}
}
}
}
/// SF Symbols for the common tools + file-change kinds, so a glance reads the action. /// SF Symbols for the common tools + file-change kinds, so a glance reads the action.
enum ToolGlyph { enum ToolGlyph {
static func icon(_ name: String) -> String { static func icon(_ name: String) -> String {