dsh-notch 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/BASELINE.md +13 -0
  2. package/DESIGN.md +34 -0
  3. package/LICENSE +21 -0
  4. package/README.en.md +136 -0
  5. package/README.md +145 -0
  6. package/cordis.yml +5 -0
  7. package/docs/motion-audit.md +65 -0
  8. package/docs/motion-continuity.md +77 -0
  9. package/docs/releases/v0.3.0.md +20 -0
  10. package/dshx.yml +14 -0
  11. package/macos/Package.swift +17 -0
  12. package/macos/STATUS-MOTION.md +49 -0
  13. package/macos/Sources/Client.swift +139 -0
  14. package/macos/Sources/IdleRobot.swift +423 -0
  15. package/macos/Sources/NotchMarkdown.swift +266 -0
  16. package/macos/Sources/Panel.swift +153 -0
  17. package/macos/Sources/Resources/Idle/balance.json +1 -0
  18. package/macos/Sources/Resources/Idle/blink.json +1 -0
  19. package/macos/Sources/Resources/Idle/cube-in.json +1 -0
  20. package/macos/Sources/Resources/Idle/dance.json +1 -0
  21. package/macos/Sources/Resources/Idle/hop.json +1 -0
  22. package/macos/Sources/Resources/Idle/nod.json +1 -0
  23. package/macos/Sources/Resources/Idle/satellite-in.json +1 -0
  24. package/macos/Sources/Resources/Idle/satellite-out.json +1 -0
  25. package/macos/Sources/Resources/Idle/scan.json +1 -0
  26. package/macos/Sources/Resources/Idle/sleep.json +1 -0
  27. package/macos/Sources/Resources/Idle/sneeze.json +1 -0
  28. package/macos/Sources/Resources/Idle/stretch.json +1 -0
  29. package/macos/Sources/Resources/Idle/tilt.json +1 -0
  30. package/macos/Sources/ReviewStudio.swift +337 -0
  31. package/macos/Sources/RootView.swift +1093 -0
  32. package/macos/Sources/StatusOrbit.swift +642 -0
  33. package/macos/Sources/main.swift +153 -0
  34. package/macos/Tests/ExpandedHeightProbe.swift +108 -0
  35. package/macos/Tests/GeometryProbe.swift +98 -0
  36. package/macos/Tests/IdleProbe.swift +294 -0
  37. package/macos/Tests/MotionProbe.swift +106 -0
  38. package/macos/Tests/OutcomeProbe.swift +133 -0
  39. package/macos/Tests/ScrollbarProbe.swift +91 -0
  40. package/macos/Tests/expanded-height.sh +13 -0
  41. package/macos/Tests/geometry.sh +14 -0
  42. package/macos/Tests/idle.sh +14 -0
  43. package/macos/Tests/motion.sh +14 -0
  44. package/macos/Tests/outcome.sh +14 -0
  45. package/macos/Tests/scrollbar.sh +13 -0
  46. package/package.json +46 -0
  47. package/src/board.ts +299 -0
  48. package/src/browse-sync.ts +59 -0
  49. package/src/dsh-notch.ts +60 -0
  50. package/src/http.ts +215 -0
  51. package/src/session-state.ts +31 -0
  52. package/src/store.ts +49 -0
  53. package/src/types.ts +65 -0
  54. package/tests/answer-lifecycle.test.mjs +23 -0
  55. package/tests/board.test.mjs +7 -0
  56. package/tests/browse-sync.test.mjs +20 -0
  57. package/tests/mirror.test.mjs +4 -0
  58. package/tests/peer-range.test.mjs +21 -0
  59. package/tests/session-state.test.mjs +54 -0
  60. package/tests/unread-hold.test.mjs +90 -0
  61. package/tools/desktop-shell/README.md +56 -0
  62. package/tools/desktop-shell/child-log.mjs +6 -0
  63. package/tools/desktop-shell/child-log.test.mjs +14 -0
  64. package/tools/desktop-shell/package-recovery.py +78 -0
  65. package/tools/desktop-shell/recovery-probe.cjs +50 -0
  66. package/tools/desktop-shell/renderer-recovery.mjs +95 -0
  67. package/tools/desktop-shell/renderer-recovery.test.mjs +24 -0
  68. package/tools/diagnostics/completion-watch.py +101 -0
  69. package/tools/diagnostics/completion-watch.test.py +17 -0
  70. package/tools/diagnostics/renderer-diagnostics.mjs +56 -0
  71. package/tools/diagnostics/renderer-diagnostics.test.mjs +32 -0
  72. package/tools/idle/LICENSE.open-bot-motion +21 -0
  73. package/tools/idle/export.mjs +40 -0
  74. package/tools/idle/index.html +4 -0
  75. package/tools/idle/motions.js +83 -0
  76. package/tools/idle/original.js +1673 -0
  77. package/tools/idle/renderer.js +1673 -0
  78. package/tools/recording/Client.swift +76 -0
  79. package/tools/recording/DemoCatalog.swift +174 -0
  80. package/tools/recording/README.md +23 -0
  81. package/tools/recording/build.sh +41 -0
  82. package/tools/recording/main.swift +100 -0
  83. package/tools/review/ReviewStudio.swift +236 -0
  84. package/tools/review/audit.sh +19 -0
  85. package/tools/review/build.sh +7 -0
  86. package/tools/review/main.swift +158 -0
  87. package/tools/review/render-audit.swift +32 -0
