Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//
// ClaudeArtifact.swift
// AgentHub
//
// A Claude artifact (https://claude.ai/code/artifact/<id>) 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))"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -92,6 +97,7 @@ public struct SessionMonitorState: Equatable, Sendable {
self.detectedMCPAppResources = detectedMCPAppResources
self.detectedMCPAppInvocations = detectedMCPAppInvocations
self.detectedLocalhostURL = detectedLocalhostURL
self.detectedArtifacts = detectedArtifacts
}

// MARK: - Computed Properties
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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<String>()
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 }

Expand Down
Loading
Loading