From 4eb7f65f23b7cde3b97f178449df432a30e1877b Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 21 Sep 2026 21:08:09 -0700 Subject: [PATCH] Keep Aesel connection failures off the notebook and refine native controls --- apple/aesel/MacInfo.plist | 4 +- apple/aesel/README.md | 11 +- .../provider-codex.svg | 2 +- apple/aesel/Sources/AeselApp.swift | 2 +- apple/aesel/Sources/AeselButtonStyle.swift | 16 +++ apple/aesel/Sources/AeselPieceView.swift | 29 ++++- apple/aesel/Sources/AeselProviderPicker.swift | 25 ++-- apple/aesel/Sources/ApplePlatform.swift | 2 +- apple/aesel/Sources/ContentView.swift | 118 +++++++++++++----- apple/aesel/Sources/Session.swift | 36 +++++- apple/aesel/Sources/SessionHost.swift | 3 + apple/aesel/Tests/SessionNoticeChecks.swift | 26 ++++ apple/aesel/Tests/preview-bridge.test.mjs | 15 ++- easel/phone/session.mjs | 32 +++-- easel/src/ac-server.mjs | 16 ++- easel/src/network.mjs | 33 +++++ easel/src/publish.mjs | 41 +++--- easel/test/ac-server.test.mjs | 15 +++ easel/test/network.test.mjs | 17 +++ easel/test/phone-session.test.mjs | 33 ++++- 20 files changed, 395 insertions(+), 81 deletions(-) create mode 100644 apple/aesel/Tests/SessionNoticeChecks.swift create mode 100644 easel/src/network.mjs create mode 100644 easel/test/network.test.mjs diff --git a/apple/aesel/MacInfo.plist b/apple/aesel/MacInfo.plist index 0242dbd6cf..d9851c0f7d 100644 --- a/apple/aesel/MacInfo.plist +++ b/apple/aesel/MacInfo.plist @@ -4,8 +4,8 @@ CFBundleDevelopmentRegionen CFBundleExecutable$(EXECUTABLE_NAME) CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleNameaesel - CFBundleDisplayNameaesel + CFBundleNameAesel + CFBundleDisplayNameAesel CFBundlePackageTypeAPPL CFBundleShortVersionString0.7.5 CFBundleVersion2 diff --git a/apple/aesel/README.md b/apple/aesel/README.md index e0caa049fc..0bd1fcedb7 100644 --- a/apple/aesel/README.md +++ b/apple/aesel/README.md @@ -16,12 +16,19 @@ The [experience model](EXPERIENCE.md) defines draft, sign-in, generation, and publishing behavior, including the proposed guest AI allowance. The [integration roadmap](ROADMAP.md) inventories the remaining ports and their acceptance gates, including providers, paired hosts, media, files, and releases. -The native provider dropdown retains AC, Claude, and Codex with their desktop -images. Claude and Codex use the optional local helper, installed from the repo +The native provider dropdown lists Aesthetic.Computer, Claude, and Codex. +Codex uses the OpenAI knot mark; provider artwork comes from the +[attributed desktop assets](../../easel/desktop/assets/provider-marks.txt). Claude and Codex use the optional local helper, installed from the repo root with `node easel/native/install.mjs`. A connected provider can generate without AC sign-in; AC generation and publication retain their account gates. AC's model is Automatic. iPhone pairing to the helper is not implemented. +Upload interruptions retry twice, with a 20-second deadline per attempt, outside +notebook history. After retries, a temporary notice offers Retry; source remains +saved locally. AC inference has a 45-second connection deadline and a 60-second +idle-stream deadline, without replaying paid requests. A speaker beside the title +opens volume when the preview produces audio; mute remains reachable through silence. + Native CLI sessions share Electron's piece-first, responsive-layout, network-clock, sound-design and reply instructions, plus the bundled AC guides. Each turn includes a fresh preview image when available. `ac_frame` and `ac_preview` inspect diff --git a/apple/aesel/Resources/Assets.xcassets/provider-codex.imageset/provider-codex.svg b/apple/aesel/Resources/Assets.xcassets/provider-codex.imageset/provider-codex.svg index f03c011f1d..8fece25767 100644 --- a/apple/aesel/Resources/Assets.xcassets/provider-codex.imageset/provider-codex.svg +++ b/apple/aesel/Resources/Assets.xcassets/provider-codex.imageset/provider-codex.svg @@ -1 +1 @@ -Codex \ No newline at end of file +OpenAI \ No newline at end of file diff --git a/apple/aesel/Sources/AeselApp.swift b/apple/aesel/Sources/AeselApp.swift index 08d3f08aed..86d73d8c55 100644 --- a/apple/aesel/Sources/AeselApp.swift +++ b/apple/aesel/Sources/AeselApp.swift @@ -18,7 +18,7 @@ struct AeselApp: App { var body: some Scene { #if os(macOS) - Window("aesel", id: "workspace") { + Window("", id: "workspace") { GeometryReader { geometry in workspace .frame(width: ceil(geometry.size.width * displayScale) / displayScale / uiScale, diff --git a/apple/aesel/Sources/AeselButtonStyle.swift b/apple/aesel/Sources/AeselButtonStyle.swift index c15e895170..ca28902cc1 100644 --- a/apple/aesel/Sources/AeselButtonStyle.swift +++ b/apple/aesel/Sources/AeselButtonStyle.swift @@ -11,6 +11,22 @@ struct AeselButtonStyle: ButtonStyle { } } +/// KidLisp's stop interaction: a small lift on hover, press inward. +struct AeselStopButtonStyle: ButtonStyle { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var hovered = false + func makeBody(configuration: Configuration) -> some View { + configuration.label + .contentShape(Circle()) + .scaleEffect(reduceMotion ? 1 : configuration.isPressed ? 0.95 : hovered ? 1.05 : 1) + .brightness(configuration.isPressed ? -0.1 : 0) + .animation(.easeOut(duration: 0.15), value: hovered) + .animation(.easeOut(duration: 0.15), value: configuration.isPressed) + .onHover { hovered = $0 } + .modifier(AeselButtonPointer(enabled: true)) + } +} + struct AeselButtonPointer: ViewModifier { let enabled: Bool @ViewBuilder func body(content: Content) -> some View { diff --git a/apple/aesel/Sources/AeselPieceView.swift b/apple/aesel/Sources/AeselPieceView.swift index c0231d3157..1d1fa86c11 100644 --- a/apple/aesel/Sources/AeselPieceView.swift +++ b/apple/aesel/Sources/AeselPieceView.swift @@ -6,6 +6,7 @@ import WebKit final class PiecePreview: NSObject, ObservableObject, WKNavigationDelegate { @Published var failure: String? @Published var background: Color? + @Published var hasAudio = false @Published var volume: Double = 1 { didSet { updateSource() } } private var source = "" private var requestedURL: URL? @@ -18,6 +19,7 @@ final class PiecePreview: NSObject, ObservableObject, WKNavigationDelegate { messages.owner = self configuration.userContentController.add(messages, name: "previewFailure") configuration.userContentController.add(messages, name: "previewBackdrop") + configuration.userContentController.add(messages, name: "previewAudio") #if os(iOS) configuration.allowsInlineMediaPlayback = true #endif @@ -25,6 +27,21 @@ final class PiecePreview: NSObject, ObservableObject, WKNavigationDelegate { configuration.userContentController.addUserScript(WKUserScript(source: """ (() => { let ready = false, rendered = '', failed = false, startupTimedOut = false; + let audioSource = '', lastSound = -Infinity, audioVisible = false; + setInterval(() => { + if (audioSource !== window.__aeselSource) { + audioSource = window.__aeselSource; lastSound = -Infinity; audioVisible = false; + } + let waveform = []; + try { waveform = window.AC?.readOutputWaveform?.(128) || []; } catch { /* Audio can detach while a piece reloads. */ } + if (waveform.some(value => Math.abs(value) > 0.001)) lastSound = performance.now(); + // Hold through short musical rests; keep mute reachable after muting. + const active = performance.now() - lastSound < 3000 || (audioVisible && window.__aeselVolume === 0); + if (active !== audioVisible) { + audioVisible = active; + window.webkit.messageHandlers.previewAudio.postMessage({active, source: audioSource || ''}); + } + }, 200); const reportFailure = message => { if (failed) return; failed = true; @@ -89,7 +106,7 @@ final class PiecePreview: NSObject, ObservableObject, WKNavigationDelegate { func update(url: URL, source: String, scheme: ColorScheme) { ApplePlatform.setAppearance(view, colorScheme: scheme) - if self.source != source { failure = nil } + if self.source != source { failure = nil; hasAudio = false } self.source = source if requestedURL != url { failure = nil; requestedURL = url @@ -117,7 +134,10 @@ final class PiecePreview: NSObject, ObservableObject, WKNavigationDelegate { view.scrollView.backgroundColor = color #endif } - func reload() { failure = nil; view.reload() } + func setAudio(_ active: Bool, source: String) { + if self.source == source { hasAudio = active } + } + func reload() { failure = nil; hasAudio = false; view.reload() } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { updateSource() } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { if (error as NSError).code != NSURLErrorCancelled { failure = error.localizedDescription } @@ -135,7 +155,10 @@ private final class PreviewMessages: NSObject, WKScriptMessageHandler { weak var owner: PiecePreview? func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { guard message.frameInfo.isMainFrame else { return } - if message.name == "previewBackdrop", let rgb = message.body as? [Double] { + if message.name == "previewAudio", let audio = message.body as? [String: Any], + let active = audio["active"] as? Bool, let source = audio["source"] as? String { + owner?.setAudio(active, source: source) + } else if message.name == "previewBackdrop", let rgb = message.body as? [Double] { owner?.setBackdrop(rgb) } else if message.name == "previewFailure", let text = message.body as? String { owner?.failure = text diff --git a/apple/aesel/Sources/AeselProviderPicker.swift b/apple/aesel/Sources/AeselProviderPicker.swift index a3cc9241fb..aeddd69116 100644 --- a/apple/aesel/Sources/AeselProviderPicker.swift +++ b/apple/aesel/Sources/AeselProviderPicker.swift @@ -31,37 +31,46 @@ struct AeselProviderPicker: View { Button { expanded.toggle() } label: { HStack(spacing: 9) { mark(session.provider) - Text(session.provider == "ac" ? "AC" : session.provider.capitalized) + providerName(session.provider) Spacer() Image(systemName: "chevron.down").font(.system(size: 11)) } .frame(maxWidth: .infinity).frame(height: 36) - .contentShape(Rectangle()) + .contentShape(RoundedRectangle(cornerRadius: 8)) } .frame(height: 36) .disabled(session.busy || session.hostOperationID != nil) - .accessibilityLabel("Provider, \(session.provider)") + .accessibilityLabel("Provider, \(session.provider == "ac" ? "Aesthetic.Computer" : session.provider.capitalized)") .accessibilityValue(expanded ? "Expanded" : "Collapsed") .popover(isPresented: $expanded, arrowEdge: .bottom) { VStack(alignment: .leading, spacing: 0) { - option("ac", title: "AC", available: true) + option("ac", title: "Aesthetic.Computer", available: true) option("claude", title: "Claude", available: session.providers.first { $0.id == "claude" }?.available == true) option("codex", title: "Codex", available: session.providers.first { $0.id == "codex" }?.available == true) Button("Refresh connection") { host.refreshProviders() }.padding(6) } - .frame(width: 285) + .padding(8) + .frame(width: 310) .font(Paint.font(15)).foregroundStyle(paint.ink) - .background(paint.bg) + .background(paint.bg, in: RoundedRectangle(cornerRadius: 14)) + .clipShape(RoundedRectangle(cornerRadius: 14)) .buttonStyle(AeselButtonStyle()) .presentationCompactAdaptation(.popover) } } + private func providerName(_ provider: String) -> Text { + if provider == "ac" { + return Text("Aesthetic") + Text(".").foregroundColor(Color(red: 1, green: 0.25, blue: 0.6)) + Text("Computer") + } + return Text(provider.capitalized) + } + private func option(_ provider: String, title: String, available: Bool) -> some View { Button { host.setProvider(provider); expanded = false } label: { HStack(spacing: 9) { mark(provider, size: 28) - Text(title) + providerName(provider) Spacer() if session.provider == provider { Image(systemName: "checkmark") @@ -70,7 +79,7 @@ struct AeselProviderPicker: View { } } .padding(.horizontal, 8).frame(maxWidth: .infinity, minHeight: 40) - .contentShape(Rectangle()) + .contentShape(RoundedRectangle(cornerRadius: 8)) } .disabled(!available) } diff --git a/apple/aesel/Sources/ApplePlatform.swift b/apple/aesel/Sources/ApplePlatform.swift index 8ab76ad365..f9f274f3c9 100644 --- a/apple/aesel/Sources/ApplePlatform.swift +++ b/apple/aesel/Sources/ApplePlatform.swift @@ -160,7 +160,7 @@ private struct AeselWindowTitle: NSViewRepresentable { let title: String let paper: Color final class Carrier: NSView { - var pieceTitle = "Aesel" + var pieceTitle = "" var paperColor = NSColor.clear override func viewDidMoveToWindow() { super.viewDidMoveToWindow() diff --git a/apple/aesel/Sources/ContentView.swift b/apple/aesel/Sources/ContentView.swift index 406f25fe89..95ff619b83 100644 --- a/apple/aesel/Sources/ContentView.swift +++ b/apple/aesel/Sources/ContentView.swift @@ -20,6 +20,7 @@ struct ContentView: View { @State private var showExport = false @State private var fileNotice: String? @State private var showSettings = false + @State private var showVolume = false @State private var braincells = Braincells() @StateObject private var preview = PiecePreview() @State private var expandedPreview = false @@ -57,6 +58,7 @@ struct ContentView: View { "height": max(0, previewBounds.top + previewBounds.height + 8 + 16 - notebookTop), "top": max(0, previewBounds.top - 16 - notebookTop)] } + private var speakerVisible: Bool { preview.hasAudio || preview.volume == 0 || showVolume } private var compact: Bool { sheetSize.width < 340 || sheetSize.height < 240 } /// Slab's palette for what the session is doing right now. @@ -75,6 +77,7 @@ struct ContentView: View { AeselHomeView(session: session, host: host) { showHome = false } } } + .overlay(alignment: .bottomLeading) { connectionNotices } .overlay { if showSettings { settingsPane } } .font(Paint.font()) .buttonStyle(AeselButtonStyle()) @@ -191,6 +194,14 @@ struct ContentView: View { } .coordinateSpace(name: "notebook-scroll") .aeselKeyboardScrolling() + .mask(alignment: .top) { + VStack(spacing: 0) { + let fade = min(1, max(0, (paperTop - notebookTop) / row)) + LinearGradient(colors: [.black.opacity(1 - fade), .black], startPoint: .top, endPoint: .bottom) + .frame(height: row) + Rectangle().fill(.black) + } + } .overlay(alignment: .topTrailing) { if previewVisible { previewBox(container: geometry.size) } } @@ -215,7 +226,8 @@ struct ContentView: View { private var headerControls: some View { GeometryReader { geometry in HStack(spacing: 12) { - title(availableWidth: max(0, geometry.size.width - edgeInset * 2 - 24 - 48)) + title(availableWidth: max(0, geometry.size.width - edgeInset * 2 - 24 - 48 - (speakerVisible ? 36 : 0))) + if speakerVisible { speakerControl } Spacer(minLength: 8) .frame(height: 32) .background { AeselWindowDragArea() } @@ -226,19 +238,69 @@ struct ContentView: View { } } + private var speakerControl: some View { + Button { showVolume.toggle() } label: { + Image(systemName: preview.volume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill") + .font(.system(size: 12)).frame(width: 24, height: 24) + } + .buttonStyle(AeselButtonStyle()) + .accessibilityLabel("Volume") + .popover(isPresented: $showVolume) { + HStack(spacing: 10) { + Button { preview.volume = preview.volume == 0 ? 1 : 0 } label: { + Image(systemName: preview.volume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill") + }.accessibilityLabel(preview.volume == 0 ? "Unmute" : "Mute") + Slider(value: $preview.volume, in: 0...1).tint(paint.accent).accessibilityLabel("Volume") + } + .padding(14).frame(width: 210) + .foregroundStyle(paint.ink).background(paint.bg) + .buttonStyle(AeselButtonStyle()).presentationCompactAdaptation(.popover) + } + } + + private var connectionNotices: some View { + VStack(alignment: .leading, spacing: 6) { + ForEach(session.notices.keys.sorted(), id: \.self) { scope in + if let notice = session.notices[scope] { + HStack(spacing: 10) { + Text(notice.text).font(Paint.font(13)).fixedSize(horizontal: false, vertical: true) + if let action = notice.action { + Button(action == "publish" ? "Retry" : "Sign in") { + if action == "publish" { host.publish() } else { host.signIn() } + } + } + Button { session.notices[scope] = nil } label: { + Image(systemName: "xmark").font(.system(size: 10)).frame(width: 24, height: 24) + }.accessibilityLabel("Dismiss status") + } + .padding(.horizontal, 10).padding(.vertical, 6) + .background(paint.bg, in: RoundedRectangle(cornerRadius: 8)) + .overlay { RoundedRectangle(cornerRadius: 8).stroke(paint.ink.opacity(0.3), lineWidth: 1) } + .task(id: notice.id) { + guard !notice.working else { return } + try? await Task.sleep(for: .seconds(15)) + if !Task.isCancelled && session.notices[scope]?.id == notice.id { session.notices[scope] = nil } + } + } + } + }.padding(12).frame(maxWidth: 460, alignment: .leading) + } + private var titleURL: URL? { session.shareURL ?? (session.route.isEmpty ? nil : URL(string: "https://aesthetic.computer/")?.appendingPathComponent(session.route)) } - private func title(availableWidth: CGFloat) -> some View { - Button { if let url = titleURL { openURL(url) } } label: { - AeselTitle(text: session.route.isEmpty ? "aesel" : session.route, colors: session.handleColors, size: compact ? 12 : 16, - maximumWidth: availableWidth, horizontalInset: 0, - hoverSound: { AeselHoverSound.play(project: session.route, revision: session.currentRevision, control: "title") }) + @ViewBuilder private func title(availableWidth: CGFloat) -> some View { + if !session.route.isEmpty { + Button { if let url = titleURL { openURL(url) } } label: { + AeselTitle(text: session.route, colors: session.handleColors, size: compact ? 12 : 16, + maximumWidth: availableWidth, horizontalInset: 0, + hoverSound: { AeselHoverSound.play(project: session.route, revision: session.currentRevision, control: "title") }) + } + .buttonStyle(AeselButtonStyle()) + .accessibilityLabel("Open piece in browser") + .frame(height: row) } - .buttonStyle(AeselButtonStyle()) - .accessibilityLabel("Open piece in browser") - .frame(height: row) } /// The corner piece keeps the original desktop wood grain and resize edges. @@ -288,8 +350,13 @@ struct ContentView: View { .frame(height: max(composerHeight, minHeight)) if session.busy { Button { host.stop() } label: { - Text("■").font(Paint.font(16)).foregroundStyle(paint.you).frame(width: row, height: row) + RoundedRectangle(cornerRadius: 1.5).fill(.white).frame(width: 8, height: 8) + .frame(width: row, height: row) + .background(Color(red: 0.94, green: 0.325, blue: 0.314), in: Circle()) + .overlay { Circle().stroke(.white.opacity(0.25), lineWidth: 0.5) } + .shadow(color: .black.opacity(0.3), radius: 1, y: 1) } + .buttonStyle(AeselStopButtonStyle()) .accessibilityLabel("Stop") } } @@ -364,16 +431,6 @@ struct ContentView: View { if session.hostOperationID != nil && !session.busy { settingsItem("Reconnect to current turn") { host.resumeHostTurn() } } - VStack(alignment: .leading, spacing: 8) { - Text("Preview volume").font(Paint.font(13)).foregroundStyle(paint.dim) - HStack(spacing: 10) { - Slider(value: $preview.volume, in: 0...1).tint(paint.accent).accessibilityLabel("Preview volume") - Button { preview.volume = preview.volume == 0 ? 1 : 0 } label: { - Image(systemName: preview.volume == 0 ? "speaker.slash" : "speaker.wave.2") - .frame(width: 36, height: 32).background(paint.accent.opacity(0.18), in: RoundedRectangle(cornerRadius: 6)) - }.accessibilityLabel(preview.volume == 0 ? "Unmute preview" : "Mute preview") - } - } Rectangle().fill(paint.ink.opacity(0.16)).frame(height: 1) AeselVersionList(session: session, host: host) }.padding(.horizontal, 20).padding(.bottom, 20) @@ -444,7 +501,7 @@ struct ContentView: View { VStack(alignment: .leading, spacing: 24) { AeselWordmark() Text("describe a picture, a sound, or a little world. aesel writes it while you watch.") - Text("/new start a piece\n/publish share your piece\n/open open in your browser\n/login your AC account") + Text("/new start a piece\n/publish share your piece\n/open open in your browser\n/login your AC account\n/logout sign out") .foregroundStyle(paint.dim) Button("/close") { showHelp = false }.foregroundStyle(paint.you) Spacer() @@ -458,15 +515,16 @@ struct ContentView: View { private func send() { let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } - switch text { - case "/new", "/home": writing = false; showHome = true - case "/publish": host.publish() - case "/login": host.signIn() - case "/logout": host.signOut() - case "/open": if let url = session.shareURL { openURL(url) } - case "/settings": openSettings() - case "/buy": openSettings(); Task { await braincells.buy() } - case "/help": showHelp = true + switch Session.command(for: text) { + case "new", "home": writing = false; showHome = true + case "publish": host.publish() + case "login": host.signIn() + case "logout": host.signOut() + case "stop": host.stop() + case "open": if let url = session.shareURL { openURL(url) } + case "settings": openSettings() + case "buy": openSettings(); Task { await braincells.buy() } + case "help": showHelp = true default: if session.provider == "ac" && !session.signedIn { host.signIn(); return } guard session.canStartTurn else { openSettings(); return } diff --git a/apple/aesel/Sources/Session.swift b/apple/aesel/Sources/Session.swift index 2bb2787b06..f59f3c5209 100644 --- a/apple/aesel/Sources/Session.swift +++ b/apple/aesel/Sources/Session.swift @@ -43,6 +43,13 @@ struct ProviderApproval: Identifiable { let detail: String } +struct SessionNotice: Identifiable { + let id = UUID() + let text: String + let action: String? + let working: Bool +} + struct InspectedRevision { let version: Int; let source: String } struct SourceRevision: Identifiable { @@ -65,6 +72,19 @@ struct SessionSummary: Identifiable { /// something a view can draw. @Observable final class Session { + static func command(for text: String) -> String? { + let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let aliases = ["logout":"logout", "log out":"logout", "signout":"logout", "sign out":"logout", + "login":"login", "log in":"login", "signin":"login", "sign in":"login", + "settings":"settings", "help":"help", "stop":"stop", "publish":"publish", + "open":"open", "new":"new", "home":"home"] + if normalized.hasPrefix("/") { + let name = String(normalized.dropFirst()) + return name == "buy" ? "buy" : aliases[name] + } + return aliases[normalized] + } + static let draftPreviewURL = embeddedPreviewURL(URL(string: "https://aesthetic.computer/wipe?noauth=true")!) static func embeddedPreviewURL(_ url: URL) -> URL { guard var target = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url } @@ -75,6 +95,7 @@ final class Session { target.queryItems = query return target.url ?? url } + var notices: [String: SessionNotice] = [:] var entries: [Entry] = [] var history: [SessionSummary] = [] var medium = "piece" @@ -127,6 +148,7 @@ final class Session { private var streamingIndex: Int? func reset() { + notices.removeAll() entries.removeAll() streamingIndex = nil } @@ -261,6 +283,11 @@ final class Session { previewURL = Self.draftPreviewURL } + case "notice": + let scope = event["scope"] as? String ?? "connection" + let text = event["text"] as? String ?? "" + notices[scope] = text.isEmpty ? nil : SessionNotice(text: text, action: event["action"] as? String, working: event["working"] as? Bool ?? false) + case "status": status = event["text"] as? String ?? status health = Self.health(event["kind"] as? String) @@ -275,7 +302,9 @@ final class Session { append(.note, event["text"] as? String ?? "") case "bad": - append(.bad, event["text"] as? String ?? "") + let text = event["text"] as? String ?? "" + // Old transport failures stay in the saved history, not on the paper. + if !text.hasPrefix("Publish failed at") && text != "Load failed" { append(.bad, text) } health = .failed case "bridge": @@ -338,7 +367,10 @@ final class Session { let turn = params["turn"] as? [String: Any] ?? [:] if let error = turn["error"] as? [String: Any], let message = error["message"] as? String { - append(.bad, message) + if message != "Load failed" { append(.bad, message) } + status = "failed" + health = .failed + } else if turn["status"] as? String == "failed" { status = "failed" health = .failed } else if turn["status"] as? String == "interrupted" { diff --git a/apple/aesel/Sources/SessionHost.swift b/apple/aesel/Sources/SessionHost.swift index cf98780df9..76b7c9a383 100644 --- a/apple/aesel/Sources/SessionHost.swift +++ b/apple/aesel/Sources/SessionHost.swift @@ -245,6 +245,9 @@ extension SessionHost: WKScriptMessageHandler { catch { session.fatal = "Your latest changes could not be saved. " + error.localizedDescription } return } + if type == "diagnostic" { + NSLog("[aesel] %@ at %@: %@", body["operation"] as? String ?? "request", body["step"] as? String ?? "response", body["message"] as? String ?? "unknown failure") + } session.receive(body) } } diff --git a/apple/aesel/Tests/SessionNoticeChecks.swift b/apple/aesel/Tests/SessionNoticeChecks.swift new file mode 100644 index 0000000000..0c20628f9f --- /dev/null +++ b/apple/aesel/Tests/SessionNoticeChecks.swift @@ -0,0 +1,26 @@ +import Foundation + +@main struct SessionNoticeChecks { + static func main() { + for text in ["logout", "/logout", "LOG OUT", " sign out "] { precondition(Session.command(for: text) == "logout") } + precondition(Session.command(for: "Log in") == "login") + for text in ["make a logout button", "stop the apple bouncing", "publish a painting", "buy"] { precondition(Session.command(for: text) == nil) } + let session = Session() + session.receive(["type":"notice", "scope":"publish", "text":"Retrying…", "working":true]) + precondition(session.entries.isEmpty && session.notices["publish"]?.working == true) + session.receive(["type":"notice", "scope":"publish", "text":"Saved locally", "action":"publish"]) + precondition(session.notices.count == 1 && session.notices["publish"]?.action == "publish") + session.receive(["type":"notice", "scope":"inference", "text":"Connection interrupted"]) + session.receive(["type":"notice", "scope":"publish", "text":""]) + precondition(session.notices["publish"] == nil && session.notices["inference"] != nil) + session.receive(["type":"bridge", "method":"turn/completed", "params":["turn":["status":"failed"]]]) + precondition(session.entries.isEmpty && session.health == .failed) + session.receive(["type":"thread", "id":"restored", "events":[ + ["type":"you", "text":"Keep my piece"], + ["type":"bad", "text":"Publish failed at \"requesting upload grant\": Load failed"], + ["type":"bridge", "method":"turn/completed", "params":["turn":["status":"failed", "error":["message":"Load failed"]]]] + ]]) + precondition(session.notices.isEmpty && session.entries.count == 1 && session.entries[0].text == "Keep my piece") + print("Notices replace in place, remain outside the notebook, clear on success/thread changes, and suppress legacy transport spam.") + } +} diff --git a/apple/aesel/Tests/preview-bridge.test.mjs b/apple/aesel/Tests/preview-bridge.test.mjs index a100a390c6..17fd804546 100644 --- a/apple/aesel/Tests/preview-bridge.test.mjs +++ b/apple/aesel/Tests/preview-bridge.test.mjs @@ -11,7 +11,7 @@ test('native source injection carries view flags without forwarding auth paramet const sent=[],ticks=[]; const window={preloaded:true,__aeselSource:'export function paint({wipe}) { wipe("orange"); }',acSEND:message=>sent.push(message),addEventListener(){},webkit:{messageHandlers:{previewFailure:{postMessage(){}}}}}; vm.runInNewContext(script,{window,location:{search:'?noauth=true&nolabel=true&preview&code=private&state=private'},URLSearchParams,setInterval:fn=>(ticks.push(fn),1),clearInterval(){},setTimeout(){}}); - ticks[0]();ticks[0](); + ticks[1]();ticks[1](); assert.equal(sent.length,1); assert.equal(sent[0].type,'dropped:piece'); const flags=new URLSearchParams(sent[0].content.search); @@ -35,3 +35,16 @@ test('a late ready event recovers the startup timeout and injects the saved piec events.error({message:'A later runtime error'}); assert.equal(messages[2],'A later runtime error'); }); + +test('speaker indicator follows audio, holds musical rests and remains reachable while muted',()=>{ + let now=0,samples=[];const ticks=[],reported=[]; + const window={__aeselSource:'piece',__aeselVolume:1,AC:{readOutputWaveform:()=>samples},addEventListener(){},webkit:{messageHandlers:{previewAudio:{postMessage:m=>reported.push(m)}}}}; + vm.runInNewContext(script,{window,location:{search:''},URLSearchParams,performance:{now:()=>now},setInterval:fn=>ticks.push(fn),clearInterval(){},setTimeout(){}}); + const tick=ticks[0];tick();assert.equal(reported.length,0); + samples=[0.1];tick();assert.equal(reported.at(-1).active,true); + samples=[];now=1000;tick();assert.equal(reported.length,1); + window.__aeselVolume=0;now=4000;tick();assert.equal(reported.length,1); + window.__aeselVolume=1;tick();assert.equal(reported.at(-1).active,false); + window.__aeselSource='new piece';tick();assert.equal(reported.length,2); + samples=[0.2];tick();assert.equal(reported.at(-1).source,'new piece'); +}); diff --git a/easel/phone/session.mjs b/easel/phone/session.mjs index 79f53f7c90..b2ffbe31f6 100644 --- a/easel/phone/session.mjs +++ b/easel/phone/session.mjs @@ -16,6 +16,7 @@ import { AcServer, DEFAULT_AC_MODEL } from "/easel/src/ac-server.mjs"; import { fetchHandleColors, handleCharacterColors } from "/easel/src/handle-colors.mjs"; +import { isTransientNetworkError } from "/easel/src/network.mjs"; import { publishPiece } from "/easel/src/publish.mjs"; import * as vfs from "/easel/phone/shim/fs.mjs"; import { createCredits } from "./credits.mjs"; @@ -88,7 +89,7 @@ const memoryStore = () => { }; }; -export function createSession({ storage = memoryStore(), emit = () => {}, hostRPC = null } = {}) { +export function createSession({ storage = memoryStore(), emit = () => {}, hostRPC = null, retryOptions = {} } = {}) { const state = { token: "", handle: "", @@ -386,20 +387,22 @@ export function createSession({ storage = memoryStore(), emit = () => {}, hostRP async function publish() { if (state.publishing) return state.publishing; if (!state.token || !state.handle) { - say("note", { text: "Sign in with an AC @handle to publish." }); + say("notice", {scope:"publish", text: "Sign in with an Aesthetic.Computer @handle to publish.", action:"signIn" }); return; } state.dirty = false; const snapshot={source:vfs.readFileSync(state.file),handle:state.handle,token:state.token, - version:state.revisions.at(-1)?.version ?? 0,threadID:state.id}; + version:state.revisions.at(-1)?.version ?? 0,threadID:state.id,file:state.file,slug:state.slug}; let lastStep = "starting"; state.publishing = (async () => { try { - say("status", { text: "publishing", kind: "working" }); + say("notice", {scope:"publish", text:"Publishing…", working:true}); const result=await publishPiece({version:null,source:snapshot.source, - file:state.file,slug:state.slug,cwd:"/piece",site:SITE, + file:snapshot.file,slug:snapshot.slug,cwd:"/piece",site:SITE, session:{handle:snapshot.handle,signedIn:true,token:async()=>snapshot.token},fetch:browserFetch, - onStep:step=>{lastStep=step;say("status",{text:step,kind:"working"});}, + retryOptions, + onRetry:({attempt})=>say("notice",{scope:"publish",text:`Connection interrupted—retrying upload (${attempt}/2)…`,working:true}), + onStep:step=>{lastStep=step;}, }); if(!result.verified)throw new Error("Upload sent, but its public bytes could not be verified. Retry Publish to verify the saved source."); if(state.id!==snapshot.threadID)return; @@ -407,14 +410,20 @@ export function createSession({ storage = memoryStore(), emit = () => {}, hostRP state.published=state.handle===snapshot.handle && vfs.readFileSync(state.file)===snapshot.source; state.owner=snapshot.handle; write({published:state.published});saveCurrent();reportRevisions(); + say("notice",{scope:"publish",text:""}); say("publication",{url:state.published?result.route:null}); if(state.published) { say("status",{text:"live",kind:"live"}); say("preview",{url:pieceUrl()}); } } catch(error) { - say("bad",{text:`Publish failed at "${lastStep}": ${error.message}`}); - say("status",{text:"not published",kind:"failed"}); + if(state.id!==snapshot.threadID)return; + say("diagnostic",{operation:"publish",step:lastStep,message:error.message}); + const auth=error.status===401 || error.status===403; + const text=isTransientNetworkError(error) ? "Upload paused. Your piece is saved here." : + auth ? "Sign in again to publish. Your piece is saved here." : + lastStep==="verifying" ? "Your piece is saved here; publication could not be verified." : "Couldn’t publish: "+error.message; + say("notice",{scope:"publish",text,action:auth?"signIn":"publish"}); } finally { state.publishing=null; if(state.dirty && state.autoPublish)void publish(); @@ -464,6 +473,12 @@ export function createSession({ storage = memoryStore(), emit = () => {}, hostRP } server.on("notification", ({ method, params }) => { if (method === "model/reported") say("model", {requested: params.requested, reported: params.reported}); + if(method==="turn/completed" && params.turn?.error) { + const error=params.turn.error; + say("diagnostic",{operation:"inference",message:error.message}); + say("notice",{scope:"inference",text:error.network ? "Connection interrupted. Your saved changes are safe. Send a message to continue." : error.message}); + params={...params,turn:{...params.turn,error:undefined}}; + } say("bridge", { method, params }); }); return server; @@ -541,6 +556,7 @@ export function createSession({ storage = memoryStore(), emit = () => {}, hostRP say("bad", { text: "This provider is not connected. Refresh the connection in Settings." }); return; } if (!state.title || state.title === state.slug) state.title = text.trim().slice(0, 120); + say("notice",{scope:"inference",text:""}); say("you", { text }); state.busy = true; say("busy", { busy: true }); diff --git a/easel/src/ac-server.mjs b/easel/src/ac-server.mjs index 6e3e2a0595..7c20dc555b 100644 --- a/easel/src/ac-server.mjs +++ b/easel/src/ac-server.mjs @@ -1,3 +1,4 @@ +import {withNetworkDeadline, isTransientNetworkError} from "./network.mjs"; import {bundledContext} from './piece-context.mjs'; import {PIECE_VISUAL,PIECE_RESPONSIVE,PIECE_CLOCK,PIECE_SOUND} from './piece-prompt.mjs'; import {SETTINGS_TOOL,PIECE_INSTRUCTIONS} from './harness-contract.mjs'; @@ -85,6 +86,7 @@ export class AcServer extends EventEmitter { fetch = globalThis.fetch, site = SITE, jev = configuredJev(), + networkTimeouts = {}, } = {}) { super(); this.cwd = cwd; @@ -99,6 +101,7 @@ export class AcServer extends EventEmitter { this.fetch = fetch; this.site = site; this.jev = jev; + this.networkTimeouts = {connect:45000, idle:60000, ...networkTimeouts}; this.threadId = resumeThreadId || ""; this.turnId = null; this.turns = 0; @@ -223,7 +226,7 @@ export class AcServer extends EventEmitter { turn: { ...turn, status: aborted ? "interrupted" : "failed", - error: aborted ? undefined : { message: error.message }, + error: aborted ? undefined : { message: error.message, network: isTransientNetworkError(error) }, }, }, }); @@ -268,7 +271,9 @@ export class AcServer extends EventEmitter { if(last?.role==='user')messages[messages.length-1]={...last,content:[...(Array.isArray(last.content)?last.content:[{type:'text',text:last.content}]),diagnostic]}; else messages.push({role:'user',content:[diagnostic]}); } - const response = await this.fetch(`${this.site}/api/easel-inference`, { + const slowConnection = setTimeout(() => this.emit("notification", {method:"turn/progress",params:{phase:"waiting for Aesthetic.Computer"}}), 8000); + let response; + try { response = await withNetworkDeadline(() => this.fetch(`${this.site}/api/easel-inference`, { method: "POST", signal: controller.signal, headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, @@ -279,7 +284,8 @@ export class AcServer extends EventEmitter { tools, max_tokens: 8192, }), - }); + }), {controller, timeoutMs:this.networkTimeouts.connect}); } + finally { clearTimeout(slowConnection); } if (!response.ok) { let message = `inference failed (HTTP ${response.status})`; @@ -314,7 +320,7 @@ export class AcServer extends EventEmitter { try { for (;;) { controller.signal.throwIfAborted(); - const { done, value } = await reader.read(); + const { done, value } = await withNetworkDeadline(() => reader.read(), {controller, timeoutMs:this.networkTimeouts.idle}); controller.signal.throwIfAborted(); if (done) break; received += value.byteLength; @@ -532,7 +538,7 @@ export class AcServer extends EventEmitter { return { type: "tool_result", tool_use_id: block.id, - content: "Saved. It is live for anyone watching, and published if auto-publish is on.", + content: "Saved to the local preview. Publication runs separately; do not claim it is published without confirmation.", }; } catch (error) { this.emit("notification", { diff --git a/easel/src/network.mjs b/easel/src/network.mjs new file mode 100644 index 0000000000..d19096003e --- /dev/null +++ b/easel/src/network.mjs @@ -0,0 +1,33 @@ +// Retry only idempotent transfers. Inference uses deadlines without replaying a paid request. +export function isTransientNetworkError(error) { + return error?.name === 'TimeoutError' || error?.status === 408 || error?.status === 429 || error?.status >= 500 || + /^(Load failed|Failed to fetch|fetch failed|NetworkError|Network request failed)/i.test(error?.message || ''); +} + +export function httpError(message, status) { + return Object.assign(new Error(message), {status}); +} + +export async function withNetworkDeadline(operation, {controller = new AbortController(), timeoutMs = 20000} = {}) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + const error = Object.assign(new Error('The connection timed out.'), {name: 'TimeoutError'}); + reject(error); + controller.abort(error); + }, timeoutMs); + }); + try { return await Promise.race([operation(controller.signal), timeout]); } + finally { clearTimeout(timer); } +} + +export async function retryNetwork(operation, {delays = [1000, 3000], sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), onRetry = () => {}, timeoutMs = 20000} = {}) { + for (let attempt = 0; ; attempt++) { + try { return await withNetworkDeadline(operation, {timeoutMs}); } + catch (error) { + if (!isTransientNetworkError(error) || attempt >= delays.length) throw error; + onRetry({attempt: attempt + 1, delay: delays[attempt]}); + await sleep(delays[attempt]); + } + } +} diff --git a/easel/src/publish.mjs b/easel/src/publish.mjs index 3cb5700125..3a9ede41ca 100644 --- a/easel/src/publish.mjs +++ b/easel/src/publish.mjs @@ -1,3 +1,4 @@ +import {retryNetwork, httpError} from "./network.mjs"; import {createHash} from 'node:crypto'; import { PieceRevisions, validatePieceSource } from "./revisions.mjs"; // publish.mjs — put a piece live under the signed-in user's @handle. @@ -56,6 +57,8 @@ export async function publishPiece({ cwd = process.cwd(), site = SITE, onStep = () => {}, + onRetry = () => {}, + retryOptions = {}, source: snapshot, // Hosts without a desktop revision ledger can explicitly supply null. version: snapshotVersion, @@ -80,28 +83,34 @@ export async function publishPiece({ await validatePieceSource(source, plan.path); const token = await session.token(); onStep("requesting upload grant"); - const presign = await fetch(plan.grantUrl, { - headers: { Authorization: `Bearer ${token}`, "User-Agent": USER_AGENT, Accept: "application/json" }, + const retry = operation => retryNetwork(operation, {...retryOptions, onRetry}); + const grant = await retry(async signal => { + const presign = await fetch(plan.grantUrl, { + signal, headers: { Authorization: `Bearer ${token}`, "User-Agent": USER_AGENT, Accept: "application/json" }, + }); + const grant = await presign.json().catch(error => { if (error instanceof SyntaxError) return {}; throw error; }); + if (!presign.ok || !grant.uploadURL) { + throw httpError(grant.error || grant.message || `upload grant failed (HTTP ${presign.status})`, presign.status); + } + return grant; }); - const grant = await presign.json().catch(() => ({})); - if (!presign.ok || !grant.uploadURL) { - throw new Error(grant.error || grant.message || `upload grant failed (HTTP ${presign.status})`); - } onStep("uploading"); - const put = await fetch(grant.uploadURL, { - method: "PUT", - headers: { "Content-Type": plan.mime, "Content-Disposition": "inline", "x-amz-acl": "public-read" }, - body: source, + await retry(async signal => { + const put = await fetch(grant.uploadURL, { + signal, method: "PUT", + headers: { "Content-Type": plan.mime, "Content-Disposition": "inline", "x-amz-acl": "public-read" }, + body: source, + }); + if (!put.ok) throw httpError(`upload failed (HTTP ${put.status})`, put.status); }); - if (!put.ok) throw new Error(`upload failed (HTTP ${put.status})`); onStep("verifying"); - let verified = false; - try { - const check = await fetch(plan.mediaUrl, { headers: { "User-Agent": USER_AGENT }, redirect: "follow" }); - verified = check.ok && (await check.text()).trim() === source.trim(); - } catch {} + const verified = await retry(async signal => { + const check = await fetch(plan.mediaUrl, { signal, headers: { "User-Agent": USER_AGENT }, redirect: "follow" }); + if (!check.ok) throw httpError(`verification failed (HTTP ${check.status})`, check.status); + return (await check.text()).trim() === source.trim(); + }); let registration=null; const savedVersion=snapshotVersion === undefined ? new PieceRevisions(plan.path).list().findLast(v=>v.revision===createHash('sha256').update(source).digest('hex'))?.version : snapshotVersion; diff --git a/easel/test/ac-server.test.mjs b/easel/test/ac-server.test.mjs index 5f2cd4b02c..c8cf6b873a 100644 --- a/easel/test/ac-server.test.mjs +++ b/easel/test/ac-server.test.mjs @@ -331,3 +331,18 @@ test('hosted settings tool returns its result without writing the piece or inter assert.equal(events.findLast(event=>event.method==='turn/completed').params.turn.status,'completed'); assert(!events.some(event=>event.params?.item?.type==='fileChange')); }); + +test('stalled AC headers and streams time out without replaying paid requests', async () => { + for (const stage of ['headers','stream']) { + let requests=0,signal,completed; + const engine=new AcServer({token:async()=>'test',jev:null,networkTimeouts:{connect:10,idle:10},fetch:async(_,options)=>{ + requests++;signal=options.signal; + if(stage==='headers')return new Promise(()=>{}); + return {ok:true,body:{getReader:()=>({read:()=>new Promise(()=>{}),cancel:async()=>{}})}}; + }}); + engine.on('notification',({method,params})=>{if(method==='turn/completed')completed=params.turn;}); + await engine.startTurn('continue'); + assert.equal(requests,1);assert.equal(signal.aborted,true); + assert.equal(completed.status,'failed');assert.equal(completed.error.network,true); + } +}); diff --git a/easel/test/network.test.mjs b/easel/test/network.test.mjs new file mode 100644 index 0000000000..3df3d71a1a --- /dev/null +++ b/easel/test/network.test.mjs @@ -0,0 +1,17 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {retryNetwork, httpError} from '../src/network.mjs'; + +test('transient failures retry twice; authorization and source errors do not retry',async()=>{ + for(const [error,count] of [[new TypeError('Load failed'),3],[httpError('unavailable',503),3],[httpError('sign in',401),1],[new Error('invalid source'),1]]) { + let calls=0;const waits=[]; + await assert.rejects(retryNetwork(()=>{calls++;throw error;},{sleep:async ms=>waits.push(ms)})); + assert.equal(calls,count);assert.equal(waits.length,count-1); + } +}); + +test('stalled transfers abort and exhaust a bounded number of attempts',async()=>{ + const signals=[]; + await assert.rejects(retryNetwork(signal=>{signals.push(signal);return new Promise(()=>{});},{timeoutMs:5,sleep:async()=>{}}),{name:'TimeoutError'}); + assert.equal(signals.length,3);assert(signals.every(signal=>signal.aborted)); +}); diff --git a/easel/test/phone-session.test.mjs b/easel/test/phone-session.test.mjs index 88c18db683..c086061b84 100644 --- a/easel/test/phone-session.test.mjs +++ b/easel/test/phone-session.test.mjs @@ -150,7 +150,7 @@ test('publication never marks newer draft or unverified bytes public',async()=>{ assert.equal(events.some(e=>e.type==='preview'),false); globalThis.fetch=async(url,options={})=>url.includes('/presigned-upload-url/')?Response.json({uploadURL:'https://upload.test/piece'}):new Response(options.method==='PUT'?'':'different source'); await session.publish();assert.equal(session.state.published,false); - assert.equal(events.some(e=>e.type==='bad' && e.text.includes('could not be verified')),true); + assert.equal(events.some(e=>e.type==='notice' && e.text.includes('could not be verified')),true); }finally{globalThis.fetch=originalFetch;} }); @@ -246,3 +246,34 @@ test('connected Claude and Codex update signed-out drafts without AC inference o assert.deepEqual(network, []); } finally { globalThis.fetch = originalFetch; globalThis.__aeselGuides = previousGuides; } }); + +test('upload recovery is ephemeral, preserves the draft, and never retries authorization',async()=>{ + const originalFetch=globalThis.fetch; + try { + for(const mode of ['recover','offline','auth']) { + const events=[];let grants=0,putSource; + const session=createSession({emit:e=>events.push(e),retryOptions:{sleep:async()=>{}}}); + await session.open();session.setAutoPublish(false); + const source='export function paint(){return "saved"}';await session.editSource(source); + session.state.token='test';session.state.handle='test'; + globalThis.fetch=async(url,options={})=>{ + if(url.includes('/presigned-upload-url/')) { + grants++;assert(!Object.keys(options.headers).some(key=>key.toLowerCase()==='user-agent')); + if(mode==='auth')return Response.json({error:'expired'},{status:401}); + if(mode==='offline'||grants===1)throw new TypeError('Load failed'); + return Response.json({uploadURL:'https://upload.test/piece'}); + } + if(options.method==='PUT'){putSource=options.body;return new Response('');} + return new Response(putSource); + }; + await session.publish(); + assert.equal(grants,mode==='auth'?1:mode==='offline'?3:2); + assert.equal(session.state.published,mode==='recover'); + assert.equal(session.state.revisions.at(-1).source,source); + assert(!session.state.transcript.some(e=>e.type==='notice'||e.type==='bad')); + const notice=events.filter(e=>e.type==='notice').at(-1); + if(mode==='recover')assert.equal(notice.text,''); + else {assert.equal(notice.action,mode==='auth'?'signIn':'publish');assert(!notice.text.includes('Load failed'));} + } + } finally {globalThis.fetch=originalFetch;} +}); -- 2.51.2