diff --git a/Sources/Hopet/IPC/EventRouter.swift b/Sources/Hopet/IPC/EventRouter.swift index 096e59b..125a0da 100644 --- a/Sources/Hopet/IPC/EventRouter.swift +++ b/Sources/Hopet/IPC/EventRouter.swift @@ -190,16 +190,14 @@ public final class EventRouter { /// claude-code 子进程换 PID + 换 sid。不清的话用户就会看到"一个会话却有多个气泡"。 /// /// 但 IDE(Cursor)同一项目下也支持多 chat panel 并行,真终端也支持多 tab 并发——不能因为 - /// 同 cwd / 同宿主就一律清。判据只看"是否还活着": - /// - 状态是终态(idle / completed / errorInterrupted)→ 清,旧会话已经收尾。 - /// - 状态 running 但 lastActivityAt 远超 stalenessThreshold → 清,认定卡死躺尸。 - /// - 状态 running 且最近有事件 → 保留,是用户主动并行的合法会话。 + /// 同 cwd / 同宿主就一律清。判据只看"是否还活着":lastActivityAt 距今是否超过 stalenessThreshold。 + /// 状态枚举不能作为判据——`idle` 不是终态,是活会话两轮对话之间的常驻状态(responding → + /// completed → idle),刚答完的兄弟 panel 几秒内就会落到 idle。按状态终态判会把合法兄弟会话误杀。 private static let stalenessThreshold: TimeInterval = 90 private func pruneStaleSiblings(of new: Session) { let now = Date() let victims = registry.activeSessions(of: new.tool).filter { other in guard other.id != new.id, other.cwd == new.cwd else { return false } - if !other.currentState.isRunning { return true } return now.timeIntervalSince(other.lastActivityAt) > Self.stalenessThreshold } for v in victims { diff --git a/Sources/Hopet/Pet/PetStageView.swift b/Sources/Hopet/Pet/PetStageView.swift index 93e9fc7..49c7be7 100644 --- a/Sources/Hopet/Pet/PetStageView.swift +++ b/Sources/Hopet/Pet/PetStageView.swift @@ -2,8 +2,10 @@ import SwiftUI /// 一只宠物 + 紧贴它头顶的会话气泡列。整体放进 PetWindow 里。 /// -/// 气泡列从下往上堆叠:最贴近宠物的是最新一个会话,越上越旧;最多保留 5 条, -/// 第 6 个新会话进来时把最旧那条挤出列表。空状态下只显示宠物。 +/// 气泡列从下往上堆叠:最贴近宠物的是"第一个气泡"(即最早的会话),越上越新—— +/// 新会话从上方滑入叠在已有气泡上面,旧气泡位置保持。可视区高度按 +/// `maxVisibleBubbles` 个 default 卡片估算,超出后 ScrollView 锚定 +/// 最旧气泡贴底,更新的气泡溢出到顶部之外,由右侧 PixelScrollThumb 提示并往上滚查看。 public struct PetStageView: View { @ObservedObject var registry: SessionRegistry @ObservedObject var themes: ThemeStore @@ -14,10 +16,22 @@ public struct PetStageView: View { let onResolveAskUser: (String, String, [String: String], Bool) -> Void @State private var now: Date = Date() + @State private var scrollMetrics = ScrollMetrics() private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + private static let scrollSpaceName = "petStageScroll" - /// 列表最多展示几个会话气泡。超出的旧会话被滚出视野(仍存在于 registry,仅不显示)。 + /// 可视区目标可见数:用来推导 `bubbleAreaMaxHeight`,并不是硬上限—— + /// 卡片膨胀(permission / elicitation / plan-approval)时单卡占高更大,可见数自然变少。 private static let maxVisibleBubbles: Int = 5 + /// 单个 default 卡片的估算高度(两行文本 + 内外 padding),见 SessionBubbleView.defaultCard。 + private static let defaultBubbleHeight: CGFloat = 56 + /// 气泡可视区域的最大高度:约 5 个 default 卡片 + 间距,超出则启用垂直滚动。 + private static let bubbleAreaMaxHeight: CGFloat = + defaultBubbleHeight * CGFloat(maxVisibleBubbles) + + interBubbleSpacing * CGFloat(maxVisibleBubbles - 1) + + contentVerticalPadding * 2 + /// ScrollView 内容给描边预留的上下安全边。 + private static let contentVerticalPadding: CGFloat = 1 /// 气泡列与宠物头顶之间的视觉间距。 private static let bubbleToPetGap: CGFloat = 6 /// 气泡之间的纵向间距。 @@ -43,44 +57,106 @@ public struct PetStageView: View { registry.pets[tool] ?? PetInstance(tool: tool) } - /// 列表里的会话:按 `startedAt` 升序倒排,最新的排数组开头。 - /// 注意 ForEach 渲染时再做一次 reverse —— 想让"最新会话紧贴宠物",VStack 里 - /// 它必须出现在最后一个位置。 + /// 列表里的会话:按 `startedAt` 倒序——最新在数组头,最旧在数组末。 + /// VStack 末项(数组末)= 最旧会话 = 紧贴宠物头顶;新会话从顶部插入。 private var sessions: [Session] { registry.activeSessions(of: tool) .sorted { $0.startedAt > $1.startedAt } - .prefix(PetStageView.maxVisibleBubbles) - .map { $0 } + } + + /// 少于 5 条时视口跟内容等高,否则离海豹头顶会出现一截空白;超过 5 条时 cap 住启用滚动。 + private func bubbleViewportHeight(sessions: [Session]) -> CGFloat { + guard !sessions.isEmpty else { return 0 } + let measured = scrollMetrics.contentHeight > 0 + ? scrollMetrics.contentHeight + : estimatedBubbleContentHeight(count: sessions.count) + return min(measured, PetStageView.bubbleAreaMaxHeight) + } + + private func estimatedBubbleContentHeight(count: Int) -> CGFloat { + let visibleCount = min(count, PetStageView.maxVisibleBubbles) + let spacingCount = max(visibleCount - 1, 0) + return PetStageView.defaultBubbleHeight * CGFloat(visibleCount) + + PetStageView.interBubbleSpacing * CGFloat(spacingCount) + + PetStageView.contentVerticalPadding * 2 } public var body: some View { - VStack(spacing: 0) { + let sessions = self.sessions + let ids = sessions.map(\.id) + let viewportHeight = bubbleViewportHeight(sessions: sessions) + + return VStack(spacing: 0) { Spacer(minLength: 0) - // 旧 → 新(自上而下)。VStack 末项 = 最新会话,紧贴宠物头顶。 - VStack(spacing: PetStageView.interBubbleSpacing) { - ForEach(Array(sessions.reversed())) { session in - SessionBubbleView( - bubble: makeBubble(session: session), - isLeader: pet.drivenBySessionId == session.id, - stateDurationPhrase: session.stateDurationPhrase(now: now), - onResolvePermission: { decision, reason in - guard let pp = session.pendingPermission else { return } - onResolvePermission(session.id, pp.requestId, decision, reason) - }, - onResolveAskUser: { answers, cancel in - guard let pa = session.pendingAskUser else { return } - onResolveAskUser(session.id, pa.requestId, answers, cancel) + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: PetStageView.interBubbleSpacing) { + ForEach(sessions) { session in + SessionBubbleView( + bubble: makeBubble(session: session), + isLeader: pet.drivenBySessionId == session.id, + stateDurationPhrase: session.stateDurationPhrase(now: now), + onResolvePermission: { decision, reason in + guard let pp = session.pendingPermission else { return } + onResolvePermission(session.id, pp.requestId, decision, reason) + }, + onResolveAskUser: { answers, cancel in + guard let pa = session.pendingAskUser else { return } + onResolveAskUser(session.id, pa.requestId, answers, cancel) + } + ) + .id(session.id) + .transition(.asymmetric( + insertion: .move(edge: .top).combined(with: .opacity), + removal: .opacity + )) + } + } + // 让 1px 描边不被 ScrollView 的 clip 切掉。 + .padding(.vertical, PetStageView.contentVerticalPadding) + .background( + GeometryReader { inner in + Color.clear + .preference( + key: ScrollMetricsKey.self, + value: ScrollMetrics( + contentHeight: inner.size.height, + offset: -inner.frame(in: .named(PetStageView.scrollSpaceName)).minY + ) + ) } ) - .transition(.asymmetric( - insertion: .move(edge: .bottom).combined(with: .opacity), - removal: .opacity - )) + } + .coordinateSpace(name: PetStageView.scrollSpaceName) + .frame(height: viewportHeight) + .overlay(alignment: .trailing) { + PixelScrollThumb( + contentHeight: scrollMetrics.contentHeight, + viewportHeight: viewportHeight, + offset: scrollMetrics.offset + ) + .padding(.trailing, 2) + } + .padding(.bottom, sessions.isEmpty ? 0 : PetStageView.bubbleToPetGap) + .animation(.spring(response: 0.32, dampingFraction: 0.82), value: ids) + .onPreferenceChange(ScrollMetricsKey.self) { metrics in + scrollMetrics = metrics + } + .onAppear { + if let oldest = ids.last { + proxy.scrollTo(oldest, anchor: .bottom) + } + } + // 维持"最旧气泡贴海豹"的锚定语义:session 变化时重新对齐底部, + // 否则 ScrollView cap 后新气泡会把旧的顶到不可见区。 + .onChange(of: ids) { _, newIds in + guard let oldest = newIds.last else { return } + withAnimation(.easeOut(duration: 0.25)) { + proxy.scrollTo(oldest, anchor: .bottom) + } } } - .padding(.bottom, PetStageView.bubbleToPetGap) - .animation(.spring(response: 0.32, dampingFraction: 0.82), value: sessions.map(\.id)) PetBadgeView( tool: tool, @@ -109,3 +185,72 @@ public struct PetStageView: View { ) } } + +/// 滚动度量:内容总高度 + 当前向下偏移(content 顶距 viewport 顶的距离,向下滚动越大)。 +private struct ScrollMetrics: Equatable { + var contentHeight: CGFloat = 0 + var offset: CGFloat = 0 +} + +private struct ScrollMetricsKey: PreferenceKey { + static let defaultValue = ScrollMetrics() + static func reduce(value: inout ScrollMetrics, nextValue: () -> ScrollMetrics) { + value = nextValue() + } +} + +/// 像素风滚动条 thumb:仅在内容超过 viewport 时显示。 +/// 配色随 colorScheme 切换以匹配 PixelChrome;无交互(拖动不归它管)。 +private struct PixelScrollThumb: View { + @Environment(\.colorScheme) private var colorScheme + let contentHeight: CGFloat + let viewportHeight: CGFloat + let offset: CGFloat + + private static let pixel: CGFloat = 2 + private static let thumbWidth: CGFloat = 6 + private static let minThumbHeight: CGFloat = 14 + + var body: some View { + GeometryReader { proxy in + let trackHeight = proxy.size.height + let overflow = max(0, contentHeight - viewportHeight) + if overflow > 1 { + let ratio = max(0, min(1, viewportHeight / max(contentHeight, 1))) + let thumbH = max(PixelScrollThumb.minThumbHeight, Self.quantize(trackHeight * ratio)) + let normalized = min(max(offset / overflow, 0), 1) + let thumbY = Self.quantize((trackHeight - thumbH) * normalized) + let isDark = colorScheme == .dark + let bodyFill: Color = isDark + ? Color(red: 0.32, green: 0.32, blue: 0.36) + : Color(red: 0.86, green: 0.86, blue: 0.90) + let highlight: Double = isDark ? 0.30 : 0.70 + + ZStack(alignment: .topLeading) { + Rectangle() + .fill(Color.black.opacity(0.25)) + .frame(width: PixelScrollThumb.thumbWidth, height: thumbH) + .offset(x: PixelScrollThumb.pixel, y: PixelScrollThumb.pixel) + Rectangle() + .fill(bodyFill) + .frame(width: PixelScrollThumb.thumbWidth, height: thumbH) + .overlay(alignment: .top) { + Rectangle() + .fill(Color.white.opacity(highlight)) + .frame(height: PixelScrollThumb.pixel) + } + .overlay(Rectangle().stroke(Color.black.opacity(0.85), lineWidth: 1)) + .offset(y: thumbY) + } + .animation(.easeOut(duration: 0.12), value: [thumbH, thumbY]) + } + } + .frame(width: PixelScrollThumb.thumbWidth + PixelScrollThumb.pixel) + .allowsHitTesting(false) + } + + /// 对齐到 2px 网格,保持像素颗粒感不被亚像素抗锯齿模糊。 + private static func quantize(_ value: CGFloat) -> CGFloat { + (value / pixel).rounded() * pixel + } +} diff --git a/Sources/Hopet/Pet/SessionBubbleView.swift b/Sources/Hopet/Pet/SessionBubbleView.swift index f4a4eac..a755aff 100644 --- a/Sources/Hopet/Pet/SessionBubbleView.swift +++ b/Sources/Hopet/Pet/SessionBubbleView.swift @@ -598,10 +598,16 @@ private struct PopParticle: Identifiable { var scale: CGFloat } -/// 8-bit 像素风外壳:阶梯像素圆角 + 近白底 + 顶部高光 + 块状阴影。所有几何沿 `pixelSize` 方格对齐, +/// 8-bit 像素风外壳:阶梯像素圆角 + 主体填充 + 顶部高光 + 块状阴影。所有几何沿 `pixelSize` 方格对齐, /// 圆角处呈现可见的 2pt 颗粒阶梯——视觉上对齐参考素材的复古 UI 边缘,与小海豹 sprite 同语言。 /// 描边宽度 / 颜色独立可调:leader session 偏好略粗的黑描边(强调),其余气泡保持基础粗细。 +/// +/// 配色随系统 colorScheme 切换: +/// - light:近白冷调底 + accent 10% 轻染 + 顶部白高光,搭配系统 .primary 黑字。 +/// - dark:深冷调底 + accent 40% 强染("夜晚饱和度拉高"),顶高光压暗,搭配 .primary 白字 +/// 仍保持 4:1+ 对比度(包括最亮的 askUser 黄)。 private struct PixelChrome: ViewModifier { + @Environment(\.colorScheme) private var colorScheme let cornerRadius: CGFloat let accent: Color let strokeWidth: CGFloat @@ -611,6 +617,12 @@ private struct PixelChrome: ViewModifier { // pixel pitch:圆角阶梯 / 阴影偏移按这个量化;描边宽度由 caller 单独控制,不必整数倍 pixel。 let pixel: CGFloat = 2 let strokeInset = strokeWidth + let isDark = colorScheme == .dark + let baseFill: Color = isDark + ? Color(red: 0.13, green: 0.13, blue: 0.16) + : Color(red: 0.97, green: 0.97, blue: 0.99) + let accentTint: Double = isDark ? 0.40 : 0.10 + let topHighlight: Double = isDark ? 0.18 : 0.45 return content .padding(strokeInset + 1) // 让内容不撞到内层亮边 @@ -629,14 +641,13 @@ private struct PixelChrome: ViewModifier { .offset(x: 0, y: pixel * 2) // 2. 描边底(外层 shape 整面填描边色,内层填浅色后只剩 strokeInset 宽的描边)。 outer.fill(strokeColor) - // 3. 内层:近白冷调主体 + accent 轻染 + 顶部高光带。padding(strokeInset) 让其向内缩。 + // 3. 内层:base 主体 + accent 染色(夜晚比例更高) + 顶部高光带。padding(strokeInset) 让其向内缩。 ZStack { - inner.fill(Color(red: 0.97, green: 0.97, blue: 0.99)) - // accent 轻染——状态色在卡片上隐隐透出,不抢内容。 - inner.fill(accent.opacity(0.10)) + inner.fill(baseFill) + inner.fill(accent.opacity(accentTint)) // 顶部 4px 像素高光带,强调"自上而来的光源"。 inner - .fill(Color.white.opacity(0.45)) + .fill(Color.white.opacity(topHighlight)) .mask( VStack(spacing: 0) { Rectangle().frame(height: pixel * 2)