Nucleic: Collapseable session transcript blocks
This commit is contained in:
@@ -20,11 +20,7 @@ struct ToolCallCard: View {
|
||||
// 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.
|
||||
if !group.lockLines.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(Array(group.lockLines.enumerated()), id: \.offset) { _, lock in
|
||||
LockLineRow(lock: lock)
|
||||
}
|
||||
}
|
||||
LockLinesSection(locks: group.lockLines)
|
||||
}
|
||||
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.
|
||||
enum ToolGlyph {
|
||||
static func icon(_ name: String) -> String {
|
||||
|
||||
Reference in New Issue
Block a user