From 81555a4ea1f71ec1a5a586b1aa24e6dbbd3e2cc9 Mon Sep 17 00:00:00 2001 From: Andrew Moore Date: Fri, 7 Aug 2026 00:07:19 -0700 Subject: [PATCH] Nucleic: Collapseable session transcript blocks --- .../Views/Transcript/ToolGroupRow.swift | 99 ++++++++++++++++++- 1 file changed, 94 insertions(+), 5 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift index ef24529..2203326 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift @@ -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 {