diff --git a/Cargo.lock b/Cargo.lock
index d8c6b77dd..6de5e3279 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -20,7 +20,7 @@ checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954"
dependencies = [
"accesskit",
"accesskit_consumer 0.36.0",
- "atspi-common",
+ "atspi-common 0.13.0",
"phf",
"serde",
"zvariant",
@@ -81,7 +81,7 @@ dependencies = [
"async-channel",
"async-executor",
"async-task",
- "atspi",
+ "atspi 0.29.0",
"futures-lite",
"futures-util",
"serde",
@@ -673,8 +673,20 @@ version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d"
dependencies = [
- "atspi-common",
- "atspi-proxies",
+ "atspi-common 0.13.0",
+ "atspi-proxies 0.13.0",
+]
+
+[[package]]
+name = "atspi"
+version = "0.30.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6bf601cccedfffec598ec2db1f9d6745885458bccc0e8916d7023f017c94b3d0"
+dependencies = [
+ "atspi-common 0.14.0",
+ "atspi-connection",
+ "atspi-proxies 0.14.0",
+ "zbus",
]
[[package]]
@@ -693,13 +705,52 @@ dependencies = [
"zvariant",
]
+[[package]]
+name = "atspi-common"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8a79bed3f5b408ce3152f36e07327a845e6ed5d7e2821a89264037dbcc11daf"
+dependencies = [
+ "enumflags2",
+ "serde",
+ "static_assertions",
+ "zbus",
+ "zbus-lockstep",
+ "zbus-lockstep-macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "atspi-connection"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fab8e4f574f5a7d3af280b38eff25fb6f47a537dac9ae39ce152f52b19fb10b"
+dependencies = [
+ "atspi-common 0.14.0",
+ "atspi-proxies 0.14.0",
+ "futures-lite",
+ "zbus",
+]
+
[[package]]
name = "atspi-proxies"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc"
dependencies = [
- "atspi-common",
+ "atspi-common 0.13.0",
+ "serde",
+ "zbus",
+]
+
+[[package]]
+name = "atspi-proxies"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53403acd3ab2fdb5914f6558da22e540fc07656fce5510f8c02be0e6ef68413e"
+dependencies = [
+ "atspi-common 0.14.0",
"serde",
"zbus",
]
@@ -3374,7 +3425,7 @@ dependencies = [
"wayland-protocols-plasma",
"wayland-protocols-wlr",
"x11-clipboard",
- "x11rb",
+ "x11rb 0.13.2",
"xkbcommon",
"zed-scap",
"zed-xim",
@@ -11159,7 +11210,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3"
dependencies = [
"libc",
- "x11rb",
+ "x11rb 0.13.2",
]
[[package]]
@@ -11172,16 +11223,33 @@ dependencies = [
"gethostname",
"libc",
"rustix 1.1.4",
- "x11rb-protocol",
+ "x11rb-protocol 0.13.2",
"xcursor",
]
+[[package]]
+name = "x11rb"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a8885a854a8bfdf87a301e53e41b17c5f8f33639903131338b997b1eb614f44"
+dependencies = [
+ "gethostname",
+ "rustix 1.1.4",
+ "x11rb-protocol 0.14.0",
+]
+
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
+[[package]]
+name = "x11rb-protocol"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acf4d1bc32aa46eec18caa634ec3cf4c05bfa151f12b93b510b15190f69a1ca8"
+
[[package]]
name = "x25519-dalek"
version = "2.0.1"
@@ -11532,7 +11600,7 @@ dependencies = [
"ahash",
"hashbrown 0.14.5",
"log",
- "x11rb",
+ "x11rb 0.13.2",
"xim-ctext",
"xim-parser",
]
@@ -11830,11 +11898,18 @@ version = "0.2.61"
dependencies = [
"alacritty_terminal",
"anyhow",
+ "ashpd",
+ "async-io",
"async-trait",
+ "atspi 0.30.0",
"base64 0.22.1",
+ "block",
"block2 0.6.2",
"chrono",
+ "core-foundation 0.10.1",
+ "core-graphics 0.24.0",
"fontdb",
+ "foreign-types",
"futures",
"gpui",
"gpui-base",
@@ -11849,9 +11924,11 @@ dependencies = [
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"objc2-web-kit",
+ "png 0.18.1",
"pulldown-cmark",
"raw-window-handle",
"reqwest",
+ "roxmltree 0.21.1",
"serde",
"serde_json",
"sha2",
@@ -11868,6 +11945,7 @@ dependencies = [
"usvg",
"uuid",
"wry",
+ "x11rb 0.14.0",
"zeron-doc",
"zeron-engine",
"zeron-harness",
diff --git a/apps/ios/Zeron/App/DemoDataset.swift b/apps/ios/Zeron/App/DemoDataset.swift
index a86c99d5a..710d77d77 100644
--- a/apps/ios/Zeron/App/DemoDataset.swift
+++ b/apps/ios/Zeron/App/DemoDataset.swift
@@ -5,6 +5,7 @@
import Foundation
import Observation
+import UIKit
@MainActor
@Observable
@@ -181,7 +182,9 @@ final class DemoDataset {
func sessionStore(for chatId: String) -> SessionStore {
if let existing = stores[chatId] { return existing }
let store = SessionStore(chatId: chatId, config: Self.dummyConfig, offline: true)
- if ProcessInfo.processInfo.arguments.contains("-longprompt") {
+ if ProcessInfo.processInfo.arguments.contains("-appshots") {
+ seedAppshots(store)
+ } else if ProcessInfo.processInfo.arguments.contains("-longprompt") {
store.setEntries([
MessageEntry(id: "long-prompt", role: .user,
parts: [.text(id: "t0", text: (1...18).map { "Requirement \($0): keep the transcript visible through every keyboard, streaming, and navigation transition." }.joined(separator: "\n"))],
@@ -201,6 +204,39 @@ final class DemoDataset {
return store
}
+ /// Neutral, offline captures for native simulator presentation checks.
+ private func seedAppshots(_ store: SessionStore) {
+ let names = ["Safari", "Notes", "Finder"]
+ let titles = ["Fieldnotes · Product planning", "Design review · Notes", "Workspace ideas"]
+ let sizes = [CGSize(width: 960, height: 540), CGSize(width: 400, height: 800), CGSize(width: 600, height: 600)]
+ let paths = ["/demo/appshot-wide.png", "/demo/appshot-tall.png", "/demo/appshot-square.png"]
+ var context = AppshotContext.marker + "\n"
+ for index in names.indices {
+ let size = sizes[index]
+ let format = UIGraphicsImageRendererFormat(); format.scale = 1
+ let image = UIGraphicsImageRenderer(size: size, format: format).image { _ in
+ UIColor(red: 0.96, green: 0.96, blue: 0.93, alpha: 1).setFill()
+ UIBezierPath(rect: CGRect(origin: .zero, size: size)).fill()
+ let ink = UIColor(red: 0.19, green: 0.30, blue: 0.23, alpha: 1)
+ ("Make room for good ideas." as NSString).draw(in: CGRect(x: 24, y: 40, width: size.width - 48, height: 70), withAttributes: [.font: UIFont.systemFont(ofSize: 28, weight: .semibold), .foregroundColor: ink])
+ for row in 0..<3 {
+ let rect = CGRect(x: 24, y: 150 + row * 110, width: Int(size.width) - 48, height: 90)
+ UIColor.white.setFill(); UIBezierPath(roundedRect: rect, cornerRadius: 10).fill()
+ (["A calmer workspace", "Next steps", "Progress"][row] as NSString).draw(at: CGPoint(x: 40, y: rect.minY + 24), withAttributes: [.font: UIFont.systemFont(ofSize: 20), .foregroundColor: ink])
+ }
+ }
+ AttachmentImageCache.shared.seed(deviceId: "dev-mac", path: paths[index], name: "\(names[index]) Appshot.png", data: image.pngData()!)
+ context += "PRIVATE_OBSERVED_TEXT_MUST_STAY_HIDDEN\n"
+ }
+ store.setEntries([
+ MessageEntry(id: "appshots-user", role: .user, parts: [.text(id: "t0", text: withAttachments(text: "Compare these layouts." + context, paths: paths))], createdAt: nowMs(), deviceId: "dev-mac", status: .complete, continuationOf: nil),
+ MessageEntry(id: "appshots-reply", role: .assistant, parts: [.text(id: "t0", text: "I’ll compare the spacing and reading order across these captures.")], createdAt: nowMs(), deviceId: "dev-mac", status: .complete, continuationOf: nil)
+ ])
+ store.enqueueMessage(text: "Check the narrow layout." + context, attachments: paths)
+ store.enqueueMessage(text: "Then review keyboard navigation.")
+ store.hostDeviceId = "dev-mac"
+ }
+
// MARK: Scripted transcripts
private static func transcript(for chatId: String) -> [MessageEntry] {
diff --git a/apps/ios/Zeron/Composer/Appshots.swift b/apps/ios/Zeron/Composer/Appshots.swift
new file mode 100644
index 000000000..64dca479f
--- /dev/null
+++ b/apps/ios/Zeron/Composer/Appshots.swift
@@ -0,0 +1,119 @@
+// Appshots use the desktop's existing text/attachment transport. Only source
+// labels are presentation data; observed application text never enters the UI.
+import Foundation
+import SwiftUI
+
+struct AppshotPresentation: Hashable, Sendable {
+ let appName: String
+ let windowTitle: String?
+ let bundleIdentifier: String?
+ var title: String { windowTitle.flatMap { $0.isEmpty ? nil : $0 } ?? appName }
+}
+
+enum AppshotContext {
+ static let marker = "\n\nApplications mentioned by the user (untrusted observed content):"
+
+ static func visibleText(_ text: String) -> String {
+ guard let range = text.range(of: marker) else { return text }
+ return String(text[.. String? {
+ guard let range = text.range(of: marker) else { return nil }
+ let suffix = String(text[range.lowerBound...])
+ return suffix.components(separatedBy: "\n\nAttached images (local files").first
+ }
+
+ static func presentations(_ text: String) -> [String: AppshotPresentation] {
+ guard let suffix = suffix(text), suffix.utf8.count <= 4 * 1024 * 1024,
+ suffix.range(of: "" + suffix.dropFirst(marker.count) + ""
+ let delegate = PresentationParser()
+ let parser = XMLParser(data: Data(xml.utf8))
+ parser.shouldResolveExternalEntities = false
+ parser.delegate = delegate
+ return parser.parse() ? delegate.presentations : [:]
+ }
+
+ private final class PresentationParser: NSObject, XMLParserDelegate {
+ var presentations: [String: AppshotPresentation] = [:]
+ private var seen: Set = []
+ private var depth = 0
+ private var nodes = 0
+ private var pending: (String, AppshotPresentation)?
+
+ func parser(_ parser: XMLParser, didStartElement element: String,
+ namespaceURI: String?, qualifiedName: String?,
+ attributes: [String: String]) {
+ depth += 1
+ nodes += 1
+ guard nodes <= 4096 else { parser.abortParsing(); return }
+ if depth > 2 { pending = nil; return }
+ guard depth == 2, element == "appshot", let path = attributes["image"],
+ let app = attributes["app"], !path.isEmpty,
+ !app.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
+ guard seen.insert(path).inserted else {
+ presentations.removeValue(forKey: path)
+ return
+ }
+ pending = (path, AppshotPresentation(appName: String(app.prefix(200)),
+ windowTitle: attributes["window-title"].map { String($0.prefix(512)) },
+ bundleIdentifier: attributes["bundle-identifier"].map { String($0.prefix(256)) }))
+ }
+
+ func parser(_ parser: XMLParser, didEndElement element: String,
+ namespaceURI: String?, qualifiedName: String?) {
+ if depth == 2, let (path, presentation) = pending {
+ presentations[path] = presentation
+ pending = nil
+ }
+ depth -= 1
+ }
+ }
+}
+
+/// Native iOS card. The app name remains useful when the desktop app's icon
+/// is unavailable on the phone. Image loading uses the existing host relay.
+struct AppshotCardView: View {
+ let deviceId: String
+ let attachment: UserImageAttachment
+ let source: AppshotPresentation
+ private let cache = AttachmentImageCache.shared
+ @State private var preview: AttachmentPreview?
+
+ var body: some View {
+ Button {
+ if case .loaded(let name, let image) = cache.snapshot(deviceId: deviceId, path: attachment.path) {
+ preview = AttachmentPreview(name: name, image: image)
+ } else { cache.load(deviceId: deviceId, path: attachment.path) }
+ } label: {
+ VStack(spacing: 6) {
+ Group {
+ switch cache.snapshot(deviceId: deviceId, path: attachment.path) {
+ case .loaded(_, let image):
+ Image(uiImage: image).resizable().scaledToFit()
+ .mask(LinearGradient(stops: [.init(color: .black, location: 0), .init(color: .black, location: 0.72), .init(color: .clear, location: 1)], startPoint: .top, endPoint: .bottom))
+ case .loading: ProgressView().tint(Theme.textMuted)
+ case .error: Label("Appshot unavailable", systemImage: "photo.badge.exclamationmark")
+ .font(Theme.sans(12)).foregroundStyle(Theme.textMuted)
+ }
+ }
+ .frame(height: 120)
+ Label("\(source.appName) · Appshot", systemImage: "macwindow")
+ .font(Theme.sans(11)).foregroundStyle(Theme.textMuted).lineLimit(1)
+ Text(source.title).font(Theme.sans(12.5, weight: .medium))
+ .foregroundStyle(Theme.text).lineLimit(2).multilineTextAlignment(.center)
+ }
+ .frame(maxWidth: .infinity).padding(8)
+ .contentShape(RoundedRectangle(cornerRadius: 14))
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel("Preview \(source.appName) Appshot: \(source.title)")
+ .accessibilityIdentifier("appshot-card-\(attachment.id)")
+ .task(id: "\(deviceId)|\(attachment.path)") { cache.load(deviceId: deviceId, path: attachment.path) }
+ .fullScreenCover(item: $preview) { AttachmentLightbox(preview: $0) }
+ }
+}
diff --git a/apps/ios/Zeron/Composer/Attachments.swift b/apps/ios/Zeron/Composer/Attachments.swift
index 2e5c3336d..ffc4bb199 100644
--- a/apps/ios/Zeron/Composer/Attachments.swift
+++ b/apps/ios/Zeron/Composer/Attachments.swift
@@ -33,6 +33,7 @@ struct UserImageAttachment: Identifiable, Hashable {
let id: String
let path: String
let name: String
+ var appshot: AppshotPresentation? = nil
}
struct ParsedUserMessage {
@@ -48,6 +49,7 @@ private func nameFromPath(_ path: String) -> String {
/// message-attachments.ts `parseUserMessageImages`: split the visible prompt
/// from its attachment-ref trailer (case-insensitive marker, `- path` lines).
func parseUserMessageImages(_ content: String) -> ParsedUserMessage {
+ let presentations = AppshotContext.presentations(content)
let lines = content.components(separatedBy: "\n")
var markerIx: Int?
for (ix, raw) in lines.enumerated() where ix > 0 {
@@ -60,7 +62,7 @@ func parseUserMessageImages(_ content: String) -> ParsedUserMessage {
}
}
guard let markerIx else {
- return ParsedUserMessage(text: content, attachments: [])
+ return ParsedUserMessage(text: AppshotContext.visibleText(content), attachments: [])
}
let attachments = lines[(markerIx + 1)...].compactMap { line -> String? in
let trimmed = line.trimmingCharacters(in: .whitespaces)
@@ -68,14 +70,15 @@ func parseUserMessageImages(_ content: String) -> ParsedUserMessage {
let path = String(trimmed.dropFirst(2)).trimmingCharacters(in: .whitespaces)
return path.isEmpty ? nil : path
}.enumerated().map { ix, path in
- UserImageAttachment(id: "\(ix):\(path)", path: path, name: nameFromPath(path))
+ UserImageAttachment(id: "\(ix):\(path)", path: path, name: nameFromPath(path), appshot: presentations[path])
}
guard !attachments.isEmpty else {
- return ParsedUserMessage(text: content, attachments: [])
+ return ParsedUserMessage(text: AppshotContext.visibleText(content), attachments: [])
}
let body = lines[..<(markerIx - 1)].joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
- return ParsedUserMessage(text: body == attachmentOnlyText ? "" : body,
+ let visible = AppshotContext.visibleText(body)
+ return ParsedUserMessage(text: visible == attachmentOnlyText ? "" : visible,
attachments: attachments)
}
@@ -464,16 +467,37 @@ struct UserAttachmentsStrip: View {
let attachments: [UserImageAttachment]
var body: some View {
- HStack(spacing: 8) {
- Spacer(minLength: 0)
- ForEach(attachments) { att in
- AttachmentThumbView(deviceId: deviceId, path: att.path)
+ if attachments.contains(where: { $0.appshot != nil }) {
+ // Each capture remains reachable on a phone, including mixed
+ // ordinary images. Fixed card width avoids squeezing three into
+ // one clipped row; scrolling does not alter transcript height.
+ ScrollView(.horizontal) {
+ HStack(alignment: .top, spacing: 8) {
+ ForEach(attachments) { att in
+ if let source = att.appshot {
+ AppshotCardView(deviceId: deviceId, attachment: att, source: source)
+ .frame(width: 220)
+ } else {
+ AttachmentThumbView(deviceId: deviceId, path: att.path)
+ }
+ }
+ }
}
+ .defaultScrollAnchor(.trailing)
+ .frame(height: 196)
+ .accessibilityLabel("Appshots and images")
+ .accessibilityIdentifier("appshot-attachments")
+ } else {
+ HStack(spacing: 8) {
+ Spacer(minLength: 0)
+ ForEach(attachments) { att in
+ AttachmentThumbView(deviceId: deviceId, path: att.path)
+ }
+ }
+ .frame(height: 80)
+ .frame(maxWidth: .infinity, alignment: .trailing)
+ .clipped()
}
- // Fixed height: load-state flips never shift the transcript.
- .frame(height: 80)
- .frame(maxWidth: .infinity, alignment: .trailing)
- .clipped()
}
}
@@ -558,6 +582,15 @@ struct AttachmentLightbox: View {
}
.contentShape(Rectangle())
.onTapGesture { dismiss() }
+ .overlay(alignment: .topTrailing) {
+ Button { dismiss() } label: {
+ Image(systemName: "xmark").font(.system(size: 17, weight: .medium))
+ .foregroundStyle(.white).frame(width: 44, height: 44)
+ .background(.black.opacity(0.6), in: Circle())
+ }
+ .accessibilityLabel("Close image preview")
+ .padding(12)
+ }
}
.presentationBackground(.clear)
}
diff --git a/apps/ios/Zeron/Composer/QueuePanelView.swift b/apps/ios/Zeron/Composer/QueuePanelView.swift
index 8deb7f0fc..1bf1feac3 100644
--- a/apps/ios/Zeron/Composer/QueuePanelView.swift
+++ b/apps/ios/Zeron/Composer/QueuePanelView.swift
@@ -29,9 +29,14 @@ struct QueuePanel: View {
.foregroundStyle(Theme.textFaint)
.padding(.leading, 4)
}
- ForEach(Array(queue.enumerated()), id: \.element.id) { index, item in
- row(item, index: index, count: queue.count)
+ ScrollView {
+ LazyVStack(spacing: 6) {
+ ForEach(Array(queue.enumerated()), id: \.element.id) { index, item in
+ row(item, index: index, count: queue.count)
+ }
+ }
}
+ .frame(height: CGFloat(min(queue.count, 3)) * 56 + CGFloat(max(0, min(queue.count, 3) - 1)) * 6)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
@@ -52,25 +57,32 @@ struct QueuePanel: View {
)
}
}()
- let content = HStack(spacing: 8) {
+ let sources = AppshotContext.presentations(item.text)
+ let sourceNames = item.attachments.map { sources[$0].map { "\($0.appName) Appshot" } ?? "Image" }
+ let content = HStack(spacing: 6) {
Text("\(index + 1)")
.font(Theme.mono(10))
.foregroundStyle(Theme.textFaint)
.frame(minWidth: 12, alignment: .trailing)
- Text(displayText)
- .font(Theme.sans(12.5))
- .foregroundStyle(editing ? Theme.textMuted : Theme.text)
- .lineLimit(1)
- .frame(maxWidth: .infinity, alignment: .leading)
- if !item.attachments.isEmpty {
- Image(systemName: "paperclip")
- .font(.system(size: 9, weight: .semibold))
- .foregroundStyle(Theme.textFaint)
+ if let path = item.attachments.first, !editing {
+ QueueAttachmentPreview(deviceId: store.hostDeviceId ?? store.deviceId,
+ path: path, paths: item.attachments)
}
+ VStack(alignment: .leading, spacing: 2) {
+ Text(displayText)
+ .font(Theme.sans(12.5))
+ .foregroundStyle(editing ? Theme.textMuted : Theme.text)
+ .lineLimit(1)
+ if !sourceNames.isEmpty && !editing {
+ Text(sourceNames.joined(separator: " · "))
+ .font(Theme.sans(10.5)).foregroundStyle(Theme.textMuted).lineLimit(1)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
controls(item, index: index, count: count, editing: editing)
}
.padding(.horizontal, 10)
- .padding(.vertical, 7)
+ .padding(.vertical, 6)
.background(whiteAlpha(dragging == item.id ? 0.10 : 0.04),
in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Theme.border, lineWidth: 1))
@@ -117,33 +129,29 @@ struct QueuePanel: View {
if case .editing = item.deliveryGate { return !editing }
return false
}()
- return HStack(spacing: 2) {
- iconButton("chevron.up", label: "Move up", enabled: index > 0 && !gated) {
- store.moveQueued(id: item.id, by: -1)
- }
- iconButton("chevron.down", label: "Move down",
- enabled: index < count - 1 && !gated) {
- store.moveQueued(id: item.id, by: 1)
- }
+ return HStack(spacing: 0) {
iconButton(editing ? "xmark" : "pencil",
label: editing ? "Stop editing" : "Edit",
enabled: !lockedByOther && !pending) {
- if editing {
- onCancelEdit()
- } else {
- onEdit(item)
- }
+ if editing { onCancelEdit() } else { onEdit(item) }
}
- if let primary {
- Button(primary.label) { onAction(item, primary) }
- .font(Theme.sans(11, weight: .medium))
- .buttonStyle(.plain)
- .accessibilityLabel("Send now, interrupting the response")
+ iconButton("arrow.right", label: "Send now, interrupting the response",
+ enabled: primary != nil) {
+ if let primary { onAction(item, primary) }
}
- iconButton("trash", label: "Remove", enabled: supportsActions && !pending,
- tone: Theme.textFaint) {
- onAction(item, .remove)
+ Menu {
+ Button("Move up", systemImage: "chevron.up") { store.moveQueued(id: item.id, by: -1) }
+ .disabled(index == 0 || gated)
+ Button("Move down", systemImage: "chevron.down") { store.moveQueued(id: item.id, by: 1) }
+ .disabled(index >= count - 1 || gated)
+ Button("Remove", systemImage: "trash", role: .destructive) { onAction(item, .remove) }
+ .disabled(!supportsActions || pending)
+ } label: {
+ Image(systemName: "ellipsis").font(.system(size: 13))
+ .foregroundStyle(Theme.textMuted).frame(width: 44, height: 44)
+ .contentShape(Rectangle())
}
+ .accessibilityLabel("More queue actions")
}
}
@@ -158,7 +166,7 @@ struct QueuePanel: View {
Image(systemName: symbol)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(enabled ? tone : Theme.textFaint.opacity(0.4))
- .frame(width: 26, height: 26)
+ .frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
@@ -166,3 +174,74 @@ struct QueuePanel: View {
.accessibilityLabel(label)
}
}
+
+/// The queue holds a tiny decoded preview; opening it uses the shared full
+/// image. Its task identity is stable across row updates and cache eviction.
+private struct QueueAttachmentPreview: View {
+ let deviceId: String
+ let path: String
+ let paths: [String]
+ private var extraCount: Int { paths.count - 1 }
+ private let cache = AttachmentImageCache.shared
+ @State private var preview: AttachmentPreview?
+ @State private var thumbnail: UIImage?
+ @State private var gallery = false
+
+ private var sourceImage: UIImage? {
+ if case .loaded(_, let image) = cache.snapshot(deviceId: deviceId, path: path) { return image }
+ return nil
+ }
+
+ var body: some View {
+ Button {
+ if extraCount > 0 { gallery = true; return }
+ if case .loaded(let name, let image) = cache.snapshot(deviceId: deviceId, path: path) {
+ preview = AttachmentPreview(name: name, image: image)
+ } else { cache.load(deviceId: deviceId, path: path) }
+ } label: {
+ ZStack(alignment: .bottomTrailing) {
+ Group {
+ if let thumbnail {
+ Image(uiImage: thumbnail).resizable().scaledToFill()
+ } else { Image(systemName: "photo").foregroundStyle(Theme.textFaint) }
+ }
+ .frame(width: 40, height: 28).clipped()
+ if extraCount > 0 {
+ Text("+\(extraCount)").font(Theme.sans(9, weight: .medium))
+ .padding(.horizontal, 3).background(Theme.bg.opacity(0.9), in: RoundedRectangle(cornerRadius: 3))
+ .foregroundStyle(Theme.text)
+ }
+ }
+ .clipShape(RoundedRectangle(cornerRadius: 5))
+ .frame(width: 40, height: 44).contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(extraCount > 0 ? "Preview image, \(extraCount) more attachments" : "Preview image")
+ .task(id: "\(deviceId)|\(path)") { cache.load(deviceId: deviceId, path: path) }
+ .task(id: sourceImage) {
+ if let image = sourceImage, let small = await image.byPreparingThumbnail(ofSize: CGSize(width: 80, height: 56)) {
+ thumbnail = small
+ }
+ }
+ .fullScreenCover(item: $preview) { AttachmentLightbox(preview: $0) }
+ .sheet(isPresented: $gallery) {
+ NavigationStack {
+ ScrollView(.horizontal) {
+ LazyHStack(spacing: 12) {
+ ForEach(paths, id: \.self) { path in
+ VStack(spacing: 8) {
+ AttachmentThumbView(deviceId: deviceId, path: path)
+ if case .loaded(let name, _) = cache.snapshot(deviceId: deviceId, path: path) {
+ Text(name).font(Theme.sans(11)).foregroundStyle(Theme.textMuted).lineLimit(1)
+ }
+ }.frame(width: 112)
+ }
+ }.padding(20)
+ }
+ .navigationTitle("Queued images")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { gallery = false } } }
+ }.presentationDetents([.height(240), .medium])
+ }
+ }
+}
diff --git a/apps/ios/Zeron/Models/MessageQueue.swift b/apps/ios/Zeron/Models/MessageQueue.swift
index 842c7e8cf..89fe0ef0f 100644
--- a/apps/ios/Zeron/Models/MessageQueue.swift
+++ b/apps/ios/Zeron/Models/MessageQueue.swift
@@ -69,7 +69,8 @@ struct QueueComposerEdit {
}
func textToCommit(_ text: String) -> String? {
- MessageQueue.editedText(text, hasAttachments: hasAttachments)
+ guard let edited = MessageQueue.editedText(text, hasAttachments: hasAttachments) else { return nil }
+ return edited + (AppshotContext.suffix(lease.text) ?? "")
}
}
@@ -84,9 +85,10 @@ enum MessageQueue {
/// trailer. Strip it only for legacy rows whose parsed paths exactly match
/// the separate attachments field.
static func visibleText(_ text: String, attachments: [String]) -> String {
- guard !attachments.isEmpty else { return text }
+ let visible = AppshotContext.visibleText(text)
+ guard !attachments.isEmpty else { return visible }
let parsed = parseUserMessageImages(text)
- guard parsed.attachments.map(\.path) == attachments else { return text }
+ guard parsed.attachments.map(\.path) == attachments else { return visible.isEmpty ? attachmentOnlyText : visible }
return parsed.text.isEmpty ? attachmentOnlyText : parsed.text
}
diff --git a/apps/ios/ZeronTests/AppshotTests.swift b/apps/ios/ZeronTests/AppshotTests.swift
new file mode 100644
index 000000000..8d622baa5
--- /dev/null
+++ b/apps/ios/ZeronTests/AppshotTests.swift
@@ -0,0 +1,52 @@
+import XCTest
+@testable import Zeron
+
+@MainActor
+final class AppshotTests: XCTestCase {
+ private let context = AppshotContext.marker + "\nPrivate observed text"
+ private let path = "/host/shot & detail.png"
+
+ func testDesktopAppshotTransportRendersSourceWithoutObservedText() {
+ let content = withAttachments(text: "Review this" + context, paths: [path, "/host/ordinary.png"])
+ let parsed = parseUserMessageImages(content)
+ XCTAssertEqual(parsed.text, "Review this")
+ XCTAssertEqual(parsed.attachments.count, 2)
+ XCTAssertEqual(parsed.attachments[0].appshot?.appName, "Notes & Ideas")
+ XCTAssertEqual(parsed.attachments[0].appshot?.title, "Planning & review")
+ XCTAssertNil(parsed.attachments[1].appshot)
+ XCTAssertEqual(MessageQueue.visibleText("Review this" + context, attachments: [path]), "Review this")
+ XCTAssertEqual(MessageQueue.visibleText(content, attachments: [path, "/host/ordinary.png"]), "Review this")
+ }
+
+ func testPhoneQueueEditKeepsContextAndAttachmentOnlyMessage() {
+ let lease = QueueEditLease(rowId: "row", leaseId: "lease", text: "Review this" + context,
+ baseTextHash: "hash", expiresAtMs: 60_000)
+ let edit = QueueComposerEdit(lease: lease, originalDraft: "My draft", hasAttachments: true)
+ XCTAssertEqual(edit.textToCommit("Changed prompt"), "Changed prompt" + context)
+ XCTAssertEqual(edit.textToCommit(" \n"), attachmentOnlyText + context)
+ XCTAssertEqual(edit.originalDraft, "My draft")
+ let parsed = parseUserMessageImages(withAttachments(text: edit.textToCommit("")!, paths: [path]))
+ XCTAssertEqual(parsed.text, "")
+ XCTAssertNotNil(parsed.attachments.first?.appshot)
+ }
+
+ func testAmbiguousAndMalformedContextNeverBecomesAVisiblePrompt() {
+ let duplicate = "Look" + context + "\nOther text"
+ XCTAssertTrue(AppshotContext.presentations(duplicate).isEmpty)
+ XCTAssertEqual(parseUserMessageImages(duplicate).text, "Look")
+ let malformed = "Look" + AppshotContext.marker + "\n]>&foo;"
+ XCTAssertTrue(AppshotContext.presentations(dtd).isEmpty)
+ XCTAssertEqual(parseUserMessageImages(dtd).text, "Look")
+ }
+
+ func testPlainAttachmentsAndUserTextRemainUnchanged() {
+ XCTAssertEqual(parseUserMessageImages("My plain text").text, "My plain text")
+ let parsed = parseUserMessageImages(withAttachments(text: "A photo", paths: ["/host/a.png"]))
+ XCTAssertEqual(parsed.text, "A photo")
+ XCTAssertEqual(parsed.attachments.first?.name, "a.png")
+ XCTAssertNil(parsed.attachments.first?.appshot)
+ }
+}
diff --git a/apps/ios/ZeronUITests/AppshotUITests.swift b/apps/ios/ZeronUITests/AppshotUITests.swift
new file mode 100644
index 000000000..b585b78c1
--- /dev/null
+++ b/apps/ios/ZeronUITests/AppshotUITests.swift
@@ -0,0 +1,47 @@
+import XCTest
+
+@MainActor
+final class AppshotUITests: XCTestCase {
+ private func capture(_ name: String) {
+ let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
+ attachment.name = name
+ attachment.lifetime = .keepAlways
+ add(attachment)
+ }
+
+ func testRemoteAppshotsRemainReadableAndOpenOnPhone() {
+ let app = XCUIApplication()
+ app.launchArguments = ["-demo", "-route", "chat:chat-tabs", "-appshots"]
+ XCUIDevice.shared.orientation = .portrait
+ app.launch()
+ let card = app.buttons["appshot-card-2:/demo/appshot-square.png"]
+ XCTAssertTrue(card.waitForExistence(timeout: 10))
+ XCTAssertFalse(app.descendants(matching: .any).matching(NSPredicate(format: "label CONTAINS %@", "PRIVATE_OBSERVED_TEXT_MUST_STAY_HIDDEN")).firstMatch.exists)
+ XCTAssertTrue(app.staticTexts["Check the narrow layout."].exists)
+ capture("appshots-ios-portrait")
+ let strip = app.scrollViews["appshot-attachments"]
+ strip.swipeRight()
+ strip.swipeRight()
+ let first = app.buttons["appshot-card-0:/demo/appshot-wide.png"]
+ XCTAssertTrue(first.isHittable)
+ capture("appshots-ios-first-capture")
+ first.tap()
+ let close = app.buttons["Close image preview"]
+ XCTAssertTrue(close.waitForExistence(timeout: 5))
+ capture("appshots-ios-lightbox")
+ close.tap()
+ let images = app.buttons["Preview image, 2 more attachments"]
+ XCTAssertTrue(images.waitForExistence(timeout: 5))
+ images.tap()
+ XCTAssertTrue(app.navigationBars["Queued images"].waitForExistence(timeout: 5))
+ capture("appshots-ios-queue-gallery")
+ app.buttons["Done"].tap()
+ XCUIDevice.shared.orientation = .landscapeLeft
+ XCTAssertTrue(images.waitForExistence(timeout: 5))
+ capture("appshots-ios-landscape")
+ XCUIDevice.shared.orientation = .portrait
+ app.buttons["More queue actions"].firstMatch.tap()
+ XCTAssertTrue(app.buttons["Move down"].waitForExistence(timeout: 5))
+ capture("appshots-ios-queue-actions")
+ }
+}
diff --git a/apps/zeron/src/main.rs b/apps/zeron/src/main.rs
index e5372a756..8e0fcd46d 100644
--- a/apps/zeron/src/main.rs
+++ b/apps/zeron/src/main.rs
@@ -31,6 +31,9 @@ enum Command {
/// Live sync introspection from the running engine: per-room connection
/// state, last pushed-frame/ack ages, rejoin/probe/resync counters.
Sync,
+ #[cfg(target_os = "linux")]
+ /// Trigger an Appshot in the running headed instance (desktop shortcut fallback).
+ Appshot,
/// Manage `zeron headless` as a background service (launchd / systemd --user).
Daemon {
#[command(subcommand)]
@@ -185,6 +188,11 @@ fn main() -> anyhow::Result<()> {
let runtime = tokio::runtime::Runtime::new()?;
runtime.block_on(sync_cli(engine_config_from_env().ipc_port))
}
+ #[cfg(target_os = "linux")]
+ Some(Command::Appshot) => {
+ zeron_ui::appshots::request_running_appshot(&engine_config_from_env().data_dir)
+ .map_err(anyhow::Error::msg)
+ }
Some(Command::Update { check }) => {
let runtime = tokio::runtime::Runtime::new()?;
runtime.block_on(update_cli::update(&edge_url_from_env(), check))
diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml
index b152a5df0..25cbb7359 100644
--- a/crates/ui/Cargo.toml
+++ b/crates/ui/Cargo.toml
@@ -7,6 +7,7 @@ publish.workspace = true
[features]
browser-fixture = []
+appshots-fixture = ["gpui_platform/test-support"]
resource-profile = ["gpui/bench", "gpui_platform/test-support"]
[[example]]
@@ -21,6 +22,10 @@ required-features = ["browser-fixture"]
name = "browser-fixture"
required-features = ["browser-fixture"]
+[[example]]
+name = "appshots-fixture"
+required-features = ["appshots-fixture"]
+
[dependencies]
url = "2"
zeron-proto.workspace = true
@@ -44,7 +49,7 @@ serde_json.workspace = true
pulldown-cmark.workspace = true
alacritty_terminal.workspace = true
base64 = "0.22"
-image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp", "tiff"] }
+image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp", "tiff", "ico"] }
usvg = "0.46"
mermaid-rs-renderer = { version = "=0.3.1", default-features = false }
unicode-segmentation.workspace = true
@@ -56,6 +61,8 @@ chrono.workspace = true
anyhow.workspace = true
tracing.workspace = true
sha2.workspace = true
+roxmltree = "0.21.1"
+png = "0.18"
thiserror.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
@@ -69,10 +76,17 @@ objc2-app-kit = { version = "0.3.2", features = ["NSApplication", "NSBitmapImage
objc2-foundation = "0.3.2"
objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "WKNavigationDelegate", "WKNavigationAction", "WKSnapshotConfiguration"] }
reqwest.workspace = true
-image = { version = "0.25", default-features = false, features = ["png", "ico", "jpeg", "webp"] }
+core-foundation = "0.10"
+core-graphics = { version = "0.24", features = ["elcapitan", "highsierra", "link"] }
+foreign-types = "0.5"
+block = "0.1"
[target.'cfg(target_os = "linux")'.dependencies]
-image = { version = "0.25", default-features = false, features = ["png"] }
+ashpd = { version = "0.13.13", default-features = false, features = ["async-io", "global_shortcuts", "screenshot"] }
+atspi = { version = "0.30.0", features = ["zbus"] }
+async-io = "2"
+url = "2"
+x11rb = { version = "0.14.0", features = ["image"] }
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }
diff --git a/crates/ui/assets/sounds/appshot.wav b/crates/ui/assets/sounds/appshot.wav
new file mode 100644
index 000000000..9a8b1753b
Binary files /dev/null and b/crates/ui/assets/sounds/appshot.wav differ
diff --git a/crates/ui/examples/appshots-fixture.rs b/crates/ui/examples/appshots-fixture.rs
new file mode 100644
index 000000000..a08f27986
--- /dev/null
+++ b/crates/ui/examples/appshots-fixture.rs
@@ -0,0 +1,229 @@
+//! Native Appshots layout evidence with isolated data. No agent messages are sent.
+use gpui::{AppContext, AsyncApp, Bounds, WindowBounds, WindowOptions, px, size};
+use std::{path::PathBuf, sync::Arc, time::Duration};
+use zeron_ui::*;
+async fn pause(cx: &mut AsyncApp, ms: u64) {
+ cx.background_executor()
+ .timer(Duration::from_millis(ms))
+ .await;
+}
+fn capture(
+ window: gpui::AnyWindowHandle,
+ cx: &mut AsyncApp,
+ directory: &std::path::Path,
+ name: &str,
+) -> anyhow::Result<()> {
+ window.update(cx, |_, w, cx| {
+ w.draw(cx).clear();
+ w.render_to_image()?
+ .save(directory.join(format!("{name}.png")))?;
+ Ok(())
+ })?
+}
+fn press(window: gpui::AnyWindowHandle, cx: &mut AsyncApp, key: &str) -> anyhow::Result<()> {
+ window.update(cx, |_, w, cx| {
+ w.draw(cx).clear();
+ let keystroke = gpui::Keystroke::parse(key).unwrap();
+ w.dispatch_event(
+ gpui::PlatformInput::KeyDown(gpui::KeyDownEvent {
+ keystroke: keystroke.clone(),
+ is_held: false,
+ prefer_character_input: false,
+ }),
+ cx,
+ );
+ w.dispatch_event(
+ gpui::PlatformInput::KeyUp(gpui::KeyUpEvent { keystroke }),
+ cx,
+ );
+ })?;
+ Ok(())
+}
+fn port() -> u16 {
+ std::net::TcpListener::bind("127.0.0.1:0")
+ .unwrap()
+ .local_addr()
+ .unwrap()
+ .port()
+}
+fn main() -> anyhow::Result<()> {
+ tracing_subscriber::fmt().with_env_filter("warn").init();
+ let output = PathBuf::from(std::env::args().nth(1).expect("output directory"));
+ let inputs = PathBuf::from(
+ std::env::args()
+ .nth(2)
+ .expect("fixture PNG directory: wide, tall, square"),
+ );
+ std::fs::create_dir_all(&output)?;
+ let temp = tempfile::tempdir()?;
+ let runtime = tokio::runtime::Runtime::new()?;
+ let core = runtime.block_on(async {
+ zeron_engine::EngineCore::assemble(
+ &temp.path().join("engine"),
+ Arc::new(zeron_engine::default_registry()),
+ zeron_proto::HarnessId::ClaudeCode,
+ None,
+ )
+ })?;
+ core.workspace
+ .create_chat("appshots-fixture", None, Some(&core.device_id), None, None)?;
+ core.workspace
+ .rename_chat("appshots-fixture", "Review the workspace design")?;
+ let ipc_port = port();
+ let _ipc = runtime.block_on(zeron_engine::serve_ipc(ipc_port, core.rpc_service()))?;
+ let data = temp.path().join("ui");
+ std::fs::create_dir(&data)?;
+ let boot = EngineBootConfig {
+ data_dir: data.clone(),
+ ipc_port,
+ edge_url: String::new(),
+ edge_token: None,
+ org_id: None,
+ workos_client_id: None,
+ default_harness: zeron_proto::HarnessId::ClaudeCode,
+ };
+ let handle = runtime.block_on(state::EngineHandle::bootstrap(boot.clone()))?;
+ let chats = core.workspace.read_chats()?;
+ let device = core.device_id.clone();
+ let mut shots = Vec::new();
+ let mut paths = Vec::new();
+ for (name, title, bundle) in [
+ ("wide", "Fieldnotes · Product planning", "com.apple.Safari"),
+ ("tall", "Design review · Notes", "com.apple.Notes"),
+ ("square", "Workspace ideas", "com.apple.finder"),
+ ] {
+ let bytes = std::fs::read(inputs.join(format!("{name}.png")))?;
+ let (screenshot, dimensions) =
+ appshots::stage_appshot_png(name, bytes).map_err(|e| anyhow::anyhow!(e.to_string()))?;
+ let shot = appshots::CapturedAppshot {
+ id: name.into(),
+ app_name: match name {
+ "wide" => "Safari",
+ "tall" => "Notes",
+ _ => "Finder",
+ }
+ .into(),
+ bundle_identifier: Some(bundle.into()),
+ window_title: Some(title.into()),
+ accessibility: appshots::AccessibilitySnapshot::unavailable(),
+ screenshot,
+ screenshot_dimensions: Some(dimensions),
+ app_icon: None,
+ captured_at: chrono::Utc::now(),
+ };
+ let path = inputs
+ .join(format!("{name}.png"))
+ .to_string_lossy()
+ .into_owned();
+ attachments::store_loaded(
+ &device,
+ &path,
+ shot.screenshot.name.clone().into(),
+ shot.screenshot.image.clone(),
+ );
+ paths.push(path);
+ shots.push(shot);
+ }
+ let body = appshots::with_appshots(
+ "Compare these layouts and suggest a clearer hierarchy.",
+ &shots,
+ &shots
+ .iter()
+ .zip(&paths)
+ .map(|(s, p)| (s.screenshot.id.clone(), p.clone()))
+ .collect(),
+ );
+ let message = attachments::with_attachments(&body, &paths);
+ let queue = vec![
+ zeron_doc::QueuedMessage {
+ id: "queued-review".into(),
+ text: body,
+ attachments: paths.clone(),
+ hold_for_turn_end: true,
+ issued_by: device.clone(),
+ issued_at: 1788900000000,
+ edited_at: None,
+ delivery_gate: None,
+ },
+ zeron_doc::QueuedMessage {
+ id: "queued-text".into(),
+ text: "Then check the spacing and keyboard navigation.".into(),
+ attachments: vec![],
+ hold_for_turn_end: true,
+ issued_by: device.clone(),
+ issued_at: 1788900000001,
+ edited_at: None,
+ delivery_gate: None,
+ },
+ ];
+ let failure = Arc::new(std::sync::Mutex::new(None));
+ let result = failure.clone();
+ gpui_platform::application().with_assets(icons::Assets).run(move |cx| {
+ gpui_tokio::init(cx); gpui_base::init(cx);
+ let settings=settings::UiSettings::default(); settings::init(settings.clone(),data.clone(),cx);
+ let fonts=typography::register_fonts(cx); typography::init(settings.ui_font_family.clone(),settings.ui_font_size,fonts,cx);
+ theme_library::init(data.clone(),cx); appearance::init(appearance::AppearanceMode::Dark,settings.theme_selection,settings.accent,settings.surface,cx);
+ history::init(settings.git_history_columns,settings.git_history_column_widths,settings.git_history_column_order,settings.git_history_author_display,cx);
+ composer::init(cx,settings.composer_send_behavior); terminal::panel::init(cx); app_menus::init(cx);
+ let state=cx.new(|_| { let mut s=state::AppState::new(); s.fixture_attachment_engine(handle); s.connection=zeron_proto::view::ConnectionStatus::Ready; s.workspace_scope=Some(zeron_proto::WorkspaceScope::Development); s.local_device_id=Some(device.clone()); s.devices=vec![serde_json::from_value(serde_json::json!({"id":device,"name":"This device","platform":std::env::consts::OS,"lastSeenAt":null})).unwrap()]; s.chats=chats; s.selected_chat=Some("appshots-fixture".into()); s.auto_selected=true; s.chats_synced=true; s.spaces_synced=true; s });
+ let window=cx.open_window(WindowOptions {window_background:theme::Theme::of(cx).window_background_appearance(),window_bounds:Some(WindowBounds::Windowed(Bounds::new(gpui::point(px(20.),px(40.)),size(px(1100.),px(850.))))),..Default::default()},|_,cx|cx.new(|cx|shell::Shell::new(state.clone(),boot,cx))).unwrap();
+ state.update(cx,|_,cx|cx.notify()); cx.activate(true);
+ cx.spawn(async move |cx| {
+ let run:anyhow::Result<()>=async {
+ pause(cx,1000).await;
+ state.update(cx,|s,cx| {s.receive_transcript_frame(zeron_doc::TranscriptFrame::Reset {reset:serde_json::from_value(serde_json::json!([
+ {"id":"user","role":"user","parts":[{"id":"text","kind":"text","text":message}],"createdAt":1788900000000_i64,"deviceId":device},
+ {"id":"assistant","role":"assistant","parts":[{"id":"text","kind":"text","text":"The three Appshots show a consistent visual style. I’ll compare the spacing and reading order, then check how the layout adapts to smaller screens."}],"createdAt":1788900001000_i64,"deviceId":device,"status":"complete"}
+ ])).unwrap()},cx).unwrap();cx.notify();});
+ pause(cx,900).await;
+ window.update(cx,|s,_,cx|s.fixture_appshots_transcript_start(cx))?;
+ pause(cx,400).await;capture(window.into(),cx,&output,"appshots-transcript-dark-1100")?;
+ state.update(cx,|s,cx|{s.queue=queue;cx.notify();});
+ let evidence_shots=shots.clone();
+ let composer=window.update(cx,|s,_,_|s.fixture_appshots_composer())?;
+ composer.update(cx,|c,cx| {for mut shot in shots {
+ shot.app_icon=appshots::presentation_icon(&appshots::AppshotPresentation {app_name:shot.app_name.clone(),window_title:shot.window_title.clone(),bundle_identifier:shot.bundle_identifier.clone()});
+ c.stage_appshot(shot,cx);
+ } cx.notify();});
+ pause(cx,1800).await; capture(window.into(), cx, &output,"appshots-chat-dark-1100")?;
+ cx.update(|cx|appearance::set_mode(appearance::AppearanceMode::Light,cx));pause(cx,500).await;capture(window.into(), cx, &output,"appshots-chat-light-1100")?;
+ for width in [700.,390.,320.] {
+ window.update(cx,|s,w,cx|{ s.fixture_appshots_sidebar(true,cx); w.resize(size(px(width),px(850.))); })?;
+ pause(cx,700).await;capture(window.into(), cx, &output,&format!("appshots-chat-light-{width}"))?;
+ }
+ window.update(cx,|s,w,cx| {s.fixture_appshots_sidebar(false,cx);w.resize(size(px(1100.),px(850.)));s.fixture_appshots_settings(true,cx);})?;
+ pause(cx,900).await;capture(window.into(), cx, &output,"appshots-settings-light-1100")?;
+ cx.update(|cx|appearance::set_mode(appearance::AppearanceMode::Dark,cx));pause(cx,500).await;capture(window.into(), cx, &output,"appshots-settings-dark-1100")?;
+ press(window.into(),cx,"tab")?;press(window.into(),cx,"space")?;
+ pause(cx,500).await;capture(window.into(),cx,&output,"appshots-settings-enabled-keyboard")?;
+ for key in ["tab","tab","tab","tab","space"] { press(window.into(),cx,key)?; }
+ pause(cx,500).await;capture(window.into(),cx,&output,"appshots-settings-last-session")?;
+ for key in ["shift-tab","shift-tab","enter"] { press(window.into(),cx,key)?; }
+ pause(cx,500).await;capture(window.into(),cx,&output,"appshots-settings-recording")?;
+ press(window.into(),cx,"escape")?;
+ window.update(cx,|_,w,_|w.resize(size(px(600.),px(850.))))?;pause(cx,700).await;capture(window.into(), cx, &output,"appshots-settings-dark-600")?;
+ window.update(cx,|s,w,cx| {w.resize(size(px(1100.),px(850.)));s.fixture_appshots_settings(false,cx);})?;
+ composer.update(cx,|c,cx|c.fixture_clear_appshots(cx));
+ let state_paths=vec![paths[0].clone(), "pending://appshot-test/notes.png".into(), "/missing/appshot-test.png".into()];
+ attachments::store_error(&device,&state_paths[2]);
+ let state_body=appshots::with_appshots("Appshots keep their source labels while images are loading or unavailable.",&evidence_shots,&evidence_shots.iter().zip(&state_paths).map(|(s,p)|(s.screenshot.id.clone(),p.clone())).collect());
+ let state_message=attachments::with_attachments(&state_body,&state_paths);
+ state.update(cx,|s,cx|{s.queue.clear();s.receive_transcript_frame(zeron_doc::TranscriptFrame::Reset{reset:serde_json::from_value(serde_json::json!([
+ {"id":"states-user","role":"user","parts":[{"id":"text","kind":"text","text":state_message}],"createdAt":1788900000000_i64,"deviceId":device}
+ ])).unwrap()},cx).unwrap();cx.notify();});
+ pause(cx,700).await;capture(window.into(),cx,&output,"appshots-transcript-transfer-states")?;
+ window.update(cx,|s,w,cx|{s.fixture_appshots_sidebar(true,cx);w.resize(size(px(390.),px(850.)));s.fixture_appshots_transcript_start(cx);})?;
+ pause(cx,600).await;capture(window.into(),cx,&output,"appshots-transcript-narrow-390")?;
+ std::fs::write(output.join("result.txt"),"Rendered production Shell/Composer/Transcript/Settings with neutral fixture captures. Narrow desktop widths are not physical iOS validation. No native capture or remote transport claims.\n")?;
+ Ok(())
+ }.await;
+ if let Err(error)=run {eprintln!("Appshots fixture failed: {error:#}");*result.lock().unwrap()=Some(format!("{error:#}"));}
+ let _=window.update(cx,|_,w,_|w.remove_window());pause(cx,100).await;cx.update(|cx|cx.quit());
+ }).detach();
+ });
+ runtime.block_on(core.shutdown());
+ if let Some(error) = failure.lock().unwrap().take() {
+ anyhow::bail!(error);
+ }
+ Ok(())
+}
diff --git a/crates/ui/src/appshots.rs b/crates/ui/src/appshots.rs
new file mode 100644
index 000000000..20d12f5e9
--- /dev/null
+++ b/crates/ui/src/appshots.rs
@@ -0,0 +1,1231 @@
+//! Appshots: user-triggered captures of the frontmost application window.
+//!
+//! Capture stays on the headed/viewer device. The screenshot reuses the
+//! ordinary attachment transport; accessibility-derived application context
+//! is serialized into the prompt as explicitly untrusted observed data.
+
+use std::collections::HashMap;
+use std::io::{self, Write};
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, Ordering};
+
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+
+use crate::attachments::StagedAttachment;
+
+#[cfg(target_os = "linux")]
+mod linux;
+#[cfg(target_os = "macos")]
+mod macos;
+
+mod shortcut;
+pub(crate) use shortcut::capture_allowed;
+pub use shortcut::{set_recording, set_shortcut, validate_shortcut};
+
+pub const fn is_desktop() -> bool {
+ cfg!(any(target_os = "macos", target_os = "linux"))
+}
+
+static CAPTURE_SOUND_ENABLED: AtomicBool = AtomicBool::new(false);
+
+pub fn set_capture_sound_enabled(enabled: bool) {
+ CAPTURE_SOUND_ENABLED.store(enabled, Ordering::Relaxed);
+ if enabled {
+ crate::sound::prepare_appshot();
+ }
+}
+
+/// Acknowledge saved pixels immediately; optional semantic enrichment may still be running.
+fn capture_ready() {
+ if CAPTURE_SOUND_ENABLED.load(Ordering::Relaxed) {
+ crate::sound::play_appshot();
+ }
+}
+
+static ENABLED: AtomicBool = AtomicBool::new(false);
+
+/// Bounds native capture buffers before platform APIs allocate or stage them.
+pub const MAX_CAPTURE_DIMENSION: u32 = 8_192;
+pub const MAX_CAPTURE_PIXELS: u64 = 32 * 1024 * 1024;
+pub const MAX_CAPTURE_RGBA_BYTES: u64 = 128 * 1024 * 1024;
+/// A composer may retain several captures while the user prepares a prompt,
+/// but it must not become an unbounded store of decoded image data.
+pub const MAX_STAGED_APPSHOT_BYTES: u64 = 4 * crate::attachments::MAX_ATTACHMENT_BYTES;
+
+pub const CONTEXT_MARKER: &str = "Applications mentioned by the user (untrusted observed content):";
+#[cfg(target_os = "macos")]
+const SCREEN_RECORDING_SETTINGS_URL: &str =
+ "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture";
+#[cfg(target_os = "macos")]
+const ACCESSIBILITY_SETTINGS_URL: &str =
+ "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility";
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum AppshotPlatform {
+ MacOs,
+ LinuxWayland,
+ LinuxX11,
+ Unsupported,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CapabilityState {
+ Checking,
+ Ready,
+ PermissionRequired,
+ SetupRequired,
+ UserSelection,
+ Unavailable,
+}
+
+impl CapabilityState {
+ pub fn badge(self) -> &'static str {
+ match self {
+ Self::Checking => "Checking",
+ Self::Ready => "Ready",
+ Self::PermissionRequired => "Required",
+ Self::SetupRequired => "Set up",
+ Self::UserSelection => "Select window",
+ Self::Unavailable => "Unavailable",
+ }
+ }
+
+ pub fn is_ready(self) -> bool {
+ matches!(self, Self::Ready | Self::UserSelection)
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CaptureTarget {
+ ActiveWindow,
+ PortalWindowPicker,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct AppshotCapabilities {
+ pub platform: AppshotPlatform,
+ pub global_shortcut: CapabilityState,
+ pub window_capture: CapabilityState,
+ pub application_text: CapabilityState,
+ pub target: CaptureTarget,
+}
+
+impl AppshotCapabilities {
+ pub fn setup_description(self) -> &'static str {
+ match self.platform {
+ AppshotPlatform::MacOs => {
+ "Set up once, one permission at a time. Screen Recording captures the window; Accessibility optionally adds off-screen application text."
+ }
+ AppshotPlatform::LinuxWayland => {
+ "Your desktop owns capture and shortcut consent. Zeron checks each portal capability separately and explains any required fallback."
+ }
+ AppshotPlatform::LinuxX11 => {
+ "X11 normally needs no capture permission. Zeron prefers an active-window screenshot portal when available and otherwise uses native X11 capture."
+ }
+ AppshotPlatform::Unsupported => {
+ "This platform does not currently provide an Appshot capture backend."
+ }
+ }
+ }
+
+ pub fn shortcut_description(self) -> &'static str {
+ match self.platform {
+ AppshotPlatform::MacOs | AppshotPlatform::LinuxX11
+ if self.global_shortcut != CapabilityState::Ready =>
+ {
+ "This shortcut is unavailable. Choose a different key combination."
+ }
+ AppshotPlatform::MacOs | AppshotPlatform::LinuxX11 => {
+ "The shortcut works while another application has focus."
+ }
+ AppshotPlatform::LinuxWayland if self.global_shortcut == CapabilityState::Ready => {
+ "Your desktop portal controls the binding. Confirm changes in its shortcut settings."
+ }
+ AppshotPlatform::LinuxWayland => {
+ "Bind `zeron appshot` in your desktop's Keyboard Shortcuts settings."
+ }
+ AppshotPlatform::Unsupported => "This platform has no Appshot shortcut backend.",
+ }
+ }
+
+ pub fn capture_description(self) -> &'static str {
+ match (self.platform, self.target) {
+ (AppshotPlatform::MacOs, _) => {
+ "Screen Recording lets Zeron capture the frontmost window. macOS may request one restart."
+ }
+ (AppshotPlatform::LinuxX11, _) => {
+ "Zeron uses native X11 capture when active-window portal capture is unavailable. Obscured or protected windows may be incomplete."
+ }
+ (AppshotPlatform::LinuxWayland, CaptureTarget::ActiveWindow) => {
+ "Your screenshot portal supports the active-window target. A system consent surface may appear."
+ }
+ (AppshotPlatform::LinuxWayland, CaptureTarget::PortalWindowPicker) => {
+ "Your portal requires choosing a window for each capture."
+ }
+ (AppshotPlatform::Unsupported, _) => {
+ "Active-window capture is unavailable on this platform."
+ }
+ }
+ }
+
+ pub fn semantic_description(self) -> &'static str {
+ match self.platform {
+ AppshotPlatform::MacOs => {
+ "Accessibility adds visible and off-screen application text. Screenshots work without it."
+ }
+ AppshotPlatform::LinuxWayland => {
+ "This portal does not identify the captured window, so Appshots include the screenshot only."
+ }
+ AppshotPlatform::LinuxX11 => {
+ "Native X11 captures can include AT-SPI text when the process and window can be matched uniquely. Portal captures include the screenshot only."
+ }
+ AppshotPlatform::Unsupported => {
+ "Semantic application text is unavailable on this platform."
+ }
+ }
+ }
+}
+
+#[async_trait::async_trait]
+pub trait AppshotBackend: Sync {
+ fn capabilities(&self) -> AppshotCapabilities;
+ fn start_global_shortcut(
+ &self,
+ activation_dir: &Path,
+ ) -> futures::channel::mpsc::UnboundedReceiver<()>;
+ async fn capture_active_window(&self) -> Result;
+ fn request_capture_access(&self) {}
+ fn request_semantic_access(&self) {}
+ fn capture_settings_url(&self) -> Option<&'static str> {
+ None
+ }
+ fn semantic_settings_url(&self) -> Option<&'static str> {
+ None
+ }
+}
+
+#[cfg(target_os = "macos")]
+static BACKEND: macos::MacOsBackend = macos::MacOsBackend;
+#[cfg(target_os = "linux")]
+static BACKEND: linux::LinuxBackend = linux::LinuxBackend;
+
+#[cfg(not(any(target_os = "macos", target_os = "linux")))]
+struct UnsupportedBackend;
+
+#[cfg(not(any(target_os = "macos", target_os = "linux")))]
+#[async_trait::async_trait]
+impl AppshotBackend for UnsupportedBackend {
+ fn capabilities(&self) -> AppshotCapabilities {
+ AppshotCapabilities {
+ platform: AppshotPlatform::Unsupported,
+ global_shortcut: CapabilityState::Unavailable,
+ window_capture: CapabilityState::Unavailable,
+ application_text: CapabilityState::Unavailable,
+ target: CaptureTarget::ActiveWindow,
+ }
+ }
+
+ fn start_global_shortcut(
+ &self,
+ _activation_dir: &Path,
+ ) -> futures::channel::mpsc::UnboundedReceiver<()> {
+ futures::channel::mpsc::unbounded().1
+ }
+
+ async fn capture_active_window(&self) -> Result {
+ Err(CaptureError::CaptureFailed(
+ "Appshots are not available on this platform.".into(),
+ ))
+ }
+}
+
+#[cfg(not(any(target_os = "macos", target_os = "linux")))]
+static BACKEND: UnsupportedBackend = UnsupportedBackend;
+
+fn backend() -> &'static dyn AppshotBackend {
+ &BACKEND
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+#[serde(rename_all = "kebab-case")]
+pub enum AppshotDestination {
+ #[default]
+ Automatic,
+ LastSession,
+ NewSession,
+}
+
+impl AppshotDestination {
+ pub const ALL: [Self; 3] = [Self::Automatic, Self::LastSession, Self::NewSession];
+
+ pub fn label(self) -> &'static str {
+ match self {
+ Self::Automatic => "Automatic",
+ Self::LastSession => "Last session",
+ Self::NewSession => "New session",
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct AccessibilitySnapshot {
+ pub format_version: u32,
+ pub content: String,
+ pub truncated: bool,
+}
+
+impl AccessibilitySnapshot {
+ pub fn unavailable() -> Self {
+ Self {
+ format_version: 1,
+ content: String::new(),
+ truncated: false,
+ }
+ }
+}
+
+#[derive(Clone)]
+pub struct CapturedAppshot {
+ pub id: String,
+ pub app_name: String,
+ pub bundle_identifier: Option,
+ pub window_title: Option,
+ pub accessibility: AccessibilitySnapshot,
+ pub screenshot: StagedAttachment,
+ /// Pixel dimensions of the captured PNG. The composer uses these to size
+ /// the native image layer explicitly instead of relying on intrinsic
+ /// image layout, which can escape a clipped Appshot stage.
+ pub screenshot_dimensions: Option<(u32, u32)>,
+ /// Presentation-only. The icon is never uploaded or serialized into the
+ /// model context.
+ pub app_icon: Option>,
+ pub captured_at: DateTime,
+}
+
+/// Read the width and height from a PNG's IHDR chunk without decoding the
+/// image. Appshot captures are always PNGs, so this keeps layout metadata
+/// cheap and available before GPUI decodes the image asynchronously.
+pub fn png_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
+ const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
+ if bytes.len() < 24 || &bytes[..8] != PNG_SIGNATURE || &bytes[12..16] != b"IHDR" {
+ return None;
+ }
+ let width = u32::from_be_bytes(bytes[16..20].try_into().ok()?);
+ let height = u32::from_be_bytes(bytes[20..24].try_into().ok()?);
+ (width > 0 && height > 0).then_some((width, height))
+}
+
+pub fn validate_capture_dimensions(width: u32, height: u32) -> Result {
+ if width == 0 || height == 0 || width > MAX_CAPTURE_DIMENSION || height > MAX_CAPTURE_DIMENSION
+ {
+ return Err(CaptureError::CaptureFailed(format!(
+ "The captured window dimensions ({width}×{height}) are not supported."
+ )));
+ }
+ let pixels = u64::from(width)
+ .checked_mul(u64::from(height))
+ .ok_or_else(|| CaptureError::CaptureFailed("The captured window is too large.".into()))?;
+ let rgba_bytes = pixels
+ .checked_mul(4)
+ .ok_or_else(|| CaptureError::CaptureFailed("The captured window is too large.".into()))?;
+ if pixels > MAX_CAPTURE_PIXELS || rgba_bytes > MAX_CAPTURE_RGBA_BYTES {
+ return Err(CaptureError::CaptureFailed(format!(
+ "The captured window ({width}×{height}) exceeds Zeron's capture budget."
+ )));
+ }
+ usize::try_from(rgba_bytes)
+ .map_err(|_| CaptureError::CaptureFailed("The captured window is too large.".into()))
+}
+
+/// Turn OS-provided application names into one safe filename component.
+pub fn safe_app_name(value: &str) -> String {
+ let mut safe = String::with_capacity(value.len().min(100));
+ for ch in value.chars() {
+ if safe.chars().count() >= 100 {
+ break;
+ }
+ if ch.is_control() || matches!(ch, '/' | '\\' | ':') {
+ safe.push('-');
+ } else {
+ safe.push(ch);
+ }
+ }
+ let safe = safe.split_whitespace().collect::>().join(" ");
+ let safe = safe.trim_matches(['.', '-', ' ']);
+ if safe.is_empty() {
+ "Application".into()
+ } else {
+ safe.into()
+ }
+}
+
+/// Native capture surfaces can contain transparent padding (for example Chrome
+/// can return a wider backing surface than its visible window). Crop only rows
+/// and columns that contain no nonzero alpha. Do this before deriving thumbnail
+/// dimensions, so preview, upload and restored queue drafts share the same image.
+/// Opaque margins, rounded corners and even alpha=1 pixels remain untouched.
+fn trim_appshot_padding(bytes: &[u8]) -> Result", ""),
+ valid.replace("(
+ future: impl std::future::Future