Show HTML pages in a task view beside the transcript

This commit is contained in:
Ian Gulliver
2026-10-21 17:52:04 -07:00
parent af9c750298
commit 585c8ba5f8
16 changed files with 600 additions and 102 deletions
-2
View File
@@ -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.
+21
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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(), &params.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{
"hCc4xK08I5ffpykFN3OQhZLWMavShpdXRfWfKTLByaAo3x8edX15W1CyGnbPQNsKubDDRTUlZY0Tkj0M",
"yS8lPvsvKG9tpVHHSejaKbPkenKSXTSV1kTFtBnIL6S3zr65pexgrcF7HSNR4FXOG9yiuTzrEuxpiWeg",
"c7yURkC+ZFKFN7dx5KX0sVAB3iTWNoLWcVFCpnMV3kijHlJy6vr/ig7gG3XCRW2OMTB2Z7wzNXyL7T4R",
"s2Vda0qt8KaXn+CQUl3j+P5xxST0z8hrlY8Iy1qiG0fNFQdTiW97STi11qs1ea//hu16dJFF72lwAdl/",
"Ext9kYh5GuwQav+saaLu4BDNeOz5gU4B9f51/JsS0KZdnBhQn3DEIqhPPvCvPZ4gObwf6O7Ys+7+mj83",
"s5egcD9+rwTOE9nWOhzn1XQebB7VHnUxTiUr1YPJ3oFtISMd3UZd6bBynfgjVCkrH7gAWMw0TQoAHAJs",
"dZdzQXdAUgbO+eqvcu5UmjirlB087zzW5V7LGgMuk5Nlat75NWNBF+sgWsNu4kQwAZKvYrW+lQ69FP2j",
"m/UZfJZKyzwoXUmjfGHubM8JiwzafanLfdWUxcNDKVK/jdMzyAEbc7LDQz9AxejA67tYYlQDoZ+l6Z+Y",
"2MOuI1NEVSrA+t+4+q3gKsy5bhu1W8zPkCS2r4S31igfCEmoUjhK+LFJ5jSRHpW8Ju7gKpEm+0XcJHi0",
"gwX+OLtv656fcrQkDtELmSbO986ItcfLiX0pZf82Sme/v+mFrFfkFMKtHspKz91/FWeQhC3TvG8C0lc9",
"8mhX3ylovcMWX+KMUUDEYeerI0JcWZiZLiI7/1RnLqvamdWVTaVZ0/3bLXlYbxbb67CO5URTDUkfrUXW",
"9StmVlw8EM4wHHSMQZIhe0/5rPNqztgrTUcXnZ1pQbiLD3Okszqx/3qxOvsOMheBm7y28QjCRmf13ZAp",
"tDYlG51GMW1i2Dpnex9J1liF7gEZKex/R2SFx4whHvDei0vZeNWJWIU6d8ZGQPwTSeKwyR3J/opcAlUk",
"xTDBvFB+JRyyANGHAZ5FK3Iqvt+TnrsQxl6Vvsh5DAIYO9lZNypmLu5pItfSd4mPMNShGHsuXUg1BB+I",
"VqWKd/8dVngoYWNB8uQD/QCMv9QGsMmffNCmVu8/Hob38fs7oX1VztWB44NDmPWoVE/WFhzjyMcmsdjD",
"E8wxPZ4Tzv+QCfXcYvY4ssm8LqKsa6cwxkfGSVHO+NqaOF/0fvfK1F38Q1Zi0YpL/V5YDJPWK2M5rqrs",
"90YjnHP8+oO4v2VDfJ0wGdJQTB0nAukXu/eTTHnyIf4c+SKUHAtyse6MmTkdYvlo4YMsyW51dEqoyq7k",
"ufT2dR0cOmB8FnXZ25gpr4MaOuBhsnm2rSuYdRtU5iAFVXYOloDZV/1bk4CffDEJ+ItLv9ED4+QD/9pj",
"KIoOHg8Io0N8y3iyD3VyIijux6OWYHrCHj2HXdT3nMAUg/qMaiEpSgzAnR9FDpP41bU0tb28FI9WVjYV",
"pnZn3yQfZFDwRNaCsqr1Qo7R3aZR/jE4TShOi4HWbWocuu2ixIAbLloLFEOSzodfW9Mxqci2MjeceOA4",
"yVIejyp+zjRD1V0RG9Vw2STb1N36y1k+cZtyNH+APHw0yLfruhnhjNt4I1nJcY+b6r4sb/EIYTUM126/",
"ziF6GevGcWFjcv0dIdCrOMlJSvlltzIBDfcxJaX5ujvZ8/z+0ht5HiNbUexDDE+JhJKLclSRZJQLiGeK",
"u2b6lEgp9qN99IKvhh7kv7WqVakYwDKWe+LQbAyr6HzKrW3QuIAKStl5ek6gHHjJszf4v6qvOYJ/PSJU",
"35okH6RujAo7FZKpsh84cBzFCqf4nTBYK6sX7k/R91jiL6ZkZoEXlNWKaqCSXcrYZJSqhLepPnB+7UJW",
"6KU0wusVPTWYadhkBVU1pyKSF6D4NKyoxwmWblTQgdLbB1WD8hCTHuw1SisA23l/R/yVP/kA/+3hfJMC",
"/8FWMK2gypRTnz2RK678foxuH3gnWN/uMJJ+n1Gn6Pkb6ylrHlXvS0YjmE1uqlXulPWwPhXjgx6jqT0G",
"+PA1D6+g14niY71QIyQ/VCOJeOzoqo8HhdpEF+KuxNyY4JfODyzvnf0DwvZhqDv2DcPchb5/P+EFYT1x",
"Go79hCNUwzdLkns4zN4VkxT6ZZaz3uQ2ymSYfEQObugPh3bCuhKtoV8IjNYEJ5dX8BzmpZaIRPOVDqhb",
"VI+5EiUWlYRFyCzZITAVbqWOL6RXCR2BFclspLtNVtHm6bVZMqOz0tfKUEBTXAZfPKid7dI/cUJEzKbb",
"5TLEOcDSNete4ysu86dkvX8hVF4DIJfWMDVzWp+61rb1+Vg4B1wQ1jmE2bhWTd5J/uo5b/aITg7LZfDE",
"go3LoInw4uI+dAuqKB0+7iPGQ0ROMMatrsG5W/xsg5BNY28iWOJikq58mBUV9mx2J239sxxC5pQjESLW",
"yJTGS5v+3gknOUqCyydjPuWIHpMT5K9nOxX4o0n2EOq0D+TiblMZ5/QX7Q9YI6SLKo3OTmaNejw54aC2",
"n5pj+BMt94yEhav/jXLHaF72QYZvLd3gF2YuIGXtTr65NfpSqxpz28KJtIZTyl+ocKOUgTtferqWOw+S",
"VY8SzKcoxQsY/bDgcenVXe1rpX5gip+jH96QT+7HNvWC+9rJoz1c/gXdKNyGKdcnGZbrf/Uz4iaPCNSr",
"7Cor2MsS71KlFlr5mBAiYDx/yg1mAr6Ev2pkWjA+rlGXQdg2nInExtRCcVtooM2yaSM7uJHuCviIZ7Bp",
"a7p3GiWvWSnSTSesnW1XazGfU3Jzf7tptLmqSAZFp4cd1/sL7fZd7eeN9Otjr6BRII6skeQ2YXtzQX+g",
"6N9tHac1SBcNvJ26Yr7qqXmhHXgr7qgTkpDiyMcd+1c/RX75VQRZStmenUsWOuHkqhr2nyOaATlvnA4q",
"NY6RhFVUgMWsjl15qIEmE7OyaMNq0F6aDB0TIRiQRUxNhdtvtFdCenRpj2J1zPmRmXuQZzOWvqEXsiZ5",
"SGyUNOijdCZqZ7eUQgTzPyDrfBbDc4dGJLG2N5ibVgcexSmIt/VFAQw6YWkoZsOF7mGekfh0HwrebcWF",
"+bkzkl6rCFb8kP3xndpAxdaB01XqT/s4Wpex/kLB4GkklGQAwuyh5dsL+NSfUX027Ib8TJfAg7ZbcSl1",
"Q9SJKGpYp3cT6XVhpLLG6ndfTGNVcWYYWmb0nWUwfBWz0uCkH5A4ifEhOGnoFetuJMToDvM2UNKifr6i",
"RnI8L6lmubwN3H2Q9b/f9dDcSQhF8hm05mqysYI2ICXdq3GwJD9GJxZcYbzt49tBlzKIR9CVt4B/ZA2G",
"V8Je8q/HZ5z/iY/AlVLbmCEBtdNY5mMqIgXQ5C7plz5j8RnM81DOvvT0yYOnXzrIux8hc4h7P+VkwhXF",
"fciSMj15/O3rh0Ea21GT1KlV20hHQltZuTYX70Bfitwf3A6qjkdBeP0PzutxJqiIFnYUWwpjY/XWB+c7",
"QVL5vIznHtbyTrzHgwpoz7mjiVsBduRfm7nEOsXThyCVHoczkEJcRmIaEOnX0l3V9oa/IAc8+liGIJfr",
"DVU+Q0ynPGy2aYMChJ7CtTO+3JzytrmGS4URH7WEsWq5Nl7XqvfhfFDKvMJC5jil8//nj5VYwu2BUqR6",
"H5RB163p8/NqQ5bsQ+pTff4D8M9a+9pfr/79/ab56qWzAXVj6ex7nfXv/jUpA4pAX17oTFHwTGRwGoug",
"Q9OlW7TmUjvQ9+BjVgllWcjQc4B95VHg426OtamSDKaxdO61DnjeoW9Q6rNfHnZcdCAUBtCwaWKZA/9b",
"K/16QvTTJliUl6PBBrqt+wz1YI3W7IsibMOaKvrfZqbkVMncHscGx9DAgaTmkEBjOqC+om0ZW/tKbFtc",
"huZUiFB+rBKXUMvx0robGTPydhNbrtXyCiAZbExCZXqCcEkCLki8uPo6eTJxtBC+0j1g9GTZZOBKORuj",
"9esRgZM8nBZYE014FR7n8Uwo1Bvb9aANjLJyyvvhqNH8xzAb6C1KWRhpJyG+Tb9nGUmg60trgm66lS8b",
"i6X2BvFSY+ct1uql2UZBHtadioliTkcyOFUsNrEkj0nACGT8pAtiZZiqmpAVTgX1MQh2ZehS7X1qkTRC",
"MCN/RaX0eI5BPKINEPqSnXRiMtPHVQJpnB8BIp3TOhYPy2epTX/oH18+exGDI5Wpo4nFXvabcbpYxpsF",
"LHLBCyS86DbFPC5pUTqIDbaFK4Jn28GDDCE5Fy814zTuUce0D5U1BSVMtEXbG8M7jTzRIsYJwXGBMRmx",
"b1U4HdYPTq4PfDLyDnqHYyNrVZEuTftIWFLUMj6kJVSdQyJ306crnFI5Cfic/9AnbVwXjNU38mrPOpos",
"MTMVjhtC/yzTAtIugApvG6mrzPUYxoYsKvHR90/+47FoTQNnPcutknXOkAa9wgKdyx/x1oGqjU+TsRwM",
"mYPzcTXWl3IRSc7jK0n7xyRyEPEKJLdjdkEbCXddo5fBp0NjRCNN3VFY4a24lHwB8E1iFzhRvm7y6+VR",
"Nj1g1vN3WGWSHE5qVLF0Xu+3nkrm5DpJjupcpVLFSIXjacrvzFNWzkQ3W/I+5Y3toS+1gw/9XLwKqFR0",
"AD0fJGIMpqG7XVxat8CpD7c1hpwOTUo8zYwMkf8GNtf51VLOnuxWnxoxX872hxHQ1WybPfowyzFqzCHh",
"ZIhC5jrv/kG8wwnMvEaMvZnPqpHPQTVLJ2E8nZ8iMc+REZ5FrRwhEXzcGwtofhe3OB4yw8wJx1TkvDpf",
"C2x6ipdAozG5ZqBaUf/H78RyLZ1cBuXwtiUmlYxl3bmejyWKakbvxxOAD8Sj7Ag/Po0TsibYnIEY8XBz",
"vhdO42WZPihcvGdIbvr3OcxUGVAT/hknQrKpMmH21+ESqtn7Y2h6fC0dcOvAw0d0hpW9ps+zJ2+4p4/l",
"NJNfzs96lzqfAJep7CukPhVe6MHS06w8QLwtrROPEi4/poB3Shn/bbtpjyWzY1zccU4kysGlv2L9RHnj",
"hRREOka3ViwdzHfvjdQhVlCk3OHQ6CzR6sQyFlIGSbNUDQ7zDAbgwNyv5KYceSDAkxsGg/n29dJRVv3y",
"wncKzOsxMImr30XUOiqWuX9OcjPsjLxSBrHHrAT/VDWpyefinIfOppOHC+JZTkXhpuzFe2Wrydv+1VeP",
"lGKYl0PevnEStSWasSdDnXWhgG7q/db6yMfTHwtoOqwIGEsF1r2AEWgpTIu1DrqglTxgpRIWbPfbBq7w",
"H9+9e1MN5fJOJrYuM8hP2BPfxLV+mRTHnOrg0PxbNLdvn+ihSADpQ7Zt2JMu4Z28Up7t1zGQLTo0P6Kw",
"4pj/ypO7MzdCM+XjvPZPJqFproLfhWTgp63JP+7oSeK/48hrrPujDAarQoNr5UooE+/jPELukEiGOM6n",
"XWbfiNK5+lDOvYKbv7On/bb3KQxDyXFnGo5UxjqhBsVoFSVPvH/a1Ur5sGAakaLYIhXpqlrTx8F23/I3",
"C+5DQSVr/HBObVH0jXiXsLKU5YXn/RbX9xVYrQi3+tunModcS8nlCptWKFBulcsCTHoa3+Kd8PYLXglv",
"73Qj/NNcCIVaNoXsst96RZkeZzNRX+fb2Yp9Ieqmv5CpkLMucFGHKDRstqGfJqMcO845Zx803QX3/nVS",
"VB7C+WcesP8c7H4kjQcR1tR4aDqLXL2M5lLWZXZ45cGapLqQxxvlVApqvFKAYgFsK/4E+P7SLE8+mI+H",
"sfukAHf2JrrYJnWSDJPJrhMcvkiyax7tUFqUAP9PQPzjVGG/TsDn88GUIWVW1Nw9BeCeXJwxqSO7HZdy",
"CGepe3P/2KMYicre7DGou0+LUYcHCKZNq/ywA2q7tOZaOY9Yx7GWPlibksz2wxmvlamtY0NRHAb9b8kO",
"lJGpo5yqU9ztICsolapEu+guX3W0BHPlDtZ5xzlFy0UXyY1riv3LILq6ruXMxz9Yd3XeRYT+d27P/YQ9",
"q4v1oOrIg/JnlmtXZRxIVsRKK8++uF79Bi62WJqXcaThmGNrVDLeJ7TikPuo7gaxDOtgOWyusFIfwwVF",
"tQrOR+ePQ/XmRjpF6a6y2mEPxdlkI3zxgmr3Tfk3QLmg3EYb2fiv4HsW1clxDpNu3+gewGX7F7quSnVc",
"e7X9kSbebjs3m06B5NdQUYOy2NS3nXNMmkRAfReOQJ0dUbzj2AWCoiDPocPphMrSX73jrh8qqTJ3/9US",
"K6f1TUllEbRfOeAn4frJh/hzj+7zR2lWRNiyZQCy4Z6PxSm4Tkc7vo8E4Ff30+ZMxFL8qi7O7fJKBXYG",
"J1+xzF8rEvAIELQc6X9Qu6VtwIEXmXFiPVDx75jDIFHzJt4SaXPphlgqE5pbsXXaoJ04xABE24Zty0Ip",
"nCzlIUKugQccsBH9VWhMqnnDo6GqstCUVcgwdfRnwxqe6eXaNjVl30lHRP+j8zONtEC919GTldbJ/nE3",
"RHVqJb5/8uRJIhTQHDA5tD4FQirpd/q0Z/hwgFs7bMBOErszbmkiGAp28/6dDh3nnz55Oka88xsdIrIB",
"WDo03Dob7NI2/xrRH2UZpyM5n0Vmy2unTeoAsqJmnaYV6lA5+48UHIw2Dg4ZrLKcMHB0o2tr6si1hsvL",
"COsiSUEerSuKMhc/yaCcUFiqD85NzSm3MxUD9lZbqAJILOCEFMEnqFcP7QE17v2KaIX4BoCcSSv49mX7",
"ONMT15p9FczIIXu4SYAwJEVzWVvthSYvQ/BK0IYrAAQCTidNogs84QVjSFAxltrdxt4k3lJqsw3ArkWp",
"Gzrnx55HpxvEU7eAeTHYHUT4ufj+yfeMrF3HGqtMSsLaCeyKO/62fdC81vkwU8YCPFtxH75F1MqFhekE",
"aRmlofwAHZvR2JU2dO96KqedB7bhzvLHfWUlXNmANd2+e2EdpwPoJAg/Z4datuMNQz80SgYXzsp6KX2I",
"QelbedtYWacMZtw63tqga5wMb04geUjClE1kH5Pdr6YfD/Fuu86vqdWXUKTG0Vh8PkSfSmgVF8MCGd8s",
"qV3nsVKEwMkHeDdg90tFHKavmh1FHMQ7Sd5ckskg0MMsUn5pt7efwuJPkq0vQbNeJCo/hX3Z1nRXwgNR",
"MPzvc2i8orYYY2K5vEXv5ktuLdmaSBcL6Kd98n6GO/OpCFY8ffKk5/2cYUXUfBF29MYZ4gkpPCjZgw7C",
"KRjPD+YVLE4DaJherik+11iM0QUVhqF7OFbM6oVe3MTuew9oUeQyXkxF2uZId4AcAyPsT330+RUiU2j7",
"5Qp9jEjchIkoYGjhgJn8ZpTUHz/+rwEA",
"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",
}
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
+50
View File
@@ -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
+13
View File
@@ -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>
+1 -1
View File
@@ -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
}
+7
View File
@@ -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)))
}
+4
View File
@@ -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)
+12
View File
@@ -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":
+301
View File
@@ -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()
}
}
}
+9 -6
View File
@@ -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 }
}
}
+14 -2
View File
@@ -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:
+17
View File
@@ -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)
}
}
}
+4 -1
View File
@@ -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()
+3 -1
View File
@@ -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)