Show HTML pages in a task view beside the transcript
This commit is contained in:
@@ -17,7 +17,5 @@
|
||||
|
||||
# M8 — macOS client
|
||||
|
||||
- Page view for HTML
|
||||
Build once the attached sheet over the lead pane (mockup 3b, task "Left-side task context") has landed. A task view that shows an HTML page in a `WKWebView` inside that sheet, with the task's transcript staying on the right. It opens from an HTML attachment on a question, from clicking an `.html` file in Files, or from a preview link. It reloads in place when the agent changes the file, and has a "↗ Browser" button that opens the page at full width in the default browser. One-file pages load through the worktree file endpoint; a page's neighboring CSS and image files need serving too. Wireframe: `mockups/html-preview.html`, option 2. `docs/macos.md`, "Decision", allows this one web view.
|
||||
- Phone app
|
||||
A native client for the phone over the same API, sharing the macOS client's SwiftUI code where it can. Every surveyed tool has one (Claude, Codex, Cursor and Conductor apps, GitHub Mobile, Superset for iPhone); it needs its own layout, since two half-width panes don't fit a phone.
|
||||
|
||||
@@ -798,6 +798,27 @@ paths:
|
||||
"404": { $ref: "#/components/responses/Error" }
|
||||
"409": { $ref: "#/components/responses/Error" }
|
||||
|
||||
/api/tasks/{taskId}/raw:
|
||||
parameters:
|
||||
- { name: taskId, in: path, required: true, schema: { type: string } }
|
||||
get:
|
||||
operationId: getTaskRaw
|
||||
description: >
|
||||
A regular file in the task's worktree, whole, with a content type chosen by its extension: an HTML page in the
|
||||
app's Page view and the stylesheets, scripts and images it loads. The response carries a sandbox
|
||||
Content-Security-Policy, so a page opened from this address directly can't act on the API as its viewer.
|
||||
A path that leaves the worktree, through .. or a symlink, is refused.
|
||||
parameters:
|
||||
- { name: path, in: query, required: true, schema: { type: string }, description: "Slash-separated, relative to the worktree root." }
|
||||
responses:
|
||||
"200":
|
||||
description: The file's bytes.
|
||||
content:
|
||||
"*/*": { schema: { type: string, format: binary } }
|
||||
"400": { $ref: "#/components/responses/Error" }
|
||||
"404": { $ref: "#/components/responses/Error" }
|
||||
"409": { $ref: "#/components/responses/Error" }
|
||||
|
||||
/api/terminals:
|
||||
get:
|
||||
operationId: listTerminals
|
||||
|
||||
@@ -35,6 +35,8 @@ Every build is signed for distribution outside the App Store: the team's Develop
|
||||
- The transcript is a plain `VStack` in a `ScrollView`, not lazy: a `LazyVStack` estimates the height of rows it hasn't shown yet and corrects it when they scroll into view, without moving the scroll offset, so scrolling up through rows not yet shown jumped by each correction; SwiftUI can't move the offset mid-scroll without stopping its momentum.
|
||||
- The terminal cursor is a steady block: SwiftTerm's blinking cursor restarts its Core Animation fade on every keypress, scheduled 0.7 s out, and on a ProMotion display macOS then holds the window's screen update until that fade starts, so each typed letter appears about 0.7 s late. SwiftTerm redraws the whole screen whenever a terminal color is set, even when its value hasn't changed.
|
||||
|
||||
- The Page view loads a worktree page from a plain-HTTP server the app runs on a loopback port (`http://127.0.0.1:<port>/<task id>/<path>`), which fetches each file through the server's `getTaskRaw` endpoint. The page's relative links to its stylesheets, scripts and images resolve to files beside it, and "↗ Browser" opens the same address. Previews load from their loopback forwards at `<name>-<task>.localhost`. App Transport Security blocks plain HTTP to `*.localhost` even with `NSAllowsLocalNetworking`; the `localhost` exception domain with subdomains in `Info.plist` allows it (loopback IP addresses need no exception).
|
||||
|
||||
## Updates
|
||||
|
||||
The update UI must not offer Skip or turning automatic updates off: on a Mac running the server, `byre-update` would override either choice while the app isn't running.
|
||||
|
||||
+142
-89
@@ -1892,6 +1892,12 @@ type MergeTaskJSONBody struct {
|
||||
// MergeTaskJSONBodyTarget defines parameters for MergeTask.
|
||||
type MergeTaskJSONBodyTarget string
|
||||
|
||||
// GetTaskRawParams defines parameters for GetTaskRaw.
|
||||
type GetTaskRawParams struct {
|
||||
// Path Slash-separated, relative to the worktree root.
|
||||
Path string `form:"path" json:"path"`
|
||||
}
|
||||
|
||||
// GetTaskTerminalParams defines parameters for GetTaskTerminal.
|
||||
type GetTaskTerminalParams struct {
|
||||
Cols int `form:"cols" json:"cols"`
|
||||
@@ -2200,6 +2206,9 @@ type ServerInterface interface {
|
||||
// (DELETE /api/tasks/{taskId}/queue/{inputId})
|
||||
WithdrawMessage(w http.ResponseWriter, r *http.Request, taskId string, inputId int64)
|
||||
|
||||
// (GET /api/tasks/{taskId}/raw)
|
||||
GetTaskRaw(w http.ResponseWriter, r *http.Request, taskId string, params GetTaskRawParams)
|
||||
|
||||
// (DELETE /api/tasks/{taskId}/ready)
|
||||
DismissReady(w http.ResponseWriter, r *http.Request, taskId string)
|
||||
|
||||
@@ -4004,6 +4013,48 @@ func (siw *ServerInterfaceWrapper) WithdrawMessage(w http.ResponseWriter, r *htt
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetTaskRaw operation middleware
|
||||
func (siw *ServerInterfaceWrapper) GetTaskRaw(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var err error
|
||||
_ = err
|
||||
|
||||
// ------------- Path parameter "taskId" -------------
|
||||
var taskId string
|
||||
|
||||
err = runtime.BindStyledParameterWithOptions("simple", "taskId", r.PathValue("taskId"), &taskId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
|
||||
if err != nil {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "taskId", Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
// Parameter object where we will unmarshal all parameters from the context
|
||||
var params GetTaskRawParams
|
||||
|
||||
// ------------- Required query parameter "path" -------------
|
||||
|
||||
err = runtime.BindQueryParameterWithOptions("form", true, true, "path", r.URL.Query(), ¶ms.Path, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
|
||||
if err != nil {
|
||||
var requiredError *runtime.RequiredParameterError
|
||||
if errors.As(err, &requiredError) {
|
||||
siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "path"})
|
||||
} else {
|
||||
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "path", Err: err})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
siw.Handler.GetTaskRaw(w, r, taskId, params)
|
||||
}))
|
||||
|
||||
for _, middleware := range siw.HandlerMiddlewares {
|
||||
handler = middleware(handler)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// DismissReady operation middleware
|
||||
func (siw *ServerInterfaceWrapper) DismissReady(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -4661,6 +4712,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/diff", wrapper.GetTaskDiff)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/dir", wrapper.GetTaskDir)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/file", wrapper.GetTaskFile)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/raw", wrapper.GetTaskRaw)
|
||||
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/terminals", wrapper.ListTerminals)
|
||||
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/tasks/{taskId}/terminals", wrapper.StartTaskTerminal)
|
||||
m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/api/tasks/{taskId}/terminals/{terminalId}", wrapper.CloseTaskTerminal)
|
||||
@@ -4959,95 +5011,96 @@ var swaggerSpec = []string{
|
||||
"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",
|
||||
"5Qp9jEjchIkoYGjhgJn8ZpTUHz/+rwEA",
|
||||
"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",
|
||||
}
|
||||
|
||||
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
@@ -215,6 +216,55 @@ func (s *Server) GetTaskFile(w http.ResponseWriter, r *http.Request, taskID stri
|
||||
writeJSON(w, http.StatusOK, fc)
|
||||
}
|
||||
|
||||
func (s *Server) GetTaskRaw(w http.ResponseWriter, r *http.Request, taskID string, params api.GetTaskRawParams) {
|
||||
t, ok := s.activeTask(w, r, taskID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if params.Path == "" {
|
||||
writeError(w, http.StatusBadRequest, errors.New("path is required"))
|
||||
return
|
||||
}
|
||||
name, ok := worktreeName(w, params.Path)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
root, err := os.OpenRoot(t.WorktreePath)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
defer root.Close()
|
||||
|
||||
f, err := root.Open(name)
|
||||
if err != nil {
|
||||
writePathError(w, err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if !fi.Mode().IsRegular() {
|
||||
writeError(w, http.StatusBadRequest, fmt.Errorf("%s is not a regular file", params.Path))
|
||||
return
|
||||
}
|
||||
|
||||
contentType := mime.TypeByExtension(path.Ext(name))
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Content-Security-Policy", "sandbox")
|
||||
w.Header().Set("Cache-Control", "private, no-cache")
|
||||
http.ServeContent(w, r, "", fi.ModTime(), f)
|
||||
}
|
||||
|
||||
func worktreeName(w http.ResponseWriter, rel string) (string, bool) {
|
||||
if rel == "" {
|
||||
return ".", true
|
||||
|
||||
@@ -24,6 +24,19 @@
|
||||
<true/>
|
||||
<key>SUAutomaticallyUpdate</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSExceptionDomains</key>
|
||||
<dict>
|
||||
<key>localhost</key>
|
||||
<dict>
|
||||
<key>NSIncludesSubdomains</key>
|
||||
<true/>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>byre's agents drive Orion on this Mac through AppleScript when a task asks them to.</string>
|
||||
</dict>
|
||||
|
||||
@@ -1676,7 +1676,7 @@ struct NoteView: View {
|
||||
continue
|
||||
}
|
||||
var a = AttributedString(url.absoluteString)
|
||||
a.link = url
|
||||
a.link = AppLink.preview(taskId, port)
|
||||
a.foregroundColor = Palette.accent
|
||||
out += a
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ extension Store {
|
||||
}
|
||||
|
||||
func openFile(_ taskId: String, _ path: String) {
|
||||
if isPage(path) {
|
||||
route("#/task/\(taskId)")
|
||||
openPage(.file(taskId: taskId, path: path))
|
||||
return
|
||||
}
|
||||
filePlaces[taskId] = path.hasSuffix("/") ? FilePlace(dir: String(path.dropLast())) : FilePlace(dir: parent(path), file: path)
|
||||
route("#/task/\(taskId)/files")
|
||||
}
|
||||
@@ -266,6 +271,8 @@ private struct FilesBody: View {
|
||||
EntryRow(label: e._type == .dir ? "\(e.name)/" : e.name, kind: e._type, ignored: e.ignored, meta: meta, symlink: e.symlink) {
|
||||
if e._type == .dir {
|
||||
model.go(FilePlace(dir: join(l.path, e.name)))
|
||||
} else if e._type == .file && isPage(e.name), let taskId = store.selectedTaskId {
|
||||
store.openPage(.file(taskId: taskId, path: join(l.path, e.name)))
|
||||
} else if e._type == .file {
|
||||
model.go(FilePlace(dir: l.path, file: join(l.path, e.name)))
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@ final class Forwards {
|
||||
return c.url
|
||||
}
|
||||
|
||||
func name(_ taskId: String, _ port: Int) -> String? {
|
||||
localPorts.keys.first { $0.taskId == taskId && $0.port == port }?.name
|
||||
}
|
||||
|
||||
func load(_ api: API, _ taskId: String) async {
|
||||
do {
|
||||
set(taskId, try await api.client.listPreviews(path: .init(taskId: taskId)).ok.body.json)
|
||||
|
||||
@@ -26,6 +26,14 @@ enum AppLink {
|
||||
return c.url!
|
||||
}
|
||||
|
||||
static func preview(_ taskId: String, _ port: Int) -> URL {
|
||||
var c = URLComponents()
|
||||
c.scheme = "byre-app"
|
||||
c.host = "preview"
|
||||
c.queryItems = [URLQueryItem(name: "task", value: taskId), URLQueryItem(name: "port", value: String(port))]
|
||||
return c.url!
|
||||
}
|
||||
|
||||
static func todo(_ projectId: String, _ title: String, discuss: Bool) -> URL {
|
||||
var c = URLComponents()
|
||||
c.scheme = "byre-app"
|
||||
@@ -54,6 +62,10 @@ func openAppLink(_ url: URL, _ store: Store, _ overlays: Overlays) -> OpenURLAct
|
||||
store.route(q["hash"] ?? "")
|
||||
case "file":
|
||||
store.openFile(q["task"] ?? "", q["path"] ?? "")
|
||||
case "preview":
|
||||
let taskId = q["task"] ?? ""
|
||||
store.route("#/task/\(taskId)")
|
||||
store.openPage(.preview(taskId: taskId, port: Int(q["port"] ?? "") ?? 0))
|
||||
case "todo":
|
||||
store.startTodo(q["project"] ?? "", q["title"] ?? "", discuss: q["discuss"] == "1", overlays)
|
||||
case "suggest":
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
enum Page: Equatable {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@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<String> = ["html", "htm"]
|
||||
|
||||
func isPage(_ path: String) -> Bool {
|
||||
pageExts.contains((path as NSString).pathExtension.lowercased())
|
||||
}
|
||||
|
||||
extension Store {
|
||||
func openPage(_ page: Page) {
|
||||
sheet = Sheet(owner: .task(page.taskId), view: .page(page))
|
||||
}
|
||||
}
|
||||
|
||||
@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[..<end.lowerBound], as: UTF8.self))
|
||||
return
|
||||
}
|
||||
if complete || error != nil || buf.count > maxHead {
|
||||
conn.cancel()
|
||||
return
|
||||
}
|
||||
self.read(conn, buf)
|
||||
}
|
||||
}
|
||||
|
||||
private func serve(_ conn: NWConnection, _ head: String) {
|
||||
let parts = head.components(separatedBy: "\r\n")[0].split(separator: " ")
|
||||
guard parts.count == 3, parts[0] == "GET", let path = URLComponents(string: String(parts[1]))?.path else {
|
||||
respond(conn, 400, "text/plain; charset=utf-8", Data("bad request".utf8))
|
||||
return
|
||||
}
|
||||
let route = path.drop(while: { $0 == "/" }).split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
guard route.count == 2, !route[1].isEmpty else {
|
||||
respond(conn, 404, "text/plain; charset=utf-8", Data("not found".utf8))
|
||||
return
|
||||
}
|
||||
let taskId = String(route[0])
|
||||
let file = route[1].hasSuffix("/") ? "\(route[1])index.html" : String(route[1])
|
||||
let api = api
|
||||
Task {
|
||||
let (status, type, body) = await Pages.fetch(api, taskId, file)
|
||||
self.served(taskId, file, body)
|
||||
self.respond(conn, status, type, body)
|
||||
}
|
||||
}
|
||||
|
||||
private func respond(_ conn: NWConnection, _ status: Int, _ type: String, _ body: Data) {
|
||||
let head = "HTTP/1.1 \(status) \(HTTPURLResponse.localizedString(forStatusCode: status))\r\nContent-Type: \(type)\r\nContent-Length: \(body.count)\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n"
|
||||
conn.send(content: Data(head.utf8) + body, isComplete: true, completion: .contentProcessed { _ in
|
||||
conn.cancel()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
private final class PageWatch {
|
||||
private(set) var reloads = 0
|
||||
@ObservationIgnored private var timer: Task<Void, Never>?
|
||||
|
||||
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 PageView: View {
|
||||
let page: Page
|
||||
@Environment(Store.self) private var store
|
||||
@ViewState private var watch = PageWatch()
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let url = page.url(store) {
|
||||
WebPage(url: url, reloads: watch.reloads)
|
||||
} else {
|
||||
EmptyText(text: "Loading…")
|
||||
}
|
||||
}
|
||||
.task(id: page) {
|
||||
guard case .file(let taskId, _) = page else {
|
||||
return
|
||||
}
|
||||
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 WebPage: NSViewRepresentable {
|
||||
let url: URL
|
||||
let reloads: Int
|
||||
|
||||
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) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,8 +277,8 @@ private struct TaskBar: View {
|
||||
HStack(spacing: 12) {
|
||||
Spacer(minLength: 0)
|
||||
ForEach(previews, id: \.port) { p in
|
||||
if let url = store.forwards.url(p.taskId, p.port) {
|
||||
PreviewLink(preview: p, url: url)
|
||||
if store.forwards.url(p.taskId, p.port) != nil {
|
||||
PreviewLink(preview: p)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -336,18 +336,21 @@ private struct TaskBar: View {
|
||||
|
||||
private struct PreviewLink: View {
|
||||
let preview: Components.Schemas.Preview
|
||||
let url: URL
|
||||
@Environment(Store.self) private var store
|
||||
@Environment(\.paneAccent) private var accent
|
||||
@ViewState private var hover = false
|
||||
|
||||
var body: some View {
|
||||
Link(destination: url) {
|
||||
Text("\(preview.name) ↗")
|
||||
Button {
|
||||
store.openPage(.preview(taskId: preview.taskId, port: preview.port))
|
||||
} label: {
|
||||
Text(preview.name)
|
||||
.foregroundStyle(accent)
|
||||
.underline(hover)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.help("Open \(preview.name) (port \(preview.port)) in a new window")
|
||||
.buttonStyle(.plain)
|
||||
.help("Show \(preview.name) (port \(preview.port)) beside the transcript")
|
||||
.onHover { hover = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,8 +506,20 @@ private struct Attachments: View {
|
||||
private func content(_ a: Attachment) -> some View {
|
||||
switch a.kind {
|
||||
case .file:
|
||||
let lines = a.startLine.map { ":\($0)\(a.endLine.map { "-\($0)" } ?? "")" } ?? ""
|
||||
code("\(a.path ?? "")\(lines)").textSelection(.enabled)
|
||||
let path = a.path ?? ""
|
||||
let root = (store.findTask(taskId)?.worktreePath ?? "") + "/"
|
||||
let rel = path.hasPrefix(root) ? String(path.dropFirst(root.count)) : path
|
||||
if isPage(rel) && !rel.hasPrefix("/") {
|
||||
Button(path) {
|
||||
store.openFile(taskId, rel)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(Palette.accent)
|
||||
.help("Show the page beside the transcript")
|
||||
} else {
|
||||
let lines = a.startLine.map { ":\($0)\(a.endLine.map { "-\($0)" } ?? "")" } ?? ""
|
||||
code("\(path)\(lines)").textSelection(.enabled)
|
||||
}
|
||||
case .diff:
|
||||
Text("diff \(code(a.ref ?? ""))").textSelection(.enabled)
|
||||
case .image:
|
||||
|
||||
@@ -5,6 +5,7 @@ enum SheetView {
|
||||
case tab(TaskTab)
|
||||
case image(NSImage)
|
||||
case diagram(BoxLayout)
|
||||
case page(Page)
|
||||
}
|
||||
|
||||
struct Sheet {
|
||||
@@ -81,6 +82,19 @@ private struct SheetPanel: View {
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Spacer(minLength: 0)
|
||||
if case .page(let page) = sheet.view, let url = page.url(store) {
|
||||
Button {
|
||||
NSWorkspace.shared.open(url)
|
||||
} label: {
|
||||
Text("↗ Browser")
|
||||
.font(Fonts.font(13, weight: 700))
|
||||
.foregroundStyle(accent)
|
||||
.padding(.horizontal, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Open the page in the default browser")
|
||||
}
|
||||
Button {
|
||||
store.sheet = nil
|
||||
} label: {
|
||||
@@ -115,6 +129,7 @@ private struct SheetPanel: View {
|
||||
case .tab(let t): tabLabels[t]!
|
||||
case .image: "Image"
|
||||
case .diagram: "Diagram"
|
||||
case .page(let page): page.title(store)
|
||||
}
|
||||
if !overLeft {
|
||||
return "Lead · \(label)"
|
||||
@@ -144,6 +159,8 @@ private struct SheetPanel: View {
|
||||
DiagramViewer(layout: layout)
|
||||
.id(layout.id)
|
||||
.padding(12)
|
||||
case .page(let page):
|
||||
PageView(page: page)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +172,7 @@ final class Connection {
|
||||
let origin: URL
|
||||
let overlays = Overlays()
|
||||
let forwards: Forwards
|
||||
let pages: Pages
|
||||
var phase: Phase = .starting
|
||||
@ObservationIgnored private var stopped = false
|
||||
@ObservationIgnored private var restarting = false
|
||||
@@ -179,6 +180,7 @@ final class Connection {
|
||||
init(origin: URL) {
|
||||
self.origin = origin
|
||||
forwards = Forwards(host: origin.host()!)
|
||||
pages = Pages(api: API(origin: origin))
|
||||
Task {
|
||||
await start()
|
||||
}
|
||||
@@ -194,6 +196,7 @@ final class Connection {
|
||||
func stop() async {
|
||||
stopped = true
|
||||
forwards.closeAll()
|
||||
pages.close()
|
||||
if let store {
|
||||
await store.stop()
|
||||
}
|
||||
@@ -228,7 +231,7 @@ final class Connection {
|
||||
}
|
||||
|
||||
private func attempt(_ old: Store?) async -> Bool {
|
||||
let store = Store(api: API(origin: origin), images: old?.images ?? ImageDrafts(), forwards: forwards)
|
||||
let store = Store(api: API(origin: origin), images: old?.images ?? ImageDrafts(), forwards: forwards, pages: pages)
|
||||
do {
|
||||
let status = try await store.api.client.getStatus().ok.body.json
|
||||
async let me: Void = store.loadMe()
|
||||
|
||||
@@ -88,6 +88,7 @@ final class Store {
|
||||
let paneFocus: PaneFocus
|
||||
let remote: Remote
|
||||
let forwards: Forwards
|
||||
let pages: Pages
|
||||
|
||||
private var taskFeeds: [String: Feed] = [:]
|
||||
@ObservationIgnored private var historyLoads: Set<ObjectIdentifier> = []
|
||||
@@ -102,10 +103,11 @@ final class Store {
|
||||
let changesMemory = ChangesMemory()
|
||||
@ObservationIgnored private var localSends: [String: LocalSends] = [:]
|
||||
|
||||
init(api: API, images: ImageDrafts, forwards: Forwards) {
|
||||
init(api: API, images: ImageDrafts, forwards: Forwards, pages: Pages) {
|
||||
self.api = api
|
||||
self.images = images
|
||||
self.forwards = forwards
|
||||
self.pages = pages
|
||||
leadFeed = Feed(shown: { true })
|
||||
paneFocus = PaneFocus(api: api)
|
||||
remote = Remote(api: api)
|
||||
|
||||
Reference in New Issue
Block a user