Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions Sources/Hopet/IPC/EventRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
203 changes: 174 additions & 29 deletions Sources/Hopet/Pet/PetStageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
/// 气泡之间的纵向间距。
Expand All @@ -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,
Expand Down Expand Up @@ -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
}
}
23 changes: 17 additions & 6 deletions Sources/Hopet/Pet/SessionBubbleView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) // 让内容不撞到内层亮边
Expand All @@ -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)
Expand Down
Loading