Repository navigation
Play a video or recording an agent writes inline in the chat #18402
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
56 commits
Select commit
Hold shift + click to select a range
57014cf
feat(agent-pane): image viewer for chat images
teamleaderleo b8a5068
Merge remote-tracking branch 'origin/feat-cmux-next' into feat-cmux-n…
teamleaderleo 4d83e2c
gallery: image viewer entry
teamleaderleo 66c27e3
test(agent-pane): image viewer list, focus and fallback regressions
teamleaderleo c45616f
fix(agent-pane): image viewer list, pinch, zoom center and focus
teamleaderleo d9eb740
test(agent-pane): image list follows drawn footnote order and any-cas…
teamleaderleo bd87b45
fix(agent-pane): image list in drawn footnote order, any-case data URLs
teamleaderleo d84bd65
fix(agent-pane): neutral sample images in the image viewer fixtures
teamleaderleo 4ed11e6
fix(agent-pane): image viewer on the shared dialog
teamleaderleo 7a5f8b8
Merge remote-tracking branch 'origin/feat-cmux-next' into feat-cmux-n…
teamleaderleo c4c5fab
test: image viewer lists long data URL images and skips those past th…
teamleaderleo 302a63a
fix: image viewer resolves long data URL refs and skips images past t…
teamleaderleo 896ae91
test: image viewer keys from the focused dialog and Control-scroll zoom
teamleaderleo 0ef1688
fix: image viewer keys and pinch work inside ui/Dialog
teamleaderleo a916684
build: regenerate agent pane bundle
teamleaderleo 0938810
Merge remote-tracking branch 'origin/feat-cmux-next' into feat-cmux-n…
teamleaderleo e234ca2
Merge remote-tracking branch 'origin/feat-cmux-next' into feat-cmux-n…
teamleaderleo 6fef528
gallery: image viewer play states (open, next, zoomed)
teamleaderleo e716c59
Merge remote-tracking branch 'origin/feat-cmux-next' into feat-cmux-n…
teamleaderleo a17b18e
Merge remote-tracking branch 'origin/feat-cmux-next' into feat-cmux-n…
teamleaderleo 3a60dfb
build: regenerate the agent pane bundle after the base merge
teamleaderleo 6b700ef
Merge remote-tracking branch 'origin/feat-cmux-next' into feat-cmux-n…
teamleaderleo 5535129
build: regenerate the agent pane bundle after the base merge
teamleaderleo 5c3dbb8
test: tool calls show the images they return or write
teamleaderleo 6bb5245
feat: tool calls show the images they return or write
teamleaderleo dc5750e
Merge remote-tracking branch 'origin/feat-cmux-next' into feat-cmux-n…
teamleaderleo 14b4d69
Merge branch 'feat-cmux-next-image-viewer' into feat-cmux-next-tool-i…
teamleaderleo 3e904d9
build: regenerate the agent pane bundle after merging the image viewe…
teamleaderleo 788b662
test: a PDF an agent writes or links shows its first page
teamleaderleo 084c5d6
feat: show a PDF an agent writes or links as its first page
teamleaderleo ceefe3b
test: a video or recording an agent writes plays in the chat
teamleaderleo c24deb9
feat: play a video or recording an agent writes inline in the chat
teamleaderleo 546013d
fix: list the media player in the transcript gallery's coverage
teamleaderleo 6d1a62a
Merge feat-cmux-next into feat-cmux-next-image-viewer
teamleaderleo 86f20fb
Merge feat-cmux-next-image-viewer into feat-cmux-next-tool-images
teamleaderleo ff68f29
Merge feat-cmux-next-tool-images into feat-cmux-next-media-thumbnails
teamleaderleo 1685e35
Merge feat-cmux-next-media-thumbnails into feat-cmux-next-media-video
teamleaderleo 78a1667
chore: keep base's string catalog formatting, adding only the media keys
teamleaderleo a38eadd
Merge feat-cmux-next (754e544ae11, #18409) into feat-cmux-next-image-…
teamleaderleo 345078d
Merge feat-cmux-next-image-viewer into feat-cmux-next-tool-images
teamleaderleo 4122c7d
Merge feat-cmux-next-tool-images into feat-cmux-next-media-thumbnails
teamleaderleo 3ee03d8
Merge feat-cmux-next-media-thumbnails into feat-cmux-next-media-video
teamleaderleo 6c94740
test: a PDF thumbnail fills its canvas and follows /Rotate
teamleaderleo 8eb5524
fix: draw a PDF thumbnail at the canvas size
teamleaderleo 476b12d
test: match a PDF thumbnail's blue by hue, not exact bytes
teamleaderleo ec70a0a
Merge remote-tracking branch 'origin/feat-cmux-next' into HEAD
teamleaderleo 079d73b
Merge remote-tracking branch 'origin/feat-cmux-next' into HEAD
teamleaderleo c7a8db3
build: regenerate pane strings after the base merge
teamleaderleo f7b03b9
Merge remote-tracking branch 'origin/feat-cmux-next-image-viewer' int…
teamleaderleo f3a58a0
build: keep the pane catalog in base's format after the base merge
teamleaderleo 56450c9
Merge remote-tracking branch 'origin/feat-cmux-next-image-viewer' int…
teamleaderleo 654801f
Merge remote-tracking branch 'origin/feat-cmux-next' into HEAD
teamleaderleo 64b724d
Merge commit '654801fecca' into HEAD
teamleaderleo 90286e6
Merge remote-tracking branch 'origin/feat-cmux-next' into HEAD
teamleaderleo 41ccb71
build: keep the pane catalog in base's format and regenerate strings …
teamleaderleo 9506c26
Merge remote-tracking branch 'origin/feat-cmux-next' into HEAD
teamleaderleo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
71 changes: 71 additions & 0 deletions
71
Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneReplyMedia.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| import Foundation | ||
|
|
||
| /// Local video and audio files the pane may play. The page's CSP loads media only from the pane's | ||
| /// own origin, so a checked file gets an unguessable `cmux-agent://pane/__media/<token>.<ext>` URL | ||
| /// that ``AgentPaneSchemeHandler`` serves in byte ranges. The page never names a file itself: it | ||
| /// asks `media.load` with the path from the reply, and the host checks it like a reply image. | ||
| final class AgentPaneMediaGrants { | ||
| static let shared = AgentPaneMediaGrants() | ||
|
|
||
| /// The extensions the pane plays, with the type each is served as. | ||
| nonisolated static let types: [String: String] = [ | ||
| "mp4": "video/mp4", "m4v": "video/x-m4v", "mov": "video/quicktime", "webm": "video/webm", | ||
| "mp3": "audio/mpeg", "m4a": "audio/mp4", "aac": "audio/aac", "wav": "audio/wav", "flac": "audio/flac", | ||
| ] | ||
| /// Largest file the pane plays (it reads only the ranges the player asks for). | ||
| nonisolated static let maximumBytes = 4 << 30 | ||
| /// Most files granted at once; the oldest grant goes first. | ||
| static let maximumGrants = 256 | ||
| static let pathPrefix = "/__media/" | ||
|
|
||
| private var files: [String: URL] = [:] | ||
| private var tokens: [URL: String] = [:] | ||
| private var order: [String] = [] | ||
|
|
||
| /// The URL the page plays `file` (canonical, already checked) by; the same file keeps its URL. | ||
| func grant(_ file: URL) -> String { | ||
| let name: String | ||
| if let known = tokens[file] { | ||
| name = known | ||
| } else { | ||
| name = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + "." + file.pathExtension.lowercased() | ||
| files[name] = file | ||
| tokens[file] = name | ||
| order.append(name) | ||
| if order.count > Self.maximumGrants, let oldest = order.first { | ||
| order.removeFirst() | ||
| if let gone = files.removeValue(forKey: oldest) { tokens[gone] = nil } | ||
| } | ||
| } | ||
| return "\(AgentPaneSource.bundledScheme)://\(AgentPaneSource.bundledHost)\(Self.pathPrefix)\(name)" | ||
| } | ||
|
|
||
| /// The granted file a media URL names, nil for any other URL. | ||
| func file(for url: URL) -> URL? { | ||
| guard url.scheme?.lowercased() == AgentPaneSource.bundledScheme, | ||
| url.host?.lowercased() == AgentPaneSource.bundledHost, | ||
| url.path.hasPrefix(Self.pathPrefix) else { return nil } | ||
| return files[String(url.path.dropFirst(Self.pathPrefix.count))] | ||
| } | ||
| } | ||
|
|
||
| extension AgentPaneModel { | ||
| /// A video or audio file inside the roots, as a URL the pane plays. Web media is refused | ||
| /// (the pane fetches nothing itself). | ||
| func loadReplyMedia(_ src: String) async -> [String: Any] { | ||
| if let scheme = URL(string: src)?.scheme?.lowercased(), scheme != "file" { return Self.replyFailure(.mediaRefused) } | ||
| guard let resolved = replyPaths().resolve(src) else { return Self.replyFailure(.pathInvalid) } | ||
| switch resolved.place { | ||
| case .root: break | ||
| case .denied: return Self.replyFailure(.pathDenied) | ||
| case .outside: return Self.replyFailure(.pathOutsideRoots) | ||
| case .missing: return Self.replyFailure(.pathInvalid) | ||
| } | ||
| let file = URL(fileURLWithPath: resolved.path) | ||
| guard AgentPaneMediaGrants.types[file.pathExtension.lowercased()] != nil, | ||
| let values = try? file.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), values.isRegularFile == true, | ||
| (values.fileSize ?? Int.max) <= AgentPaneMediaGrants.maximumBytes | ||
| else { return Self.replyFailure(.mediaRefused) } | ||
| return AgentPaneReply.success(["src": AgentPaneMediaGrants.shared.grant(file)]) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
98 changes: 98 additions & 0 deletions
98
Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneSchemeHandler+Media.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| import Foundation | ||
| import WebKit | ||
|
|
||
| extension AgentPaneSchemeHandler { | ||
| /// Bytes one media response carries at most; the player asks for the next range. | ||
| nonisolated static let mediaChunk = 8 << 20 | ||
|
|
||
| /// One answer to a media request: 200 for the whole file, 206 for a range, 416 past the end. | ||
| nonisolated struct MediaSlice: Sendable { | ||
| var status: Int | ||
| var headers: [String: String] | ||
| var data: Data | ||
| } | ||
|
|
||
| /// The granted file a `cmux-agent://pane/__media/` URL names (``AgentPaneMediaGrants``). | ||
| static func mediaFile(for url: URL) -> URL? { | ||
| AgentPaneMediaGrants.shared.file(for: url) | ||
| } | ||
|
|
||
| /// The part of `file` an HTTP `Range` header asks for (`bytes=a-b`, `bytes=a-` or `bytes=-n`), | ||
| /// at most ``mediaChunk`` bytes. Nil when the file cannot be read or is no longer the regular | ||
| /// file that was granted (a link swapped in after the grant). | ||
| nonisolated static func mediaSlice(of file: URL, range: String?) -> MediaSlice? { | ||
| guard let values = try? file.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey]), | ||
| values.isRegularFile == true, values.isSymbolicLink != true, let size = values.fileSize | ||
| else { return nil } | ||
| var headers = [ | ||
| "Content-Type": AgentPaneMediaGrants.types[file.pathExtension.lowercased()] ?? "application/octet-stream", | ||
| "Accept-Ranges": "bytes", | ||
| "Cache-Control": "no-store", | ||
| "X-Content-Type-Options": "nosniff", | ||
| "Content-Security-Policy": resourcePolicy, | ||
| ] | ||
| var lower = 0, upper = size - 1 | ||
| if let range { | ||
| guard let (start, end) = byteRange(range, size: size), start < size else { | ||
| headers["Content-Range"] = "bytes */\(size)" | ||
| headers["Content-Length"] = "0" | ||
| return MediaSlice(status: 416, headers: headers, data: Data()) | ||
| } | ||
| lower = start | ||
| upper = min(end, size - 1) | ||
| } | ||
| upper = min(upper, lower + mediaChunk - 1) | ||
| let partial = range != nil || upper < size - 1 | ||
| guard let handle = try? FileHandle(forReadingFrom: file) else { return nil } | ||
| defer { try? handle.close() } | ||
| guard (try? handle.seek(toOffset: UInt64(lower))) != nil, | ||
| let data = size == 0 ? Data() : try? handle.read(upToCount: upper - lower + 1) else { return nil } | ||
| headers["Content-Length"] = String(data.count) | ||
| if partial { headers["Content-Range"] = "bytes \(lower)-\(lower + data.count - 1)/\(size)" } | ||
| return MediaSlice(status: partial ? 206 : 200, headers: headers, data: data) | ||
| } | ||
|
|
||
| /// The first and last byte a `Range` header names, nil for a header this does not read. | ||
| nonisolated static func byteRange(_ header: String, size: Int) -> (Int, Int)? { | ||
| let spec = header.trimmingCharacters(in: .whitespaces) | ||
| guard spec.lowercased().hasPrefix("bytes="), !spec.contains(",") else { return nil } | ||
| let parts = spec.dropFirst(6).split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false) | ||
| guard parts.count == 2 else { return nil } | ||
| let first = parts[0].trimmingCharacters(in: .whitespaces), last = parts[1].trimmingCharacters(in: .whitespaces) | ||
| if first.isEmpty { | ||
| guard let count = Int(last), count > 0 else { return nil } | ||
| return (max(0, size - count), size - 1) | ||
| } | ||
| guard let start = Int(first), start >= 0 else { return nil } | ||
| if last.isEmpty { return (start, size - 1) } | ||
| guard let end = Int(last), end >= start else { return nil } | ||
| return (start, end) | ||
| } | ||
|
|
||
| /// Reads the slice off the main thread. | ||
| @concurrent nonisolated static func readSlice(of file: URL, range: String?) async -> MediaSlice? { | ||
| // concurrency-allow: @concurrent, so this read never runs on the main actor | ||
| mediaSlice(of: file, range: range) | ||
| } | ||
|
|
||
| /// Answers `task` for the granted `file`; a stopped task is never answered. | ||
| func serveMedia(_ file: URL, url: URL, task: any WKURLSchemeTask) { | ||
| let id = ObjectIdentifier(task) | ||
| active.insert(id) | ||
| let range = task.request.value(forHTTPHeaderField: "Range") | ||
| // task-owner: one range read per media request; a stopped task is never answered | ||
| Task { @MainActor [weak self] in | ||
| let slice = await Self.readSlice(of: file, range: range) | ||
| guard let self, self.active.remove(id) != nil else { return } | ||
| guard let slice, | ||
| let response = HTTPURLResponse(url: url, statusCode: slice.status, httpVersion: "HTTP/1.1", headerFields: slice.headers) | ||
| else { | ||
| task.didFailWithError(URLError(.fileDoesNotExist)) | ||
| return | ||
| } | ||
| task.didReceive(response) | ||
| task.didReceive(slice.data) | ||
| task.didFinish() | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
69 changes: 69 additions & 0 deletions
69
Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneReplyMediaTests.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import CmuxNextSettings | ||
| import Foundation | ||
| import Testing | ||
| @testable import CmuxNextAgentPane | ||
|
|
||
| /// Local video and audio in replies and tool output: the host checks the path like a reply image, | ||
| /// then lets the page play the file by an unguessable `cmux-agent://pane/__media/` URL that the | ||
| /// pane's scheme handler serves in byte ranges. The page never names a file itself. | ||
| @MainActor | ||
| @Suite struct AgentPaneReplyMediaTests { | ||
| static func load(_ model: AgentPaneModel, _ src: String) async -> [String: Any] { | ||
| await model.respond(to: AgentPaneRequest(body: ["method": "media.load", "params": ["src": src]] as [String: Any])) | ||
| } | ||
|
|
||
| @Test func aVideoInsideTheRootsGetsAPlayableURLAndNothingElseDoes() async throws { | ||
| let base = URL(fileURLWithPath: NSTemporaryDirectory()).appending(path: "reply-media-\(UUID().uuidString)") | ||
| let root = base.appending(path: "repo") | ||
| try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) | ||
| defer { try? FileManager.default.removeItem(at: base) } | ||
| try Data(repeating: 7, count: 64).write(to: root.appending(path: "demo.mp4")) | ||
| try Data(repeating: 7, count: 64).write(to: root.appending(path: "notes.txt")) | ||
| try Data(repeating: 7, count: 64).write(to: base.appending(path: "outside.mp4")) | ||
| let model = AgentPaneReplyImageTests.model(root: root) | ||
|
|
||
| let src = try #require(AgentPaneReplyImageTests.src(await Self.load(model, root.appending(path: "demo.mp4").path))) | ||
| #expect(src.hasPrefix("cmux-agent://pane/__media/")) | ||
| #expect(src.hasSuffix(".mp4")) | ||
| #expect(!src.contains("demo")) | ||
| // The same file keeps its URL, so a re-render does not restart playback. | ||
| #expect(AgentPaneReplyImageTests.src(await Self.load(model, "demo.mp4")) == src) | ||
| #expect(AgentPaneSchemeHandler.mediaFile(for: try #require(URL(string: src)))?.lastPathComponent == "demo.mp4") | ||
|
|
||
| #expect(AgentPaneReplyLinkTests.code(await Self.load(model, root.appending(path: "notes.txt").path)) == "link.media_refused") | ||
| #expect(AgentPaneReplyLinkTests.code(await Self.load(model, base.appending(path: "outside.mp4").path)) == "link.path_outside_roots") | ||
| #expect(AgentPaneReplyLinkTests.code(await Self.load(model, "https://example.com/demo.mp4")) == "link.media_refused") | ||
| #expect(AgentPaneSchemeHandler.mediaFile(for: try #require(URL(string: "cmux-agent://pane/__media/0000.mp4"))) == nil) | ||
| } | ||
|
|
||
| @Test func theSchemeHandlerServesAGrantedFileInByteRanges() throws { | ||
| let file = URL(fileURLWithPath: NSTemporaryDirectory()).appending(path: "media-\(UUID().uuidString).mp4") | ||
| try Data((0 ..< 100).map { UInt8($0) }).write(to: file) | ||
| defer { try? FileManager.default.removeItem(at: file) } | ||
|
|
||
| let part = try #require(AgentPaneSchemeHandler.mediaSlice(of: file, range: "bytes=10-19")) | ||
| #expect(part.status == 206) | ||
| #expect(part.data == Data((10 ..< 20).map { UInt8($0) })) | ||
| #expect(part.headers["Content-Range"] == "bytes 10-19/100") | ||
| #expect(part.headers["Content-Type"] == "video/mp4") | ||
| #expect(part.headers["Accept-Ranges"] == "bytes") | ||
| #expect(part.headers["Content-Length"] == "10") | ||
|
|
||
| let tail = try #require(AgentPaneSchemeHandler.mediaSlice(of: file, range: "bytes=90-")) | ||
| #expect(tail.headers["Content-Range"] == "bytes 90-99/100") | ||
| let whole = try #require(AgentPaneSchemeHandler.mediaSlice(of: file, range: nil)) | ||
| #expect(whole.status == 200) | ||
| #expect(whole.data.count == 100) | ||
| // A range past the end is unsatisfiable. | ||
| #expect(AgentPaneSchemeHandler.mediaSlice(of: file, range: "bytes=200-")?.status == 416) | ||
| } | ||
|
|
||
| @Test func theParamsContractTakesOneSource() { | ||
| func request(_ params: [String: Any]) -> AgentPaneRequest { | ||
| AgentPaneRequest(body: ["method": "media.load", "params": params] as [String: Any]) | ||
| } | ||
| #expect(request(["src": "/a.mp4"]) == .reply(.loadMedia("/a.mp4"))) | ||
| #expect(request(["src": "/a.mp4", "autoplay": true]) == .unsupported("media.load")) | ||
| #expect(request(["src": ""]) == .unsupported("media.load")) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unranged media returns partial 206
Medium Severity
An un-ranged GET for a file larger than
mediaChunkstill caps the body at 8 MB and answers206with aContent-Range. HTTP forbids a partial response when the request had noRange, and the media player can treat that first 8 MB as the whole resource, so videos over 8 MB may not play through.Reviewed by Cursor Bugbot for commit c24deb9. Configure here.