import AppKit import Foundation import Network import Observation import SwiftUI import WebKit enum Page: Hashable { case file(taskId: String, path: String) case preview(taskId: String, port: Int) var taskId: String { switch self { case .file(let taskId, _), .preview(let taskId, _): taskId } } var isFile: Bool { if case .file = self { return true } return false } @MainActor func title(_ store: Store) -> String { switch self { case .file(_, let path): (path as NSString).lastPathComponent case .preview(let taskId, let port): store.forwards.name(taskId, port) ?? "port \(port)" } } @MainActor func url(_ store: Store) -> URL? { switch self { case .file(let taskId, let path): store.pages.url(taskId, path) case .preview(let taskId, let port): store.forwards.url(taskId, port) } } } private let pageExts: Set = ["html", "htm"] func isPage(_ path: String) -> Bool { pageExts.contains((path as NSString).pathExtension.lowercased()) } struct ShownPages { var pages: [Page] var current: Page } extension Store { func openPage(_ page: Page) { var shown = shownPages[page.taskId] ?? ShownPages(pages: [], current: page) if !shown.pages.contains(page) { shown.pages.append(page) } shown.current = page shownPages[page.taskId] = shown sheet = Sheet(owner: .task(page.taskId), view: .tab(.page)) } func closePage(_ page: Page) { guard var shown = shownPages[page.taskId], let i = shown.pages.firstIndex(of: page) else { return } shown.pages.remove(at: i) if shown.pages.isEmpty { shownPages[page.taskId] = nil if sheet?.tab == .page { sheet = nil } return } if shown.current == page { shown.current = shown.pages[min(i, shown.pages.count - 1)] } shownPages[page.taskId] = shown } func currentPage() -> Page? { selectedTaskId.flatMap { shownPages[$0]?.current } } } @MainActor @Observable final class Pages { private(set) var port: Int? @ObservationIgnored var served: [String: [String: Data]] = [:] @ObservationIgnored private var listener: PageListener? init(api: API) { listener = PageListener(api: api, ready: { [weak self] port in Task { @MainActor in self?.port = port } }, served: { [weak self] taskId, path, body in Task { @MainActor in self?.served[taskId, default: [:]][path] = body } }) } func url(_ taskId: String, _ path: String) -> URL? { guard let port else { return nil } var c = URLComponents() c.scheme = "http" c.host = "127.0.0.1" c.port = port c.path = "/\(taskId)/\(path)" return c.url } func close() { listener?.close() listener = nil port = nil } static func fetch(_ api: API, _ taskId: String, _ path: String) async -> (status: Int, type: String, body: Data) { do { let (body, response) = try await api.session.data(from: api.url("/api/tasks/\(taskId)/raw", [URLQueryItem(name: "path", value: path)])) let http = response as! HTTPURLResponse return (http.statusCode, http.value(forHTTPHeaderField: "Content-Type") ?? "application/octet-stream", body) } catch { return (502, "text/plain; charset=utf-8", Data(errorText(error).utf8)) } } } private let headEnd = Data("\r\n\r\n".utf8) private let maxHead = 64 << 10 private final class PageListener: @unchecked Sendable { private let api: API private let queue = DispatchQueue(label: "run.fc.byre.pages") private let served: @Sendable (String, String, Data) -> Void private var listener: NWListener? init(api: API, ready: @escaping @Sendable (Int) -> Void, served: @escaping @Sendable (String, String, Data) -> Void) { self.api = api self.served = served let params = NWParameters.tcp params.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: .any) let listener: NWListener do { listener = try NWListener(using: params) } catch { log.error("page listener: \(String(describing: error), privacy: .public)") return } self.listener = listener listener.stateUpdateHandler = { [weak listener] state in switch state { case .ready: if let port = listener?.port { ready(Int(port.rawValue)) } case .failed(let error): log.error("page listener: \(String(describing: error), privacy: .public)") default: break } } listener.newConnectionHandler = { [weak self] conn in guard let self else { conn.cancel() return } conn.start(queue: self.queue) self.read(conn, Data()) } listener.start(queue: queue) } func close() { queue.async { self.listener?.newConnectionHandler = nil self.listener?.cancel() self.listener = nil } } private func read(_ conn: NWConnection, _ buf: Data) { conn.receive(minimumIncompleteLength: 1, maximumLength: maxHead) { data, _, complete, error in var buf = buf if let data { buf.append(data) } if let end = buf.range(of: headEnd) { self.serve(conn, String(decoding: buf[.. maxHead { conn.cancel() return } self.read(conn, buf) } } private func serve(_ conn: NWConnection, _ head: String) { let lines = head.components(separatedBy: "\r\n") let hosts = lines.dropFirst().compactMap { line -> String? in guard let colon = line.firstIndex(of: ":"), line[..? func check(_ api: API, _ pages: Pages, _ taskId: String) { timer?.cancel() timer = Task { try? await Task.sleep(for: .milliseconds(1500)) var changed = false for (path, shown) in pages.served[taskId] ?? [:] { let fresh = await Pages.fetch(api, taskId, path).body if Task.isCancelled { return } if fresh != shown { pages.served[taskId]?[path] = fresh changed = true } } if changed { reloads += 1 } } } func stop() { timer?.cancel() timer = nil } } struct PageTabs: View { let shown: ShownPages @Environment(Store.self) private var store @ViewState private var watch = PageWatch() var body: some View { VStack(spacing: 0) { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 4) { ForEach(shown.pages, id: \.self) { page in PageTabButton(page: page, active: page == shown.current) } } .padding(.horizontal, 12) } .fixedSize(horizontal: false, vertical: true) .overlay(alignment: .bottom) { Rectangle().fill(Palette.border).frame(height: 1) } ZStack { ForEach(shown.pages, id: \.self) { page in if let url = page.url(store) { WebPage(url: url, reloads: page.isFile ? watch.reloads : 0, shown: page == shown.current) } else if page == shown.current { EmptyText(text: "Loading…") } } } } .task(id: shown.current.taskId) { let taskId = shown.current.taskId let watch = watch let api = store.api let pages = store.pages let off = store.selectedFeed?.appended.add { events in if events.contains(where: { $0.kind == "agent.tool_result" || $0.kind == "turn.ended" }) { watch.check(api, pages, taskId) } } await untilCancelled() off?() watch.stop() } } } private struct PageTabButton: View { let page: Page let active: Bool @Environment(Store.self) private var store @ViewState private var hover = false var body: some View { HStack(spacing: 0) { PaneTabButton(label: page.title(store), active: active) { store.openPage(page) } Button { store.closePage(page) } label: { Text("✕") .font(Fonts.font(11)) .foregroundStyle(hover ? Palette.text : Palette.muted) .padding(.trailing, 6) .contentShape(Rectangle()) } .buttonStyle(.plain) .help("Close this page") .onHover { hover = $0 } } .help(page.url(store)?.absoluteString ?? "") } } private struct WebPage: NSViewRepresentable { let url: URL let reloads: Int let shown: Bool final class Coordinator: NSObject, WKUIDelegate { var url: URL? var reloads = 0 func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { if let url = action.request.url { NSWorkspace.shared.open(url) } return nil } } func makeCoordinator() -> Coordinator { Coordinator() } func makeNSView(context: Context) -> WKWebView { let config = WKWebViewConfiguration() config.websiteDataStore = .nonPersistent() let view = WKWebView(frame: .zero, configuration: config) view.uiDelegate = context.coordinator return view } func updateNSView(_ view: WKWebView, context: Context) { view.isHidden = !shown let c = context.coordinator if c.url != url { c.url = url c.reloads = reloads view.load(URLRequest(url: url)) return } if c.reloads != reloads { c.reloads = reloads view.reload() } } }