diff --git a/CLAUDE.md b/CLAUDE.md index b89ae3dc..de0264de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -294,6 +294,15 @@ Measurements are scoped to the **project**, not the session: they outlive the co Read **`Measurements.md`** before editing anything named `Measurement*`, the `SidePanelContent.measurements` case, `agenthub_record_measurement` / `agenthub_list_measurements`, or the `session_measurements` table. +### Artifact Panel + +Artifacts an agent published to claude.ai during a session (`/design` canvases, reports, mockups) open in a dedicated side panel. Detection is passive and read-only, on the same rails as localhost web preview: `SessionJSONLParser` files an artifact from the `Artifact` tool's publish result (which carries the title and source path), from the `frame-link` entry Claude Code writes alongside it, or from a bare artifact URL in the transcript — into `SessionMonitorState.detectedArtifacts`. An **Artifact** button appears in the card header only once one has been detected, and toggles `SidePanelContent.artifact` open/closed. + +- **Claude only.** `CodexSessionJSONLParser` never files artifacts, and the card gates the button on `providerKind == .claude`. +- Artifacts are keyed by the id in the URL, so a republish updates the existing entry and bumps `revision` — which is what makes an open panel reload — instead of appending a duplicate. The canonical URL drops query/fragment (`?via=auto_preview` is provenance, not identity). +- `ArtifactWebView` uses the **default (persistent)** website data store: artifact pages are private to the signed-in account, so the sign-in has to survive a panel close and a relaunch. Links leaving claude.ai open in the user's browser. A signed-out load lands on Anthropic's sign-in page (`ClaudeArtifactURLDetector.isSignInURL`); the panel banners it as Anthropic's, not AgentHub's, and tells the user to sign in **in the panel**. A browser session is a different cookie jar and never carries over, so that banner must not offer an open-in-browser escape. +- Detection changes belong in `ClaudeArtifactURLDetector` and need unit tests. + ### Command Palette `CommandPaletteView` — Cmd+K for quick session/repository/action access. diff --git a/app/modules/AgentHubCore/Sources/AgentHub/Models/ClaudeArtifact.swift b/app/modules/AgentHubCore/Sources/AgentHub/Models/ClaudeArtifact.swift new file mode 100644 index 00000000..69bc5258 --- /dev/null +++ b/app/modules/AgentHubCore/Sources/AgentHub/Models/ClaudeArtifact.swift @@ -0,0 +1,59 @@ +// +// ClaudeArtifact.swift +// AgentHub +// +// A Claude artifact (https://claude.ai/code/artifact/) published by an +// agent during a session and rendered in the Artifact side panel. +// + +import Foundation + +// MARK: - ClaudeArtifact + +/// An artifact page the agent published from this session. +/// +/// Parsed out of the session JSONL — either from the `Artifact` tool's +/// publish result (which carries a title and the source file path) or from a +/// bare artifact URL mentioned in the transcript. +public struct ClaudeArtifact: Identifiable, Equatable, Hashable, Sendable { + /// The artifact id from the URL path; also the identity used for dedupe, so a + /// republish updates the existing entry instead of appending a duplicate. + public let id: String + /// Canonical page URL (query/fragment stripped — `?via=…` is provenance). + public let url: URL + public var title: String? + /// Local file the artifact was published from, when the publish result named one. + public var filePath: String? + /// Bumped on every republish. The open panel watches this to reload the same + /// URL when the content behind it changes. + public var revision: Int + public var detectedAt: Date + + public init( + id: String, + url: URL, + title: String? = nil, + filePath: String? = nil, + revision: Int = 0, + detectedAt: Date = Date() + ) { + self.id = id + self.url = url + self.title = title + self.filePath = filePath + self.revision = revision + self.detectedAt = detectedAt + } + + /// Title for the picker/header, falling back to the source file name and then + /// to a short id so an untitled artifact is still distinguishable. + public var displayTitle: String { + if let title, !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return title + } + if let fileName = filePath?.split(separator: "/").last, !fileName.isEmpty { + return String(fileName) + } + return "Artifact \(id.prefix(8))" + } +} diff --git a/app/modules/AgentHubCore/Sources/AgentHub/Models/SessionMonitorState.swift b/app/modules/AgentHubCore/Sources/AgentHub/Models/SessionMonitorState.swift index 6955ee55..fd32a64d 100644 --- a/app/modules/AgentHubCore/Sources/AgentHub/Models/SessionMonitorState.swift +++ b/app/modules/AgentHubCore/Sources/AgentHub/Models/SessionMonitorState.swift @@ -52,6 +52,10 @@ public struct SessionMonitorState: Equatable, Sendable { // Localhost URL detected from agent's dev server output public var detectedLocalhostURL: URL? + // Claude artifacts published from this session (Claude only — Codex has no + // equivalent surface), oldest first + public var detectedArtifacts: [ClaudeArtifact] + public init( status: SessionStatus = .idle, currentTool: String? = nil, @@ -71,7 +75,8 @@ public struct SessionMonitorState: Equatable, Sendable { detectedResourceLinks: [ResourceLink] = [], detectedMCPAppResources: [MCPAppResourceDescriptor] = [], detectedMCPAppInvocations: [MCPAppInvocation] = [], - detectedLocalhostURL: URL? = nil + detectedLocalhostURL: URL? = nil, + detectedArtifacts: [ClaudeArtifact] = [] ) { self.status = status self.currentTool = currentTool @@ -92,6 +97,7 @@ public struct SessionMonitorState: Equatable, Sendable { self.detectedMCPAppResources = detectedMCPAppResources self.detectedMCPAppInvocations = detectedMCPAppInvocations self.detectedLocalhostURL = detectedLocalhostURL + self.detectedArtifacts = detectedArtifacts } // MARK: - Computed Properties diff --git a/app/modules/AgentHubCore/Sources/AgentHub/Services/SessionFileWatcher.swift b/app/modules/AgentHubCore/Sources/AgentHub/Services/SessionFileWatcher.swift index cc94f4e3..6379e2fa 100644 --- a/app/modules/AgentHubCore/Sources/AgentHub/Services/SessionFileWatcher.swift +++ b/app/modules/AgentHubCore/Sources/AgentHub/Services/SessionFileWatcher.swift @@ -603,7 +603,8 @@ public actor SessionFileWatcher { detectedResourceLinks: result.detectedResourceLinks, detectedMCPAppResources: result.detectedMCPAppResources, detectedMCPAppInvocations: result.detectedMCPAppInvocations, - detectedLocalhostURL: result.detectedLocalhostURL + detectedLocalhostURL: result.detectedLocalhostURL, + detectedArtifacts: result.detectedArtifacts ) } diff --git a/app/modules/AgentHubCore/Sources/AgentHub/Services/SessionJSONLParser.swift b/app/modules/AgentHubCore/Sources/AgentHub/Services/SessionJSONLParser.swift index 887af2ab..18094612 100644 --- a/app/modules/AgentHubCore/Sources/AgentHub/Services/SessionJSONLParser.swift +++ b/app/modules/AgentHubCore/Sources/AgentHub/Services/SessionJSONLParser.swift @@ -19,6 +19,7 @@ public struct SessionJSONLParser { private static let maxDetectedResourceLinks = 50 private static let maxDetectedMCPAppResources = 50 private static let maxDetectedMCPAppInvocations = 12 + private static let maxDetectedArtifacts = 20 // MARK: - Entry Types @@ -30,6 +31,32 @@ public struct SessionJSONLParser { let message: MessageContent? let costUSD: Double? let durationMs: Int? + // `frame-link` entries: the artifact publish record Claude Code appends + // alongside the Artifact tool result (see `detectedArtifacts`). + let frameUrl: String? + let title: String? + let path: String? + + private enum CodingKeys: String, CodingKey { + case type, timestamp, uuid, message, costUSD, durationMs, frameUrl, title, path + } + + /// `title` and `path` are generic enough that some other entry type could + /// one day use them for a non-string value. Decoding them leniently keeps + /// that from throwing, which would drop the whole entry — and with it the + /// token counts and activity the rest of the parser depends on. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + type = try container.decode(String.self, forKey: .type) + timestamp = try container.decodeIfPresent(String.self, forKey: .timestamp) + uuid = try container.decodeIfPresent(String.self, forKey: .uuid) + message = try container.decodeIfPresent(MessageContent.self, forKey: .message) + costUSD = try container.decodeIfPresent(Double.self, forKey: .costUSD) + durationMs = try container.decodeIfPresent(Int.self, forKey: .durationMs) + frameUrl = try? container.decodeIfPresent(String.self, forKey: .frameUrl) + title = try? container.decodeIfPresent(String.self, forKey: .title) + path = try? container.decodeIfPresent(String.self, forKey: .path) + } } /// Message content within an entry @@ -100,10 +127,26 @@ public struct SessionJSONLParser { /// In-flight MCP tool calls keyed by tool_use id, finalized when the result arrives. public var pendingMCPInvocations: [String: MCPAppInvocation] = [:] public var detectedLocalhostURL: URL? + /// Claude artifacts published from this session, oldest first. + public var detectedArtifacts: [ClaudeArtifact] = [] + /// In-flight `Artifact` tool calls keyed by tool_use id, so the URL in the + /// result can be filed with the title the agent published it under. + public var pendingArtifactPublishes: [String: PendingArtifactPublish] = [:] public init() {} } + /// Title/source metadata from an in-flight `Artifact` tool call + public struct PendingArtifactPublish: Sendable { + public let title: String? + public let filePath: String? + + public init(title: String?, filePath: String?) { + self.title = title + self.filePath = filePath + } + } + /// Info about a pending tool use public struct PendingToolInfo: Sendable { public let toolName: String @@ -245,6 +288,21 @@ public struct SessionJSONLParser { // Summary entries may contain git branch info break + case "frame-link": + // Claude Code records every artifact publish as its own entry, with the + // title already resolved — the most direct signal we get. + if let frameUrl = entry.frameUrl, + let url = ClaudeArtifactURLDetector.canonicalURL(from: frameUrl) { + appendArtifact( + url: url, + title: entry.title, + filePath: entry.path, + timestamp: timestamp, + isPublish: true, + to: &result + ) + } + default: break } @@ -297,6 +355,12 @@ public struct SessionJSONLParser { to: &result ) + // Capture the artifact publish's title/source path; the URL only + // arrives with the tool result. + if name == artifactToolName { + result.pendingArtifactPublishes[id] = artifactPublish(from: block.input) + } + // Capture the in-flight MCP tool call so we can render its app once // the result arrives (see `detectedMCPAppInvocations`). if let server = MCPAppResourceExtractor.serverName(fromToolName: name) { @@ -331,6 +395,20 @@ public struct SessionJSONLParser { AppLogger.devServer.info("[SessionJSONLParser] Detected localhost URL from tool_result: \(localhostURL.absoluteString)") result.detectedLocalhostURL = localhostURL } + // An artifact URL in the result of the Artifact tool is a publish; + // the same URL echoed by any other tool is only a mention. + let publish = result.pendingArtifactPublishes.removeValue(forKey: toolUseId) + for url in extractArtifactURLs(from: block.content) { + appendArtifact( + url: url, + title: publish?.title, + filePath: publish?.filePath, + timestamp: timestamp, + isPublish: publish != nil, + to: &result + ) + } + appendResourceLinks(extractResourceLinks(from: block.content, timestamp: timestamp), to: &result) appendMCPAppResources( extractMCPAppResources(from: block.content, serverName: serverName), @@ -365,6 +443,9 @@ public struct SessionJSONLParser { } appendResourceLinks(extractResourceLinks(from: text, timestamp: timestamp), to: &result) appendMCPAppResources(MCPAppResourceExtractor.extract(from: text), to: &result) + for url in ClaudeArtifactURLDetector.extractAll(from: text) { + appendArtifact(url: url, timestamp: timestamp, isPublish: false, to: &result) + } // Extract localhost URLs from assistant text (e.g. "Your app is running at http://localhost:5173") if let localhostURL = extractLocalhostURLFromText(text) { AppLogger.devServer.info("[SessionJSONLParser] Detected localhost URL from assistant text: \(localhostURL.absoluteString)") @@ -713,6 +794,86 @@ public struct SessionJSONLParser { // MARK: - Localhost URL Extraction /// Extracts a localhost URL from tool_result content (AnyCodable: string or [{type, text}]) + // MARK: - Artifacts + + /// The tool Claude Code publishes artifacts with. + private static let artifactToolName = "Artifact" + + private static func artifactPublish(from input: AnyCodable?) -> PendingArtifactPublish { + guard let dictionary = input?.value as? [String: Any] else { + return PendingArtifactPublish(title: nil, filePath: nil) + } + + return PendingArtifactPublish( + title: dictionary["title"] as? String, + filePath: dictionary["file_path"] as? String + ) + } + + private static func extractArtifactURLs(from content: AnyCodable?) -> [URL] { + guard let content else { return [] } + + if let text = content.value as? String { + return ClaudeArtifactURLDetector.extractAll(from: text) + } + + if let blocks = content.value as? [[String: Any]] { + var seenURLs = Set() + var urls: [URL] = [] + for block in blocks { + guard let text = block["text"] as? String else { continue } + for url in ClaudeArtifactURLDetector.extractAll(from: text) + where seenURLs.insert(url.absoluteString).inserted { + urls.append(url) + } + } + return urls + } + + return [] + } + + /// Files an artifact keyed by its id, so a republish updates the existing + /// entry — bumping `revision`, which is what makes an open panel reload — + /// rather than appending a duplicate. Most recently seen artifact stays last. + private static func appendArtifact( + url: URL, + title: String? = nil, + filePath: String? = nil, + timestamp: Date?, + isPublish: Bool, + to result: inout ParseResult + ) { + guard let identifier = ClaudeArtifactURLDetector.identifier(from: url) else { return } + let detectedAt = timestamp ?? Date() + + if let index = result.detectedArtifacts.firstIndex(where: { $0.id == identifier }) { + var artifact = result.detectedArtifacts.remove(at: index) + artifact.title = title ?? artifact.title + artifact.filePath = filePath ?? artifact.filePath + artifact.detectedAt = detectedAt + if isPublish { + artifact.revision += 1 + } + result.detectedArtifacts.append(artifact) + } else { + result.detectedArtifacts.append( + ClaudeArtifact( + id: identifier, + url: url, + title: title, + filePath: filePath, + revision: isPublish ? 1 : 0, + detectedAt: detectedAt + ) + ) + } + + if result.detectedArtifacts.count > maxDetectedArtifacts { + result.detectedArtifacts.removeFirst(result.detectedArtifacts.count - maxDetectedArtifacts) + } + } + private static func extractLocalhostURL(from content: AnyCodable?) -> URL? { guard let content = content else { return nil } diff --git a/app/modules/AgentHubCore/Sources/AgentHub/UI/ArtifactSidePanelView.swift b/app/modules/AgentHubCore/Sources/AgentHub/UI/ArtifactSidePanelView.swift new file mode 100644 index 00000000..aa98a14d --- /dev/null +++ b/app/modules/AgentHubCore/Sources/AgentHub/UI/ArtifactSidePanelView.swift @@ -0,0 +1,303 @@ +// +// ArtifactSidePanelView.swift +// AgentHub +// +// Embedded side panel that renders the Claude artifacts an agent published +// during a session (parsed from the session JSONL into +// `SessionMonitorState.detectedArtifacts`). +// + +import SwiftUI + +// MARK: - ArtifactSignInBanner + +/// Shown when the web view lands on a sign-in page instead of the artifact. +/// The sign-in is Anthropic's, not ours — this says so, so a login wall inside +/// AgentHub doesn't read as an AgentHub bug or an AgentHub credential prompt. +/// +/// Deliberately offers no "open in browser" escape: a browser session is a +/// different cookie jar, so signing in there does nothing for this panel and +/// would just send the user around a loop. +private struct ArtifactSignInBanner: View { + var body: some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "lock") + .foregroundStyle(Color.brandPrimary) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 2) { + Text("Sign in below to view this artifact") + .font(.caption.weight(.semibold)) + Text("Artifacts are hosted by Anthropic and private to your account. This is Anthropic's standard sign-in page — AgentHub doesn't handle it. Signing in in your browser doesn't carry over, so sign in here; you only need to do it once.") + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 8) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.brandPrimary.opacity(0.10)) + } +} + +// MARK: - ArtifactSidePanelView + +/// Side panel host for agent-published artifact pages. Reads the live list from +/// the session's monitor state, so a republish updates the open panel. +struct ArtifactSidePanelView: View { + let artifacts: [ClaudeArtifact] + let onDismiss: () -> Void + var isEmbedded = false + let isExpanded: Bool + var onToggleExpanded: (() -> Void)? + + @State private var selectedArtifactID: String? + @State private var manualReloadCount = 0 + @State private var isLoading = false + @State private var failureMessage: String? + @State private var currentURL: URL? + @Environment(\.openURL) private var openURL + + private var isShowingSignIn: Bool { + currentURL.map(ClaudeArtifactURLDetector.isSignInURL) ?? false + } + + private var artifactIDs: [String] { + artifacts.map(\.id) + } + + /// Identity of what should currently be on screen: switching artifacts, the + /// agent republishing the open one, and Reload all reload the page. + private var reloadToken: String { + guard let selectedArtifact else { return "none" } + return "\(selectedArtifact.id)#\(selectedArtifact.revision)#\(manualReloadCount)" + } + + /// Newest artifact by default — the one the agent just published. + private var selectedArtifact: ClaudeArtifact? { + guard let selectedArtifactID else { return artifacts.last } + return artifacts.first { $0.id == selectedArtifactID } ?? artifacts.last + } + + var body: some View { + VStack(spacing: 0) { + header + + Divider() + + if let selectedArtifact { + content(for: selectedArtifact) + } else { + emptyState + } + } + .frame( + minWidth: 300, idealWidth: .infinity, maxWidth: .infinity, + minHeight: 300, idealHeight: .infinity, maxHeight: .infinity + ) + .onAppear { + selectedArtifactID = selectedArtifactID ?? artifacts.last?.id + } + .onChange(of: artifactIDs) { previousIDs, currentIDs in + reconcileSelection(previousIDs: previousIDs, currentIDs: currentIDs) + } + .onChange(of: reloadToken) { + failureMessage = nil + currentURL = nil + } + .onKeyPress(.escape) { + guard !isEmbedded else { return .handled } + onDismiss() + return .handled + } + } + + // MARK: - Content + + @ViewBuilder + private func content(for artifact: ClaudeArtifact) -> some View { + if let failureMessage { + failureState(for: artifact, message: failureMessage) + } else { + VStack(spacing: 0) { + if isShowingSignIn { + ArtifactSignInBanner() + Divider() + } + + ArtifactWebView( + url: artifact.url, + reloadToken: reloadToken, + onLoadingChange: { isLoading = $0 }, + onFailure: { failureMessage = $0 }, + onPageChange: { currentURL = $0 } + ) + .overlay(alignment: .top) { + if isLoading { + ProgressView() + .progressViewStyle(.linear) + .frame(height: 2) + .accessibilityLabel("Loading artifact") + } + } + } + } + } + + private var emptyState: some View { + ContentUnavailableView( + "No Artifacts", + systemImage: "sparkles.rectangle.stack", + description: Text("This session has not published an artifact yet.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func failureState(for artifact: ClaudeArtifact, message: String) -> some View { + VStack(spacing: DesignTokens.Spacing.md) { + ContentUnavailableView( + "Couldn't Load Artifact", + systemImage: "exclamationmark.triangle", + description: Text(message) + ) + + HStack(spacing: DesignTokens.Spacing.sm) { + Button("Try Again", action: reload) + Button("Open in Browser") { openURL(artifact.url) } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Header + + private var header: some View { + HStack(spacing: 10) { + Image(systemName: "sparkles.rectangle.stack") + .foregroundStyle(Color.brandPrimary) + .accessibilityHidden(true) + + Text("Artifact") + .font(.headline) + + if artifacts.count == 1, let selectedArtifact { + Text(selectedArtifact.displayTitle) + .font(.secondaryCaption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + + Spacer(minLength: 12) + + if artifacts.count > 1 { + Picker("Artifact", selection: Binding( + get: { selectedArtifact?.id }, + set: { selectedArtifactID = $0 } + )) { + ForEach(Array(artifacts.enumerated()), id: \.element.id) { index, artifact in + Text(pickerLabel(for: artifact, at: index)) + .tag(Optional(artifact.id)) + } + } + .labelsHidden() + .frame(maxWidth: 220) + .accessibilityLabel("Select artifact") + } + + Button(action: reload) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(selectedArtifact == nil) + .accessibilityLabel("Reload artifact") + .help("Reload artifact") + + if let selectedArtifact { + Button { + openURL(selectedArtifact.url) + } label: { + Image(systemName: "safari") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Open artifact in browser") + .help("Open in browser") + } + + if let onToggleExpanded { + Button(action: onToggleExpanded) { + Image(systemName: isExpanded ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(isExpanded ? "Collapse artifact" : "Expand artifact to full width") + .help(isExpanded ? "Collapse artifact (⌘⇧O)" : "Expand artifact to full width (⌘⇧O)") + } + + closeButton + } + .overlay { + if let onToggleExpanded { + Button("") { onToggleExpanded() } + .keyboardShortcut("o", modifiers: [.command, .shift]) + .hidden() + .frame(width: 0, height: 0) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + @ViewBuilder + private var closeButton: some View { + if isEmbedded { + Button("Close", action: onDismiss) + } else { + Button("Close", action: onDismiss) + .keyboardShortcut(.cancelAction) + } + } + + // MARK: - Actions + + private func reload() { + failureMessage = nil + manualReloadCount += 1 + } + + /// Picker label, disambiguated by position when artifacts share a title + /// (successive publishes of differently-named files can still collide). + private func pickerLabel(for artifact: ClaudeArtifact, at index: Int) -> String { + let sharesTitle = artifacts.filter { $0.displayTitle == artifact.displayTitle }.count > 1 + return sharesTitle ? "\(artifact.displayTitle) \(index + 1)" : artifact.displayTitle + } + + /// Follows the agent: a newly published artifact takes the selection, an + /// existing selection survives reordering, and a vanished one falls back to + /// the newest. + private func reconcileSelection(previousIDs: [String], currentIDs: [String]) { + let added = Set(currentIDs).subtracting(previousIDs) + if let newest = currentIDs.last(where: { added.contains($0) }) { + selectedArtifactID = newest + failureMessage = nil + return + } + if let selectedArtifactID, currentIDs.contains(selectedArtifactID) { + return + } + selectedArtifactID = currentIDs.last + } +} diff --git a/app/modules/AgentHubCore/Sources/AgentHub/UI/ArtifactWebView.swift b/app/modules/AgentHubCore/Sources/AgentHub/UI/ArtifactWebView.swift new file mode 100644 index 00000000..55e9d6eb --- /dev/null +++ b/app/modules/AgentHubCore/Sources/AgentHub/UI/ArtifactWebView.swift @@ -0,0 +1,165 @@ +// +// ArtifactWebView.swift +// AgentHub +// +// WKWebView host for a Claude artifact page. +// + +import AppKit +import SwiftUI +import WebKit + +/// Renders one artifact page. +/// +/// Uses the default (persistent) website data store on purpose: artifact pages +/// are private to the signed-in account, so the sign-in has to survive a panel +/// close and an app relaunch. Links that leave claude.ai open in the user's +/// browser rather than turning this panel into a general-purpose browser. +struct ArtifactWebView: NSViewRepresentable { + let url: URL + /// Changing this reloads the page — a republish by the agent, or the user + /// pressing Reload. + let reloadToken: String + let onLoadingChange: (Bool) -> Void + let onFailure: (String) -> Void + /// The page the web view actually landed on — a signed-out load redirects to + /// claude.ai's sign-in, which the panel explains rather than leaving bare. + let onPageChange: (URL?) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onLoadingChange: onLoadingChange, onFailure: onFailure, onPageChange: onPageChange) + } + + func makeNSView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .default() + // Without a Safari version in the user agent, some app shells serve an + // "unsupported browser" page to WKWebView. + configuration.applicationNameForUserAgent = "Version/18.0 Safari/605.1.15" + + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.navigationDelegate = context.coordinator + webView.uiDelegate = context.coordinator + webView.allowsBackForwardNavigationGestures = true + + context.coordinator.load(url, token: reloadToken, in: webView) + return webView + } + + func updateNSView(_ webView: WKWebView, context: Context) { + context.coordinator.onLoadingChange = onLoadingChange + context.coordinator.onFailure = onFailure + context.coordinator.onPageChange = onPageChange + context.coordinator.load(url, token: reloadToken, in: webView) + } + + static func dismantleNSView(_ webView: WKWebView, coordinator: Coordinator) { + webView.navigationDelegate = nil + webView.uiDelegate = nil + webView.stopLoading() + } + + // MARK: - Coordinator + + final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate { + var onLoadingChange: (Bool) -> Void + var onFailure: (String) -> Void + var onPageChange: (URL?) -> Void + + private var loadedToken: String? + + init( + onLoadingChange: @escaping (Bool) -> Void, + onFailure: @escaping (String) -> Void, + onPageChange: @escaping (URL?) -> Void + ) { + self.onLoadingChange = onLoadingChange + self.onFailure = onFailure + self.onPageChange = onPageChange + } + + func load(_ url: URL, token: String, in webView: WKWebView) { + guard loadedToken != token else { return } + loadedToken = token + webView.load(URLRequest(url: url)) + } + + /// Loading state is reported from the delegate rather than from `load`, + /// which runs inside `updateNSView` where touching SwiftUI state is illegal. + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + onLoadingChange(true) + } + + func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { + onPageChange(webView.url) + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + onLoadingChange(false) + onPageChange(webView.url) + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: any Error) { + report(error) + } + + func webView( + _ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: any Error + ) { + report(error) + } + + /// Keeps in-page navigation inside the panel and sends anything that leaves + /// claude.ai to the browser, so a link in an artifact can't strand the user + /// in a panel with no address bar. + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + guard navigationAction.navigationType == .linkActivated, + let url = navigationAction.request.url, + !Self.isClaudeHost(url) else { + decisionHandler(.allow) + return + } + + decisionHandler(.cancel) + NSWorkspace.shared.open(url) + } + + /// `target="_blank"` links: WKWebView has no window to open, so hand them + /// to the browser instead of dropping them. + func webView( + _ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + if let url = navigationAction.request.url { + NSWorkspace.shared.open(url) + } + return nil + } + + private func report(_ error: any Error) { + onLoadingChange(false) + + // Cancelled loads and policy-interrupted frame loads are the normal + // result of navigating away or handing a link to the browser. + let nsError = error as NSError + let isCancelled = nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled + let isPolicyInterrupted = nsError.domain == "WebKitErrorDomain" && nsError.code == 102 + guard !isCancelled, !isPolicyInterrupted else { return } + + onFailure(error.localizedDescription) + } + + private static func isClaudeHost(_ url: URL) -> Bool { + guard let host = url.host?.lowercased() else { return false } + return host == "claude.ai" || host.hasSuffix(".claude.ai") + } + } +} diff --git a/app/modules/AgentHubCore/Sources/AgentHub/UI/MonitoringCardView.swift b/app/modules/AgentHubCore/Sources/AgentHub/UI/MonitoringCardView.swift index cbfc774b..054cb478 100644 --- a/app/modules/AgentHubCore/Sources/AgentHub/UI/MonitoringCardView.swift +++ b/app/modules/AgentHubCore/Sources/AgentHub/UI/MonitoringCardView.swift @@ -43,6 +43,7 @@ public struct MonitoringCardView: View { let onShowPendingChanges: ((CLISession, PendingToolUse) -> Void)? let onShowMCPApp: ((CLISession, String) -> Void)? let onShowMeasurement: ((CLISession) -> Void)? + let onShowArtifact: ((CLISession) -> Void)? let onShowSimulatorPreview: ((CLISession, String) -> Void)? let onFork: ((CLISession, SessionProviderKind) -> Void)? let onPromptConsumed: (() -> Void)? @@ -99,6 +100,7 @@ public struct MonitoringCardView: View { onShowPendingChanges: ((CLISession, PendingToolUse) -> Void)? = nil, onShowMCPApp: ((CLISession, String) -> Void)? = nil, onShowMeasurement: ((CLISession) -> Void)? = nil, + onShowArtifact: ((CLISession) -> Void)? = nil, onShowSimulatorPreview: ((CLISession, String) -> Void)? = nil, onFork: ((CLISession, SessionProviderKind) -> Void)? = nil, onPromptConsumed: (() -> Void)? = nil, @@ -139,6 +141,7 @@ public struct MonitoringCardView: View { self.onShowPendingChanges = onShowPendingChanges self.onShowMCPApp = onShowMCPApp self.onShowMeasurement = onShowMeasurement + self.onShowArtifact = onShowArtifact self.onShowSimulatorPreview = onShowSimulatorPreview self.onFork = onFork self.onPromptConsumed = onPromptConsumed @@ -222,6 +225,18 @@ public struct MonitoringCardView: View { viewModel?.measurements(for: session).count ?? 0 } + /// Claude-only: Codex has no artifact surface, so its parser never files any. + private var detectedArtifacts: [ClaudeArtifact] { + guard providerKind == .claude else { return [] } + return state?.detectedArtifacts ?? [] + } + + private var artifactButtonAccessibilityLabel: String { + detectedArtifacts.count == 1 + ? "Open 1 published artifact" + : "Open \(detectedArtifacts.count) published artifacts" + } + private var measurementButtonAccessibilityLabel: String { measurementCount == 1 ? "Open 1 recorded measurement" : "Open \(measurementCount) recorded measurements" } @@ -835,6 +850,28 @@ public struct MonitoringCardView: View { .accessibilityLabel(measurementButtonAccessibilityLabel) } + // Artifact button — only once the agent has actually published one to + // claude.ai, and only where the panel can host it. + if onShowArtifact != nil, !detectedArtifacts.isEmpty { + Button(action: { + onShowArtifact?(session) + }) { + HStack(spacing: 4) { + Image(systemName: "sparkles.rectangle.stack") + .font(.caption2) + Text("Artifact") + if detectedArtifacts.count > 1 { + Text("\(detectedArtifacts.count)") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + } + .buttonStyle(.agentHubOutlined) + .help("Open artifacts published in this session") + .accessibilityLabel(artifactButtonAccessibilityLabel) + } + // Mermaid diagram button (only visible when mermaid content is detected) if state?.hasMermaidContent == true { Button(action: { diff --git a/app/modules/AgentHubCore/Sources/AgentHub/UI/MultiProviderMonitoringPanelView.swift b/app/modules/AgentHubCore/Sources/AgentHub/UI/MultiProviderMonitoringPanelView.swift index ee99f056..485e7f51 100644 --- a/app/modules/AgentHubCore/Sources/AgentHub/UI/MultiProviderMonitoringPanelView.swift +++ b/app/modules/AgentHubCore/Sources/AgentHub/UI/MultiProviderMonitoringPanelView.swift @@ -23,6 +23,7 @@ enum SidePanelContent: Equatable { case mcpApp(sessionId: String, session: CLISession, projectPath: String) case simulator(sessionId: String, session: CLISession, projectPath: String) case measurements(sessionId: String, session: CLISession) + case artifact(sessionId: String, session: CLISession) static func == (lhs: SidePanelContent, rhs: SidePanelContent) -> Bool { switch (lhs, rhs) { @@ -44,6 +45,8 @@ enum SidePanelContent: Equatable { return id1 == id2 && p1 == p2 case (.measurements(let id1, _), .measurements(let id2, _)): return id1 == id2 + case (.artifact(let id1, _), .artifact(let id2, _)): + return id1 == id2 default: return false } } @@ -606,6 +609,12 @@ public struct MultiProviderMonitoringPanelView: View { forItemID: item.id ) }, + onShowArtifact: { session in + toggleSidePanel( + .artifact(sessionId: session.id, session: session), + forItemID: item.id + ) + }, onShowSimulatorPreview: { session, projectPath in toggleSidePanel( .simulator(sessionId: session.id, session: session, projectPath: projectPath), @@ -706,6 +715,12 @@ public struct MultiProviderMonitoringPanelView: View { forItemID: item.id ) }, + onShowArtifact: { session in + toggleSidePanel( + .artifact(sessionId: session.id, session: session), + forItemID: item.id + ) + }, onShowSimulatorPreview: { session, projectPath in toggleSidePanel( .simulator(sessionId: session.id, session: session, projectPath: projectPath), @@ -895,7 +910,7 @@ public struct MultiProviderMonitoringPanelView: View { switch content { case .edits, .plan: return true - case .diff, .webPreview, .mermaid, .gitHub, .mcpApp, .simulator, .measurements: + case .diff, .webPreview, .mermaid, .gitHub, .mcpApp, .simulator, .measurements, .artifact: return false } } @@ -1141,6 +1156,14 @@ public struct MultiProviderMonitoringPanelView: View { onDismiss: closeEmbeddedSidePanel, isEmbedded: true ) + case .artifact(let sessionId, _): + ArtifactSidePanelView( + artifacts: viewModel.monitorStates[sessionId]?.detectedArtifacts ?? [], + onDismiss: closeEmbeddedSidePanel, + isEmbedded: true, + isExpanded: sidePanelExpansion.isExpanded(for: payload), + onToggleExpanded: { toggleEmbeddedSidePanelExpansion(for: payload) } + ) } } diff --git a/app/modules/AgentHubCore/Sources/AgentHub/Utils/ClaudeArtifactURLDetector.swift b/app/modules/AgentHubCore/Sources/AgentHub/Utils/ClaudeArtifactURLDetector.swift new file mode 100644 index 00000000..e8cada22 --- /dev/null +++ b/app/modules/AgentHubCore/Sources/AgentHub/Utils/ClaudeArtifactURLDetector.swift @@ -0,0 +1,112 @@ +// +// ClaudeArtifactURLDetector.swift +// AgentHub +// +// Recognizes Claude artifact URLs in agent output so a published artifact can +// be opened in its own side panel, the way a localhost URL opens web preview. +// + +import Foundation + +enum ClaudeArtifactURLDetector { + private static let host = "claude.ai" + private static let pathPrefix = "code/artifact" + + /// Cheap prefilter so the hot parse path skips the regex on ordinary text. + private static let marker = "claude.ai/code/artifact/" + + /// The id class stops at prose and markdown punctuation, so a URL written as + /// `…/artifact/abc).` or `…/artifact/abc.` still yields `abc`. + private static let regex = try? NSRegularExpression( + pattern: #"https?://(?:www\.)?claude\.ai/code/artifact/[A-Za-z0-9][A-Za-z0-9_-]{7,63}"#, + options: [.caseInsensitive] + ) + + /// The artifact id in `url`, or nil when it isn't an artifact URL. + static func identifier(from url: URL) -> String? { + guard let scheme = url.scheme?.lowercased(), + scheme == "https" || scheme == "http", + let urlHost = url.host?.lowercased(), + urlHost == host || urlHost == "www.\(host)" else { + return nil + } + return identifier(fromPath: url.path) + } + + /// Canonical URL for `candidate`, or nil when it isn't an artifact URL. + /// + /// Query and fragment are dropped: `?via=auto_preview` records how the link + /// was surfaced, not which artifact it is, and keeping it would file the same + /// artifact twice under two URLs. + static func canonicalURL(from candidate: String) -> URL? { + let trimmed = candidate.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: trimmed), let identifier = identifier(from: url) else { + return nil + } + return canonicalURL(forIdentifier: identifier) + } + + static func canonicalURL(forIdentifier identifier: String) -> URL? { + URL(string: "https://\(host)/\(pathPrefix)/\(identifier)") + } + + /// Canonical artifact URLs mentioned in `text`, in first-seen order and + /// deduped by id. + static func extractAll(from text: String) -> [URL] { + guard text.range(of: marker, options: [.caseInsensitive]) != nil, let regex else { + return [] + } + + var seenIdentifiers = Set() + var urls: [URL] = [] + + for match in regex.matches(in: text, range: NSRange(text.startIndex..., in: text)) { + guard let range = Range(match.range, in: text), + let url = canonicalURL(from: String(text[range])), + let identifier = identifier(from: url), + seenIdentifiers.insert(identifier).inserted else { + continue + } + urls.append(url) + } + + return urls + } + + /// True when the web view has been redirected to a sign-in page instead of + /// the artifact — claude.ai's own login, or an identity provider it hands off + /// to. Artifacts are private to the account, so this is the expected first + /// stop for a signed-out web view. + static func isSignInURL(_ url: URL) -> Bool { + guard let urlHost = url.host?.lowercased() else { return false } + + if urlHost == "accounts.google.com" { + return true + } + + guard urlHost == host || urlHost == "www.\(host)", + let firstSegment = url.path.split(separator: "/", omittingEmptySubsequences: true).first else { + return false + } + + return ["login", "signup", "sign-in", "magic-link", "oauth", "auth"].contains(firstSegment.lowercased()) + } + + private static func identifier(fromPath path: String) -> String? { + let segments = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init) + guard segments.count >= 3, + segments[0].lowercased() == "code", + segments[1].lowercased() == "artifact", + isValidIdentifier(segments[2]) else { + return nil + } + return segments[2] + } + + private static func isValidIdentifier(_ candidate: String) -> Bool { + guard (8...64).contains(candidate.count) else { return false } + return candidate.allSatisfy { character in + character.isASCII && (character.isLetter || character.isNumber || character == "-" || character == "_") + } + } +} diff --git a/app/modules/AgentHubCore/Tests/AgentHubTests/ClaudeArtifactURLDetectorTests.swift b/app/modules/AgentHubCore/Tests/AgentHubTests/ClaudeArtifactURLDetectorTests.swift new file mode 100644 index 00000000..e56ca9cd --- /dev/null +++ b/app/modules/AgentHubCore/Tests/AgentHubTests/ClaudeArtifactURLDetectorTests.swift @@ -0,0 +1,72 @@ +import Foundation +import Testing + +@testable import AgentHubCore + +@Suite("ClaudeArtifactURLDetector") +struct ClaudeArtifactURLDetectorTests { + private let artifactID = "1502bf42-f488-49b5-a995-0402ef54bf6b" + + @Test("Strips the provenance query so one artifact has one canonical URL") + func stripsProvenanceQuery() { + let canonical = ClaudeArtifactURLDetector.canonicalURL( + from: "https://claude.ai/code/artifact/\(artifactID)?via=auto_preview" + ) + + #expect(canonical?.absoluteString == "https://claude.ai/code/artifact/\(artifactID)") + } + + @Test("Rejects claude.ai URLs that are not artifacts, and artifact paths on other hosts") + func rejectsNonArtifactURLs() { + #expect(ClaudeArtifactURLDetector.canonicalURL(from: "https://claude.ai/chat/\(artifactID)") == nil) + #expect(ClaudeArtifactURLDetector.canonicalURL(from: "https://claude.ai/code/artifacts") == nil) + #expect(ClaudeArtifactURLDetector.canonicalURL(from: "https://evil.example/code/artifact/\(artifactID)") == nil) + #expect(ClaudeArtifactURLDetector.canonicalURL(from: "https://claude.ai.evil.example/code/artifact/\(artifactID)") == nil) + } + + @Test("Trailing prose and markdown punctuation is not part of the id") + func trimsTrailingPunctuation() { + let text = """ + Published at https://claude.ai/code/artifact/\(artifactID). Also see \ + [the canvas](https://claude.ai/code/artifact/\(artifactID)). + """ + + let urls = ClaudeArtifactURLDetector.extractAll(from: text) + + #expect(urls.map(\.absoluteString) == ["https://claude.ai/code/artifact/\(artifactID)"]) + } + + @Test("Extracts every distinct artifact in first-seen order") + func extractsDistinctArtifactsInOrder() { + let second = "9c2b6c5e-1111-2222-3333-444455556666" + let text = """ + First https://claude.ai/code/artifact/\(artifactID) + Second https://claude.ai/code/artifact/\(second)?via=auto_preview + Repeat https://claude.ai/code/artifact/\(artifactID) + """ + + let urls = ClaudeArtifactURLDetector.extractAll(from: text) + + #expect(urls.map(\.absoluteString) == [ + "https://claude.ai/code/artifact/\(artifactID)", + "https://claude.ai/code/artifact/\(second)", + ]) + } + + @Test("Recognizes the sign-in pages a signed-out artifact load lands on") + func recognizesSignInPages() { + #expect(ClaudeArtifactURLDetector.isSignInURL(URL(string: "https://claude.ai/login?returnTo=%2Fcode")!)) + #expect(ClaudeArtifactURLDetector.isSignInURL(URL(string: "https://claude.ai/oauth/authorize")!)) + #expect(ClaudeArtifactURLDetector.isSignInURL(URL(string: "https://accounts.google.com/o/oauth2/v2/auth")!)) + #expect(!ClaudeArtifactURLDetector.isSignInURL(URL(string: "https://claude.ai/code/artifact/\(artifactID)")!)) + #expect(!ClaudeArtifactURLDetector.isSignInURL(URL(string: "https://evil.example/login")!)) + } + + @Test("Identifier round-trips from a canonical URL") + func identifierRoundTrips() { + let url = URL(string: "https://www.claude.ai/code/artifact/\(artifactID)?via=auto_preview")! + + #expect(ClaudeArtifactURLDetector.identifier(from: url) == artifactID) + #expect(ClaudeArtifactURLDetector.identifier(from: URL(string: "https://claude.ai/code/artifact/short")!) == nil) + } +} diff --git a/app/modules/AgentHubCore/Tests/AgentHubTests/SessionJSONLParserArtifactTests.swift b/app/modules/AgentHubCore/Tests/AgentHubTests/SessionJSONLParserArtifactTests.swift new file mode 100644 index 00000000..c6cc82b3 --- /dev/null +++ b/app/modules/AgentHubCore/Tests/AgentHubTests/SessionJSONLParserArtifactTests.swift @@ -0,0 +1,135 @@ +import Foundation +import Testing + +@testable import AgentHubCore + +@Suite("SessionJSONLParser artifact detection") +struct SessionJSONLParserArtifactTests { + private let artifactID = "1502bf42-f488-49b5-a995-0402ef54bf6b" + + private func toolUseLine(title: String, filePath: String) -> String { + """ + {"type":"assistant","timestamp":"2026-01-01T00:00:00Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"tu1","name":"Artifact","input":{"file_path":"\(filePath)","title":"\(title)"}}]}} + """ + } + + private func toolResultLine() -> String { + """ + {"type":"user","timestamp":"2026-01-01T00:00:01Z","message":{"role":"user","content":[{"tool_use_id":"tu1","type":"tool_result","content":"Published /tmp/hero.html at https://claude.ai/code/artifact/\(artifactID)"}]}} + """ + } + + private func frameLinkLine() -> String { + """ + {"type":"frame-link","sessionId":"s1","path":"/tmp/hero.html","frameUrl":"https://claude.ai/code/artifact/\(artifactID)","title":"Hero Directions","timestamp":"2026-01-01T00:00:01Z"} + """ + } + + @Test("An Artifact publish is filed with the title the agent published it under") + func filesPublishWithTitle() throws { + var result = SessionJSONLParser.ParseResult() + SessionJSONLParser.parseNewLines( + [toolUseLine(title: "Hero Directions", filePath: "/tmp/hero.html"), toolResultLine()], + into: &result + ) + + #expect(result.detectedArtifacts.count == 1) + let artifact = try #require(result.detectedArtifacts.first) + #expect(artifact.id == artifactID) + #expect(artifact.url.absoluteString == "https://claude.ai/code/artifact/\(artifactID)") + #expect(artifact.title == "Hero Directions") + #expect(artifact.filePath == "/tmp/hero.html") + #expect(artifact.revision == 1) + #expect(result.pendingArtifactPublishes.isEmpty) + } + + @Test("A frame-link entry files the artifact on its own") + func filesFrameLinkArtifact() throws { + var result = SessionJSONLParser.ParseResult() + SessionJSONLParser.parseNewLines([frameLinkLine()], into: &result) + + let artifact = try #require(result.detectedArtifacts.first) + #expect(artifact.title == "Hero Directions") + #expect(artifact.filePath == "/tmp/hero.html") + #expect(artifact.revision == 1) + } + + @Test("Republishing the same artifact bumps its revision instead of duplicating it") + func republishBumpsRevision() throws { + var result = SessionJSONLParser.ParseResult() + SessionJSONLParser.parseNewLines( + [toolUseLine(title: "Hero Directions", filePath: "/tmp/hero.html"), toolResultLine()], + into: &result + ) + let firstRevision = result.detectedArtifacts.first?.revision ?? 0 + + SessionJSONLParser.parseNewLines( + [toolUseLine(title: "Hero Directions v2", filePath: "/tmp/hero.html"), toolResultLine()], + into: &result + ) + + #expect(result.detectedArtifacts.count == 1) + let artifact = try #require(result.detectedArtifacts.first) + #expect(artifact.revision == firstRevision + 1) + #expect(artifact.title == "Hero Directions v2") + } + + @Test("A bare mention in assistant text is filed, but not as a publish") + func filesBareMentionWithoutPublish() throws { + let line = """ + {"type":"assistant","timestamp":"2026-01-01T00:00:00Z","message":{"role":"assistant","content":[{"type":"text","text":"Two hero directions: https://claude.ai/code/artifact/\(artifactID)"}]}} + """ + + var result = SessionJSONLParser.ParseResult() + SessionJSONLParser.parseNewLines([line], into: &result) + + let artifact = try #require(result.detectedArtifacts.first) + #expect(artifact.id == artifactID) + #expect(artifact.revision == 0) + #expect(artifact.title == nil) + } + + @Test("A mention after a publish keeps the published title and revision") + func mentionDoesNotClobberPublishMetadata() throws { + let mention = """ + {"type":"assistant","timestamp":"2026-01-01T00:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"Here it is: https://claude.ai/code/artifact/\(artifactID)"}]}} + """ + + var result = SessionJSONLParser.ParseResult() + SessionJSONLParser.parseNewLines( + [toolUseLine(title: "Hero Directions", filePath: "/tmp/hero.html"), toolResultLine(), mention], + into: &result + ) + + #expect(result.detectedArtifacts.count == 1) + let artifact = try #require(result.detectedArtifacts.first) + #expect(artifact.title == "Hero Directions") + #expect(artifact.revision == 1) + } + + @Test("An entry whose title/path aren't strings still parses") + func lenientFrameLinkFieldDecoding() { + let line = """ + {"type":"assistant","timestamp":"2026-01-01T00:00:00Z","path":{"nested":true},"title":42,"message":{"role":"assistant","content":[{"type":"text","text":"hello"}],"usage":{"output_tokens":7}}} + """ + + var result = SessionJSONLParser.ParseResult() + SessionJSONLParser.parseNewLines([line], into: &result) + + #expect(result.messageCount == 1) + #expect(result.totalOutputTokens == 7) + #expect(result.detectedArtifacts.isEmpty) + } + + @Test("Sessions without artifacts stay empty") + func noArtifactsWithoutSignal() { + let line = """ + {"type":"assistant","timestamp":"2026-01-01T00:00:00Z","message":{"role":"assistant","content":[{"type":"text","text":"Preview is ready at http://localhost:3000"}]}} + """ + + var result = SessionJSONLParser.ParseResult() + SessionJSONLParser.parseNewLines([line], into: &result) + + #expect(result.detectedArtifacts.isEmpty) + } +}