dsh-notch 0.3.1 → 0.3.4
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.
- package/README.en.md +58 -47
- package/README.md +60 -55
- package/cordis.patch.yml +3 -0
- package/desktop/README.md +29 -0
- package/desktop/notch-lifecycle.mjs +120 -0
- package/desktop/restart-probe.mjs +61 -0
- package/docs/desktop-017rc2.md +23 -0
- package/docs/elastic-edge-dock.md +127 -0
- package/docs/motion-audit.md +1 -1
- package/docs/releases/v0.3.2.md +38 -0
- package/docs/releases/v0.3.3.md +9 -0
- package/docs/subagent-compatibility.md +51 -0
- package/docs/verification/elastic-algorithm-timing.json +337 -0
- package/docs/verification/elastic-attention.json +44 -0
- package/docs/verification/elastic-frame-timing.json +240 -0
- package/docs/verification/elastic-tension.json +59 -0
- package/docs/verification/elastic-tip-taper.json +181 -0
- package/dshx.yml +2 -0
- package/lib/client.js +112 -0
- package/lib/client.js.map +1 -0
- package/lib/index.mjs +990 -0
- package/lib/index.mjs.map +1 -0
- package/package.json +58 -10
- package/src/board.ts +178 -35
- package/src/client/index.ts +104 -0
- package/src/dsh-notch.ts +19 -2
- package/src/http.ts +17 -2
- package/src/session-state.ts +15 -1
- package/src/store.ts +3 -3
- package/src/types.ts +3 -0
- package/BASELINE.md +0 -13
- package/DESIGN.md +0 -34
- package/cordis.yml +0 -5
- package/macos/Package.swift +0 -17
- package/macos/STATUS-MOTION.md +0 -49
- package/macos/Sources/Client.swift +0 -139
- package/macos/Sources/IdleRobot.swift +0 -423
- package/macos/Sources/NotchMarkdown.swift +0 -266
- package/macos/Sources/Panel.swift +0 -153
- package/macos/Sources/Resources/Idle/balance.json +0 -1
- package/macos/Sources/Resources/Idle/blink.json +0 -1
- package/macos/Sources/Resources/Idle/cube-in.json +0 -1
- package/macos/Sources/Resources/Idle/dance.json +0 -1
- package/macos/Sources/Resources/Idle/hop.json +0 -1
- package/macos/Sources/Resources/Idle/nod.json +0 -1
- package/macos/Sources/Resources/Idle/satellite-in.json +0 -1
- package/macos/Sources/Resources/Idle/satellite-out.json +0 -1
- package/macos/Sources/Resources/Idle/scan.json +0 -1
- package/macos/Sources/Resources/Idle/sleep.json +0 -1
- package/macos/Sources/Resources/Idle/sneeze.json +0 -1
- package/macos/Sources/Resources/Idle/stretch.json +0 -1
- package/macos/Sources/Resources/Idle/tilt.json +0 -1
- package/macos/Sources/ReviewStudio.swift +0 -337
- package/macos/Sources/RootView.swift +0 -1093
- package/macos/Sources/StatusOrbit.swift +0 -642
- package/macos/Sources/main.swift +0 -153
- package/macos/Tests/ExpandedHeightProbe.swift +0 -108
- package/macos/Tests/GeometryProbe.swift +0 -98
- package/macos/Tests/IdleProbe.swift +0 -294
- package/macos/Tests/MotionProbe.swift +0 -106
- package/macos/Tests/OutcomeProbe.swift +0 -133
- package/macos/Tests/ScrollbarProbe.swift +0 -91
- package/macos/Tests/expanded-height.sh +0 -13
- package/macos/Tests/geometry.sh +0 -14
- package/macos/Tests/idle.sh +0 -14
- package/macos/Tests/motion.sh +0 -14
- package/macos/Tests/outcome.sh +0 -14
- package/macos/Tests/scrollbar.sh +0 -13
- package/tests/answer-lifecycle.test.mjs +0 -23
- package/tests/board.test.mjs +0 -7
- package/tests/browse-sync.test.mjs +0 -20
- package/tests/mirror.test.mjs +0 -4
- package/tests/peer-range.test.mjs +0 -21
- package/tests/session-state.test.mjs +0 -54
- package/tests/unread-hold.test.mjs +0 -90
- package/tools/desktop-shell/README.md +0 -56
- package/tools/desktop-shell/child-log.mjs +0 -6
- package/tools/desktop-shell/child-log.test.mjs +0 -14
- package/tools/desktop-shell/package-recovery.py +0 -78
- package/tools/desktop-shell/recovery-probe.cjs +0 -50
- package/tools/desktop-shell/renderer-recovery.mjs +0 -95
- package/tools/desktop-shell/renderer-recovery.test.mjs +0 -24
- package/tools/diagnostics/completion-watch.py +0 -101
- package/tools/diagnostics/completion-watch.test.py +0 -17
- package/tools/diagnostics/renderer-diagnostics.mjs +0 -56
- package/tools/diagnostics/renderer-diagnostics.test.mjs +0 -32
- package/tools/idle/LICENSE.open-bot-motion +0 -21
- package/tools/idle/export.mjs +0 -40
- package/tools/idle/index.html +0 -4
- package/tools/idle/motions.js +0 -83
- package/tools/idle/original.js +0 -1673
- package/tools/idle/renderer.js +0 -1673
- package/tools/recording/Client.swift +0 -76
- package/tools/recording/DemoCatalog.swift +0 -174
- package/tools/recording/README.md +0 -23
- package/tools/recording/build.sh +0 -41
- package/tools/recording/main.swift +0 -100
- package/tools/review/ReviewStudio.swift +0 -236
- package/tools/review/audit.sh +0 -19
- package/tools/review/build.sh +0 -7
- package/tools/review/main.swift +0 -158
- package/tools/review/render-audit.swift +0 -32
|
@@ -1,1093 +0,0 @@
|
|
|
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
|
-
}
|