PR #399 — macOS image detail windows from clipboard/bundled/remote documents no longer collapse onto a shared nil identity. Diff reviewed: git diff origin/main...HEAD.
ImageDetailIdentifier.documentURL: URL? → ownerURL: URL; ImageDetailWindowData.documentURL deleted (it was derivable from sourceType + ownerURL).MediaZoomPresenter.openImageWindow no longer passes documentFileURL; the diagram path still does (deliberately deferred, see findings).Decodable ignores a stale documentURL key and ownerURL already existed.ImageDetailWindowDataTests (4 tests) plus 3 existing suites migrated; targeted run of 5 suites passed, make lint 0 violations.DiagramIdentifier keeps the same latent bug (also in its persistenceKey) — worth a follow-up ticket, not a blocker.Ready to push
The fix is minimal and correct: ImageDetailIdentifier now keys on the non-optional ownerURL (already used for lifecycle since T-1503), so the redundant, often-nil documentURL is removed rather than patched. Codable compatibility is preserved (synthesised conformance, no key added). Every constructor call site was migrated, targeted tests pass, lint is clean. All findings are minor or nits; none were fixed here because this review was run read-only, and none block the push.
01adb9cd Fix T-1758: Non-file image detail windows collide across documents 7519ca49 Add bugfix report for T-1758 On the Mac, double-clicking an image opens it in its own window. Prism remembers which windows are open so it does not open the same image twice; instead it brings the existing window forward. The 'which image is this' label was built from the document's file path plus the image name. Documents that are not files (pasted from the clipboard, the built-in welcome guide, or downloaded from a URL) have no file path, so their label was just the image name. Two such documents with an image called img.png looked identical, and opening the second one brought forward the first document's window.
Users saw the wrong picture. The fix uses a document key that every document has (the file path, or a synthetic per-document key for the others) so labels never collide.
ImageDetailWindowData is the Codable payload handed to WindowGroup("Image", id: "image-detail", for:). It exposes identifier, an ImageDetailIdentifier used by ImageDetailWindowManager for markPendingOpen/bringToFront/registerWindow. Previously the payload carried two keys: documentURL: URL? (file sessions only) for identity and ownerURL: URL (DocumentSession.diagramOwnerURL: source.url or prism://clipboard/{sessionID}) for lifecycle. The fix collapses both onto ownerURL.
documentURL was exactly sourceType == "file" ? ownerURL : nil..file behaviour, so consistent.DiagramIdentifier is left alone to avoid conflicts with T-2014/T-2175/T-2221; it is also Codable-persisted via persistenceKey, so changing it is a stored-state migration.The change is confined to the identity struct and its single production constructor. ImageDetailIdentifier is nonisolated struct … Hashable, Sendable, not Codable, so no persisted shape changes there. ImageDetailWindowData uses synthesised Codable with no CodingKeys; dropping an optional stored property is decode-compatible with any window-restoration payload SwiftUI may replay, and WindowGroup(for:)'s value equality classes are unchanged since the dropped field was a pure function of the remaining ones.
ImageDetailWindowManager.windowOwnership is now fully redundant: for every registration identifier.ownerURL == ownerURL. It is retained for API symmetry with DiagramWindowManager (whose identifier still has an optional URL) but is a second source of truth a caller could contradict.
URL == is exact: /tmp/x.md vs /private/tmp/x.md yields two windows; DocumentFlowCoordinator already documents this for the close path..bundled payload holds a Bundle.main path; if the bundle relocates between launches, dedup and close both miss — pre-existing for lifecycle, now extended to identity. Negligible in practice.prism://bundled/welcome.md is not a shape production ever produces) and cannot fail unless the optional is reintroduced; the wiring risk in MediaZoomPresenter is covered indirectly by DocumentFlowCoordinatorImageWindowTests.Fully implemented: identity collision for image windows across clipboard/bundled/url documents; call-site migration; report. Partially: regression coverage (exercise real DocumentSessions; a legacy-payload decode test). Missing / deferred: DiagramIdentifier parity and its persistenceKey collision.
prism/Models/ImageDetailWindowData.swift
Why it matters. This is the fix. The optional key folded every non-file document into one (nil, imageSource) bucket; the non-optional owner key gives each document its own identity space.
What to look at. prism/Models/ImageDetailWindowData.swift:28-62 (ImageDetailWindowData.ownerURL, identifier, ImageDetailIdentifier.ownerURL)
prism/Views/MediaZoomPresenter.swift
Why it matters. The only production constructor of the payload; the diagram path a few lines above still uses the optional file URL, which is the remaining instance of the same defect.
What to look at. prism/Views/MediaZoomPresenter.swift:91-110
prismTests/ImageDetailWindowDataTests.swift
Why it matters. Pins that distinct clipboard/bundled/remote owners produce distinct identifiers and that the same owner still dedups.
What to look at. prismTests/ImageDetailWindowDataTests.swift:28-90
prism/Models/ImageDetailWindowData.swift
Why it matters. ImageDetailWindowData is the value type SwiftUI persists for WindowGroup(for:) restoration; a shape change could break relaunch.
What to look at. prism/Models/ImageDetailWindowData.swift:18-46; prism/prismApp.swift:218
It is non-optional for all four sources and already governs closeWindows(ownedBy:); one key for both concerns removes the possibility of them disagreeing. Source: doc comments and specs/bugfixes/non-file-image-detail-window-collision/report.md.
Report cites conflict avoidance with T-2014, T-2175, T-2221. Review note: DiagramIdentifier.persistenceKey is written to UserDefaults, so fixing it also needs a key migration — a stronger reason to defer, and a reason to file a follow-up ticket.
It was exactly sourceType == "file" ? ownerURL : nil; keeping it would have preserved a second, contradictable source of truth.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | ImageDetailWindowManager.windowOwnership | After this change identifier.ownerURL == the ownedBy: argument for every registration, so the windowOwnership dictionary and the ownedBy: parameter are redundant state; closeWindows/minimizeWindows/restoreWindows could filter openWindows.keys on $0.ownerURL. | Not changed — review was read-only and the manager's shape mirrors DiagramWindowManager, which still needs its copy. Consider folding when DiagramIdentifier gets the same treatment. |
| minor | prismTests/ImageDetailWindowDataTests.swift | Tests build owner URLs from string literals (prism://bundled/welcome.md is not a shape production produces) and assert on synthesised Hashable of a non-optional field — they cannot fail unless the optional is reintroduced. No test exercises a real DocumentSession through the payload, and no test decodes a legacy payload containing documentURL. | Skipped (no source edits). Suggest: build fixtures from DocumentSession(...).diagramOwnerURL / BundledDocument.url(for:), collapse the three near-identical tests into @Test(arguments:), and add a legacy-JSON decode test. |
| minor | DiagramIdentifier (sibling, out of scope) | DiagramIdentifier.documentURL is still optional and feeds persistenceKey, so clipboard/bundled/remote documents collide on both window identity and persisted zoom state (diagram-clipboard-{offset}). MediaZoomPresenter.documentFileURL also discards the non-nil URL that bundled/url sources have. | Deferred per report. Recommend filing a follow-up ticket that also names the persistence-key migration. |
| nit | DocumentSession.diagramOwnerURL naming | The property is now the general per-document window identity key for images too; its doc comment still says 'diagram window ownership tracking'. | Skipped; rename/doc tweak when DiagramIdentifier is aligned. |
| nit | URL equality | Dedup now hinges on exact URL ==, so /tmp/x.md vs /private/tmp/x.md gives two windows. Already documented for the close path in DocumentFlowCoordinator but not on ImageDetailWindowData. | Skipped; pre-existing trade-off, standardizedFileURL at the diagramOwnerURL source would address both. |
| nit | DocumentFlowCoordinator.swift:657-658 | imageWindowOwnerURL and diagramOwnerURL bind the same expression and are unwrapped separately (pre-existing). | Skipped; unrelated to this diff. |
| nit | Uncommitted doc-comment edit | MediaZoomPresenter.swift:63-65 has an unstaged doc-comment change in the worktree pointing at the diagram path's remaining optional. | Left alone — another agent is concurrently committing that fix to this branch. |
Click to expand.
diff --git a/prism/Models/ImageDetailWindowData.swift b/prism/Models/ImageDetailWindowData.swiftindex e20526dc..2868a031 100644--- a/prism/Models/ImageDetailWindowData.swift+++ b/prism/Models/ImageDetailWindowData.swift@@ -25,17 +25,12 @@ struct ImageDetailWindowData: Codable, Hashable, Sendable { /// Optional title/caption for the image. let title: String? - /// Owning document URL (for window grouping and dedup identity).- ///- /// Nil for clipboard/bundled/url sources. Use `ownerURL` (not this) for- /// lifecycle ownership — `documentURL` only scopes the dedup identifier.- let documentURL: URL?-- /// Stable owner key used to close this window when the parent document- /// closes. For file sources this is the file URL; for clipboard sources it- /// is the synthetic `prism://clipboard/{sessionID}` URL (`diagramOwnerURL`),- /// so clipboard windows are still tracked under a per-document key rather- /// than a shared fallback (T-1503).+ /// Stable owner key used both for window lifecycle (closing this window+ /// when the parent document closes) and for dedup identity scoping. For+ /// file sources this is the file URL; for clipboard sources it is the+ /// synthetic `prism://clipboard/{sessionID}` URL (`diagramOwnerURL`), so+ /// non-file windows are still tracked under a per-document key rather+ /// than collapsing to a shared fallback (T-1503, T-1758). let ownerURL: URL /// Base URL for resolving relative image paths on re-load.@@ -46,7 +41,7 @@ struct ImageDetailWindowData: Codable, Hashable, Sendable { /// Derives the identifier used for window deduplication. var identifier: ImageDetailIdentifier {- ImageDetailIdentifier(documentURL: documentURL, imageSource: imageSource)+ ImageDetailIdentifier(ownerURL: ownerURL, imageSource: imageSource) } } @@ -54,8 +49,12 @@ struct ImageDetailWindowData: Codable, Hashable, Sendable { /// /// Prevents opening multiple windows for the same image in the same document. nonisolated struct ImageDetailIdentifier: Hashable, Sendable {- /// The document's file URL (nil for clipboard content).- let documentURL: URL?+ /// The document's stable owner key (never nil — see `ImageDetailWindowData.ownerURL`).+ ///+ /// Using the non-optional owner key rather than the (often-nil) file URL+ /// keeps clipboard, bundled, and remote documents from collapsing to the+ /// same identity and colliding with each other's image windows (T-1758).+ let ownerURL: URL /// The image source string. let imageSource: String
diff --git a/prism/Views/MediaZoomPresenter.swift b/prism/Views/MediaZoomPresenter.swiftindex e9bac053..fcee1f23 100644--- a/prism/Views/MediaZoomPresenter.swift+++ b/prism/Views/MediaZoomPresenter.swift@@ -60,8 +60,9 @@ struct MediaZoomPresenter: ViewModifier { } #if os(macOS)- /// The owning file URL (nil for clipboard / bundled / url documents), used as the- /// window identifier's document scope.+ /// The owning file URL (nil for clipboard / bundled / url documents). Only the+ /// diagram window path still keys on this optional (`DiagramIdentifier.documentURL`);+ /// image detail windows key on the session's non-optional owner URL instead (T-1758). private var documentFileURL: URL? { if case .file(let url) = session.source { return url } return nil@@ -93,10 +94,11 @@ struct MediaZoomPresenter: ViewModifier { imageSource: request.source, alt: request.alt, title: request.title,- documentURL: documentFileURL,- // Use the stable per-document owner key (synthetic for clipboard) so- // closeDocument can target these windows even when there is no file- // URL — mirrors the diagram window path (T-1503).+ // Use the stable per-document owner key (synthetic for clipboard,+ // resource-derived for bundled/url) both for closeDocument targeting+ // and for dedup identity scoping — mirrors the diagram window path+ // (T-1503) and prevents non-file documents from colliding on a+ // shared nil identity (T-1758). ownerURL: session.diagramOwnerURL, imageBaseURL: session.source.imageBaseURL, sourceType: session.source.imageSourceType.rawValue
diff --git a/prismTests/ImageDetailWindowDataTests.swift b/prismTests/ImageDetailWindowDataTests.swiftnew file mode 100644index 00000000..7b65bf6e--- /dev/null+++ b/prismTests/ImageDetailWindowDataTests.swift@@ -0,0 +1,90 @@+//+// ImageDetailWindowDataTests.swift+// prismTests+//+// T-1758: Non-file image detail windows collide across documents.+//+// ImageDetailIdentifier used to scope its dedup identity on `documentURL`,+// which is nil for clipboard, bundled, and remote documents (only `.file`+// sessions have a non-nil file URL). Two windows for the SAME image source+// string opened from two DIFFERENT non-file documents therefore reduced to+// the identical (nil, imageSource) identity, so the second open was treated+// as a duplicate of the first and brought the wrong document's window+// forward instead of opening the requested image.+//+// The fix scopes the identifier on `ownerURL` — the stable, non-optional+// per-document key already used for window lifecycle ownership (file URL,+// or a synthetic `prism://clipboard/{sessionID}` / resource URL for+// clipboard/bundled/url sources) — so distinct documents never collapse to+// the same identity even when they share an image source string.+//++#if os(macOS)+import Foundation+import Testing+@testable import prism++@Suite("ImageDetailWindowData identity (T-1758)")+struct ImageDetailWindowDataTests {++ private func makeData(ownerURL: URL, imageSource: String = "img.png") -> ImageDetailWindowData {+ ImageDetailWindowData(+ imageSource: imageSource,+ alt: "alt text",+ title: nil,+ ownerURL: ownerURL,+ imageBaseURL: nil,+ sourceType: "clipboard"+ )+ }++ @Test("Two clipboard sessions with the same image source get different identifiers")+ func distinctClipboardSessionsDoNotCollide() {+ // Mirrors two separate clipboard documents, each synthesising its own+ // diagramOwnerURL from its session UUID.+ let sessionAOwner = URL(string: "prism://clipboard/\(UUID().uuidString)")!+ let sessionBOwner = URL(string: "prism://clipboard/\(UUID().uuidString)")!++ let dataA = makeData(ownerURL: sessionAOwner)+ let dataB = makeData(ownerURL: sessionBOwner)++ #expect(dataA.identifier != dataB.identifier,+ "Two different clipboard documents opening the same image source must not collide")+ }++ @Test("Two different bundled documents with the same image source get different identifiers")+ func distinctBundledDocumentsDoNotCollide() {+ let welcomeOwner = URL(string: "prism://bundled/welcome.md")!+ let readmeOwner = URL(string: "prism://bundled/readme.md")!++ let dataA = makeData(ownerURL: welcomeOwner)+ let dataB = makeData(ownerURL: readmeOwner)++ #expect(dataA.identifier != dataB.identifier,+ "Two different bundled documents opening the same image source must not collide")+ }++ @Test("Two different remote documents with the same image source get different identifiers")+ func distinctRemoteDocumentsDoNotCollide() {+ let docAOwner = URL(string: "https://example.com/repo/a.md")!+ let docBOwner = URL(string: "https://example.com/repo/b.md")!++ let dataA = makeData(ownerURL: docAOwner)+ let dataB = makeData(ownerURL: docBOwner)++ #expect(dataA.identifier != dataB.identifier,+ "Two different remote documents opening the same image source must not collide")+ }++ @Test("Same owner URL and image source produce equal identifiers (dedup still works)")+ func sameOwnerAndSourceStillDedups() {+ let owner = URL(string: "prism://clipboard/\(UUID().uuidString)")!++ let dataA = makeData(ownerURL: owner)+ let dataB = makeData(ownerURL: owner)++ #expect(dataA.identifier == dataB.identifier,+ "Re-activating the same image in the same document must still dedup")+ }+}+#endif
diff --git a/prismTests/ImageDetailWindowManagerTests.swift b/prismTests/ImageDetailWindowManagerTests.swiftindex c9e25eaa..6eeac11d 100644--- a/prismTests/ImageDetailWindowManagerTests.swift+++ b/prismTests/ImageDetailWindowManagerTests.swift@@ -36,7 +36,7 @@ struct ImageDetailWindowManagerTests { imageSource: String = "img.png" ) -> ImageDetailIdentifier { ImageDetailIdentifier(- documentURL: URL(fileURLWithPath: path),+ ownerURL: URL(fileURLWithPath: path), imageSource: imageSource ) }
diff --git a/prismTests/DocumentFlowCoordinatorImageWindowTests.swift b/prismTests/DocumentFlowCoordinatorImageWindowTests.swiftindex a63346d9..545786b8 100644--- a/prismTests/DocumentFlowCoordinatorImageWindowTests.swift+++ b/prismTests/DocumentFlowCoordinatorImageWindowTests.swift@@ -74,7 +74,7 @@ struct DocumentFlowCoordinatorImageWindowTests { // Simulate the open path: register an image window owned by the session's // stable owner key (mirrors MediaZoomPresenter.openImageWindow after fix).- let identifier = ImageDetailIdentifier(documentURL: nil, imageSource: "img.png")+ let identifier = ImageDetailIdentifier(ownerURL: session.diagramOwnerURL, imageSource: "img.png") let window = makeWindow() manager.registerWindow(window, for: identifier, ownedBy: session.diagramOwnerURL) #expect(manager.openWindowCount == 1)@@ -99,7 +99,7 @@ struct DocumentFlowCoordinatorImageWindowTests { let session = DocumentSession(url: fileURL, content: "") flow.currentSession = session - let identifier = ImageDetailIdentifier(documentURL: fileURL, imageSource: "img.png")+ let identifier = ImageDetailIdentifier(ownerURL: session.diagramOwnerURL, imageSource: "img.png") let window = makeWindow() manager.registerWindow(window, for: identifier, ownedBy: session.diagramOwnerURL) #expect(manager.openWindowCount == 1)
diff --git a/prismTests/DocumentFlowCoordinatorReplaceWindowCleanupTests.swift b/prismTests/DocumentFlowCoordinatorReplaceWindowCleanupTests.swiftindex 904ce865..0d9167c5 100644--- a/prismTests/DocumentFlowCoordinatorReplaceWindowCleanupTests.swift+++ b/prismTests/DocumentFlowCoordinatorReplaceWindowCleanupTests.swift@@ -87,7 +87,7 @@ struct DocumentFlowCoordinatorReplaceWindowCleanupTests { let outgoingSession = DocumentSession(url: outgoingURL, content: "# Outgoing\n\n") flow.currentSession = outgoingSession - let imageIdentifier = ImageDetailIdentifier(documentURL: outgoingURL, imageSource: "img.png")+ let imageIdentifier = ImageDetailIdentifier(ownerURL: outgoingSession.diagramOwnerURL, imageSource: "img.png") let imageWindow = makeWindow() imageManager.registerWindow(imageWindow, for: imageIdentifier, ownedBy: outgoingSession.diagramOwnerURL) @@ -138,7 +138,7 @@ struct DocumentFlowCoordinatorReplaceWindowCleanupTests { let outgoingOwnerURL = outgoingSession.diagramOwnerURL #expect(outgoingSession.source.url == nil) - let imageIdentifier = ImageDetailIdentifier(documentURL: nil, imageSource: "img.png")+ let imageIdentifier = ImageDetailIdentifier(ownerURL: outgoingOwnerURL, imageSource: "img.png") let imageWindow = makeWindow() imageManager.registerWindow(imageWindow, for: imageIdentifier, ownedBy: outgoingOwnerURL) @@ -182,7 +182,7 @@ struct DocumentFlowCoordinatorReplaceWindowCleanupTests { let outgoingSession = DocumentSession(url: sharedURL, content: "# Outgoing\n\n") flow.currentSession = outgoingSession - let imageIdentifier = ImageDetailIdentifier(documentURL: sharedURL, imageSource: "img.png")+ let imageIdentifier = ImageDetailIdentifier(ownerURL: outgoingSession.diagramOwnerURL, imageSource: "img.png") let imageWindow = makeWindow() imageManager.registerWindow(imageWindow, for: imageIdentifier, ownedBy: outgoingSession.diagramOwnerURL)
diff --git a/specs/bugfixes/non-file-image-detail-window-collision/report.md b/specs/bugfixes/non-file-image-detail-window-collision/report.mdnew file mode 100644index 00000000..27864f46--- /dev/null+++ b/specs/bugfixes/non-file-image-detail-window-collision/report.md@@ -0,0 +1,38 @@+# Bugfix Report: Non-file image detail windows collide across documents (T-1758)++**Ticket**: T-1758+**PR**: #399+**Date**: 2026-08-29++## Description++On macOS, opening an image zoom window from a clipboard, bundled, or remote (URL) document could bring forward an unrelated document's image window instead of opening its own, whenever both documents referenced the same image source string (e.g. `img.png`).++## Root Cause++`ImageDetailIdentifier.documentURL` (`prism/Models/ImageDetailWindowData.swift`) was populated from `MediaZoomPresenter.documentFileURL`, which is only non-nil for `.file` sessions. Every non-file source produced `nil`, so two such documents sharing an image source collapsed to the identical `(nil, imageSource)` identity and deduplicated onto one window.++## Resolution++`ImageDetailIdentifier.documentURL` was replaced by a non-optional `ownerURL`, and `ImageDetailWindowData.identifier` now builds from the existing `ownerURL` field — the same per-document key (file URL, or the synthetic `prism://clipboard/{sessionID}` URL) that window-lifecycle ownership has used since T-1503. `MediaZoomPresenter.openImageWindow` no longer constructs the separate, often-nil `documentURL`.++`DiagramIdentifier`/`DiagramWindowManager` has the same latent shape but was deliberately left untouched to avoid conflicting with T-2014, T-2175 and T-2221.++## Affected Files++- `prism/Models/ImageDetailWindowData.swift`+- `prism/Views/MediaZoomPresenter.swift`+- `prismTests/ImageDetailWindowDataTests.swift` (new regression tests)+- `prismTests/ImageDetailWindowManagerTests.swift`+- `prismTests/DocumentFlowCoordinatorImageWindowTests.swift`+- `prismTests/DocumentFlowCoordinatorReplaceWindowCleanupTests.swift`++## Test Plan++- `ImageDetailWindowDataTests`: two non-file documents with the same image source yield distinct identifiers; the same document yields a stable identifier.+- Targeted suites (`ImageDetailWindowDataTests`, `ImageDetailWindowManagerTests`, `DocumentFlowCoordinatorImageWindowTests`, `DocumentFlowCoordinatorReplaceWindowCleanupTests`, `DiagramWindowDataTests`) pass 31/31; `make build-macos` and `make lint` pass.+- Full `make test-quick` hit the parallel-worktree host-launch cascade twice (other agents running concurrently); the only genuine failures among executed tests (`DocumentLayoutCoordinatorReloadTests`, `URLEncodingCorpusTests`) are unrelated and pass in isolation.++## Prevention++Identity keys for per-document windows must come from a non-optional owner key, never from a source-specific optional such as a file URL.
Open an image window from a clipboard document, quit, relaunch. The restored payload (if any) must decode; synthesised Codable says yes, but it is the one runtime path no test covers.
Both share one image window per image — intended and consistent with file sessions, but confirm it is the desired UX.