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 @@
-
\ No newline at end of file
+
\ 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;}
+});