@@ -0,0 +1,1093 @@
1
+ import AppKit
2
+ import Combine
3
+ import SwiftUI
4
+
5
+ // MARK: - Figma Canonical Design Tokens (EDkOOLoBXBuTVsPHmnAvoC)
6
+ enum NotchTokens {
7
+ static let amber = Color(red: 0.949, green: 1.0, blue: 0.078) // #F2FF14
8
+ static let amberGlow = Color(red: 0.949, green: 1.0, blue: 0.078).opacity(0.5)
9
+ static let redFail = Color(red: 1.0, green: 0.251, blue: 0.0) // #FF4000
10
+ static let redGlow = Color(red: 1.0, green: 0.251, blue: 0.0).opacity(0.5)
11
+ static let greenComplete = Color(red: 0.204, green: 0.780, blue: 0.349) // #34C759 Apple Emerald Green
12
+ static let greenGlow = Color(red: 0.204, green: 0.780, blue: 0.349).opacity(0.5)
13
+ static let deepSeekBlue = Color(red: 0.302, green: 0.420, blue: 0.996) // #4D6BFE
14
+ static let bodyBackground = Color.black
15
+ /// Pinned to the expanded width and trailing-aligned.
16
+ /// Compact 38pt sits in the last ~12%, so it stays 90–100% black.
17
+ /// Expanded left is 50% black over HUD blur — enough contrast, still reads as glass.
18
+ static let glassFade = LinearGradient(
19
+ stops: [
20
+ .init(color: Color.black.opacity(0.50), location: 0),
21
+ .init(color: Color.black.opacity(0.72), location: 0.40),
22
+ .init(color: Color.black.opacity(0.90), location: 0.82),
23
+ .init(color: Color.black, location: 1),
24
+ ],
25
+ startPoint: UnitPoint.leading,
26
+ endPoint: UnitPoint.trailing
27
+ )
28
+ static let glassRim = LinearGradient(
29
+ stops: [
30
+ .init(color: Color.white.opacity(0.10), location: 0),
31
+ .init(color: Color.white.opacity(0.04), location: 0.28),
32
+ .init(color: Color.clear, location: 0.72),
33
+ ],
34
+ startPoint: UnitPoint.leading,
35
+ endPoint: UnitPoint.trailing
36
+ )
37
+ static let fieldBackground = Color(red: 0.059, green: 0.059, blue: 0.071)// #0F0F12
38
+ static let badgeBackground = Color(red: 0.149, green: 0.149, blue: 0.149)// #262626
39
+ static let chipStroke = Color.white.opacity(0.28)
40
+ static let otherChipStroke = Color.white.opacity(0.20)
41
+ }
42
+
43
+ struct QuestionDraft {
44
+ var selected: [String] = []
45
+ var custom = ""
46
+ var skipped = false
47
+
48
+ var done: Bool {
49
+ skipped
50
+ || !selected.isEmpty
51
+ || !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
52
+ }
53
+ }
54
+
55
+ struct AskWizard {
56
+ var askId: String
57
+ var sessionId: String
58
+ var index: Int
59
+ var drafts: [String: QuestionDraft]
60
+ var showCustomField = false
61
+ }
62
+
63
+ // MARK: - Model
64
+ @MainActor
65
+ final class BoardModel: ObservableObject {
66
+ @Published var rows: [NotchRow] = []
67
+ @Published var expanded = false {
68
+ didSet { syncShowingExpanded(from: oldValue) }
69
+ }
70
+ /// Content lags collapse so the window can shrink before lamps replace the panel.
71
+ @Published var showingExpanded = false
72
+ private var contentFold: DispatchWorkItem?
73
+ @Published var selected: String?
74
+ @Published var hovered: String?
75
+ @Published var wizard: AskWizard?
76
+ @Published var error: String?
77
+ @Published var actionError: String?
78
+ private var submitting = false
79
+ @Published var connected = false
80
+ @Published var maximumExpandedHeight: CGFloat = 130
81
+ @Published var measuredContentHeight: CGFloat = 170
82
+ @Published var currentIslandWidth: CGFloat = 32
83
+ @Published var currentIslandHeight: CGFloat = 90
84
+ @Published var isPillHovered: Bool = false
85
+
86
+ @Published var orbitLayout = OrbitLayout()
87
+ var retainedBusyCount = 0
88
+ var retainedSuccessCount = 0
89
+ var retainedFailureCount = 0
90
+ private var layoutFrom = OrbitLayout()
91
+ private var layoutTarget = OrbitLayout()
92
+ private var layoutBegan = Date()
93
+ private var layoutFlightID: UUID?
94
+ private var layoutTimer: Timer?
95
+ @Published var statusFlight: StatusFlight?
96
+ @Published var decisionReturn: DecisionReturn?
97
+ private var pendingDecisionReturnCount: Int?
98
+ private var pendingFlights: [StatusFlight] = []
99
+ private var expandAfterDecision = false
100
+
101
+ private var previousBusyIds = Set<String>()
102
+ private var initialized = false
103
+
104
+ var allowExpandOnHover: Bool {
105
+ needsAction
106
+ }
107
+
108
+ private func syncShowingExpanded(from old: Bool) {
109
+ contentFold?.cancel()
110
+ contentFold = nil
111
+ if expanded {
112
+ showingExpanded = true
113
+ return
114
+ }
115
+ guard old else {
116
+ showingExpanded = false
117
+ return
118
+ }
119
+ let work = DispatchWorkItem { [weak self] in
120
+ guard let self, !self.expanded else { return }
121
+ self.showingExpanded = false
122
+ }
123
+ contentFold = work
124
+ DispatchQueue.main.asyncAfter(deadline: .now() + NotchGeometryAnimation.duration, execute: work)
125
+ }
126
+ var foldEnabled = false
127
+ private var revealed = false
128
+
129
+ let client = NotchClient()
130
+ var previewMode = false
131
+ private var timer: Timer?
132
+
133
+ var needsAction: Bool { rows.contains(where: \.needsAction) }
134
+ var anyBusy: Bool { rows.contains(where: \.busy) }
135
+ var anyFailed: Bool { !failedRows.isEmpty }
136
+ /// Robot only after status lamps have fully left. Count flicker (a poll
137
+ /// with no busy/unread rows) must not start cube-in on top of green+blue.
138
+ var showsIdleRobot: Bool {
139
+ !needsAction && !anyFailed && completedUnreadCount == 0 && busyCount == 0 && statusFlight == nil && decisionReturn == nil && orbitLayout.total < 0.0001
140
+ }
141
+
142
+ var orbitBusyCount: Int { pendingDecisionReturnCount ?? busyCount }
143
+ var busyCount: Int { rows.filter { $0.busy && !$0.needsAction }.count }
144
+ var completedUnreadCount: Int { completedUnreadRows.count }
145
+
146
+ var busyRows: [NotchRow] {
147
+ rows.filter(\.busy)
148
+ }
149
+
150
+ var completedUnreadRows: [NotchRow] {
151
+ rows.filter { $0.unread && !$0.busy && $0.lastTurn?.failed != true && !$0.needsAction }
152
+ }
153
+
154
+ var failedRows: [NotchRow] {
155
+ rows.filter(\.isFailedResult)
156
+ }
157
+
158
+ var activeActionRow: NotchRow? {
159
+ rows.first(where: \.needsAction)
160
+ }
161
+
162
+ var firstFailedRow: NotchRow? { failedRows.first }
163
+
164
+ var firstCompletedRow: NotchRow? {
165
+ completedUnreadRows.first
166
+ }
167
+
168
+ func start() {
169
+ if previewMode { return }
170
+ timer = Timer.scheduledTimer(withTimeInterval: 0.8, repeats: true) { [weak self] _ in
171
+ Task { @MainActor in await self?.refresh() }
172
+ }
173
+ timer?.tolerance = 0.2
174
+ Task { await refresh() }
175
+ }
176
+
177
+ func refresh() async {
178
+ do { applySnapshot(try await client.status()) }
179
+ catch { connected = false; self.error = "等待 Host…" }
180
+ }
181
+
182
+ func applySnapshot(_ snap: NotchSnapshot) {
183
+ let coldSnapshot = !initialized
184
+ let beforeBusy = busyCount
185
+ let beforeSuccess = completedUnreadCount
186
+ let beforeFailure = failedRows.count
187
+ let finished = initialized ? snap.rows.filter { previousBusyIds.contains($0.id) && !$0.busy && !$0.needsAction && ($0.unread || $0.lastTurn?.failed == true) } : []
188
+ previousBusyIds = Set(snap.rows.filter(\.busy).map(\.id))
189
+ initialized = true
190
+ let wasAwaitingAction = needsAction
191
+ let previousActionIds=Set(rows.filter(\.needsAction).map(\.id))
192
+ let previousAskId = activeActionRow?.ask?.id
193
+ rows = snap.rows
194
+ let introducedDecision = needsAction && !wasAwaitingAction && beforeBusy > 0
195
+ let resumedDecision=wasAwaitingAction && !needsAction && rows.contains { previousActionIds.contains($0.id) && $0.busy && !$0.needsAction }
196
+ if resumedDecision && !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion {
197
+ pendingDecisionReturnCount=beforeBusy
198
+ retainedBusyCount=beforeBusy
199
+ }
200
+ if needsAction || busyCount == 0 {
201
+ if let reply=decisionReturn {
202
+ tickOrbitLayout()
203
+ decisionReturn=nil
204
+ // Continue from the currently visible layout when the reply is interrupted.
205
+ retainedBusyCount=reply.busyBefore
206
+ }
207
+ pendingDecisionReturnCount=nil
208
+ }
209
+ if !needsAction {
210
+ expandAfterDecision = false
211
+ pendingFlights.removeAll { $0.decision }
212
+ if wasAwaitingAction { expanded = false }
213
+ }
214
+ if !previewMode && needsAction && (!wasAwaitingAction || previousAskId != activeActionRow?.ask?.id) {
215
+ if !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion && (introducedDecision || statusFlight?.decision == true || pendingFlights.contains(where: { $0.decision })) {
216
+ expandAfterDecision = true
217
+ } else { expanded = true }
218
+ }
219
+ connected = true
220
+ error = nil
221
+ if let wizard, !snap.rows.contains(where: { $0.ask?.id == wizard.askId }) { self.wizard = nil }
222
+ if selected == nil { selected = rows.first?.id }
223
+ if let selected, !rows.contains(where: { $0.id == selected }) { self.selected = rows.first?.id }
224
+ if !rows.isEmpty {
225
+ revealed = true
226
+ foldEnabled = true
227
+ }
228
+ guard !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else { updateOrbitLayout(); return }
229
+ for failed in [false, true] where finished.contains(where: { ($0.lastTurn?.failed == true) == failed }) {
230
+ if !pendingFlights.contains(where: { !$0.decision && $0.failed == failed }) {
231
+ pendingFlights.append(StatusFlight(failed: failed, startedAt: Date(), busyBefore: beforeBusy,
232
+ destinationBefore: failed ? beforeFailure : beforeSuccess, returnsToRunning: busyCount > 0))
233
+ }
234
+ }
235
+ if introducedDecision {
236
+ pendingFlights.append(StatusFlight(outcome:.decision,startedAt:Date(),busyBefore:beforeBusy,destinationBefore:0,returnsToRunning:busyCount > 0))
237
+ }
238
+ if busyCount == 0 && completedUnreadCount == 0 && failedRows.isEmpty && !needsAction {
239
+ pendingFlights.removeAll(); statusFlight=nil;decisionReturn=nil;pendingDecisionReturnCount=nil
240
+ }
241
+ startDecisionReturnIfPossible()
242
+ startNextStatusFlight()
243
+ updateOrbitLayout(animateBirth:!coldSnapshot)
244
+ }
245
+
246
+ private func startNextStatusFlight() {
247
+ guard statusFlight == nil, decisionReturn == nil, pendingDecisionReturnCount == nil, !pendingFlights.isEmpty else { return }
248
+ let next = pendingFlights.removeFirst()
249
+ let flight = StatusFlight(outcome: next.outcome, startedAt: Date(), busyBefore: next.busyBefore,
250
+ destinationBefore: next.destinationBefore, returnsToRunning: busyCount > 0, angle:decisionAngle(at:Date()))
251
+ decisionSpin=DecisionSpin(began:flight.startedAt,angle:flight.angle,initialVelocity:DecisionSpin.runningVelocity,finalVelocity:DecisionSpin.runningVelocity)
252
+ statusFlight = flight
253
+ Task { @MainActor [weak self] in
254
+ try? await Task.sleep(for: .seconds(StatusFlight.duration))
255
+ self?.finishStatusFlight(id: flight.id)
256
+ }
257
+ }
258
+
259
+ func finishStatusFlight(id: UUID) {
260
+ guard statusFlight?.id == id else { return }
261
+ let finishedDecision = statusFlight?.decision == true
262
+ if let flight=statusFlight { tickOrbitLayout(at:flight.startedAt.addingTimeInterval(StatusFlight.duration)) }
263
+ statusFlight = nil
264
+ startDecisionReturnIfPossible()
265
+ startNextStatusFlight()
266
+ updateOrbitLayout()
267
+ if finishedDecision && expandAfterDecision {
268
+ expandAfterDecision = false
269
+ if needsAction { expanded = true }
270
+ }
271
+ }
272
+
273
+ private func startDecisionReturnIfPossible() {
274
+ guard statusFlight == nil,decisionReturn == nil,let count=pendingDecisionReturnCount else {return}
275
+ pendingDecisionReturnCount=nil
276
+ guard !needsAction,busyCount > 0,orbitLayout.decision > 0.001 else {return}
277
+ let now=Date()
278
+ let began=now.addingTimeInterval(showingExpanded ? NotchGeometryAnimation.duration:0)
279
+ let travelling=orbitLayout.middle > 0.001 || orbitLayout.top > 0.001
280
+ let startAngle=travelling ? decisionAngle(at:began):(-Double.pi/2)
281
+ let reply=DecisionReturn(startedAt:began,busyBefore:count,from:orbitLayout,angle:startAngle)
282
+ decisionReturn=reply
283
+ decisionSpin=DecisionSpin(began:began,angle:startAngle,initialVelocity:DecisionSpin.runningVelocity,
284
+ finalVelocity:DecisionSpin.runningVelocity,delay:travelling ? 0:reply.duration)
285
+ Task { @MainActor [weak self] in
286
+ try? await Task.sleep(for:.seconds(max(0,began.timeIntervalSince(now))+reply.duration))
287
+ self?.finishDecisionReturn(id:reply.id)
288
+ }
289
+ }
290
+
291
+ func finishDecisionReturn(id:UUID) {
292
+ guard let reply=decisionReturn,reply.id==id else {return}
293
+ tickOrbitLayout(at:reply.startedAt.addingTimeInterval(reply.duration))
294
+ decisionReturn=nil
295
+ retainedBusyCount=busyCount
296
+ startNextStatusFlight()
297
+ updateOrbitLayout()
298
+ }
299
+
300
+ var closingDecision:Bool { layoutTarget.decision > 0 && layoutFrom.middle > 0 }
301
+ private var decisionSpin:DecisionSpin?
302
+ var decisionSpinActive:Bool { decisionSpin.map { Date().timeIntervalSince($0.began) < DecisionSpin.duration+$0.delay } ?? false }
303
+ func decisionAngle(at now:Date)->Double { decisionSpin?.position(at:now) ?? now.timeIntervalSinceReferenceDate*DecisionSpin.runningVelocity }
304
+ func decisionVelocity(at now:Date)->Double { decisionSpin?.velocity(at:now) ?? DecisionSpin.runningVelocity }
305
+ func updateOrbitLayout(at now:Date = Date(),animateBirth:Bool = true) {
306
+ let target=OrbitLayout(top:completedUnreadCount > 0 ? 1:0,middle:busyCount > 0 ? 1:0,bottom:failedRows.isEmpty ? 0:1,decision:needsAction ? 1:0)
307
+ if busyCount > 0 && decisionReturn == nil && pendingDecisionReturnCount == nil { retainedBusyCount=busyCount }
308
+ if completedUnreadCount > 0 { retainedSuccessCount=completedUnreadCount }
309
+ if !failedRows.isEmpty { retainedFailureCount=failedRows.count }
310
+ // A fast reply is queued after the outgoing yellow stroke; keep its target
311
+ // until it has arrived, instead of changing the path underneath the pen.
312
+ if pendingDecisionReturnCount != nil && statusFlight?.decision == true { return }
313
+ let presentationID=decisionReturn?.id ?? statusFlight?.id
314
+ guard target != layoutTarget || presentationID != layoutFlightID else { return }
315
+ if decisionReturn == nil && (target.decision != layoutTarget.decision || target.middle != layoutTarget.middle) {
316
+ let birth=animateBirth && orbitLayout.total < 0.0001
317
+ let fromSolid=orbitLayout.decision >= 0.9999 && orbitLayout.middle < 0.0001
318
+ let resetPen=birth || (fromSolid && target.middle > 0)
319
+ let running=target.middle > 0
320
+ let penVelocity=birth && running ? StatusBirth.bluePenVelocity:(fromSolid && running ? DecisionSpin.runningVelocity:0)
321
+ decisionSpin=DecisionSpin(began:now,angle:resetPen ? -.pi/2:decisionAngle(at:now),initialVelocity:resetPen ? penVelocity:decisionVelocity(at:now),finalVelocity:target.decision > 0 && target.middle == 0 ? 0:DecisionSpin.runningVelocity,delay:birth ? RobotDeparture.duration:(fromSolid ? DecisionMorph.resumeDuration:0))
322
+ }
323
+ layoutFrom=orbitLayout;layoutTarget=target;layoutBegan=now;layoutFlightID=presentationID
324
+ if NSWorkspace.shared.accessibilityDisplayShouldReduceMotion { orbitLayout=target;return }
325
+ layoutTimer?.invalidate()
326
+ layoutTimer=Timer.scheduledTimer(withTimeInterval:1.0/60,repeats:true) { [weak self] _ in
327
+ Task { @MainActor in self?.tickOrbitLayout() }
328
+ }
329
+ }
330
+ func tickOrbitLayout(at now:Date = Date()) {
331
+ if let reply=decisionReturn {
332
+ let p=reply.progress(at:now)
333
+ let frame=DecisionReturnFrame(progress:p,angle:reply.angle)
334
+ orbitLayout=OrbitLayout.mix(reply.from,layoutTarget,reply.travelling ? frame.collapse:p)
335
+ if p >= 1 {retainedBusyCount=busyCount}
336
+ } else if let flight=statusFlight {
337
+ orbitLayout=OrbitLayout.flight(from:layoutFrom,to:layoutTarget,progress:min(1,max(0,now.timeIntervalSince(flight.startedAt)/StatusFlight.duration)),flight:flight)
338
+ } else {
339
+ let resuming=layoutFrom.decision >= 0.9999 && layoutFrom.middle < 0.0001 && layoutTarget.middle > 0
340
+ let duration=resuming ? DecisionMorph.resumeDuration:(closingDecision ? DecisionClosing.duration:(layoutTarget.total == 0 ? 0.82 : (layoutFrom.decision != layoutTarget.decision && layoutFrom.middle != layoutTarget.middle ? DecisionSpin.duration:0.42)))
341
+ let t=min(1,max(0,now.timeIntervalSince(layoutBegan)/duration))
342
+ orbitLayout=OrbitLayout.mix(layoutFrom,layoutTarget,(resuming || closingDecision) ? t:IdleInterpolation.smooth(t))
343
+ if t >= 1 { layoutTimer?.invalidate();layoutTimer=nil }
344
+ }
345
+ if orbitLayout.top == 0 { retainedSuccessCount=0 }
346
+ if orbitLayout.bottom == 0 { retainedFailureCount=0 }
347
+ }
348
+
349
+ func pick(_ id: String) {
350
+ selected = id
351
+ if previewMode {
352
+ guard let row = rows.first(where: { $0.id == id }) else { return }
353
+ if row.needsAction {
354
+ expanded = true
355
+ return
356
+ }
357
+ if row.isFailedResult {
358
+ rows.removeAll { $0.id == id }
359
+ expanded = false
360
+ applySnapshot(NotchSnapshot(ok: true, generatedAt: Date().timeIntervalSince1970, origin: "preview", rows: rows))
361
+ } else if row.unread && !row.busy {
362
+ rows.removeAll { $0.unread && !$0.busy && !$0.isFailedResult && !$0.needsAction }
363
+ expanded = false
364
+ applySnapshot(NotchSnapshot(ok: true, generatedAt: Date().timeIntervalSince1970, origin: "preview", rows: rows))
365
+ }
366
+ return
367
+ }
368
+ Task {
369
+ try? await client.seen(sessionId: id)
370
+ try? await client.focus(sessionId: id)
371
+ activateDSH()
372
+ await refresh()
373
+ }
374
+ }
375
+
376
+ func allow(_ id: String) {
377
+ let sessionId = activeActionRow?.id ?? selected
378
+ if previewMode {
379
+ expanded = false
380
+ return
381
+ }
382
+ Task {
383
+ try? await client.approve(id: id, outcome: "allowed-once")
384
+ if let sessionId {
385
+ try? await client.focus(sessionId: sessionId)
386
+ activateDSH()
387
+ }
388
+ await refresh()
389
+ }
390
+ }
391
+
392
+ func reject(_ id: String) {
393
+ let sessionId = activeActionRow?.id ?? selected
394
+ if previewMode {
395
+ expanded = false
396
+ return
397
+ }
398
+ Task {
399
+ try? await client.approve(id: id, outcome: "rejected")
400
+ if let sessionId {
401
+ try? await client.focus(sessionId: sessionId)
402
+ activateDSH()
403
+ }
404
+ await refresh()
405
+ }
406
+ }
407
+
408
+ func readWizard(ask: NotchAsk, sessionId: String) -> AskWizard {
409
+ if let wizard, wizard.askId == ask.id { return wizard }
410
+ var drafts: [String: QuestionDraft] = [:]
411
+ for question in ask.questions { drafts[question.id] = QuestionDraft() }
412
+ return AskWizard(askId: ask.id, sessionId: sessionId, index: 0, drafts: drafts)
413
+ }
414
+
415
+ func wizard(for ask: NotchAsk, sessionId: String) -> AskWizard {
416
+ let current = readWizard(ask: ask, sessionId: sessionId)
417
+ if wizard?.askId != current.askId { wizard = current }
418
+ return wizard ?? current
419
+ }
420
+
421
+ func tapOption(ask: NotchAsk, sessionId: String, question: NotchQuestion, label: String) {
422
+ var state = wizard(for: ask, sessionId: sessionId)
423
+ var draft = state.drafts[question.id] ?? QuestionDraft()
424
+ if question.multiSelect == true {
425
+ if draft.selected.contains(label) {
426
+ draft.selected.removeAll { $0 == label }
427
+ } else {
428
+ draft.selected.append(label)
429
+ }
430
+ draft.skipped = false
431
+ } else {
432
+ draft.selected = [label]
433
+ draft.custom = ""
434
+ draft.skipped = false
435
+ }
436
+ state.drafts[question.id] = draft
437
+ state.showCustomField = false
438
+ if question.multiSelect != true, state.index < ask.questions.count - 1 {
439
+ state.index += 1
440
+ }
441
+ wizard = state
442
+ if question.multiSelect != true && ask.questions.allSatisfy({ state.drafts[$0.id]?.done == true }) {
443
+ submitAsk(ask: ask, sessionId: sessionId)
444
+ }
445
+ }
446
+
447
+ func toggleCustomField(ask: NotchAsk, sessionId: String) {
448
+ var state = wizard(for: ask, sessionId: sessionId)
449
+ state.showCustomField.toggle()
450
+ wizard = state
451
+ }
452
+
453
+ func skipQuestion(ask: NotchAsk, sessionId: String) {
454
+ var state = wizard(for: ask, sessionId: sessionId)
455
+ guard let question = ask.questions[safe: state.index] else { return }
456
+ state.drafts[question.id] = QuestionDraft(skipped: true)
457
+ if state.index < ask.questions.count - 1 {
458
+ state.index += 1
459
+ wizard = state
460
+ return
461
+ }
462
+ wizard = state
463
+ submitAsk(ask: ask, sessionId: sessionId)
464
+ }
465
+
466
+ func goQuestion(ask: NotchAsk, sessionId: String, delta: Int) {
467
+ var state = wizard(for: ask, sessionId: sessionId)
468
+ state.index = min(max(state.index + delta, 0), ask.questions.count - 1)
469
+ wizard = state
470
+ }
471
+
472
+ func setCustom(ask: NotchAsk, sessionId: String, questionId: String, text: String) {
473
+ var state = wizard(for: ask, sessionId: sessionId)
474
+ var draft = state.drafts[questionId] ?? QuestionDraft()
475
+ draft.custom = text
476
+ draft.skipped = false
477
+ if question(ask, questionId)?.multiSelect != true {
478
+ draft.selected = []
479
+ }
480
+ state.drafts[questionId] = draft
481
+ wizard = state
482
+ }
483
+
484
+ func submitAsk(ask: NotchAsk, sessionId: String) {
485
+ let state = wizard(for: ask, sessionId: sessionId)
486
+ guard ask.questions.allSatisfy({ state.drafts[$0.id]?.done == true }) else { return }
487
+ guard !submitting else { return }
488
+ if previewMode {
489
+ wizard = nil
490
+ expanded = false
491
+ rows = rows.map { row in
492
+ guard row.id == sessionId else { return row }
493
+ var next = row
494
+ next.ask = nil
495
+ next.busy = true
496
+ next.unread = false
497
+ next.lastTurn = nil
498
+ return next
499
+ }
500
+ applySnapshot(NotchSnapshot(ok: true, generatedAt: Date().timeIntervalSince1970, origin: "preview", rows: rows))
501
+ return
502
+ }
503
+ submitting = true
504
+ actionError = nil
505
+ let answers: [[String: Any]] = ask.questions.map { question in
506
+ let draft = state.drafts[question.id] ?? QuestionDraft()
507
+ if draft.skipped { return ["id": question.id, "selected": [String]()] }
508
+ let custom = draft.custom.trimmingCharacters(in: .whitespacesAndNewlines)
509
+ var item: [String: Any] = [
510
+ "id": question.id,
511
+ "selected": custom.isEmpty || question.multiSelect == true ? draft.selected : [],
512
+ ]
513
+ if !custom.isEmpty { item["custom"] = custom }
514
+ return item
515
+ }
516
+ Task {
517
+ defer { submitting = false }
518
+ do {
519
+ try await client.answer(id: ask.id, answers: answers)
520
+ await refresh()
521
+ } catch {
522
+ self.actionError = "提交失败,请重试;也可以在 DSH 中回答。"
523
+ }
524
+ }
525
+ }
526
+
527
+ private func question(_ ask: NotchAsk, _ id: String) -> NotchQuestion? {
528
+ ask.questions.first { $0.id == id }
529
+ }
530
+ }
531
+
532
+ private extension Array {
533
+ subscript(safe index: Int) -> Element? {
534
+ indices.contains(index) ? self[index] : nil
535
+ }
536
+ }
537
+
538
+ // MARK: - PreferenceKey for Hug-Content Height
539
+ private struct ContentHeightPreferenceKey: PreferenceKey {
540
+ static let defaultValue: CGFloat = 0
541
+ static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
542
+ value = max(value, nextValue())
543
+ }
544
+ }
545
+
546
+ // MARK: - Root View (Highest UI/UX Standard Dynamic Island)
547
+ struct RootView: View {
548
+ @ObservedObject var model: BoardModel
549
+ let panelSize: CGSize
550
+ let restSize: CGSize
551
+
552
+ @Environment(\.accessibilityReduceTransparency) private var reduceTransparency
553
+
554
+ // Apple Dynamic Island fluid spring: crisp, elastic, settles fast
555
+ private let morphAnimation = Animation.spring(response: 0.32, dampingFraction: 0.78)
556
+
557
+ private var restCapsuleHeight: CGFloat {
558
+ let base = model.orbitLayout.height + 24
559
+ return model.isPillHovered ? base - 2 : base
560
+ }
561
+
562
+ private var targetWidth: CGFloat {
563
+ if model.expanded {
564
+ return panelSize.width
565
+ }
566
+ return model.isPillHovered ? 42 : 38
567
+ }
568
+
569
+ private var targetHeight: CGFloat {
570
+ if model.expanded {
571
+ let floor: CGFloat = 120
572
+ return min(max(model.measuredContentHeight, floor), model.maximumExpandedHeight)
573
+ }
574
+ return restCapsuleHeight
575
+ }
576
+
577
+ private var cornerRadius: CGFloat {
578
+ 16
579
+ }
580
+
581
+ private var shellShape: UnevenRoundedRectangle {
582
+ UnevenRoundedRectangle(
583
+ topLeadingRadius: cornerRadius,
584
+ bottomLeadingRadius: cornerRadius,
585
+ bottomTrailingRadius: 0,
586
+ topTrailingRadius: 0,
587
+ style: .continuous
588
+ )
589
+ }
590
+
591
+ private var shellBackground: some View {
592
+ // Overlay only: the expanded-width gradient must not become the layout width.
593
+ shellShape
594
+ .fill(reduceTransparency ? Color.black : Color.clear)
595
+ .overlay(alignment: .trailing) {
596
+ if !reduceTransparency {
597
+ NotchTokens.glassFade
598
+ .frame(width: panelSize.width)
599
+ }
600
+ }
601
+ .overlay {
602
+ if !reduceTransparency && model.showingExpanded {
603
+ shellShape.strokeBorder(NotchTokens.glassRim, lineWidth: 0.6)
604
+ }
605
+ }
606
+ .clipShape(shellShape)
607
+ .allowsHitTesting(false)
608
+ }
609
+
610
+ var body: some View {
611
+ ZStack(alignment: .topTrailing) {
612
+ Color.clear
613
+
614
+ // One continuous shell: HUD glass fading to black at the trailing screen edge.
615
+ ZStack(alignment: .topTrailing) {
616
+ shellBackground
617
+
618
+ // Inside content reveals naturally as the single body blooms
619
+ if model.showingExpanded {
620
+ expandedSurface
621
+ .frame(width: panelSize.width, alignment: .topLeading)
622
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
623
+ .opacity(model.expanded ? 1 : 0)
624
+ .animation(.easeOut(duration: 0.16), value: model.expanded)
625
+ .clipped()
626
+ } else {
627
+ compactPillContent
628
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
629
+ }
630
+ }
631
+ .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topTrailing)
632
+ .clipShape(shellShape)
633
+ }
634
+ .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topTrailing)
635
+ // The native panel owns geometry animation; its body fills the same bounds.
636
+ .transaction { $0.animation = nil }
637
+ .onPreferenceChange(ContentHeightPreferenceKey.self) { height in
638
+ // Expanded content stays mounted during collapse. Keep its latest size:
639
+ // a new question can arrive before a quick reopen, without emitting a
640
+ // second preference change. Discarding it would retain the old viewport.
641
+ guard height.isFinite, height > 40 else { return }
642
+ model.measuredContentHeight = height
643
+ }
644
+ .onChange(of: CGSize(width: targetWidth, height: targetHeight), initial: true) { _, size in
645
+ model.currentIslandWidth = size.width
646
+ model.currentIslandHeight = size.height
647
+ }
648
+ }
649
+
650
+ private var expandedSurface: some View {
651
+ // One intrinsic measurement and one viewport for questions, Markdown, and
652
+ // task lists. Character counts and nested viewport caps don't measure the
653
+ // rendered content. Only overflow at the final screen cap needs an indicator.
654
+ ScrollView(.vertical, showsIndicators: model.measuredContentHeight > model.maximumExpandedHeight + 1) {
655
+ expandedContent
656
+ .frame(maxWidth: .infinity, alignment: .topLeading)
657
+ .fixedSize(horizontal: false, vertical: true)
658
+ .background(GeometryReader { geo in
659
+ Color.clear.preference(key: ContentHeightPreferenceKey.self, value: geo.size.height)
660
+ })
661
+ }
662
+ }
663
+
664
+ // MARK: - Rest Capsule Content (Live Multi-Thread Activity Cockpit)
665
+ private var compactPillContent: some View {
666
+ Button {
667
+ // Per spec: Click red pip directly jumps to the session; click green jumps to completed
668
+ if model.anyFailed && !model.needsAction {
669
+ if let failSession = model.firstFailedRow {
670
+ model.pick(failSession.id)
671
+ }
672
+ } else if model.completedUnreadCount > 0 && !model.needsAction {
673
+ if let compSession = model.firstCompletedRow {
674
+ model.pick(compSession.id)
675
+ }
676
+ } else {
677
+ model.expanded.toggle()
678
+ }
679
+ } label: {
680
+ ZStack {
681
+ VStack(spacing: 8) {
682
+ IdleStatusSlot(model: model)
683
+ }
684
+ }
685
+ .scaleEffect(model.isPillHovered && (model.needsAction || model.anyFailed || model.completedUnreadCount > 0 || model.busyCount > 0 || model.statusFlight != nil) ? 1.08 : 1.0)
686
+ .animation(morphAnimation, value: model.isPillHovered)
687
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
688
+ .contentShape(Rectangle())
689
+ }
690
+ .buttonStyle(.plain)
691
+ .onHover { hovering in
692
+ if !model.expanded {
693
+ model.isPillHovered = hovering
694
+ model.currentIslandWidth = targetWidth
695
+ model.currentIslandHeight = targetHeight
696
+ }
697
+ }
698
+ }
699
+
700
+ // MARK: - Expanded Content (Figma ROW B / ROW C / Glance List)
701
+ private var expandedContent: some View {
702
+ VStack(alignment: .leading, spacing: 12) {
703
+ if let error = model.actionError {
704
+ Text(error).font(.system(size: 11)).foregroundStyle(NotchTokens.redFail)
705
+ }
706
+ if let actionRow = model.activeActionRow {
707
+ if let approval = actionRow.approval {
708
+ // ROW B Approval Morph (Figma #23:70)
709
+ approvalView(approval, row: actionRow)
710
+ } else if let ask = actionRow.ask {
711
+ // ROW C Choices (Figma #23:97 / #22:16)
712
+ choicesView(ask, row: actionRow)
713
+ }
714
+ } else {
715
+ // Quick glance session list with full status overview
716
+ glanceSessionList
717
+ }
718
+ }
719
+ .padding(14)
720
+ }
721
+
722
+ // MARK: - ROW B: Approval View (✓ / ✕ Circular Buttons)
723
+ private func approvalView(_ approval: NotchApproval, row: NotchRow) -> some View {
724
+ VStack(alignment: .leading, spacing: 12) {
725
+ // Top line: Amber pip + "Allow?"
726
+ HStack(spacing: 6) {
727
+ Circle()
728
+ .fill(NotchTokens.amber)
729
+ .frame(width: 10, height: 10)
730
+ .shadow(color: NotchTokens.amberGlow, radius: 3)
731
+
732
+ Text("Allow?")
733
+ .font(.system(size: 11, weight: .medium))
734
+ .foregroundStyle(NotchTokens.amber)
735
+
736
+ Spacer()
737
+
738
+ Button {
739
+ model.expanded = false
740
+ } label: {
741
+ Image(systemName: "xmark")
742
+ .font(.system(size: 9, weight: .bold))
743
+ .foregroundStyle(.white.opacity(0.4))
744
+ }
745
+ .buttonStyle(.plain)
746
+ }
747
+
748
+ // Reason / Tool Name
749
+ Text(approval.reason ?? approval.toolName)
750
+ .font(.system(size: 12, weight: .medium))
751
+ .foregroundStyle(.white)
752
+ .fixedSize(horizontal: false, vertical: true)
753
+
754
+ // Circular 32x32 Buttons: Blue ✓ and Outline ✕ (Figma #23:74 & #23:76)
755
+ HStack(spacing: 12) {
756
+ // ✓ 允许一次 (32x32 Solid Blue Circle)
757
+ Button {
758
+ model.allow(approval.id)
759
+ } label: {
760
+ ZStack {
761
+ Circle()
762
+ .fill(NotchTokens.deepSeekBlue)
763
+ .frame(width: 32, height: 32)
764
+
765
+ Text("✓")
766
+ .font(.system(size: 14, weight: .semibold))
767
+ .foregroundStyle(.black)
768
+ }
769
+ }
770
+ .buttonStyle(.plain)
771
+
772
+ // ✕ 拒绝 (32x32 Outline Circle)
773
+ Button {
774
+ model.reject(approval.id)
775
+ } label: {
776
+ ZStack {
777
+ Circle()
778
+ .stroke(Color.white.opacity(0.35), lineWidth: 1)
779
+ .frame(width: 32, height: 32)
780
+
781
+ Text("✕")
782
+ .font(.system(size: 13, weight: .semibold))
783
+ .foregroundStyle(.white)
784
+ }
785
+ }
786
+ .buttonStyle(.plain)
787
+
788
+ Spacer()
789
+ }
790
+ .padding(.top, 4)
791
+ }
792
+ }
793
+
794
+ // MARK: - ROW C: Choices View (Chips & Inline Other Morph)
795
+ private func choicesView(_ ask: NotchAsk, row: NotchRow) -> some View {
796
+ let state = model.readWizard(ask: ask, sessionId: row.id)
797
+ let index = min(state.index, max(ask.questions.count - 1, 0))
798
+ let question = ask.questions[index]
799
+ let draft = state.drafts[question.id] ?? QuestionDraft()
800
+ let last = index == ask.questions.count - 1
801
+ let ready = ask.questions.allSatisfy { state.drafts[$0.id]?.done == true }
802
+
803
+ return VStack(alignment: .leading, spacing: 10) {
804
+ // Question Title & Counter
805
+ HStack(alignment: .top) {
806
+ Button { model.pick(row.id) } label: {
807
+ Text(question.question)
808
+ .multilineTextAlignment(.leading)
809
+ .font(.system(size: NotchMarkdown.bodySize, weight: .semibold))
810
+ .foregroundStyle(.white)
811
+ .fixedSize(horizontal: false, vertical: true)
812
+ }
813
+ .buttonStyle(NotchInteractiveButtonStyle())
814
+ .help("在 DSH 中查看上下文")
815
+
816
+ Spacer()
817
+
818
+ if ask.questions.count > 1 {
819
+ Text("\(index + 1)/\(ask.questions.count)")
820
+ .font(.system(size: 10, weight: .bold, design: .rounded))
821
+ .foregroundStyle(NotchTokens.amber)
822
+ .padding(.top, 2)
823
+ }
824
+ }
825
+
826
+ if let detail = question.detail, !detail.isEmpty {
827
+ NotchMarkdownView(source: detail)
828
+ .textSelection(.enabled)
829
+ }
830
+
831
+ // Option Chips (Figma #23:100, #22:21)
832
+ Group {
833
+ VStack(spacing: 6) {
834
+ ForEach(question.options ?? []) { option in
835
+ let on = draft.selected.contains(option.label)
836
+ Button {
837
+ model.tapOption(ask: ask, sessionId: row.id, question: question, label: option.label)
838
+ } label: {
839
+ VStack(alignment: .leading, spacing: 2) {
840
+ Text(option.label)
841
+ .font(.system(size: NotchMarkdown.bodySize, weight: .medium))
842
+ .foregroundStyle(on ? .black : .white)
843
+ .fixedSize(horizontal: false, vertical: true)
844
+ .multilineTextAlignment(.leading)
845
+
846
+ if let desc = option.description, !desc.isEmpty {
847
+ Text(desc)
848
+ .font(.system(size: 10))
849
+ .foregroundStyle(on ? Color.black.opacity(0.7) : Color.white.opacity(0.55))
850
+ .fixedSize(horizontal: false, vertical: true)
851
+ }
852
+ }
853
+ .frame(maxWidth: .infinity, alignment: .leading)
854
+ .padding(.horizontal, 12)
855
+ .padding(.vertical, 8)
856
+ .background(
857
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
858
+ .fill(on ? NotchTokens.deepSeekBlue : Color.clear)
859
+ .overlay {
860
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
861
+ .stroke(on ? Color.clear : NotchTokens.chipStroke, lineWidth: 1)
862
+ }
863
+ )
864
+ }
865
+ .buttonStyle(NotchInteractiveButtonStyle())
866
+ }
867
+
868
+ if (question.options ?? []).isEmpty || state.showCustomField {
869
+ TextField("输入你的答案", text: Binding(
870
+ get: { draft.custom },
871
+ set: { model.setCustom(ask: ask, sessionId: row.id, questionId: question.id, text: $0) }
872
+ ))
873
+ .textFieldStyle(.plain)
874
+ .font(.system(size: NotchMarkdown.bodySize))
875
+ .foregroundStyle(.white)
876
+ .padding(.horizontal, 12)
877
+ .frame(height: 36)
878
+ .background(
879
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
880
+ .fill(NotchTokens.fieldBackground)
881
+ .overlay {
882
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
883
+ .stroke(Color.white.opacity(0.18), lineWidth: 1)
884
+ }
885
+ )
886
+ .onSubmit {
887
+ if ready { model.submitAsk(ask: ask, sessionId: row.id) }
888
+ }
889
+ }
890
+ }
891
+ }
892
+
893
+ let hasOptions = !(question.options ?? []).isEmpty
894
+ let showPrev = ask.questions.count > 1
895
+ let showNext = !last && ask.questions.count > 1
896
+ let showComplete = last && (question.multiSelect == true || !hasOptions || !draft.custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
897
+ if showPrev || showNext || showComplete {
898
+ HStack(spacing: 8) {
899
+ if showPrev {
900
+ Button("上一题") { model.goQuestion(ask: ask, sessionId: row.id, delta: -1) }
901
+ .buttonStyle(NotchTinyButtonStyle())
902
+ .opacity(index == 0 ? 0.35 : 1)
903
+ .disabled(index == 0)
904
+ }
905
+ Spacer()
906
+ if showComplete {
907
+ Button("完成") { model.submitAsk(ask: ask, sessionId: row.id) }
908
+ .buttonStyle(NotchBlueTinyButtonStyle())
909
+ .opacity(ready ? 1 : 0.45)
910
+ .disabled(!ready)
911
+ } else if showNext {
912
+ Button("下一题") { model.goQuestion(ask: ask, sessionId: row.id, delta: 1) }
913
+ .buttonStyle(NotchBlueTinyButtonStyle())
914
+ }
915
+ }
916
+ .padding(.top, 4)
917
+ }
918
+ }
919
+ }
920
+
921
+ // MARK: - Glance Session List with Running & Completion Overview
922
+ private var glanceSessionList: some View {
923
+ VStack(alignment: .leading, spacing: 8) {
924
+ // Header with summary badges and "Clear All" action
925
+ HStack(spacing: 6) {
926
+ if model.busyCount > 0 {
927
+ HStack(spacing: 4) {
928
+ Circle().fill(NotchTokens.deepSeekBlue).frame(width: 6, height: 6)
929
+ Text("\(model.busyCount) 进行中")
930
+ .font(.system(size: 10, weight: .semibold))
931
+ .foregroundStyle(NotchTokens.deepSeekBlue)
932
+ }
933
+ .padding(.horizontal, 6)
934
+ .padding(.vertical, 3)
935
+ .background(Capsule().fill(NotchTokens.deepSeekBlue.opacity(0.14)))
936
+ }
937
+
938
+ if model.completedUnreadCount > 0 {
939
+ HStack(spacing: 5) {
940
+ Circle().fill(NotchTokens.greenComplete).frame(width: 6, height: 6)
941
+ Text("\(model.completedUnreadCount) 完成")
942
+ .font(.system(size: 10, weight: .semibold))
943
+ .foregroundStyle(NotchTokens.greenComplete)
944
+
945
+
946
+ }
947
+ .padding(.horizontal, 6)
948
+ .padding(.vertical, 3)
949
+ .background(Capsule().fill(NotchTokens.greenComplete.opacity(0.14)))
950
+ }
951
+
952
+ Spacer()
953
+
954
+ Button {
955
+ model.expanded = false
956
+ } label: {
957
+ Image(systemName: "xmark")
958
+ .font(.system(size: 9, weight: .bold))
959
+ .foregroundStyle(.white.opacity(0.4))
960
+ }
961
+ .buttonStyle(.plain)
962
+ }
963
+
964
+ if model.rows.isEmpty {
965
+ Text("无活跃会话")
966
+ .font(.system(size: 11))
967
+ .foregroundStyle(.white.opacity(0.35))
968
+ .padding(.vertical, 6)
969
+ } else {
970
+ VStack(spacing: 4) {
971
+ ForEach(model.rows) { row in
972
+ Button {
973
+ model.pick(row.id)
974
+ } label: {
975
+ HStack(spacing: 8) {
976
+ // Status Pip
977
+ if row.busy {
978
+ Circle()
979
+ .fill(NotchTokens.deepSeekBlue)
980
+ .frame(width: 6, height: 6)
981
+ .shadow(color: NotchTokens.deepSeekBlue.opacity(0.8), radius: 2)
982
+ } else if row.isFailedResult {
983
+ Circle()
984
+ .fill(NotchTokens.redFail)
985
+ .frame(width: 6, height: 6)
986
+ } else if row.needsAction {
987
+ Circle()
988
+ .fill(NotchTokens.amber)
989
+ .frame(width: 6, height: 6)
990
+ } else if row.unread {
991
+ Circle()
992
+ .fill(NotchTokens.greenComplete)
993
+ .frame(width: 6, height: 6)
994
+ } else {
995
+ Circle().fill(Color.white.opacity(0.25)).frame(width: 5, height: 5)
996
+ }
997
+
998
+ Text(row.title)
999
+ .font(.system(size: 12, weight: .medium))
1000
+ .foregroundStyle(.white.opacity(0.9))
1001
+ .lineLimit(2)
1002
+ .fixedSize(horizontal: false, vertical: true)
1003
+
1004
+ Spacer()
1005
+
1006
+ if row.busy {
1007
+ Text("运行中")
1008
+ .font(.system(size: 9))
1009
+ .foregroundStyle(NotchTokens.deepSeekBlue)
1010
+ } else if row.unread && row.lastTurn?.failed != true {
1011
+ Text("完成")
1012
+ .font(.system(size: 9, weight: .medium))
1013
+ .foregroundStyle(NotchTokens.greenComplete)
1014
+ }
1015
+ }
1016
+ .padding(.horizontal, 8)
1017
+ .padding(.vertical, 6)
1018
+ .background(
1019
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
1020
+ .fill(row.id == model.selected ? Color.white.opacity(0.06) : Color.clear)
1021
+ )
1022
+ }
1023
+ .buttonStyle(.plain)
1024
+ }
1025
+ }
1026
+ }
1027
+ }
1028
+ }
1029
+ }
1030
+
1031
+ // MARK: - Tiny Button Styles
1032
+ struct NotchTinyButtonStyle: ButtonStyle {
1033
+ func makeBody(configuration: Configuration) -> some View {
1034
+ configuration.label
1035
+ .font(.system(size: 10, weight: .medium))
1036
+ .foregroundStyle(.white.opacity(0.85))
1037
+ .padding(.horizontal, 8)
1038
+ .padding(.vertical, 4)
1039
+ .background(Color.white.opacity(configuration.isPressed ? 0.14 : 0.06))
1040
+ .clipShape(Capsule())
1041
+ }
1042
+ }
1043
+
1044
+ struct NotchBlueTinyButtonStyle: ButtonStyle {
1045
+ func makeBody(configuration: Configuration) -> some View {
1046
+ configuration.label
1047
+ .font(.system(size: 10, weight: .semibold))
1048
+ .foregroundStyle(.white)
1049
+ .padding(.horizontal, 9)
1050
+ .padding(.vertical, 4)
1051
+ .background(NotchTokens.deepSeekBlue.opacity(configuration.isPressed ? 0.75 : 1.0))
1052
+ .clipShape(Capsule())
1053
+ }
1054
+ }
1055
+
1056
+ /// Bring the DSH desktop app forward so the focused session is visible.
1057
+ @MainActor
1058
+ func activateDSH() {
1059
+ let apps = NSRunningApplication.runningApplications(withBundleIdentifier: "local.dsh.desktop")
1060
+ guard let app = apps.first else { return }
1061
+ app.unhide()
1062
+ _ = app.activate()
1063
+ if let url = app.bundleURL {
1064
+ let config = NSWorkspace.OpenConfiguration()
1065
+ config.activates = true
1066
+ NSWorkspace.shared.openApplication(at: url, configuration: config) { _, _ in }
1067
+ }
1068
+ }
1069
+
1070
+ /// Subtle local feedback without changing which answer is selected.
1071
+ struct NotchInteractiveButtonStyle: ButtonStyle {
1072
+ func makeBody(configuration: Configuration) -> some View {
1073
+ Feedback(configuration: configuration)
1074
+ }
1075
+ private final class HoverState: ObservableObject {
1076
+ @Published var hovered = false
1077
+ }
1078
+ private struct Feedback: View {
1079
+ let configuration: ButtonStyleConfiguration
1080
+ @StateObject private var hover = HoverState()
1081
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
1082
+ var body: some View {
1083
+ configuration.label
1084
+ .contentShape(Rectangle())
1085
+ .background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(configuration.isPressed ? 0.16 : hover.hovered ? 0.08 : 0)))
1086
+ .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.white.opacity(hover.hovered ? 0.32 : 0), lineWidth: 1))
1087
+ .scaleEffect(configuration.isPressed && !reduceMotion ? 0.985 : 1)
1088
+ .animation(reduceMotion ? nil : .easeOut(duration: 0.10), value: hover.hovered)
1089
+ .animation(reduceMotion ? nil : .easeOut(duration: 0.08), value: configuration.isPressed)
1090
+ .onHover { hover.hovered = $0 }
1091
+ }
1092
+ }
1093
+ }