diff --git a/apps/desktop/src/core/broker/service.ts b/apps/desktop/src/core/broker/service.ts index c15330ea..37b5c91e 100644 --- a/apps/desktop/src/core/broker/service.ts +++ b/apps/desktop/src/core/broker/service.ts @@ -1,5 +1,6 @@ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import { basename, join, resolve } from "node:path"; +import { randomUUID } from "node:crypto"; import { buildScoutReturnAddress as buildScoutReturnAddressRecord, @@ -30,6 +31,7 @@ import { type CollaborationPriority, type CollaborationRecord, type CollaborationWaitingOn, + type MessageAttachment, type MessageRecord, type ScoutInvocationLifecycle, type ScoutDeliverResponse, @@ -3144,12 +3146,52 @@ export async function sendScoutMessage(input: { }; } +/** Input shape for an attachment supplied by a caller (MCP). */ +export type OutgoingAttachmentInput = { + id?: string; + mediaType: string; + fileName?: string; + blobKey?: string; + url?: string; +}; + +/** + * Validate caller-supplied attachments and mint ids where absent. Drops any + * attachment lacking a media type or a way to fetch it (url/blobKey). Returns + * undefined when nothing usable remains, to keep the broker payload clean. + */ +export function normalizeOutgoingAttachments( + attachments: OutgoingAttachmentInput[] | undefined, +): MessageAttachment[] | undefined { + if (!attachments?.length) { + return undefined; + } + const normalized: MessageAttachment[] = []; + for (const attachment of attachments) { + const mediaType = attachment?.mediaType?.trim(); + const url = attachment?.url?.trim(); + const blobKey = attachment?.blobKey?.trim(); + if (!mediaType || (!url && !blobKey)) { + continue; + } + normalized.push({ + id: attachment.id?.trim() || `att-${randomUUID()}`, + mediaType, + fileName: attachment.fileName?.trim() || undefined, + url: url || undefined, + blobKey: blobKey || undefined, + }); + } + return normalized.length > 0 ? normalized : undefined; +} + export async function replyToScoutMessage(input: { senderId: string; body: string; conversationId: string; replyToMessageId: string; shouldSpeak?: boolean; + attachments?: OutgoingAttachmentInput[]; createdAtMs?: number; currentDirectory?: string; source?: string; @@ -3227,6 +3269,7 @@ export async function replyToScoutMessage(input: { class: conversation.kind === "system" ? "system" : "agent", body: input.body, speech: speechText ? { text: speechText } : undefined, + attachments: normalizeOutgoingAttachments(input.attachments), audience: notifiedActorIds.length > 0 ? { notify: notifiedActorIds, reason: "thread_reply" } : undefined, diff --git a/apps/desktop/src/core/mcp/scout-mcp.ts b/apps/desktop/src/core/mcp/scout-mcp.ts index bc23005c..bd1c93e8 100644 --- a/apps/desktop/src/core/mcp/scout-mcp.ts +++ b/apps/desktop/src/core/mcp/scout-mcp.ts @@ -44,6 +44,7 @@ import { sendScoutMessage, sendScoutMessageToAgentIds, replyToScoutMessage, + type OutgoingAttachmentInput, type ScoutManagedLocalSessionAttachment, updateScoutWorkItem, waitForScoutFlight, @@ -296,6 +297,23 @@ const mentionAgentIdsInputSchema = z .describe("Exact Scout agent ids to target directly when you already know them") .optional(); +const attachmentsInputSchema = z + .array( + z.object({ + mediaType: z + .string() + .describe("MIME type, e.g. image/png or image/jpeg"), + url: z + .string() + .describe("HTTP(S) URL where the attachment can be fetched"), + fileName: z.string().optional(), + }), + ) + .describe( + "Link-backed attachments (e.g. images). Each needs a mediaType and a fetchable url; agents should pass URLs they already have rather than uploading bytes.", + ) + .optional(); + export type ScoutMcpAgentCandidate = { agentId: string; label: string; @@ -442,6 +460,7 @@ type ScoutMcpDependencies = { conversationId: string; replyToMessageId: string; shouldSpeak?: boolean; + attachments?: OutgoingAttachmentInput[]; currentDirectory: string; source?: string; }) => Promise; @@ -2757,6 +2776,7 @@ function defaultScoutMcpDependencies( conversationId, replyToMessageId, shouldSpeak, + attachments, currentDirectory, source, }) => @@ -2766,6 +2786,7 @@ function defaultScoutMcpDependencies( conversationId, replyToMessageId, shouldSpeak, + attachments, currentDirectory, source, }), @@ -3243,6 +3264,7 @@ export function createScoutMcpServer(options: { conversationId: z.string().optional(), replyToMessageId: z.string().optional(), shouldSpeak: z.boolean().optional(), + attachments: attachmentsInputSchema, }), outputSchema: replyResultSchema, annotations: { @@ -3259,6 +3281,7 @@ export function createScoutMcpServer(options: { conversationId, replyToMessageId, shouldSpeak, + attachments, }) => { const resolvedCurrentDirectory = resolveToolCurrentDirectory( currentDirectory, @@ -3299,6 +3322,7 @@ export function createScoutMcpServer(options: { conversationId: resolvedConversationId, replyToMessageId: resolvedReplyToMessageId, shouldSpeak, + attachments, currentDirectory: resolvedCurrentDirectory, source: "scout-mcp", }); diff --git a/apps/desktop/src/core/pairing/runtime/runtime.ts b/apps/desktop/src/core/pairing/runtime/runtime.ts index 1056af53..d4a43f2c 100644 --- a/apps/desktop/src/core/pairing/runtime/runtime.ts +++ b/apps/desktop/src/core/pairing/runtime/runtime.ts @@ -1,6 +1,7 @@ import { homedir } from "node:os"; import { + createAcpAdapter as createAcp, createClaudeCodeAdapter as createClaudeCode, createCodexAdapter as createCodex, createOpenAiCompatAdapter as createOpenAI, @@ -30,6 +31,7 @@ export type StartedPairingRuntime = { export function createPairingAdapterRegistry(configAdapters?: Record) { const adapters: Record = { "claude-code": createClaudeCode, + acp: createAcp, codex: createCodex, pi: createPi, opencode: createOpenCode, diff --git a/apps/macos/Sources/Scout/ScoutCommsStore.swift b/apps/macos/Sources/Scout/ScoutCommsStore.swift index 199048bb..0cf5346d 100644 --- a/apps/macos/Sources/Scout/ScoutCommsStore.swift +++ b/apps/macos/Sources/Scout/ScoutCommsStore.swift @@ -2,6 +2,7 @@ import Combine import Foundation #if os(macOS) import AppKit +import UniformTypeIdentifiers #endif @MainActor @@ -142,22 +143,39 @@ final class ScoutCommsStore: ObservableObject { } } - func send(_ body: String) async { + func send(_ body: String, images: [ScoutComposerImage] = []) async { let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, let selectedCId, !isSending else { return } + guard let selectedCId, !isSending, !trimmed.isEmpty || !images.isEmpty else { return } isSending = true defer { isSending = false } do { + // Upload images first and turn each into a link-backed attachment. + // We want the blob present before the message lands, so the agent's + // first fetch succeeds — so this completes before /api/send. + var attachments: [[String: String]] = [] + for image in images { + let uploaded = try await uploadImage(image) + attachments.append([ + "mediaType": uploaded.mediaType, + "url": uploaded.url, + "fileName": uploaded.fileName ?? image.fileName, + ]) + } + let url = ScoutWeb.baseURL().appending(path: "api/send") var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONSerialization.data(withJSONObject: [ + var payload: [String: Any] = [ "body": trimmed, "cId": selectedCId, "conversationId": selectedCId, - ]) + ] + if !attachments.isEmpty { + payload["attachments"] = attachments + } + request.httpBody = try JSONSerialization.data(withJSONObject: payload) let (_, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw ScoutCommsError.sendFailed @@ -170,6 +188,24 @@ final class ScoutCommsStore: ObservableObject { } } + /// Push an image to the ephemeral blob route and get back a fetchable URL. + private func uploadImage(_ image: ScoutComposerImage) async throws -> ScoutBlobUploadResponse { + let url = ScoutWeb.baseURL().appending(path: "api/blobs") + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: [ + "data": image.data.base64EncodedString(), + "mediaType": image.mediaType, + "fileName": image.fileName, + ]) + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw ScoutCommsError.sendFailed + } + return try decoder.decode(ScoutBlobUploadResponse.self, from: data) + } + private func loadChannels(force: Bool) { if channelsTask != nil { return } if !force, pollTask == nil { return } @@ -309,6 +345,90 @@ enum ScoutCommsError: LocalizedError { } } +/// An image staged in the composer, ready to upload as an attachment. Holds +/// raw bytes (not an NSImage) so it stays Sendable across the upload task. +struct ScoutComposerImage: Identifiable, Sendable { + let id = UUID() + let data: Data + let mediaType: String + let fileName: String +} + +/// Response from POST /api/blobs — the link-backed attachment to send. +struct ScoutBlobUploadResponse: Decodable { + let url: String + let mediaType: String + let fileName: String? +} + +#if os(macOS) +/// Builds composer images from pasteboard, dropped files, or picked files, +/// sniffing the media type so the attachment carries a correct MIME. +enum ScoutImageIntake { + static func fromPasteboard() -> [ScoutComposerImage] { + let pb = NSPasteboard.general + // Copied image files (Finder, etc.) come through as file URLs. + if let urls = pb.readObjects( + forClasses: [NSURL.self], + options: [.urlReadingContentsConformToTypes: [UTType.image.identifier]] + ) as? [URL], !urls.isEmpty { + let images = urls.compactMap(fromFileURL) + if !images.isEmpty { return images } + } + // Raw PNG bytes (some apps put these directly on the pasteboard). + if let data = pb.data(forType: .png) { + return [ScoutComposerImage(data: data, mediaType: "image/png", fileName: "pasted-image.png")] + } + // Screenshots usually land as TIFF — re-encode to PNG. + if let tiff = pb.data(forType: .tiff), + let rep = NSBitmapImageRep(data: tiff), + let png = rep.representation(using: .png, properties: [:]) { + return [ScoutComposerImage(data: png, mediaType: "image/png", fileName: "pasted-image.png")] + } + return [] + } + + static func fromFileURL(_ url: URL) -> ScoutComposerImage? { + guard let data = try? Data(contentsOf: url) else { return nil } + let resolved = mediaType(forExtension: url.pathExtension.lowercased()) + ?? sniffMediaType(data) + guard let resolved, resolved.hasPrefix("image/") else { return nil } + return ScoutComposerImage(data: data, mediaType: resolved, fileName: url.lastPathComponent) + } + + private static func mediaType(forExtension ext: String) -> String? { + switch ext { + case "png": return "image/png" + case "jpg", "jpeg": return "image/jpeg" + case "gif": return "image/gif" + case "webp": return "image/webp" + case "heic": return "image/heic" + case "tiff", "tif": return "image/tiff" + case "bmp": return "image/bmp" + default: return nil + } + } + + private static func sniffMediaType(_ data: Data) -> String? { + let bytes = [UInt8](data.prefix(12)) + if bytes.count >= 4, bytes[0] == 0x89, bytes[1] == 0x50, bytes[2] == 0x4E, bytes[3] == 0x47 { + return "image/png" + } + if bytes.count >= 3, bytes[0] == 0xFF, bytes[1] == 0xD8, bytes[2] == 0xFF { + return "image/jpeg" + } + if bytes.count >= 3, bytes[0] == 0x47, bytes[1] == 0x49, bytes[2] == 0x46 { + return "image/gif" + } + if bytes.count >= 12, bytes[0] == 0x52, bytes[1] == 0x49, bytes[2] == 0x46, bytes[3] == 0x46, + bytes[8] == 0x57, bytes[9] == 0x45, bytes[10] == 0x42, bytes[11] == 0x50 { + return "image/webp" + } + return nil + } +} +#endif + enum ScoutWeb { private static let fallbackURL = URL(string: "http://127.0.0.1:3200")! diff --git a/apps/macos/Sources/Scout/ScoutComposerImageSupport.swift b/apps/macos/Sources/Scout/ScoutComposerImageSupport.swift new file mode 100644 index 00000000..aba7e665 --- /dev/null +++ b/apps/macos/Sources/Scout/ScoutComposerImageSupport.swift @@ -0,0 +1,112 @@ +import SwiftUI +#if os(macOS) +import AppKit + +/// Catches ⌘V at the AppKit level so an image on the pasteboard is staged in +/// the composer even while the text field holds focus. A focused field's editor +/// otherwise swallows ⌘V as an (empty) text paste — which is why pasting a +/// screenshot felt like it did nothing. The local monitor runs before that +/// dispatch, so we can claim the event when there's an image to stage. +struct ImagePasteCatcher: NSViewRepresentable { + var isActive: () -> Bool + /// Returns true if the images were staged (and the paste should be consumed). + var onPasteImages: ([ScoutComposerImage]) -> Bool + + func makeNSView(context: Context) -> NSView { + context.coordinator.install() + return NSView(frame: .zero) + } + + func updateNSView(_ nsView: NSView, context: Context) { + // Refresh the closures each render so they read current view state. + context.coordinator.isActive = isActive + context.coordinator.onPasteImages = onPasteImages + } + + static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { + coordinator.uninstall() + } + + func makeCoordinator() -> Coordinator { + Coordinator(isActive: isActive, onPasteImages: onPasteImages) + } + + final class Coordinator { + var isActive: () -> Bool + var onPasteImages: ([ScoutComposerImage]) -> Bool + private var monitor: Any? + + init( + isActive: @escaping () -> Bool, + onPasteImages: @escaping ([ScoutComposerImage]) -> Bool + ) { + self.isActive = isActive + self.onPasteImages = onPasteImages + } + + func install() { + guard monitor == nil else { return } + monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard let self, self.isActive() else { return event } + guard event.modifierFlags.contains(.command), + event.charactersIgnoringModifiers?.lowercased() == "v" else { return event } + // Only claim ⌘V when the pasteboard actually holds an image; + // otherwise let the normal text paste proceed untouched. + let images = ScoutImageIntake.fromPasteboard() + guard !images.isEmpty else { return event } + return self.onPasteImages(images) ? nil : event + } + } + + func uninstall() { + if let monitor { + NSEvent.removeMonitor(monitor) + } + monitor = nil + } + } +} + +/// Centered, dimmed full-image preview (lightbox) for a staged composer image. +/// Dismisses on background tap, the close button, or Esc. +struct ScoutImageLightbox: View { + let image: ScoutComposerImage + let onDismiss: () -> Void + + var body: some View { + ZStack { + Color.black.opacity(0.74) + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture(perform: onDismiss) + + if let nsImage = NSImage(data: image.data) { + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(48) + .shadow(color: .black.opacity(0.5), radius: 30, y: 8) + .accessibilityLabel(image.fileName) + } + + VStack { + HStack { + Spacer() + Button(action: onDismiss) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 22)) + .foregroundStyle(.white, .black.opacity(0.45)) + } + .buttonStyle(.plain).scoutPointerCursor() + .help("Close preview") + } + Spacer() + } + .padding(24) + } + .onExitCommand(perform: onDismiss) + .transition(.opacity) + } +} +#endif diff --git a/apps/macos/Sources/Scout/ScoutFileLink.swift b/apps/macos/Sources/Scout/ScoutFileLink.swift new file mode 100644 index 00000000..9ab68408 --- /dev/null +++ b/apps/macos/Sources/Scout/ScoutFileLink.swift @@ -0,0 +1,246 @@ +import AppKit +import Foundation +import Quartz +import SwiftUI + +// MARK: - Link encoding + +/// Custom URL scheme used to carry a file path (and optional line) through a +/// SwiftUI `Text` link so it can be intercepted by `OpenURLAction`. +enum ScoutFileLink { + static let scheme = "openscout-file" + + static func url(path: String, line: Int?, base: String? = nil) -> URL? { + var components = URLComponents() + components.scheme = scheme + components.host = "open" + var items = [URLQueryItem(name: "path", value: path)] + if let line { items.append(URLQueryItem(name: "line", value: String(line))) } + if let base = base?.nilIfEmpty { items.append(URLQueryItem(name: "base", value: base)) } + components.queryItems = items + return components.url + } + + static func parse(_ url: URL) -> (path: String, line: Int?, base: String?)? { + guard url.scheme == scheme, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil } + let items = components.queryItems ?? [] + guard let path = items.first(where: { $0.name == "path" })?.value, !path.isEmpty else { return nil } + let line = items.first(where: { $0.name == "line" })?.value.flatMap(Int.init) + let base = items.first(where: { $0.name == "base" })?.value + return (path, line, base) + } +} + +// MARK: - Path resolution + +/// Resolves a path token to an absolute filesystem path. Agents quote paths +/// relative to their own workspace, so the bundled `.app` (whose CWD is `/`) +/// can't find them without that context — `base` is the talking agent's +/// workspace root. Absolute (`/…`) and home (`~/…`) paths pass through. +enum ScoutFilePathResolver { + static func resolve(path: String, base: String?) -> String { + let trimmed = path.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("/") { return trimmed } + if trimmed.hasPrefix("~") { return (trimmed as NSString).expandingTildeInPath } + if let base = base?.nilIfEmpty { + let root = (base as NSString).expandingTildeInPath + return (root as NSString).appendingPathComponent(trimmed) + } + return (trimmed as NSString).expandingTildeInPath + } +} + +// MARK: - Detection + +/// Finds file-path-like tokens in plain text. Matches absolute (`/…`), home +/// (`~/…`), and relative paths that carry a file extension, with an optional +/// `:line` / `:line:col` suffix. +enum ScoutFilePathDetector { + struct Match { + /// Range (over the source string) of the whole token, including any `:line` suffix. + let nsRange: NSRange + let path: String + let line: Int? + } + + private static let regex: NSRegularExpression = { + // group 1: path (requires at least one `/`); group 2: line; trailing col ignored. + let pattern = #"(~?/?(?:[A-Za-z0-9._+\-]+/)+[A-Za-z0-9._+\-]+)(?::(\d+))?(?::\d+)?"# + return try! NSRegularExpression(pattern: pattern) + }() + + static func matches(in text: String) -> [Match] { + guard !text.isEmpty else { return [] } + let ns = text as NSString + let full = NSRange(location: 0, length: ns.length) + var out: [Match] = [] + regex.enumerateMatches(in: text, range: full) { result, _, _ in + guard let result else { return } + let pathRange = result.range(at: 1) + guard pathRange.location != NSNotFound else { return } + + var whole = result.range + var path = ns.substring(with: pathRange) + // A path never legitimately ends in a dot — trim sentence punctuation. + while path.hasSuffix(".") { + path.removeLast() + whole.length -= 1 + } + guard !path.isEmpty, isLikelyPath(path) else { return } + + // Skip URLs (the `//host/path` portion of e.g. `https://…`). + let lookbackStart = max(0, whole.location - 3) + let lookback = ns.substring(with: NSRange(location: lookbackStart, length: whole.location - lookbackStart)) + if lookback.hasSuffix("://") || lookback.hasSuffix(":/") { return } + + var line: Int? + let lineRange = result.range(at: 2) + if lineRange.location != NSNotFound { + line = Int(ns.substring(with: lineRange)) + } + out.append(Match(nsRange: whole, path: path, line: line)) + } + return out + } + + private static func isLikelyPath(_ token: String) -> Bool { + if token.hasPrefix("/") || token.hasPrefix("~/") { return true } + let last = token.split(separator: "/").last.map(String.init) ?? token + return last.contains(".") // relative path must carry an extension + } +} + +// MARK: - AttributedString linkifying + +enum ScoutFileLinkifier { + /// Applies tappable `openscout-file://` links over any file paths found in + /// the already-parsed attributed text. + static func apply(to attributed: AttributedString, accent: Color, baseDirectory: String? = nil) -> AttributedString { + var result = attributed + let plain = String(result.characters) + let matches = ScoutFilePathDetector.matches(in: plain) + guard !matches.isEmpty else { return result } + + for match in matches { + guard let range = Range(match.nsRange, in: plain), + let url = ScoutFileLink.url(path: match.path, line: match.line, base: baseDirectory) else { continue } + let startOffset = plain.distance(from: plain.startIndex, to: range.lowerBound) + let length = plain.distance(from: range.lowerBound, to: range.upperBound) + let start = result.index(result.startIndex, offsetByCharacters: startOffset) + let end = result.index(start, offsetByCharacters: length) + result[start.. URL? { + if let custom = UserDefaults.standard.string(forKey: editorDefaultsKey), + FileManager.default.fileExists(atPath: custom) { + return URL(fileURLWithPath: custom) + } + let candidates = [ + "/Applications/Cursor.app", + "\(NSHomeDirectory())/Applications/Cursor.app", + "/Applications/Visual Studio Code.app", + "/Applications/VSCodium.app", + "/Applications/Zed.app", + "/Applications/Sublime Text.app", + ] + return candidates.first { FileManager.default.fileExists(atPath: $0) }.map { URL(fileURLWithPath: $0) } + } + + private static func cliBinary(forApp app: URL) -> URL? { + let name = app.deletingPathExtension().lastPathComponent + let binNames: [String] + switch name { + case "Cursor": binNames = ["cursor", "code"] + case "Visual Studio Code": binNames = ["code"] + case "VSCodium": binNames = ["codium", "code"] + default: return nil + } + let binDir = app.appendingPathComponent("Contents/Resources/app/bin") + return binNames + .map { binDir.appendingPathComponent($0) } + .first { FileManager.default.isExecutableFile(atPath: $0.path) } + } +} diff --git a/apps/macos/Sources/Scout/ScoutFileViewerPanel.swift b/apps/macos/Sources/Scout/ScoutFileViewerPanel.swift new file mode 100644 index 00000000..ed9bb8a6 --- /dev/null +++ b/apps/macos/Sources/Scout/ScoutFileViewerPanel.swift @@ -0,0 +1,517 @@ +import AppKit +import Foundation +import HudsonShell +import HudsonUI +import SwiftUI + +// MARK: - Controller + +/// Shared, app-wide handle for the embedded file viewer. File links are detected +/// deep in the message render tree (`ScoutMarkdownView`), so rather than thread a +/// closure through every layer we route opens through this singleton; the root +/// view observes it and mounts the panel in the trailing slot. +@MainActor +final class ScoutFileViewer: ObservableObject { + static let shared = ScoutFileViewer() + + struct Target: Equatable { + let path: String + let line: Int? + } + + @Published var target: Target? + + func open(path: String, line: Int?) { + target = Target(path: path, line: line) + } + + func close() { + target = nil + } +} + +enum ScoutFileViewerMetrics { + static let defaultWidth: CGFloat = 540 + static let widthRange: ClosedRange = 380...960 + /// Files larger than this are not slurped into memory for preview. + static let maxByteSize = 2_000_000 + /// Cap the rendered line count so pathological files don't stall layout. + static let maxLines = 6000 +} + +// MARK: - Document loading + +/// A file read for preview — either its syntax-highlighted lines, or a human +/// reason it couldn't be shown (with the pop-out always available as the escape +/// hatch). Highlighting is precomputed once on load, not per scroll. +struct ScoutFileDocument: Sendable { + let url: URL + let lineCount: Int + let highlighted: [AttributedString] + let truncated: Bool + let error: String? + + /// Single lines longer than this are clipped before highlight + render, so a + /// minified one-liner (e.g. a packed JSON) can't blow up layout cost. + static let maxLineLength = 2000 + + private static func failure(_ url: URL, _ message: String) -> ScoutFileDocument { + ScoutFileDocument(url: url, lineCount: 0, highlighted: [], truncated: false, error: message) + } + + static func load(path: String) -> ScoutFileDocument { + let expanded = (path as NSString).expandingTildeInPath + let url = URL(fileURLWithPath: expanded) + + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: expanded, isDirectory: &isDir) else { + return failure(url, "Not found:\n\(expanded)") + } + if isDir.boolValue { + return failure(url, "That's a folder — open it in your editor.") + } + + let attrs = try? FileManager.default.attributesOfItem(atPath: expanded) + let size = (attrs?[.size] as? Int) ?? 0 + if size > ScoutFileViewerMetrics.maxByteSize { + let mb = Double(size) / 1_000_000 + return failure(url, String(format: "Too large to preview (%.1f MB).", mb)) + } + + guard let text = try? String(contentsOf: url, encoding: .utf8) else { + return failure(url, "Can't preview this file (binary or non-UTF-8).") + } + + let all = text.components(separatedBy: "\n") + let truncated = all.count > ScoutFileViewerMetrics.maxLines + let limited = truncated ? Array(all.prefix(ScoutFileViewerMetrics.maxLines)) : all + let lines = limited.map { $0.count > maxLineLength ? String($0.prefix(maxLineLength)) + " …" : $0 } + let language = ScoutCodeLanguage.from(ext: url.pathExtension) + let highlighted = ScoutSyntaxHighlighter.highlight(lines: lines, language: language) + return ScoutFileDocument(url: url, lineCount: lines.count, highlighted: highlighted, truncated: truncated, error: nil) + } +} + +// MARK: - Panel + +/// Embedded, resizable file preview that lives in the trailing slot beside the +/// inspector/observe sidecars. Read-only line-numbered text in the HUD palette, +/// with the active line highlighted and a one-click pop-out to the real editor. +struct ScoutFileViewerPanel: View { + let target: ScoutFileViewer.Target + @Binding var width: CGFloat + let onClose: () -> Void + let onOpenInEditor: () -> Void + + @State private var document: ScoutFileDocument? + + private var fileName: String { (target.path as NSString).lastPathComponent } + private var dirPath: String { + let dir = (target.path as NSString).deletingLastPathComponent + return dir.isEmpty ? target.path : dir + } + + var body: some View { + HudSidebarPanel( + width: $width, + edge: .trailing, + widthRange: ScoutFileViewerMetrics.widthRange + ) { + VStack(spacing: 0) { + header + HudDivider(color: ScoutDesign.hairline) + content + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(ScoutDesign.chrome) + } + .task(id: target.path) { + // Read + highlight off the main thread so a big or minified file + // shows the spinner instead of beachballing the whole app. + let path = target.path + let loaded = await Task.detached(priority: .userInitiated) { + ScoutFileDocument.load(path: path) + }.value + guard !Task.isCancelled else { return } + document = loaded + } + } + + private var header: some View { + HStack(spacing: HudSpacing.md) { + Image(systemName: glyph(forFile: fileName)) + .font(HudFont.ui(12, weight: .semibold)) + .foregroundStyle(HudPalette.accent) + .frame(width: 22, height: 22) + .background(RoundedRectangle(cornerRadius: 5, style: .continuous).fill(HudPalette.accentSoft)) + + VStack(alignment: .leading, spacing: 1) { + Text(fileName) + .font(HudFont.ui(13, weight: .semibold)) + .foregroundStyle(HudPalette.ink) + .lineLimit(1) + .truncationMode(.middle) + Text(dirPath) + .font(HudFont.mono(9)) + .foregroundStyle(HudPalette.dim) + .lineLimit(1) + .truncationMode(.middle) + } + + Spacer(minLength: HudSpacing.sm) + + if let line = target.line { + Text("L\(line)") + .font(HudFont.mono(9, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(HudPalette.muted) + .padding(.horizontal, HudSpacing.sm) + .padding(.vertical, 2) + .background(RoundedRectangle(cornerRadius: 4, style: .continuous).fill(HudSurface.inset)) + } + + Button(action: onOpenInEditor) { + Image(systemName: "arrow.up.forward.app") + .font(HudFont.ui(12, weight: .semibold)) + } + .buttonStyle(.plain).scoutPointerCursor() + .foregroundStyle(HudPalette.muted) + .frame(width: 26, height: 26) + .contentShape(Rectangle()) + .help("Open in editor") + + Button(action: onClose) { + Image(systemName: "sidebar.right") + .font(HudFont.ui(12, weight: .semibold)) + } + .buttonStyle(.plain).scoutPointerCursor() + .foregroundStyle(HudPalette.muted) + .frame(width: 26, height: 26) + .contentShape(Rectangle()) + .help("Close file viewer") + } + .padding(.horizontal, HudSpacing.lg) + .frame(height: 48) + .background(ScoutDesign.chrome) + } + + @ViewBuilder + private var content: some View { + if let document { + if let error = document.error { + errorState(error) + } else { + code(document) + } + } else { + ProgressView() + .controlSize(.small) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func code(_ document: ScoutFileDocument) -> some View { + let gutter = gutterWidth(for: document.lineCount) + return ScrollViewReader { proxy in + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(Array(document.highlighted.enumerated()), id: \.offset) { index, line in + lineRow(number: index + 1, content: line, gutter: gutter) + .id(index + 1) + } + if document.truncated { + Text("Preview truncated at \(ScoutFileViewerMetrics.maxLines) lines — open in editor for the rest.") + .font(HudFont.mono(9)) + .foregroundStyle(HudPalette.dim) + .padding(HudSpacing.lg) + } + } + .padding(.vertical, HudSpacing.sm) + .frame(maxWidth: .infinity, alignment: .leading) + .scoutOverlayScrollers() + } + .scrollIndicators(.visible) + .onAppear { jumpToTargetLine(proxy, lineCount: document.lineCount) } + .onChange(of: target.line) { _, _ in jumpToTargetLine(proxy, lineCount: document.lineCount) } + } + } + + private func lineRow(number: Int, content: AttributedString, gutter: CGFloat) -> some View { + let isTarget = target.line == number + return HStack(alignment: .top, spacing: HudSpacing.md) { + Text("\(number)") + .font(HudFont.mono(10)) + .monospacedDigit() + .foregroundStyle(isTarget ? HudPalette.accent : HudPalette.dim) + .frame(width: gutter, alignment: .trailing) + Text(content) + .font(HudFont.mono(11)) + .foregroundStyle(HudPalette.ink) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, HudSpacing.lg) + .padding(.vertical, 1) + .background(isTarget ? HudPalette.accentSoft : Color.clear) + } + + private func errorState(_ message: String) -> some View { + VStack(spacing: HudSpacing.md) { + Image(systemName: "doc.questionmark") + .font(HudFont.ui(22, weight: .regular)) + .foregroundStyle(HudPalette.dim) + Text(message) + .font(HudFont.ui(12)) + .foregroundStyle(HudPalette.muted) + .multilineTextAlignment(.center) + Button(action: onOpenInEditor) { + Text("Open in editor") + .font(HudFont.ui(11, weight: .semibold)) + } + .buttonStyle(.plain).scoutPointerCursor() + .foregroundStyle(HudPalette.accent) + } + .padding(HudSpacing.xxl) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func jumpToTargetLine(_ proxy: ScrollViewProxy, lineCount: Int) { + guard let line = target.line, line >= 1, line <= lineCount else { return } + DispatchQueue.main.async { + withAnimation(.easeOut(duration: 0.2)) { + proxy.scrollTo(line, anchor: .center) + } + } + } + + private func gutterWidth(for lineCount: Int) -> CGFloat { + let digits = max(2, String(lineCount).count) + return CGFloat(digits) * 7 + 4 + } + + private func glyph(forFile name: String) -> String { + switch (name as NSString).pathExtension.lowercased() { + case "swift", "ts", "tsx", "js", "jsx", "py", "rs", "go", "rb", "c", "cpp", "h", "java", "kt": + return "chevron.left.forwardslash.chevron.right" + case "json", "yaml", "yml", "toml", "plist", "xml": + return "curlybraces" + case "md", "markdown", "txt", "rtf": + return "doc.text" + case "png", "jpg", "jpeg", "gif", "svg", "webp", "heic": + return "photo" + default: + return "doc" + } + } +} + +// MARK: - Syntax highlighting + +/// Coarse language buckets — enough to drive comment/keyword rules for a +/// read-only preview without pulling in a full grammar. +enum ScoutCodeLanguage { + case cFamily // swift, ts/js, c/c++, go, rust, java, kotlin, css… + case script // python, ruby, shell, yaml, toml ( `#` comments ) + case json + case plain + + static func from(ext: String) -> ScoutCodeLanguage { + switch ext.lowercased() { + case "swift", "ts", "tsx", "js", "jsx", "mjs", "cjs", + "c", "cc", "cpp", "cxx", "h", "hpp", "m", "mm", + "java", "kt", "kts", "go", "rs", "scala", "cs", "php", + "css", "scss", "less": + return .cFamily + case "py", "rb", "sh", "bash", "zsh", "yaml", "yml", + "toml", "ini", "conf", "cfg", "r", "pl", "rake", "gemspec": + return .script + case "json", "jsonc": + return .json + default: + return .plain + } + } + + var lineComment: String? { + switch self { + case .cFamily: return "//" + case .script: return "#" + case .json, .plain: return nil + } + } + + var blockComments: Bool { self == .cFamily } + var usesTypes: Bool { self == .cFamily } + var highlightsLiterals: Bool { self != .plain } + + var keywords: Set { + switch self { + case .cFamily: return Self.cKeywords + case .script: return Self.scriptKeywords + case .json: return ["true", "false", "null"] + case .plain: return [] + } + } + + // A deliberately broad union across the C-family + Swift + TS/JS + Go/Rust. + // Over-matching the odd word as a keyword is harmless in a preview. + private static let cKeywords: Set = [ + "let", "var", "const", "func", "function", "fn", "def", + "class", "struct", "enum", "protocol", "interface", "extension", "impl", "trait", + "import", "export", "from", "package", "use", "mod", "module", "namespace", + "return", "if", "else", "for", "while", "switch", "case", "default", "match", + "break", "continue", "guard", "defer", "do", "try", "catch", "finally", "throw", "throws", + "async", "await", "yield", "in", "is", "as", "where", "typealias", "associatedtype", "type", + "self", "super", "this", "init", "deinit", "new", "delete", + "static", "public", "private", "internal", "fileprivate", "open", "final", "override", "pub", "mut", + "extends", "implements", "abstract", "virtual", "operator", "typeof", "instanceof", "void", + "true", "false", "nil", "null", "undefined", "let", "go", "chan", "map", "range", "select", + "int", "string", "bool", "float", "double", "char", "byte", "rune", "any", "never", "unknown", + ] + + private static let scriptKeywords: Set = [ + "def", "class", "return", "if", "elif", "else", "for", "while", + "import", "from", "as", "in", "is", "not", "and", "or", "with", + "try", "except", "finally", "raise", "lambda", "pass", "break", "continue", + "yield", "async", "await", "global", "nonlocal", "assert", "del", + "True", "False", "None", "self", "end", "do", "then", "module", + "require", "require_relative", "begin", "rescue", "ensure", "puts", "attr_accessor", + "function", "local", "export", "echo", "fi", "esac", + ] +} + +/// Minimal, allocation-light tokenizer producing one `AttributedString` per +/// line. Carries block-comment state across lines. Colors stay inside the +/// approved cyan/blue/teal/emerald/amber family (no purple). +enum ScoutSyntaxHighlighter { + private static let commentColor = HudPalette.dim + private static let stringColor = HudTint.green.color + private static let keywordColor = HudTint.blue.color + private static let numberColor = HudTint.amber.color + private static let typeColor = HudTint.teal.color + + static func highlight(lines: [String], language: ScoutCodeLanguage) -> [AttributedString] { + guard language != .plain else { + return lines.map { AttributedString($0.isEmpty ? " " : $0) } + } + var inBlock = false + return lines.map { line(from: $0, language: language, inBlock: &inBlock) } + } + + private static func line(from raw: String, language: ScoutCodeLanguage, inBlock: inout Bool) -> AttributedString { + if raw.isEmpty { return AttributedString(" ") } + var out = AttributedString() + let chars = Array(raw) + let n = chars.count + var i = 0 + + func emit(_ range: Range, _ color: Color?) { + guard !range.isEmpty else { return } + var piece = AttributedString(String(chars[range])) + if let color { piece.foregroundColor = color } + out.append(piece) + } + + // Resume an open block comment from a previous line. + if inBlock { + if let end = blockEnd(chars, from: 0) { + emit(0.. 1 { + emit(i.. Bool { + let t = Array(token) + guard i + t.count <= chars.count else { return false } + for k in 0.. Int? { + guard start < chars.count else { return nil } + var j = start + while j < chars.count - 1 { + if chars[j] == "*" && chars[j + 1] == "/" { return j + 2 } + j += 1 + } + return nil + } + + /// Index just past the closing quote (escapes skipped), clamped to EOL. + private static func stringEnd(_ chars: [Character], from start: Int, quote: Character) -> Int { + let n = chars.count + var j = start + 1 + while j < n { + if chars[j] == "\\" { j += 2; continue } + if chars[j] == quote { return min(j + 1, n) } + j += 1 + } + return n + } +} diff --git a/apps/macos/Sources/Scout/ScoutModels.swift b/apps/macos/Sources/Scout/ScoutModels.swift index 8d1db991..b72ee7fb 100644 --- a/apps/macos/Sources/Scout/ScoutModels.swift +++ b/apps/macos/Sources/Scout/ScoutModels.swift @@ -29,13 +29,6 @@ enum ScoutSection: String, CaseIterable, Identifiable { enum ScoutChannelScope { case direct case shared - - var label: String { - switch self { - case .direct: return "Private" - case .shared: return "Shared" - } - } } struct ScoutChannel: Identifiable, Decodable, Sendable { @@ -66,6 +59,32 @@ struct ScoutChannel: Identifiable, Decodable, Sendable { return .shared } + /// A DM is named by its other participant(s); operator (you) is implied. + /// Agent-to-agent DMs (no operator) read as "agent1 <> agent2". + var directPeerLabel: String { + let peers = participantDisplayNames.filter { $0 != "Operator" } + if peers.count >= 2 { + return peers.joined(separator: " <> ") + } + let names = peers.isEmpty ? participantDisplayNames : peers + return names.joined(separator: ", ").nilIfEmpty ?? displayTitle + } + + /// Channel name without any leading "#" decoration. + var channelName: String { + displayTitle.trimmingCharacters(in: CharacterSet(charactersIn: "# ")).nilIfEmpty ?? displayTitle + } + + /// Title shown next to the type icon (the icon already conveys #/person). + var rowTitle: String { + scope == .direct ? directPeerLabel : channelName + } + + /// Self-describing title where there is no type icon (header, inspector). + var displayHandle: String { + scope == .direct ? directPeerLabel : "#\(channelName)" + } + var cIdShort: String { if cId.hasPrefix("c.") { return "cId \(String(cId.dropFirst(2).prefix(8)))" @@ -80,13 +99,6 @@ struct ScoutChannel: Identifiable, Decodable, Sendable { } var participantDisplayNames: [String] { - if scope == .direct { - let peer = agentName?.nilIfEmpty - ?? participantIds.first(where: { displayName(for: $0) != "Operator" }).map(displayName(for:)) - ?? displayTitle - return uniqueMemberNames(["Operator", peer]) - } - let names = participantIds.map(displayName(for:)) return uniqueMemberNames(names.isEmpty ? [displayTitle] : names) } diff --git a/apps/macos/Sources/Scout/ScoutObserveSidecarPanel.swift b/apps/macos/Sources/Scout/ScoutObserveSidecarPanel.swift index f7ed9419..6c5ca218 100644 --- a/apps/macos/Sources/Scout/ScoutObserveSidecarPanel.swift +++ b/apps/macos/Sources/Scout/ScoutObserveSidecarPanel.swift @@ -151,7 +151,7 @@ struct ScoutObserveSidecarPanel: View { Image(systemName: "arrow.clockwise") .font(HudFont.ui(11, weight: .semibold)) } - .buttonStyle(.plain) + .buttonStyle(.plain).scoutPointerCursor() .foregroundStyle(HudPalette.muted) .help("Reload observe") @@ -159,7 +159,7 @@ struct ScoutObserveSidecarPanel: View { Image(systemName: "sidebar.right") .font(HudFont.ui(12, weight: .semibold)) } - .buttonStyle(.plain) + .buttonStyle(.plain).scoutPointerCursor() .foregroundStyle(HudPalette.muted) .frame(width: 28, height: 28) .contentShape(Rectangle()) diff --git a/apps/macos/Sources/Scout/ScoutRootView.swift b/apps/macos/Sources/Scout/ScoutRootView.swift index ed423314..22b86958 100644 --- a/apps/macos/Sources/Scout/ScoutRootView.swift +++ b/apps/macos/Sources/Scout/ScoutRootView.swift @@ -5,6 +5,7 @@ import ScoutSharedUI import SwiftUI #if os(macOS) import AppKit +import UniformTypeIdentifiers #endif struct ScoutRootView: View { @@ -17,23 +18,50 @@ struct ScoutRootView: View { @State private var agentContentMode: ScoutAgentContentMode = .roster @State private var channelFilter: ScoutChannelFilter = .all @State private var draft = "" + /// Per-conversation unsent drafts, so a message isn't lost when navigating + /// to another chat/section. Keyed by cId; the active draft mirrors into + /// `draft` and is swapped on selection change. + @State private var drafts: [String: String] = [:] + /// Set when the user hits send while dictating: we commit the recording and + /// fire the send once the final transcript has been spliced in. + @State private var pendingSendAfterDictation = false + /// Whether the transcript is pinned to the latest message. True while the + /// bottom is in view; flips false when the user scrolls up into history so + /// incoming messages don't yank them out of the zone they're reading. + @State private var followLatest = true + /// Pending one-shot jump to the newest message when a conversation opens. + @State private var pendingInitialJump = true + + private static let messageListBottomAnchor = "scout.messageList.bottom" @State private var suggestions: [MessageSuggestion] = [] @State private var selectedSuggestionIndex = 0 @State private var currentSuggestionTrigger: MessageSuggestionTrigger? @State private var dismissedSuggestionSignature: String? @State private var conversationListResizePreviewWidth: CGFloat? @State private var composerInputFrame: CGRect = .zero + /// Images staged in the composer (pasted, dropped, or picked), uploaded as + /// link-backed attachments on send. + @State private var pendingImages: [ScoutComposerImage] = [] + /// Staged image currently shown in the centered lightbox preview, if any. + @State private var previewImage: ScoutComposerImage? @State private var observeSidecarAgent: ScoutAgent? @State private var observeSidecarStagingWidth = ScoutObserveSidecarMetrics.peekWidth @State private var observeSidecarResizePreviewWidth: CGFloat? @State private var observeRestoresInspectorCollapsed = false @State private var agentPreviewPanelAgent: ScoutAgent? @State private var agentPreviewRestoresInspectorCollapsed = false + /// Non-nil while the new-session composer is presented. Configured by each + /// entry point (list "+", message context menu, agent inspector). + @State private var sessionDraft: ScoutSessionDraft? + /// Embedded file preview state. Shared so message file-links (rendered deep + /// in the markdown tree) can open it without threading a closure down. + @ObservedObject private var fileViewer = ScoutFileViewer.shared @FocusState private var composerFocused: Bool - @AppStorage("scout.navigationSidebar.labelWidth") private var navigationSidebarLabelWidth = 142.0 - @AppStorage("scout.conversationList.width") private var conversationListWidth = 286.0 + @AppStorage("scout.navigationSidebar.labelWidth.v2") private var navigationSidebarLabelWidth = 88.0 + @AppStorage("scout.conversationList.width.v2") private var conversationListWidth = 224.0 @AppStorage("scout.inspector.width") private var inspectorWidth = 320.0 @AppStorage("scout.observeSidecar.width") private var observeSidecarWidth = Double(ScoutObserveSidecarMetrics.defaultWidth) + @AppStorage("scout.fileViewer.width") private var fileViewerWidth = Double(ScoutFileViewerMetrics.defaultWidth) private var manifest: HudAppManifest { HudAppManifest( @@ -59,7 +87,7 @@ struct ScoutRootView: View { isCompact: $railCompact, labelWidth: navigationSidebarLabelWidthBinding, accent: manifest.accent, - minLabelWidth: 112, + minLabelWidth: 76, maxLabelWidth: 260, collapseLabelWidth: 44, railHeader: { @@ -108,6 +136,109 @@ struct ScoutRootView: View { store.stop() tail.stop() } + .onChange(of: store.selectedCId) { oldCId, newCId in + // Preserve the in-progress draft for the chat we're leaving and + // restore any draft saved for the one we're entering. + if let oldCId { drafts[oldCId] = draft } + draft = newCId.flatMap { drafts[$0] } ?? "" + // Staged images are tied to the chat that was open; don't carry + // them into a different conversation. + pendingImages = [] + } + .overlay { + if let sessionDraft { + ScoutSessionComposer(draft: sessionDraft) { + self.sessionDraft = nil + } onComplete: { result in + handleSessionStarted(result) + } + .transition(.opacity) + } + } + .overlay { + if let previewImage { + ScoutImageLightbox(image: previewImage) { + self.previewImage = nil + } + } + } + .animation(.easeOut(duration: 0.14), value: previewImage?.id) + .overlay(alignment: .bottomLeading) { + ScoutDesignPreviewPanel() + .padding(HudSpacing.xl) + } + } + + private func startNewConversation() { + sessionDraft = ScoutSessionDraft( + title: "New conversation", + target: .project, + projectPath: defaultProjectPath, + mode: .fresh, + instructions: "", + fromMessageId: nil, + fromConversationId: nil + ) + } + + private func startConversationFromMessage(_ message: ScoutMessage, agent: ScoutAgent?) { + let target: ScoutSessionDraft.Target = agent.map { .agent($0) } ?? .project + sessionDraft = ScoutSessionDraft( + title: "New conversation from message", + target: target, + projectPath: agent?.projectRoot?.nilIfEmpty ?? defaultProjectPath, + mode: .fresh, + instructions: message.body, + fromMessageId: message.id, + fromConversationId: message.cId + ) + } + + private func startSessionWithAgent(_ agent: ScoutAgent, mode: ScoutSessionDraft.Mode) { + sessionDraft = ScoutSessionDraft( + title: mode == .continueContext ? "Continue session" : "New session", + target: .agent(agent), + projectPath: agent.projectRoot?.nilIfEmpty ?? "", + mode: mode, + instructions: "", + fromMessageId: nil, + fromConversationId: nil + ) + } + + private func handleSessionStarted(_ result: SessionInitiationResult) { + sessionDraft = nil + section = .comms + store.refresh(force: true) + if let cId = result.conversationId?.nilIfEmpty { + store.selectChannel(cId) + } + if let agentId = result.agentId?.nilIfEmpty { + store.selectAgent(agentId) + } + } + + /// Best-guess project root for a brand-new conversation: the selected + /// agent's root, else any roster agent that exposes one. + private var defaultProjectPath: String { + store.selectedAgent?.projectRoot?.nilIfEmpty + ?? store.agents.compactMap { $0.projectRoot?.nilIfEmpty }.first + ?? "" + } + + /// Root for resolving relative file paths quoted in a message: prefer the + /// sender agent's own workspace, then the selected conversation's agent, + /// then any known project — so an agent's "apps/macos/…" resolves to the + /// repo it's actually working in. + private func fileBaseDirectory(for message: ScoutMessage) -> String? { + if let sender = agent(for: message), + let root = sender.projectRoot?.nilIfEmpty ?? sender.cwd?.nilIfEmpty { + return root + } + if let selected = store.selectedAgent?.projectRoot?.nilIfEmpty ?? store.selectedAgent?.cwd?.nilIfEmpty { + return selected + } + return defaultProjectPath.nilIfEmpty } private var sidebarEntries: [HudSidebarEntry] { @@ -168,22 +299,14 @@ struct ScoutRootView: View { query: $store.channelQuery, filter: $channelFilter, channels: commsListChannels, - totalCount: store.channels.count, selectedCId: store.selectedCId, - width: CGFloat(conversationListWidth) + width: conversationListResizePreviewWidth ?? CGFloat(conversationListWidth), + onNewConversation: { startNewConversation() } ) { channel in store.selectChannel(channel.cId) } .overlay(alignment: .trailing) { ZStack(alignment: .trailing) { - if let conversationListResizePreviewWidth { - Rectangle() - .fill(HudPalette.accent.opacity(0.62)) - .frame(width: HudStrokeWidth.standard) - .offset(x: conversationListResizePreviewWidth - CGFloat(conversationListWidth)) - .allowsHitTesting(false) - } - ScoutConversationResizeHandle( width: conversationListWidthBinding, previewWidth: $conversationListResizePreviewWidth, @@ -210,41 +333,20 @@ struct ScoutRootView: View { } } + // One clean line: just the conversation's handle. The cId and participant + // strip that used to ride a second row are redundant with the inspector + // card (which lists members + cId), so they're gone here. private var chatHeader: some View { - ScoutColumnHeader(horizontalPadding: HudSpacing.huge) { - Text(store.selectedChannel?.displayTitle ?? "Scout") - .font(HudFont.ui(22, weight: .semibold)) + HStack(spacing: HudSpacing.md) { + Text(store.selectedChannel?.displayHandle ?? "Scout") + .font(HudFont.ui(18, weight: .semibold)) .foregroundStyle(HudPalette.ink) .lineLimit(1) - } secondary: { - HStack(spacing: HudSpacing.md) { - if let channel = store.selectedChannel { - HudBadge(channel.scope.label, tint: channel.scope == .direct ? HudPalette.statusInfo : HudPalette.statusOk) - HudBadge(channel.cIdShort, tint: HudPalette.muted) - ScoutMemberStrip(members: selectedChannelMembers) { agent in - previewAgent(agent) - } - } else { - HudBadge("No channel", tint: HudPalette.muted) - } - } - } trailing: { - HStack(spacing: HudSpacing.xl) { - if let agent = store.selectedAgent { - HudButton("Agent", icon: "person.crop.circle", style: .secondary) { - previewAgent(agent) - } - } - - HudButton("Open Web", icon: "safari", style: .ghost) { - if let cId = store.selectedCId { - ScoutWeb.open(path: "/c/\(cId)") - } else { - ScoutWeb.open(path: "/messages") - } - } - } + .truncationMode(.tail) + Spacer(minLength: 0) } + .padding(.horizontal, HudSpacing.huge) + .frame(height: 42, alignment: .center) .background(ScoutDesign.bg) } @@ -264,21 +366,55 @@ struct ScoutRootView: View { ScoutMessageRow( message: message, agent: agent(for: message), - previewAgent: previewAgent + baseDirectory: fileBaseDirectory(for: message), + previewAgent: previewAgent, + onNewFromMessage: { + startConversationFromMessage(message, agent: agent(for: message)) + } ) .id(message.id) } + + // Bottom sentinel: visible only when scrolled to the + // latest message, so we can tell whether to keep + // following or leave the reader in their zone. + Color.clear + .frame(height: 1) + .id(Self.messageListBottomAnchor) + .onAppear { followLatest = true } + .onDisappear { followLatest = false } } } - .padding(HudSpacing.huge) + .padding(EdgeInsets( + top: HudSpacing.huge, + leading: HudSpacing.huge, + bottom: HudSpacing.huge, + trailing: HudSpacing.md + )) .frame(maxWidth: .infinity, alignment: .topLeading) .scoutOverlayScrollers() } .scrollIndicators(.visible) - .onChange(of: store.messages.count) { _, _ in - if let last = store.messages.last { + .onAppear { + if !store.messages.isEmpty { + proxy.scrollTo(Self.messageListBottomAnchor, anchor: .bottom) + pendingInitialJump = false + } + } + .onChange(of: store.selectedCId) { _, _ in + // Opening a conversation lands on the newest message unless the + // user deliberately scrolls up afterwards. + pendingInitialJump = true + followLatest = true + } + .onChange(of: store.messages.last?.id) { _, _ in + guard !store.messages.isEmpty else { return } + if pendingInitialJump { + pendingInitialJump = false + proxy.scrollTo(Self.messageListBottomAnchor, anchor: .bottom) + } else if followLatest { withAnimation(.easeOut(duration: 0.16)) { - proxy.scrollTo(last.id, anchor: .bottom) + proxy.scrollTo(Self.messageListBottomAnchor, anchor: .bottom) } } } @@ -287,23 +423,56 @@ struct ScoutRootView: View { private var composer: some View { VStack(alignment: .leading, spacing: HudSpacing.md) { + if !pendingImages.isEmpty { + composerAttachmentStrip + } HStack(alignment: .top, spacing: HudSpacing.md) { composerInputWell + if isDictating { + ScoutWaveform(tint: isDictationProcessing ? HudPalette.muted : HudPalette.accent) + .frame(width: 26, height: 18) + .padding(.top, HudSpacing.xs + 8) + .transition(.opacity) + } + + composerAttachButton + .padding(.top, HudSpacing.xs) + + ScoutMicButton(box: 34, glyph: 15, action: toggleDictation) + .padding(.top, HudSpacing.xs) + ScoutSendButton( - isEnabled: composerCanSend, + isEnabled: composerReady, isSending: store.isSending, - action: sendDraft + action: requestSend ) .padding(.top, HudSpacing.xs) } + .animation(.easeOut(duration: 0.16), value: isDictating) + .onChange(of: vox.state) { _, newState in + guard pendingSendAfterDictation else { return } + switch newState { + case .idle: + // Final transcript has already been spliced (it lands on + // $lastFinalText before state flips to idle), so send now. + pendingSendAfterDictation = false + sendDraft() + case .unavailable: + pendingSendAfterDictation = false + default: + break + } + } if let status = composerStatusText { Text(status) .font(HudFont.mono(9)) .foregroundStyle(HudPalette.dim) - .frame(maxWidth: .infinity, alignment: .trailing) - .padding(.horizontal, HudSpacing.xs) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, HudSpacing.xs) } } .padding(.horizontal, HudSpacing.xxl) @@ -353,26 +522,39 @@ struct ScoutRootView: View { .onChange(of: draft) { _, _ in refreshSuggestions() } .onChange(of: store.agents.count) { _, _ in refreshSuggestions() } .onReceive(vox.$lastFinalText) { spliceDictatedFinal($0) } + .background( + ImagePasteCatcher( + isActive: { store.selectedCId != nil && sessionDraft == nil }, + onPasteImages: stagePastedImages + ) + .frame(width: 0, height: 0) + .allowsHitTesting(false) + ) } private var composerInputWell: some View { HStack(alignment: .top, spacing: HudSpacing.md) { - ScoutMicButton(box: 28, glyph: 14, action: toggleDictation) - .padding(.top, 1) - ZStack(alignment: .topLeading) { TextField(showDictationPreview ? "" : composerPlaceholder, text: $draft, axis: .vertical) .textFieldStyle(.plain) .font(HudFont.mono(11)) .foregroundStyle(HudPalette.ink) + // Accent caret to match the HUD (not the system blue), and + // hidden while dictating so the waveform is the only cue. + .tint(showDictationPreview ? Color.clear : HudPalette.accent) .lineLimit(1...5) .focused($composerFocused) .disabled(store.selectedCId == nil || store.isSending) .onKeyPress(phases: .down) { press in + // ⌘V image paste is handled by ImagePasteCatcher at the + // AppKit level — the field editor swallows it here. if press.key == .return { if applySelectedSuggestion() { return .handled } - if press.modifiers.contains(.shift) { return .ignored } - sendDraft() + if press.modifiers.contains(.shift) { + draft.append("\n") + return .handled + } + requestSend() return .handled } return .ignored @@ -394,7 +576,7 @@ struct ScoutRootView: View { } if showDictationPreview { - ScoutDictationPreview(text: vox.partial.isEmpty ? voxStatusLine : vox.partial) + ScoutDictationPreview(text: vox.partial) .allowsHitTesting(false) } } @@ -428,6 +610,97 @@ struct ScoutRootView: View { x: 0, y: 3 ) + .dropDestination(for: URL.self) { urls, _ in + addImages(from: urls) + } + } + + // MARK: - Composer attachments + + private var composerAttachButton: some View { + Button(action: presentImagePicker) { + Image(systemName: "paperclip") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(HudPalette.muted) + .frame(width: 34, height: 34) + .contentShape(Rectangle()) + } + .buttonStyle(.plain).scoutPointerCursor() + .help("Attach image") + .disabled(store.selectedCId == nil || store.isSending) + } + + private var composerAttachmentStrip: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: HudSpacing.sm) { + ForEach(pendingImages) { image in + composerAttachmentChip(image) + } + } + .padding(.horizontal, HudSpacing.xs) + .padding(.vertical, 2) + } + } + + private func composerAttachmentChip(_ image: ScoutComposerImage) -> some View { + ZStack(alignment: .topTrailing) { + Group { + if let nsImage = NSImage(data: image.data) { + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Image(systemName: "photo") + .foregroundStyle(HudPalette.muted) + } + } + .frame(width: 52, height: 52) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(ScoutDesign.hairlineStrong, lineWidth: HudStrokeWidth.thin) + ) + .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .onTapGesture { previewImage = image } + .help("Click to preview") + + Button { + pendingImages.removeAll { $0.id == image.id } + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 14)) + .foregroundStyle(.white, .black.opacity(0.55)) + } + .buttonStyle(.plain).scoutPointerCursor() + .help("Remove attachment") + .offset(x: 5, y: -5) + } + } + + /// Stage images handed up by the ⌘V paste catcher. Returns false (so the + /// paste falls through to normal text handling) when we can't accept them. + private func stagePastedImages(_ images: [ScoutComposerImage]) -> Bool { + guard store.selectedCId != nil, !store.isSending, !images.isEmpty else { return false } + pendingImages.append(contentsOf: images) + return true + } + + @discardableResult + private func addImages(from urls: [URL]) -> Bool { + let images = urls.compactMap(ScoutImageIntake.fromFileURL) + guard !images.isEmpty else { return false } + pendingImages.append(contentsOf: images) + return true + } + + private func presentImagePicker() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowedContentTypes = [.image] + guard panel.runModal() == .OK else { return } + addImages(from: panel.urls) } private var composerWellFill: Color { @@ -445,18 +718,25 @@ struct ScoutRootView: View { } private var composerPlaceholder: String { - if let title = store.selectedChannel?.displayTitle, !title.isEmpty { + if let title = store.selectedChannel?.displayHandle, !title.isEmpty { return "Message \(title)" } return "Message" } private var composerCanSend: Bool { - !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + (!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !pendingImages.isEmpty) && store.selectedCId != nil && !store.isSending } + /// Whether the send button should read as *enabled* — i.e. we have a target + /// to talk to. Lit whenever a conversation is selected (not gated on having + /// typed text yet); the actual send still no-ops on an empty draft. + private var composerReady: Bool { + store.selectedCId != nil && !store.isSending + } + private var composerSuggestionX: CGFloat { guard composerInputFrame.width > 0 else { return HudSpacing.xxl } return max(HudSpacing.xs, composerInputFrame.minX) @@ -476,6 +756,10 @@ struct ScoutRootView: View { if isDictating { return voxStatusLine } if let reason = voxUnavailableReason { return reason } if store.isSending { return "Sending..." } + if !pendingImages.isEmpty { + let noun = pendingImages.count == 1 ? "image" : "images" + return "\(pendingImages.count) \(noun) attached · ↵ send · ⌘V or ⊕ to add" + } if draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "Type / for commands · @ for agents · session: for sessions" } @@ -493,6 +777,11 @@ struct ScoutRootView: View { } } + private var isDictationProcessing: Bool { + if case .processing = vox.state { return true } + return false + } + private var voxStatusLine: String { if !vox.partial.isEmpty { return vox.partial } switch vox.state { @@ -507,13 +796,30 @@ struct ScoutRootView: View { return nil } + /// Send entry point. While dictating, commit the recording first and let + /// the dictation→idle transition fire the actual send once the transcript + /// has landed — so one tap finishes transcription and sends in one shot. + private func requestSend() { + if isDictating { + guard composerReady else { return } + pendingSendAfterDictation = true + vox.stop() + return + } + sendDraft() + } + private func sendDraft() { let body = draft guard composerCanSend else { return } + let images = pendingImages draft = "" + pendingImages = [] + if let cId = store.selectedCId { drafts[cId] = nil } + followLatest = true composerFocused = true clearSuggestions(resetDismissedSignature: true) - Task { await store.send(body) } + Task { await store.send(body, images: images) } } private func toggleDictation() { @@ -534,11 +840,29 @@ struct ScoutRootView: View { } private func spliceDictatedFinal(_ text: String) { + // While the New-conversation composer is up it owns dictation; don't + // also splice into the (hidden) chat composer behind it. + guard sessionDraft == nil else { return } let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } draft = ScoutDictationBuffer.appending(trimmed, to: draft) ScoutVoxService.shared.consumeFinalText() composerFocused = true + moveComposerCaretToEnd() + } + + /// After splicing dictated text, drop the field's selection and park the + /// caret at the very end so you can keep typing/editing cleanly instead of + /// landing on an all-selected or mid-string insertion point. + private func moveComposerCaretToEnd() { + #if os(macOS) + DispatchQueue.main.async { + guard let textView = NSApp.keyWindow?.firstResponder as? NSTextView else { return } + let end = (textView.string as NSString).length + textView.setSelectedRange(NSRange(location: end, length: 0)) + textView.scrollRangeToVisible(NSRange(location: end, length: 0)) + } + #endif } private func refreshSuggestions() { @@ -672,12 +996,13 @@ struct ScoutRootView: View { } private var inspectorHeader: some View { - HStack(spacing: HudSpacing.md) { - HudSectionLabel(section == .tail ? "Tail" : (store.selectedAgent == nil ? "Context" : "Agent")) + let multiAgent = channelAgentMembers.count >= 2 + return HStack(spacing: HudSpacing.md) { + HudSectionLabel(section == .tail ? "Tail" : (multiAgent ? "Agents" : (store.selectedAgent == nil ? "Context" : "Agent"))) Spacer() if section == .tail { HudBadge(tail.isFollowing ? "Live" : "Paused", tint: tail.isFollowing ? HudPalette.statusOk : HudPalette.muted, dot: tail.isFollowing) - } else if let agent = store.selectedAgent { + } else if !multiAgent, let agent = store.selectedAgent { HudBadge(agent.state.label, tint: agent.state.tint, dot: true) } } @@ -702,7 +1027,22 @@ struct ScoutRootView: View { @ViewBuilder private var trailingPanel: some View { Group { - if let agent = observeSidecarResolvedAgent { + if let target = fileViewer.target { + ScoutFileViewerPanel( + target: target, + width: fileViewerWidthBinding, + onClose: { + withAnimation(.easeOut(duration: 0.14)) { + fileViewer.close() + } + }, + onOpenInEditor: { + ScoutFileOpener.openInEditor(path: target.path, line: target.line) + } + ) + .id(target.path) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } else if let agent = observeSidecarResolvedAgent { ScoutObserveSidecarPanel( agent: agent, stagingWidth: observeSidecarStagingWidth, @@ -733,6 +1073,9 @@ struct ScoutRootView: View { }, openProfile: { ScoutWeb.open(path: "/agents/\(agent.id)?tab=profile") + }, + startSession: { mode in + startSessionWithAgent(agent, mode: mode) } ) .id("preview-\(agent.id)") @@ -754,6 +1097,7 @@ struct ScoutRootView: View { } .animation(.interpolatingSpring(stiffness: 260, damping: 28), value: observeSidecarResolvedAgent?.id) .animation(.interpolatingSpring(stiffness: 260, damping: 28), value: agentPreviewResolvedAgent?.id) + .animation(.interpolatingSpring(stiffness: 260, damping: 28), value: fileViewer.target) } @ViewBuilder @@ -775,12 +1119,23 @@ struct ScoutRootView: View { } } - if let agent = store.selectedAgent { - ScoutAgentInspector(agent: agent, selectedChannel: store.selectedChannel) { - observeAgent(agent) - } openProfile: { - ScoutWeb.open(path: "/agents/\(agent.id)?tab=profile") - } + let members = channelAgentMembers + if members.count >= 2 { + ScoutAgentCardStack( + agents: members, + selectedChannel: store.selectedChannel, + openObserve: { observeAgent($0) }, + openProfile: { ScoutWeb.open(path: "/agents/\($0.id)?tab=profile") }, + startSession: { agent in startSessionWithAgent(agent, mode: .fresh) } + ) + } else if let agent = store.selectedAgent { + ScoutAgentInspector( + agent: agent, + selectedChannel: store.selectedChannel, + openObserve: { observeAgent(agent) }, + openProfile: { ScoutWeb.open(path: "/agents/\(agent.id)?tab=profile") }, + startSession: { mode in startSessionWithAgent(agent, mode: mode) } + ) } else if let channel = store.selectedChannel { ScoutChannelInspector(channel: channel) } else { @@ -821,11 +1176,20 @@ struct ScoutRootView: View { } } + private var fileViewerWidthBinding: Binding { + Binding { + CGFloat(fileViewerWidth) + } set: { nextWidth in + let range = ScoutFileViewerMetrics.widthRange + fileViewerWidth = Double(min(max(nextWidth, range.lowerBound), range.upperBound)) + } + } + private var navigationSidebarLabelWidthBinding: Binding { Binding { CGFloat(navigationSidebarLabelWidth) } set: { nextWidth in - navigationSidebarLabelWidth = Double(min(max(nextWidth, 112), 260)) + navigationSidebarLabelWidth = Double(min(max(nextWidth, 76), 260)) } } @@ -864,6 +1228,18 @@ struct ScoutRootView: View { } } + /// Distinct, resolved agents participating in the selected channel. + private var channelAgentMembers: [ScoutAgent] { + var seen = Set() + var result: [ScoutAgent] = [] + for member in selectedChannelMembers { + guard let agent = member.agent, !seen.contains(agent.id) else { continue } + seen.insert(agent.id) + result.append(agent) + } + return result + } + private func filterChannels(_ channels: [ScoutChannel]) -> [ScoutChannel] { let trimmed = store.channelQuery.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return channels } @@ -1062,7 +1438,7 @@ enum ScoutDesign { static let columnHeaderPrimaryRowHeight: CGFloat = 28 static let columnHeaderLineGap: CGFloat = 2 static let columnHeaderTrailingTopOffset: CGFloat = 2 - static let conversationListWidthRange: ClosedRange = 230...430 + static let conversationListWidthRange: ClosedRange = 188...440 static let inspectorWidthRange: ClosedRange = 260...520 static let conversationResizeHandleWidth: CGFloat = 12 @@ -1117,8 +1493,8 @@ private enum ScoutChannelFilter: String, CaseIterable, Identifiable { var title: String { switch self { case .all: return "All" - case .direct: return "Private" - case .shared: return "Shared" + case .direct: return "Direct" + case .shared: return "Channels" } } @@ -1188,11 +1564,13 @@ private struct ScoutConversationListBar: View { @Binding var query: String @Binding var filter: ScoutChannelFilter let channels: [ScoutChannel] - let totalCount: Int let selectedCId: String? let width: CGFloat + let onNewConversation: () -> Void let select: (ScoutChannel) -> Void + @AppStorage(ScoutDesignPreview.glow) private var glowOn = false + var body: some View { VStack(spacing: 0) { header @@ -1203,36 +1581,56 @@ private struct ScoutConversationListBar: View { } .frame(width: width) .frame(maxHeight: .infinity) - .background(ScoutDesign.chrome) + .background { + ZStack { + ScoutDesign.chrome + if glowOn { ScoutAmbientGlow() } + } + } } private var header: some View { - ScoutColumnHeader(horizontalPadding: HudSpacing.xxl) { + HStack(spacing: HudSpacing.md) { Text("Conversations") - .font(HudFont.ui(14, weight: .semibold)) + .font(HudFont.ui(13, weight: .semibold)) .foregroundStyle(HudPalette.ink) .lineLimit(1) - } secondary: { - Text("\(totalCount) cIds") - .font(HudFont.mono(9)) - .foregroundStyle(HudPalette.dim) - .lineLimit(1) - } trailing: { + if isLoading { ProgressView() .controlSize(.small) - } else { - HudBadge("\(channels.count)", tint: HudPalette.muted) } + + Spacer(minLength: 0) + + Button(action: onNewConversation) { + HStack(spacing: HudSpacing.xs) { + Image(systemName: "square.and.pencil") + .font(HudFont.ui(11, weight: .semibold)) + Text("New") + .font(HudFont.ui(11, weight: .semibold)) + } + .foregroundStyle(HudPalette.accent) + .padding(.horizontal, HudSpacing.md) + .padding(.vertical, HudSpacing.xs) + .background(Capsule().fill(HudSurface.tintGhost(HudPalette.accent))) + .overlay(Capsule().stroke(HudSurface.tintBorder(HudPalette.accent), lineWidth: 1)) + .contentShape(Capsule()) + } + .buttonStyle(.plain).scoutPointerCursor() + .help("New conversation") } + .padding(.horizontal, HudSpacing.xxl) + .frame(height: 42, alignment: .center) } private var controls: some View { - VStack(spacing: HudSpacing.lg) { + HStack(spacing: HudSpacing.md) { HudField("Search", text: $query, icon: "magnifyingglass") ScoutConversationFilterControl(selection: $filter) } .padding(.horizontal, HudSpacing.xxl) + .padding(.top, HudSpacing.md) .padding(.bottom, HudSpacing.xxl) } @@ -1275,37 +1673,33 @@ private struct ScoutConversationListBar: View { } } +/// Compact icon-only scope toggle. Tucked onto the search row rather than +/// taking a full row of its own — the active scope reads from the accent fill; +/// each segment names itself on hover. private struct ScoutConversationFilterControl: View { @Binding var selection: ScoutChannelFilter var body: some View { - HStack(spacing: HudSpacing.xs) { + HStack(spacing: 2) { ForEach(ScoutChannelFilter.allCases) { option in Button { selection = option } label: { - HStack(spacing: HudSpacing.xs) { - Image(systemName: option.icon) - .font(HudFont.ui(10, weight: .semibold)) - Text(option.title) - .font(HudFont.mono(9, weight: .semibold)) - } - .foregroundStyle(selection == option ? HudPalette.ink : HudPalette.muted) - .frame(maxWidth: .infinity) - .frame(height: 26) - .background( - RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous) - .fill(selection == option ? HudSurface.selected(HudPalette.accent) : Color.clear) - ) - .overlay( - RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous) - .stroke(selection == option ? HudSurface.tintBorder(HudPalette.accent) : Color.clear, lineWidth: HudStrokeWidth.thin) - ) + Image(systemName: option.icon) + .font(HudFont.ui(11, weight: .semibold)) + .foregroundStyle(selection == option ? HudPalette.ink : HudPalette.muted) + .frame(width: 30, height: 24) + .background( + RoundedRectangle(cornerRadius: HudRadius.standard - 2, style: .continuous) + .fill(selection == option ? HudSurface.selected(HudPalette.accent) : Color.clear) + ) + .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.plain).scoutPointerCursor() + .help(option.title) } } - .padding(3) + .padding(2) .background(RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous).fill(HudSurface.inset)) .overlay(RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous).stroke(ScoutDesign.hairline, lineWidth: HudStrokeWidth.thin)) } @@ -1317,6 +1711,7 @@ private struct ScoutConversationRow: View { let action: () -> Void @State private var isHovering = false + @AppStorage(ScoutDesignPreview.accents) private var accentsOn = false var body: some View { Button(action: action) { @@ -1329,7 +1724,7 @@ private struct ScoutConversationRow: View { VStack(alignment: .leading, spacing: HudSpacing.xs) { HStack(alignment: .firstTextBaseline, spacing: HudSpacing.sm) { - Text(channel.displayTitle) + Text(channel.rowTitle) .font(HudFont.ui(13, weight: isSelected ? .semibold : .medium)) .foregroundStyle(HudPalette.ink) .lineLimit(1) @@ -1348,9 +1743,6 @@ private struct ScoutConversationRow: View { .lineLimit(2) HStack(spacing: HudSpacing.sm) { - Text(channel.scope.label.uppercased()) - .font(HudFont.mono(8, weight: .semibold)) - .foregroundStyle(channel.scope == .direct ? HudPalette.statusInfo : HudPalette.statusOk) Text(channel.cIdShort) .font(HudFont.mono(8)) .foregroundStyle(HudPalette.dim) @@ -1369,16 +1761,28 @@ private struct ScoutConversationRow: View { .frame(maxWidth: .infinity, alignment: .leading) .background(rowBackground) .overlay(alignment: .leading) { - Rectangle() - .fill(isSelected ? HudPalette.accent : Color.clear) - .frame(width: 2) + if isSelected { + ZStack(alignment: .leading) { + if accentsOn { + // Soft bloom behind the rule so selection feels lit. + Rectangle() + .fill(HudPalette.accent) + .frame(width: 3) + .blur(radius: 4) + .opacity(0.85) + } + Rectangle() + .fill(HudPalette.accent) + .frame(width: 2) + } + } } .overlay(alignment: .bottom) { HudDivider(color: ScoutDesign.hairline) } .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.plain).scoutPointerCursor() .onHover { isHovering = $0 } .animation(.easeOut(duration: 0.10), value: isHovering) .animation(.easeOut(duration: 0.10), value: isSelected) @@ -1427,7 +1831,7 @@ private struct ScoutSidebarSettingsButton: View { .contentShape(Rectangle()) .clipped() } - .buttonStyle(.plain) + .buttonStyle(.plain).scoutPointerCursor() .help("Settings") .accessibilityLabel("Settings") .onHover { isHovering = $0 } @@ -1613,7 +2017,7 @@ private struct ScoutCompactChannelRow: View { .frame(width: 20) VStack(alignment: .leading, spacing: 2) { - Text(channel.displayTitle) + Text(channel.rowTitle) .font(HudFont.ui(12, weight: isSelected ? .semibold : .medium)) .foregroundStyle(HudPalette.ink) .lineLimit(1) @@ -1636,7 +2040,7 @@ private struct ScoutCompactChannelRow: View { .background(RoundedRectangle(cornerRadius: HudRadius.standard).fill(isSelected ? HudSurface.selected(HudPalette.accent) : HudSurface.inset)) .overlay(RoundedRectangle(cornerRadius: HudRadius.standard).stroke(isSelected ? HudSurface.tintBorder(HudPalette.accent) : HudHairline.subtle, lineWidth: 1)) } - .buttonStyle(.plain) + .buttonStyle(.plain).scoutPointerCursor() } } @@ -1673,7 +2077,7 @@ private struct ScoutMemberStrip: View { } label: { avatarGlyph(for: member) } - .buttonStyle(.plain) + .buttonStyle(.plain).scoutPointerCursor() .help("Preview \(agent.displayName)") } else { avatarGlyph(for: member) @@ -1710,7 +2114,10 @@ private struct ScoutMemberStrip: View { private struct ScoutMessageRow: View { let message: ScoutMessage let agent: ScoutAgent? + /// Workspace root for resolving relative file paths this message quotes. + let baseDirectory: String? let previewAgent: (ScoutAgent) -> Void + let onNewFromMessage: () -> Void @State private var isHoveringAgent = false @@ -1724,7 +2131,7 @@ private struct ScoutMessageRow: View { .font(HudFont.mono(9)) .foregroundStyle(HudPalette.dim) } - ScoutMarkdownView(text: message.body) + ScoutMarkdownView(text: message.body, baseDirectory: baseDirectory) } .padding(HudSpacing.xxl) .frame(maxWidth: 840, alignment: .leading) @@ -1736,11 +2143,36 @@ private struct ScoutMessageRow: View { RoundedRectangle(cornerRadius: HudRadius.card) .stroke(message.isOperator ? HudSurface.tintBorder(HudPalette.accent) : HudHairline.standard, lineWidth: 1) ) + .contextMenu { + Button { + onNewFromMessage() + } label: { + Label("New conversation from this message…", systemImage: "bubble.left.and.text.bubble.right") + } + Divider() + Button { + copyToPasteboard(message.body) + } label: { + Label("Copy message", systemImage: "doc.on.doc") + } + Button { + copyToPasteboard(message.id) + } label: { + Label("Copy message ID", systemImage: "number") + } + } if !message.isOperator { Spacer(minLength: 80) } } .frame(maxWidth: .infinity, alignment: message.isOperator ? .trailing : .leading) } + private func copyToPasteboard(_ value: String) { + #if os(macOS) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + #endif + } + @ViewBuilder private var actorChip: some View { if let agent { @@ -1749,7 +2181,7 @@ private struct ScoutMessageRow: View { } label: { actorLabel } - .buttonStyle(.plain) + .buttonStyle(.plain).scoutPointerCursor() .onHover { isHoveringAgent = $0 } .overlay(alignment: .topLeading) { if isHoveringAgent { @@ -1823,6 +2255,9 @@ private struct ScoutAgentHoverCard: View { private struct ScoutMarkdownView: View { let text: String + /// Workspace root of the agent that wrote this message — used to resolve + /// relative file paths the agent quoted from its own context. + var baseDirectory: String? = nil var body: some View { VStack(alignment: .leading, spacing: HudSpacing.md) { @@ -1832,6 +2267,26 @@ private struct ScoutMarkdownView: View { } .frame(maxWidth: .infinity, alignment: .leading) .textSelection(.enabled) + .environment(\.openURL, OpenURLAction { url in + guard let link = ScoutFileLink.parse(url) else { return .systemAction } + let resolved = ScoutFilePathResolver.resolve(path: link.path, base: link.base) + // A folder can't render in a code pane — reveal it in Finder instead. + var isDir: ObjCBool = false + if FileManager.default.fileExists(atPath: resolved, isDirectory: &isDir), isDir.boolValue { + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: resolved)]) + return .handled + } + if NSEvent.modifierFlags.contains(.command) { + // ⌘-click pops straight out to the editor (Cursor), with line jump. + ScoutFileOpener.openInEditor(path: resolved, line: link.line) + } else { + // Plain click previews in the embedded file viewer. + withAnimation(.easeOut(duration: 0.16)) { + ScoutFileViewer.shared.open(path: resolved, line: link.line) + } + } + return .handled + }) } @ViewBuilder @@ -1893,10 +2348,11 @@ private struct ScoutMarkdownView: View { } private func inline(_ body: String) -> AttributedString { - (try? AttributedString( + let parsed = (try? AttributedString( markdown: body, options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) )) ?? AttributedString(body) + return ScoutFileLinkifier.apply(to: parsed, accent: HudPalette.accent, baseDirectory: baseDirectory) } } @@ -1966,33 +2422,23 @@ private extension MessageSuggestionAgent { } } -private struct ScoutDictationPreview: View { +struct ScoutDictationPreview: View { let text: String - @State private var caretLit = false private var displayText: String { text.trimmingCharacters(in: .whitespacesAndNewlines) } var body: some View { - HStack(spacing: HudSpacing.xs) { - if !displayText.isEmpty { - Text(displayText) - .font(HudFont.mono(11)) - .foregroundStyle(HudPalette.muted) - .lineLimit(1) - .truncationMode(.tail) - } - RoundedRectangle(cornerRadius: 0.5, style: .continuous) - .fill(HudPalette.accent.opacity(caretLit ? 0.95 : 0.25)) - .frame(width: 1, height: 13) - } - .frame(maxWidth: .infinity, alignment: .leading) - .onAppear { - withAnimation(.easeInOut(duration: 0.48).repeatForever(autoreverses: true)) { - caretLit = true - } - } + // Live partial transcript only — no blinking caret. The recording cue + // is the waveform near the mic; the textual state lives in the status row. + Text(displayText) + .font(HudFont.mono(11)) + .foregroundStyle(HudPalette.muted) + .lineLimit(1) + .truncationMode(.tail) + .opacity(displayText.isEmpty ? 0 : 1) + .frame(maxWidth: .infinity, alignment: .leading) } } @@ -2017,7 +2463,7 @@ private struct ScoutSendButton: View { .frame(width: 34, height: 34) .contentShape(RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous)) } - .buttonStyle(.plain) + .buttonStyle(.plain).scoutPointerCursor() .disabled(!isEnabled || isSending) .onHover { hovering = $0 } .help(isEnabled && !isSending ? "Send message" : "") @@ -2031,9 +2477,10 @@ private struct ScoutSendButton: View { .scaleEffect(0.62) .tint(HudPalette.dim) } else { - Image(systemName: "arrow.up") - .font(.system(size: 13, weight: .bold)) + Image(systemName: "paperplane.fill") + .font(.system(size: 13, weight: .semibold)) .foregroundStyle(iconColor) + .offset(x: -1, y: 1) } } @@ -2063,13 +2510,41 @@ private struct ScoutSendButton: View { // toggle Vox dictation. Visual state mirrors ScoutVoxService.state: // idle/probing → faint stroke · recording → accent stroke + pulsing halo // processing → muted stroke that breathes · unavailable → dim + dashed. -private struct ScoutMicButton: View { +// Lightweight equalizer-style waveform shown while dictating. Decorative +// (synthetic, not amplitude-driven) — replaces the recording pulse with a +// calmer, single activity cue. Bars stay out of phase via fixed per-bar +// durations rather than any RNG. +private struct ScoutWaveform: View { + var tint: Color + @State private var animate = false + + private let lows: [CGFloat] = [4, 6, 5, 7, 4] + private let highs: [CGFloat] = [12, 17, 14, 18, 11] + private let durations: [Double] = [0.50, 0.62, 0.44, 0.70, 0.54] + + var body: some View { + HStack(alignment: .center, spacing: 2) { + ForEach(lows.indices, id: \.self) { i in + Capsule(style: .continuous) + .fill(tint.opacity(0.85)) + .frame(width: 2.5, height: animate ? highs[i] : lows[i]) + .animation( + .easeInOut(duration: durations[i]).repeatForever(autoreverses: true), + value: animate + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .onAppear { animate = true } + } +} + +struct ScoutMicButton: View { let box: CGFloat let glyph: CGFloat let action: () -> Void @ObservedObject private var vox = ScoutVoxService.shared - @State private var pulse = false @State private var hovering = false private var isRecording: Bool { vox.state.isCaptureActive } @@ -2101,11 +2576,13 @@ private struct ScoutMicButton: View { .fill(micFillColor) .frame(width: box, height: box) - if isRecording { - Circle() - .fill(HudPalette.accent.opacity(pulse ? 0.20 : 0.08)) - .frame(width: box, height: box) - } + Circle() + .stroke( + isRecording ? HudPalette.accent.opacity(0.5) : Color.clear, + lineWidth: HudStrokeWidth.thin + ) + .frame(width: box, height: box) + ScoutMicGlyphShape() .stroke( strokeColor, @@ -2117,31 +2594,22 @@ private struct ScoutMicButton: View { ) ) .frame(width: glyph, height: glyph) - .opacity(isProcessing && pulse ? 0.55 : 1.0) } .frame(width: box, height: box) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.plain).scoutPointerCursor() .help(tooltip) .onHover { hovering = $0 } .task { if vox.state == .probing { await vox.probe() } } - .onChange(of: vox.state) { _, newValue in - pulse = false - if newValue == .recording || newValue == .starting || newValue == .processing { - withAnimation(.easeInOut(duration: 0.55).repeatForever(autoreverses: true)) { - pulse = true - } - } - } } private var micFillColor: Color { if isRecording { - return HudPalette.accent.opacity(pulse ? 0.13 : 0.08) + return HudPalette.accent.opacity(0.13) } if isProcessing { - return HudSurface.hover.opacity(pulse ? 0.88 : 0.62) + return HudSurface.hover.opacity(0.7) } if hovering { return HudSurface.hover.opacity(0.86) @@ -2222,10 +2690,11 @@ private struct ScoutMarkdownTable: View { } private func inline(_ body: String) -> AttributedString { - (try? AttributedString( + let parsed = (try? AttributedString( markdown: body, options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) )) ?? AttributedString(body) + return ScoutFileLinkifier.apply(to: parsed, accent: HudPalette.accent) } } @@ -2280,66 +2749,418 @@ private struct ScoutAgentCard: View { } } +// MARK: - Design preview (toggleable look-and-feel experiments) + +/// UserDefaults keys for the three live "make it awesomer" experiments. Each is +/// off by default so the app ships at its current baseline; the floating +/// `ScoutDesignPreviewPanel` flips them so before/after is one click apart. +/// Every consumer reads the same key via `@AppStorage`, so a flip re-renders +/// all affected surfaces at once. +enum ScoutDesignPreview { + static let depth = "scout.design.preview.depth" + static let accents = "scout.design.preview.accents" + static let glow = "scout.design.preview.glow" +} + +/// "Glow": the conversation list reads as backlit — light leaks in around the +/// panel's edges (rim light), as if a source sits behind it, brightest at the +/// top where the light originates. The interior stays dark so text never +/// competes with it. Same single white light source as Depth. +private struct ScoutAmbientGlow: View { + var body: some View { + ZStack { + // Rim light bleeding in from behind the panel's edges. The stroke's + // outward blur is clipped at the panel bound, leaving an inner halo. + Rectangle() + .stroke(Color.white.opacity(0.12), lineWidth: 2) + .blur(radius: 11) + + // The source sits behind-and-above: a brighter bloom hugging the top. + LinearGradient( + colors: [Color.white.opacity(0.10), Color.clear], + startPoint: .top, + endPoint: UnitPoint(x: 0.5, y: 0.22) + ) + } + .allowsHitTesting(false) + } +} + +/// "Depth": one consistent light source — a 1px top-edge highlight that fades +/// downward plus a soft, wide shadow — so graphite cards read as lifted objects +/// rather than flat outlined boxes. No-op when the flag is off. +private struct ScoutDepthModifier: ViewModifier { + var radius: CGFloat = HudRadius.card + @AppStorage(ScoutDesignPreview.depth) private var depthOn = false + + func body(content: Content) -> some View { + content + .overlay { + if depthOn { + RoundedRectangle(cornerRadius: radius, style: .continuous) + .strokeBorder( + LinearGradient( + colors: [Color.white.opacity(0.10), Color.white.opacity(0.0)], + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 1 + ) + .allowsHitTesting(false) + } + } + .shadow(color: Color.black.opacity(depthOn ? 0.35 : 0), + radius: depthOn ? 14 : 0, x: 0, y: depthOn ? 6 : 0) + } +} + +extension View { + func scoutDepth(radius: CGFloat = HudRadius.card) -> some View { + modifier(ScoutDepthModifier(radius: radius)) + } +} + +/// Shows the pointing-hand cursor while hovering an enabled clickable. Push/pop +/// are balanced via local state (and cleaned up on disappear) so the cursor +/// never gets stuck. Respects `isEnabled` so disabled controls stay an arrow. +private struct ScoutPointerCursorModifier: ViewModifier { + @Environment(\.isEnabled) private var isEnabled + @State private var pushed = false + + func body(content: Content) -> some View { + content + .onHover { inside in + if inside, isEnabled { + if !pushed { NSCursor.pointingHand.push(); pushed = true } + } else if pushed { + NSCursor.pop(); pushed = false + } + } + .onDisappear { + if pushed { NSCursor.pop(); pushed = false } + } + } +} + +extension View { + /// Pointing-hand cursor on hover for custom (`.plain`) buttons and other + /// tap targets that don't get it for free. + func scoutPointerCursor() -> some View { + modifier(ScoutPointerCursorModifier()) + } +} + +/// "Accents": a section eyebrow that grows a small hanging accent tick when the +/// flag is on — an editorial marker that makes labels feel deliberate. +private struct ScoutEyebrow: View { + let text: String + @AppStorage(ScoutDesignPreview.accents) private var accentsOn = false + + var body: some View { + HStack(spacing: HudSpacing.sm) { + if accentsOn { + RoundedRectangle(cornerRadius: 0.5, style: .continuous) + .fill(HudPalette.accent) + .frame(width: 2, height: 9) + } + HudSectionLabel(text) + } + } +} + +/// Tiny floating control to flip the three look-and-feel experiments on/off +/// live, so before/after is one click apart. Collapses to a single chip. +private struct ScoutDesignPreviewPanel: View { + @AppStorage(ScoutDesignPreview.depth) private var depth = false + @AppStorage(ScoutDesignPreview.accents) private var accents = false + @AppStorage(ScoutDesignPreview.glow) private var glow = false + @AppStorage("scout.design.preview.panelExpanded") private var expanded = true + + var body: some View { + VStack(alignment: .leading, spacing: expanded ? HudSpacing.md : 0) { + Button { + withAnimation(.easeOut(duration: 0.16)) { expanded.toggle() } + } label: { + HStack(spacing: HudSpacing.sm) { + Image(systemName: "sparkles") + .font(HudFont.ui(10, weight: .semibold)) + .foregroundStyle(anyOn ? HudPalette.accent : HudPalette.muted) + Text("DESIGN") + .font(HudFont.mono(9, weight: .bold)) + .tracking(1.5) + .foregroundStyle(HudPalette.muted) + Spacer(minLength: HudSpacing.lg) + Image(systemName: expanded ? "chevron.down" : "chevron.up") + .font(HudFont.ui(8, weight: .bold)) + .foregroundStyle(HudPalette.dim) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain).scoutPointerCursor() + .help("Toggle design experiments") + + if expanded { + toggleRow("Depth", isOn: $depth) + toggleRow("Accents", isOn: $accents) + toggleRow("Glow", isOn: $glow) + } + } + .padding(.horizontal, HudSpacing.lg) + .padding(.vertical, HudSpacing.md) + .frame(width: expanded ? 168 : nil) + .background(RoundedRectangle(cornerRadius: HudRadius.card, style: .continuous).fill(HudPalette.surface)) + .overlay(RoundedRectangle(cornerRadius: HudRadius.card, style: .continuous).stroke(HudHairline.standard, lineWidth: 1)) + .shadow(color: Color.black.opacity(0.4), radius: 16, x: 0, y: 8) + } + + private var anyOn: Bool { depth || accents || glow } + + private func toggleRow(_ title: String, isOn: Binding) -> some View { + HStack(spacing: HudSpacing.sm) { + Text(title) + .font(HudFont.ui(11, weight: .medium)) + .foregroundStyle(HudPalette.ink) + Spacer(minLength: HudSpacing.md) + Toggle("", isOn: isOn) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.mini) + .tint(HudPalette.accent) + } + } +} + +/// One self-contained agent card: identity, runtime, workspace, optional +/// special skills, and the per-agent actions all live inside a single card so +/// the agent reads as one cohesive concept rather than a stack of fragments. private struct ScoutAgentInspector: View { let agent: ScoutAgent let selectedChannel: ScoutChannel? let openObserve: () -> Void let openProfile: () -> Void + let startSession: (ScoutSessionDraft.Mode) -> Void + + /// Conversation / work-requests / result-delivery / observe are table + /// stakes every agent has — not "abilities". Only surface skills beyond + /// that baseline, when an agent actually loads them. + private var specialCapabilities: [String] { + let baseline: Set = ["chat", "invoke", "deliver", "observe"] + return agent.capabilities.filter { + !baseline.contains($0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) + } + } + + /// The agent's live harness session, if one is bound. Observe lives with + /// this — you observe a *session* — so it (and Observe) only appear when + /// there's something to watch. + private var sessionId: String? { agent.harnessSessionId?.nilIfEmpty } var body: some View { - VStack(alignment: .leading, spacing: HudSpacing.xl) { - HudCard { - VStack(alignment: .leading, spacing: HudSpacing.md) { + HudCard { + VStack(alignment: .leading, spacing: HudSpacing.lg) { + identity + HudDivider(color: ScoutDesign.hairline) + runtime + HudDivider(color: ScoutDesign.hairline) + workspace + if sessionId != nil { + HudDivider(color: ScoutDesign.hairline) + sessionSection + } + if !specialCapabilities.isEmpty { + HudDivider(color: ScoutDesign.hairline) + skills + } + ScoutNewSessionLink(action: { startSession(.fresh) }) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .scoutDepth() + } + + /// Clickable identity header → profile. State rides the presence dot on + /// the avatar (no "AVAILABLE" tag); Observe now lives in the Session block. + private var identity: some View { + Button(action: openProfile) { + HStack(alignment: .top, spacing: HudSpacing.md) { + avatar + VStack(alignment: .leading, spacing: 2) { Text(agent.displayName) - .font(HudFont.ui(18, weight: .semibold)) + .font(HudFont.ui(16, weight: .semibold)) .foregroundStyle(HudPalette.ink) + .lineLimit(1) Text(agent.id) - .font(HudFont.mono(10)) + .font(HudFont.mono(9)) .foregroundStyle(HudPalette.dim) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) - HudBadge(agent.state.label, tint: agent.state.tint, dot: true) + .lineLimit(1) + .truncationMode(.middle) } - .frame(maxWidth: .infinity, alignment: .leading) + Spacer(minLength: 0) } + .contentShape(Rectangle()) + } + .buttonStyle(.plain).scoutPointerCursor() + .help("Open \(agent.displayName)'s profile") + } - HudCard { - VStack(alignment: .leading, spacing: HudSpacing.md) { - HudSectionLabel("Runtime") - HudKVRow("Role", value: agent.roleLabel) - HudKVRow("Harness", value: agent.harness?.nilIfEmpty ?? "—") - HudKVRow("Transport", value: agent.transport?.nilIfEmpty ?? "—") - ScoutAgentModelRow(agent: agent) - HudKVRow("Node", value: agent.nodeName?.nilIfEmpty ?? "—") - } + private var avatar: some View { + Text(String(agent.displayName.first.map(String.init) ?? "?").uppercased()) + .font(HudFont.mono(11, weight: .bold)) + .foregroundStyle(HudPalette.bg) + .frame(width: 30, height: 30) + .background(Circle().fill(HudPalette.muted)) + .overlay(alignment: .bottomTrailing) { + Circle() + .fill(agent.state.tint) + .frame(width: 9, height: 9) + .overlay(Circle().stroke(HudPalette.surface, lineWidth: 2)) } + } - HudCard { - VStack(alignment: .leading, spacing: HudSpacing.md) { - HudSectionLabel("Workspace") - HudKVRow("Branch", value: agent.branchLabel) - HudKVRow("Path", value: agent.workspace) - if let selectedChannel { - HudKVRow("cId", value: selectedChannel.cIdShort) - } - } + private var runtime: some View { + VStack(alignment: .leading, spacing: HudSpacing.sm) { + ScoutEyebrow(text: "Runtime") + HudKVRow("Role", value: agent.roleLabel) + HudKVRow("Harness", value: agent.harness?.nilIfEmpty ?? "—") + HudKVRow("Transport", value: agent.transport?.nilIfEmpty ?? "—") + ScoutAgentModelRow(agent: agent) + HudKVRow("Node", value: agent.nodeName?.nilIfEmpty ?? "—") + } + } + + private var workspace: some View { + VStack(alignment: .leading, spacing: HudSpacing.sm) { + ScoutEyebrow(text: "Workspace") + HudKVRow("Branch", value: agent.branchLabel) + HudKVRow("Path", value: agent.workspace) + if let selectedChannel { + HudKVRow("cId", value: selectedChannel.cIdShort) } + } + } - if !agent.capabilities.isEmpty { - HudCard { - VStack(alignment: .leading, spacing: HudSpacing.md) { - HudSectionLabel("Abilities") - ScoutAgentAbilityList(capabilities: agent.capabilities) - } - } + /// Live session block — the only home for Observe. The label and Observe + /// share the top line; the session's real id and last-activity sit below. + private var sessionSection: some View { + VStack(alignment: .leading, spacing: HudSpacing.sm) { + HStack(alignment: .center, spacing: HudSpacing.sm) { + ScoutEyebrow(text: "Session") + Spacer(minLength: HudSpacing.sm) + ScoutObserveChip(action: openObserve) + } + if let sessionId { + HudKVRow("id", value: Self.shortSession(sessionId)) + } + HudKVRow("Active", value: agent.updatedLabel) + } + } + + /// Real session ids are opaque (UUID-ish); show head + tail like the tail + /// view does, so it reads as an id rather than a relay label. + private static func shortSession(_ id: String) -> String { + let trimmed = id.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count > 14 else { return trimmed } + return "\(trimmed.prefix(8))…\(trimmed.suffix(4))" + } + + private var skills: some View { + VStack(alignment: .leading, spacing: HudSpacing.sm) { + ScoutEyebrow(text: "Skills") + ScoutAgentAbilityList(capabilities: specialCapabilities) + } + } +} + +/// Quiet-but-clearly-clickable Observe chip. At rest it reads as a button +/// (hairline border + faint inset), warming to observe-green on hover — +/// present without out-shouting the agent identity above it. +private struct ScoutObserveChip: View { + let action: () -> Void + @State private var hovering = false + + var body: some View { + Button(action: action) { + HStack(spacing: HudSpacing.xs) { + Image(systemName: "eye") + .font(HudFont.ui(10, weight: .semibold)) + Text("OBSERVE") + .font(HudFont.mono(9, weight: .semibold)) + } + .foregroundStyle(hovering ? HudPalette.statusOk : HudPalette.muted) + .padding(.horizontal, HudSpacing.sm) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(hovering ? HudPalette.statusOk.opacity(0.12) : HudSurface.inset) + ) + .overlay( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .stroke(hovering ? HudPalette.statusOk.opacity(0.5) : Color.white.opacity(0.22), lineWidth: HudStrokeWidth.thin) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain).scoutPointerCursor() + .onHover { hovering = $0 } + .help("Observe") + } +} + +/// Unemphasized "New session" link — muted at rest, accent on hover, since +/// continuing a conversation is already the default action in the sidebar. +private struct ScoutNewSessionLink: View { + let action: () -> Void + @State private var hovering = false + + var body: some View { + Button(action: action) { + HStack(spacing: HudSpacing.xs) { + Image(systemName: "plus") + .font(HudFont.ui(9, weight: .bold)) + Text("NEW SESSION") + .font(HudFont.mono(10, weight: .semibold)) } + .foregroundStyle(hovering ? HudPalette.accent : HudPalette.muted) + .contentShape(Rectangle()) + } + .buttonStyle(.plain).scoutPointerCursor() + .onHover { hovering = $0 } + } +} + +/// Lays out every agent in a DM as its own card — side by side when the column +/// is wide enough, otherwise stacked. +private struct ScoutAgentCardStack: View { + let agents: [ScoutAgent] + let selectedChannel: ScoutChannel? + let openObserve: (ScoutAgent) -> Void + let openProfile: (ScoutAgent) -> Void + let startSession: (ScoutAgent) -> Void - HStack { - HudButton("Observe", icon: "eye", style: .primary(.green), action: openObserve) - HudButton("Profile", icon: "person.text.rectangle", style: .secondary, action: openProfile) + var body: some View { + ViewThatFits(in: .horizontal) { + HStack(alignment: .top, spacing: HudSpacing.lg) { + ForEach(agents) { agent in + card(for: agent) + .frame(minWidth: 230, maxWidth: .infinity, alignment: .top) + } + } + VStack(spacing: HudSpacing.lg) { + ForEach(agents) { agent in + card(for: agent) + } } } } + + private func card(for agent: ScoutAgent) -> some View { + ScoutAgentInspector( + agent: agent, + selectedChannel: selectedChannel, + openObserve: { openObserve(agent) }, + openProfile: { openProfile(agent) }, + startSession: { _ in startSession(agent) } + ) + } } private struct ScoutAgentPreviewPanel: View { @@ -2348,6 +3169,7 @@ private struct ScoutAgentPreviewPanel: View { let onClose: () -> Void let openObserve: () -> Void let openProfile: () -> Void + let startSession: (ScoutSessionDraft.Mode) -> Void var body: some View { VStack(spacing: 0) { @@ -2359,7 +3181,8 @@ private struct ScoutAgentPreviewPanel: View { agent: agent, selectedChannel: selectedChannel, openObserve: openObserve, - openProfile: openProfile + openProfile: openProfile, + startSession: startSession ) .padding(HudSpacing.xl) .frame(maxWidth: .infinity, alignment: .leading) @@ -2398,7 +3221,7 @@ private struct ScoutAgentPreviewPanel: View { Image(systemName: "sidebar.right") .font(HudFont.ui(12, weight: .semibold)) } - .buttonStyle(.plain) + .buttonStyle(.plain).scoutPointerCursor() .foregroundStyle(HudPalette.muted) .frame(width: 28, height: 28) .contentShape(Rectangle()) @@ -2412,20 +3235,15 @@ private struct ScoutAgentModelRow: View { let agent: ScoutAgent var body: some View { - VStack(alignment: .leading, spacing: HudSpacing.xs) { - HudKVRow( - "Model", - value: agent.modelDisplayValue, - valueColor: agent.model?.nilIfEmpty == nil ? HudPalette.muted : HudPalette.ink - ) - if let note = agent.modelDisplayNote { - Text(note) - .font(HudFont.mono(HudTextSize.xxs)) - .foregroundStyle(HudPalette.dim) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .trailing) - } - } + // An unset model is conveyed by the muted "Default" value alone; the + // "why" lives in a tooltip rather than a wrapping sentence that ate two + // lines of the card. + HudKVRow( + "Model", + value: agent.modelDisplayValue, + valueColor: agent.model?.nilIfEmpty == nil ? HudPalette.muted : HudPalette.ink + ) + .help(agent.modelDisplayNote ?? agent.modelDisplayValue) } } @@ -2587,11 +3405,10 @@ private struct ScoutChannelInspector: View { VStack(alignment: .leading, spacing: HudSpacing.xl) { HudCard { VStack(alignment: .leading, spacing: HudSpacing.md) { - HudSectionLabel("Channel") - Text(channel.displayTitle) + HudSectionLabel(channel.scope == .direct ? "Direct message" : "Channel") + Text(channel.displayHandle) .font(HudFont.ui(18, weight: .semibold)) .foregroundStyle(HudPalette.ink) - HudBadge(channel.scope.label, tint: channel.scope == .direct ? HudPalette.statusInfo : HudPalette.statusOk) } } diff --git a/apps/macos/Sources/Scout/ScoutScrollStyle.swift b/apps/macos/Sources/Scout/ScoutScrollStyle.swift index 2c8b1a5c..9289c721 100644 --- a/apps/macos/Sources/Scout/ScoutScrollStyle.swift +++ b/apps/macos/Sources/Scout/ScoutScrollStyle.swift @@ -5,54 +5,76 @@ import AppKit #if os(macOS) -/// A thin, HUD-coherent overlay scroller. Subtly tinted, slot-less knob that -/// hugs the trailing edge so Scout's scroll areas read as intentional chrome -/// rather than the raw system scroller. +enum ScoutScrollbarMetrics { + /// Width of the reserved scroller lane (content is inset by this). + static let laneWidth: CGFloat = 12 + /// Thickness of the knob/track pill within the lane. + static let pillThickness: CGFloat = 6 + /// Inset of the pill from the ends of the track. + static let pillInset: CGFloat = 2 + static let knobAlpha: CGFloat = 0.34 + static let trackAlpha: CGFloat = 0.07 +} + +/// A slim, HUD-coherent scroller. Draws a persistent faint track plus a brighter +/// rounded knob so it's always clear a scroll area exists, while staying tight to +/// the panel edge via a narrow reserved lane. final class ScoutHudScroller: NSScroller { override class var isCompatibleWithOverlayScrollers: Bool { true } + /// Keep the reserved lane narrow so content sits tight to the divider/border. + override class func scrollerWidth( + for controlSize: NSControl.ControlSize, + scrollerStyle: NSScroller.Style + ) -> CGFloat { + ScoutScrollbarMetrics.laneWidth + } + override func drawKnobSlot(in slotRect: NSRect, highlight flag: Bool) { - // Slot-less: keep the bar minimal so it floats over HUD chrome. + let pill = pillRect(in: slotRect) + let radius = min(pill.width, pill.height) / 2 + NSColor.white.withAlphaComponent(ScoutScrollbarMetrics.trackAlpha).setFill() + NSBezierPath(roundedRect: pill, xRadius: radius, yRadius: radius).fill() } override func drawKnob() { let knobRect = rect(for: .knob) guard knobRect.width > 0, knobRect.height > 0 else { return } + let pill = pillRect(in: knobRect) + let radius = min(pill.width, pill.height) / 2 + NSColor.white.withAlphaComponent(ScoutScrollbarMetrics.knobAlpha).setFill() + NSBezierPath(roundedRect: pill, xRadius: radius, yRadius: radius).fill() + } - // Pull the knob a hair off the very edge and slim it down. - let thickness: CGFloat = 4 - let inset: CGFloat = 2 - let drawRect: NSRect - if knobRect.width >= knobRect.height { - // Horizontal scroller. - drawRect = NSRect( - x: knobRect.minX + inset, - y: knobRect.maxY - thickness - inset, - width: max(knobRect.width - inset * 2, thickness), - height: thickness + /// Slim pill centered within the lane, inset from the track ends. + private func pillRect(in rect: NSRect) -> NSRect { + let thickness = ScoutScrollbarMetrics.pillThickness + let inset = ScoutScrollbarMetrics.pillInset + let vertical = bounds.height >= bounds.width + if vertical { + return NSRect( + x: rect.midX - thickness / 2, + y: rect.minY + inset, + width: thickness, + height: max(rect.height - inset * 2, thickness) ) } else { - // Vertical scroller. - drawRect = NSRect( - x: knobRect.maxX - thickness - inset, - y: knobRect.minY + inset, - width: thickness, - height: max(knobRect.height - inset * 2, thickness) + return NSRect( + x: rect.minX + inset, + y: rect.midY - thickness / 2, + width: max(rect.width - inset * 2, thickness), + height: thickness ) } - - let radius = thickness / 2 - let path = NSBezierPath(roundedRect: drawRect, xRadius: radius, yRadius: radius) - NSColor.white.withAlphaComponent(0.22).setFill() - path.fill() } } /// Invisible AppKit probe that restyles the enclosing `NSScrollView`'s /// scrollers. SwiftUI otherwise honours the user's "Show scroll bars" setting, -/// which can render wide legacy scrollers that sit far from the panel edge in a -/// gray gutter. Forcing the overlay style + a slim tinted knob keeps every Scout -/// scroll area tight to its divider/border and visually consistent. +/// which can render wide gray legacy scrollers or auto-hiding overlay scrollers +/// that give no persistent hint the area scrolls. We pin a slim legacy-style +/// scroller (always visible while scrollable, with a faint track) so every Scout +/// scroll area reads as deliberate HUD chrome and stays tight to its edge. private struct ScoutScrollerStyler: NSViewRepresentable { func makeNSView(context: Context) -> ProbeView { ProbeView() } @@ -69,26 +91,29 @@ private struct ScoutScrollerStyler: NSViewRepresentable { func applyStyle() { DispatchQueue.main.async { [weak self] in guard let scrollView = self?.enclosingScrollView else { return } - scrollView.scrollerStyle = .overlay + // Legacy style keeps the bar persistently visible while the area + // is scrollable, instead of fading like overlay scrollers. + scrollView.scrollerStyle = .legacy + scrollView.autohidesScrollers = true scrollView.scrollerInsets = NSEdgeInsetsZero scrollView.drawsBackground = false if !(scrollView.verticalScroller is ScoutHudScroller) { let scroller = ScoutHudScroller() - scroller.scrollerStyle = .overlay + scroller.scrollerStyle = .legacy scrollView.verticalScroller = scroller } - scrollView.verticalScroller?.scrollerStyle = .overlay - scrollView.horizontalScroller?.scrollerStyle = .overlay + scrollView.verticalScroller?.scrollerStyle = .legacy + scrollView.horizontalScroller?.scrollerStyle = .legacy } } } } extension View { - /// Apply Scout's HUD overlay scrollbar treatment. Attach to the content - /// *inside* a `ScrollView` so the probe can resolve the enclosing scroll - /// view. Pairs with `.scrollIndicators(.visible)` on the `ScrollView`. + /// Apply Scout's HUD scrollbar treatment. Attach to the content *inside* a + /// `ScrollView` so the probe can resolve the enclosing scroll view. Pairs + /// with `.scrollIndicators(.visible)` on the `ScrollView`. func scoutOverlayScrollers() -> some View { background( ScoutScrollerStyler() diff --git a/apps/macos/Sources/Scout/ScoutSessionService.swift b/apps/macos/Sources/Scout/ScoutSessionService.swift new file mode 100644 index 00000000..5c0610fc --- /dev/null +++ b/apps/macos/Sources/Scout/ScoutSessionService.swift @@ -0,0 +1,448 @@ +import HudsonShell +import HudsonUI +import ScoutNativeCore +import ScoutSharedUI +import SwiftUI +#if os(macOS) +import AppKit +#endif + +// MARK: - Network spec / result + +/// Flexible session-initiation request. Mirrors `POST /api/sessions`: every +/// modality (new conversation in a project, "same agent" fresh, continue an +/// agent's session with full context, seed-from-message) is expressed by which +/// fields are set rather than by a dedicated endpoint. +struct SessionInitiationSpec { + enum Session: String { case new, existing, any } + + var targetAgentId: String? + var projectPath: String? + var harness: String? + var model: String? + var session: Session? + var targetSessionId: String? + var persistence: String? + var agentName: String? + var displayName: String? + var instructions: String? + var fromMessageId: String? + var fromConversationId: String? + + func jsonBody() -> [String: Any] { + var target: [String: Any] = [:] + if let targetAgentId { target["agentId"] = targetAgentId } + if let projectPath { target["projectPath"] = projectPath } + + var execution: [String: Any] = [:] + if let harness { execution["harness"] = harness } + if let model { execution["model"] = model } + if let session { execution["session"] = session.rawValue } + if let targetSessionId { execution["targetSessionId"] = targetSessionId } + + var agent: [String: Any] = [:] + if let persistence { agent["persistence"] = persistence } + if let agentName { agent["name"] = agentName } + if let displayName { agent["displayName"] = displayName } + + var seed: [String: Any] = [:] + if let instructions, !instructions.isEmpty { seed["instructions"] = instructions } + if let fromMessageId { seed["fromMessageId"] = fromMessageId } + if let fromConversationId { seed["fromConversationId"] = fromConversationId } + + var body: [String: Any] = [:] + if !target.isEmpty { body["target"] = target } + if !execution.isEmpty { body["execution"] = execution } + if !agent.isEmpty { body["agent"] = agent } + if !seed.isEmpty { body["seed"] = seed } + return body + } +} + +struct SessionInitiationResult: Decodable { + let ok: Bool? + let conversationId: String? + let agentId: String? + let flightId: String? + let messageId: String? +} + +enum SessionInitiationError: LocalizedError { + case invalidResponse + case httpStatus(Int, String) + + var errorDescription: String? { + switch self { + case .invalidResponse: + return "Scout returned an invalid response." + case .httpStatus(let status, let message): + return message.isEmpty ? "Scout returned HTTP \(status)." : message + } + } +} + +enum SessionInitiationService { + static func start(_ spec: SessionInitiationSpec) async throws -> SessionInitiationResult { + let url = ScoutWeb.baseURL().appending(path: "api/sessions") + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: spec.jsonBody()) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw SessionInitiationError.invalidResponse + } + guard (200..<300).contains(http.statusCode) else { + throw SessionInitiationError.httpStatus(http.statusCode, Self.decodeError(data)) + } + return try JSONDecoder().decode(SessionInitiationResult.self, from: data) + } + + private static func decodeError(_ data: Data) -> String { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let message = object["error"] as? String else { + return "" + } + return message + } +} + +// MARK: - Composer draft + +/// In-flight composer state shared by every entry point (the "+" in the +/// conversation list, a message context menu, the agent inspector). The entry +/// point configures the draft; the composer builds the `SessionInitiationSpec`. +struct ScoutSessionDraft: Identifiable { + enum Mode: Hashable { case fresh, continueContext } + enum Target { + case agent(ScoutAgent) + case project + } + + let id = UUID() + var title: String + var target: Target + var projectPath: String + var mode: Mode + var instructions: String + var fromMessageId: String? + var fromConversationId: String? + + var agent: ScoutAgent? { + if case let .agent(agent) = target { return agent } + return nil + } + + /// Whether continuing the same harness session (full context) is possible — + /// requires the agent to expose a resolvable session id. + var canContinue: Bool { + agent?.harnessSessionId?.nilIfEmpty != nil + } +} + +// MARK: - Composer + +/// Modal sheet that turns a `ScoutSessionDraft` into a session-initiation call. +/// Renders its own dimmed backdrop so the host only needs `if let draft`. +struct ScoutSessionComposer: View { + let onClose: () -> Void + let onComplete: (SessionInitiationResult) -> Void + + @State private var draft: ScoutSessionDraft + @State private var isSubmitting = false + @State private var errorText: String? + @FocusState private var instructionsFocused: Bool + @ObservedObject private var vox = ScoutVoxService.shared + + init( + draft: ScoutSessionDraft, + onClose: @escaping () -> Void, + onComplete: @escaping (SessionInitiationResult) -> Void + ) { + self.onClose = onClose + self.onComplete = onComplete + _draft = State(initialValue: draft) + } + + var body: some View { + ZStack { + Color.black.opacity(0.42) + .ignoresSafeArea() + .onTapGesture { if !isSubmitting { onClose() } } + + card + .frame(width: 460) + .padding(HudSpacing.xxl) + } + .onExitCommand { if !isSubmitting { onClose() } } + .onReceive(vox.$lastFinalText) { spliceDictatedFinal($0) } + } + + private var isDictating: Bool { vox.state.isCaptureActive } + + private var showDictationPreview: Bool { + draft.instructions.isEmpty && (vox.state.isCaptureActive || vox.state.isProcessing) + } + + private var messagePlaceholder: String { + switch draft.target { + case .agent(let agent): + return draft.mode == .continueContext + ? "Message \(agent.displayName)…" + : "What should \(agent.displayName) start on?" + case .project: + return "What should the new agent start on?" + } + } + + private func toggleDictation() { + instructionsFocused = true + Task { + switch ScoutDictationController.toggleDecision(for: vox.state) { + case .probeThenStartIfIdle: + await vox.probe() + if case .idle = vox.state { vox.start() } + case .start: + vox.start() + case .stop: + vox.stop() + case .ignore: + break + } + } + } + + private func spliceDictatedFinal(_ text: String) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + draft.instructions = ScoutDictationBuffer.appending(trimmed, to: draft.instructions) + ScoutVoxService.shared.consumeFinalText() + instructionsFocused = true + } + + private var card: some View { + VStack(alignment: .leading, spacing: HudSpacing.xl) { + header + targetSection + instructionsSection + if let errorText { + Text(errorText) + .font(HudFont.mono(10)) + .foregroundStyle(HudPalette.accent) + .fixedSize(horizontal: false, vertical: true) + } + footer + } + .padding(HudSpacing.xxl) + .background( + RoundedRectangle(cornerRadius: HudRadius.card, style: .continuous) + .fill(HudPalette.surface) + ) + .overlay( + RoundedRectangle(cornerRadius: HudRadius.card, style: .continuous) + .stroke(HudHairline.standard, lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.35), radius: 30, y: 12) + } + + private var header: some View { + VStack(alignment: .leading, spacing: HudSpacing.xs) { + Text(draft.title) + .font(HudFont.ui(16, weight: .semibold)) + .foregroundStyle(HudPalette.ink) + Text(subtitle) + .font(HudFont.mono(10)) + .foregroundStyle(HudPalette.dim) + .lineLimit(1) + } + } + + private var subtitle: String { + switch draft.target { + case .agent(let agent): + return draft.mode == .continueContext + ? "Continue \(agent.displayName) with full context" + : "Fresh session with \(agent.displayName)" + case .project: + return "Start a new agent in a project" + } + } + + @ViewBuilder + private var targetSection: some View { + switch draft.target { + case .agent(let agent): + VStack(alignment: .leading, spacing: HudSpacing.md) { + HStack(spacing: HudSpacing.md) { + Image(systemName: "person.crop.circle") + .font(HudFont.ui(12, weight: .semibold)) + .foregroundStyle(HudPalette.accent) + Text(agent.displayName) + .font(HudFont.ui(12, weight: .semibold)) + .foregroundStyle(HudPalette.ink) + Text(agent.detail) + .font(HudFont.mono(9)) + .foregroundStyle(HudPalette.dim) + .lineLimit(1) + } + if draft.canContinue { + modePicker + } + } + case .project: + HudField("Project path", text: $draft.projectPath, icon: "folder") + } + } + + private var modePicker: some View { + HStack(spacing: HudSpacing.xs) { + modeButton(.fresh, title: "Fresh session", icon: "plus.bubble") + modeButton(.continueContext, title: "Continue (full context)", icon: "arrow.uturn.forward") + } + .padding(3) + .background(RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous).fill(HudSurface.inset)) + .overlay(RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous).stroke(HudHairline.standard, lineWidth: HudStrokeWidth.thin)) + } + + private func modeButton(_ mode: ScoutSessionDraft.Mode, title: String, icon: String) -> some View { + Button { + draft.mode = mode + } label: { + HStack(spacing: HudSpacing.xs) { + Image(systemName: icon) + .font(HudFont.ui(10, weight: .semibold)) + Text(title) + .font(HudFont.mono(9, weight: .semibold)) + } + .foregroundStyle(draft.mode == mode ? HudPalette.ink : HudPalette.muted) + .frame(maxWidth: .infinity) + .frame(height: 26) + .background( + RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous) + .fill(draft.mode == mode ? HudSurface.selected(HudPalette.accent) : Color.clear) + ) + } + .buttonStyle(.plain).scoutPointerCursor() + } + + private var instructionsSection: some View { + VStack(alignment: .leading, spacing: HudSpacing.xs) { + HudSectionLabel(draft.mode == .continueContext ? "Follow-up message" : "First message") + messageWell + } + } + + private var messageWell: some View { + HStack(alignment: .bottom, spacing: HudSpacing.sm) { + ZStack(alignment: .topLeading) { + if draft.instructions.isEmpty && !showDictationPreview { + Text(messagePlaceholder) + .font(HudFont.ui(12)) + .foregroundStyle(HudPalette.dim) + .padding(.horizontal, 5) + .padding(.vertical, 8) + .allowsHitTesting(false) + } + + TextEditor(text: $draft.instructions) + .font(HudFont.ui(12)) + .foregroundStyle(HudPalette.ink) + .tint(showDictationPreview ? Color.clear : HudPalette.accent) + .scrollContentBackground(.hidden) + .focused($instructionsFocused) + .frame(minHeight: 64, maxHeight: 132) + + if showDictationPreview { + ScoutDictationPreview(text: vox.partial) + .padding(.horizontal, 5) + .padding(.vertical, 8) + .allowsHitTesting(false) + } + } + + ScoutMicButton(box: 30, glyph: 14, action: toggleDictation) + .padding(.bottom, 2) + } + .padding(6) + .background( + RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous) + .fill(HudSurface.inset) + ) + .overlay( + RoundedRectangle(cornerRadius: HudRadius.standard, style: .continuous) + .stroke(instructionsFocused ? HudSurface.tintBorder(HudPalette.accent) : HudHairline.standard, lineWidth: HudStrokeWidth.thin) + ) + } + + private var footer: some View { + HStack { + HudButton("Cancel", style: .ghost) { onClose() } + .disabled(isSubmitting) + Spacer() + if isSubmitting { + ProgressView().controlSize(.small) + } + HudButton(startTitle, icon: "paperplane.fill", style: .primary(.green)) { + submit() + } + .disabled(isSubmitting || !canSubmit) + } + } + + private var startTitle: String { + draft.mode == .continueContext ? "Continue" : "Start" + } + + private var canSubmit: Bool { + switch draft.target { + case .agent: + if draft.mode == .continueContext { return draft.canContinue } + return true + case .project: + return !draft.projectPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } + + private func makeSpec() -> SessionInitiationSpec { + var spec = SessionInitiationSpec() + spec.persistence = "sticky" + let trimmed = draft.instructions.trimmingCharacters(in: .whitespacesAndNewlines) + spec.instructions = trimmed.isEmpty ? nil : trimmed + spec.fromMessageId = draft.fromMessageId + spec.fromConversationId = draft.fromConversationId + + switch draft.target { + case .agent(let agent): + spec.targetAgentId = agent.id + spec.agentName = agent.name.nilIfEmpty + if draft.mode == .continueContext { + spec.session = .existing + spec.targetSessionId = agent.harnessSessionId?.nilIfEmpty + } else { + spec.session = .new + } + case .project: + spec.projectPath = draft.projectPath.trimmingCharacters(in: .whitespacesAndNewlines) + spec.session = .new + } + return spec + } + + private func submit() { + guard !isSubmitting, canSubmit else { return } + isSubmitting = true + errorText = nil + let spec = makeSpec() + Task { + do { + let result = try await SessionInitiationService.start(spec) + isSubmitting = false + onComplete(result) + } catch { + isSubmitting = false + errorText = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + } + } + } +} diff --git a/apps/macos/Sources/Scout/ScoutTailView.swift b/apps/macos/Sources/Scout/ScoutTailView.swift index a21ac0c2..75ae0ff0 100644 --- a/apps/macos/Sources/Scout/ScoutTailView.swift +++ b/apps/macos/Sources/Scout/ScoutTailView.swift @@ -540,7 +540,7 @@ private struct ScoutTailRow: View { } .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.plain).scoutPointerCursor() .onHover { isHovering = $0 } .contextMenu { Button("Copy event ID") { @@ -779,7 +779,7 @@ private struct ScoutTailFilterMenu: View { ) } .menuStyle(.borderlessButton) - .buttonStyle(.plain) + .buttonStyle(.plain).scoutPointerCursor() .onHover { isHovering = $0 } .animation(.easeOut(duration: 0.10), value: isHovering) } @@ -813,7 +813,7 @@ private struct ScoutTailToolbarButton: View { .stroke(isActive ? HudSurface.tintBorder(HudPalette.accent) : ScoutDesign.hairline, lineWidth: HudStrokeWidth.standard) ) } - .buttonStyle(.plain) + .buttonStyle(.plain).scoutPointerCursor() .help(title) .onHover { isHovering = $0 } .animation(.easeOut(duration: 0.10), value: isHovering) @@ -842,7 +842,7 @@ private struct ScoutTailIconButton: View { .stroke(isHovering ? ScoutDesign.hairlineStrong : ScoutDesign.hairline, lineWidth: HudStrokeWidth.standard) ) } - .buttonStyle(.plain) + .buttonStyle(.plain).scoutPointerCursor() .help(title) .accessibilityLabel(title) .onHover { isHovering = $0 } diff --git a/design/studio/app/studies/agent-inspector-card/page.tsx b/design/studio/app/studies/agent-inspector-card/page.tsx new file mode 100644 index 00000000..6ba9d9b1 --- /dev/null +++ b/design/studio/app/studies/agent-inspector-card/page.tsx @@ -0,0 +1,338 @@ +import { Fragment } from "react"; + +/** + * Agent Inspector Card — study (locked direction: variant C, refined). + * + * The per-agent card in the Scout macOS sidebar inspector. A DM shows one + * card per participant. Settled design: + * + * · no "AVAILABLE" tag — state rides the presence dot on the avatar + * · the whole identity header is clickable → opens the agent's profile + * · the card is ONE cohesive concept with internal sections + * · Observe lives WITH the live session (you observe a session), not + * floating top-right — it only appears when there's a session to watch + * · "New session" is a quiet inline link at the foot (continuing is the + * default action elsewhere, so it stays unemphasized) + * · Observe / New session read as buttons at rest but never out-shout + * the agent identity + * + * Ports to: apps/macos/Sources/Scout/ScoutRootView.swift (ScoutAgentInspector) + */ + +type AgentState = "working" | "available" | "needs-attention" | "idle" | "offline"; + +interface InspectorAgent { + name: string; + id: string; + state: AgentState; + role: string; + harness: string; + transport: string; + model: string; + node: string; + branch: string; + path: string; + cid: string; + session?: { id: string; started: string }; + skills?: string[]; +} + +const STATE_COLOR: Record = { + working: "var(--status-warn-fg)", + "needs-attention": "var(--status-error-fg)", + available: "var(--status-ok-fg)", + idle: "var(--scout-accent)", + offline: "var(--studio-ink-faint)", +}; + +const DEWEY: InspectorAgent = { + name: "Dewey", + id: "dewey.main.arts-mac-mini-local", + state: "available", + role: "Relay agent", + harness: "claude", + transport: "claude_stream_json", + model: "—", + node: "arts-mac-mini-local", + branch: "main", + path: "~/dev/dewey", + cid: "c.960f31ec", + session: { id: "3e9c6337-7aec-4367-b43d-291c873fd60e", started: "6m" }, + skills: ["docs.audit", "docs.score"], +}; + +const SCOUT: InspectorAgent = { + name: "Scout", + id: "scoutbot", + state: "working", + role: "Relay agent", + harness: "codex", + transport: "codex_app_server", + model: "gpt-5.5", + node: "arts-mac-mini-local", + branch: "main", + path: "~/dev/openscout", + cid: "c.960f31ec", + session: { id: "0199a2f1-8c4d-7b2a-9e10-4f6db8c1a233", started: "2m" }, +}; + +const ATLAS: InspectorAgent = { + name: "Atlas", + id: "atlas.main.arts-mac-mini-local", + state: "idle", + role: "Session agent", + harness: "claude", + transport: "claude_stream_json", + model: "opus-4.7", + node: "arts-mac-mini-local", + branch: "design/atlas-iconography", + path: "~/dev/atlas", + cid: "c.7f21aa0c", + // no session → no Observe row +}; + +function avatarColor(_name: string): string { + return "oklch(0.42 0.008 80)"; +} + +export default function AgentInspectorCardPage() { + return ( +
+
+
+ · studies · macos · agent-inspector-card +
+

+ Agent inspector card +

+

+ The per-agent card in the Scout sidebar. No AVAILABLE{" "} + tag (state rides the dot), header click opens the profile, the + card is one cohesive unit. Observe lives with the + live session — it only shows when there's a session to + watch. New session is a quiet inline link. Rendered at + the real inspector width (~300px). +

+
+ +
+ + + + + + +
+ +

+ In a DM — two cards stacked +

+

+ Dewey {"<>"} Scout, stacked in the narrow inspector column. +

+
+ + +
+
+ ); +} + +function Labeled({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
+ {label} +
+ {children} +
+ ); +} + +/* ── The card ───────────────────────────────────────────────────── */ + +function AgentCard({ agent }: { agent: InspectorAgent }) { + return ( +
+ + +
+ +
+ {agent.session ? ( + <> + + + + ) : null} + {agent.skills?.length ? ( + <> + + + + ) : null} + +
+ ); +} + +/** Clickable identity header → profile. State rides the presence dot only. */ +function CardHeader({ agent }: { agent: InspectorAgent }) { + const stateColor = STATE_COLOR[agent.state]; + return ( + + ); +} + +/** Live session block — the only home for Observe. */ +function SessionSection({ session }: { session: NonNullable }) { + return ( +
+
+
+ Session +
+ +
+
+ id + + {session.id.slice(0, 8)} + …{session.id.slice(-4)} + + started + {session.started} ago +
+
+ ); +} + +/** Reads as a button at rest (hairline border + faint inset), muted; warms + * to observe-green on hover so it never out-shouts the identity. */ +function ObserveButton() { + return ( + + ); +} + +function NewSessionLink() { + return ( + + ); +} + +function Section({ label, rows }: { label: string; rows: [string, string][] }) { + return ( +
+
+ {label} +
+
+ {rows.map(([k, v]) => ( + + {k} + {v} + + ))} +
+
+ ); +} + +function Skills({ skills }: { skills: string[] }) { + return ( +
+
+ Skills +
+
+ {skills.map((s) => ( + + {s} + + ))} +
+
+ ); +} + +function Divider() { + return
; +} + +/* ── Icons ──────────────────────────────────────────────────────── */ + +function EyeIcon() { + return ( + + + + + ); +} + +function PlusIcon() { + return ( + + + + ); +} diff --git a/design/studio/lib/studio-pages.ts b/design/studio/lib/studio-pages.ts index 05cf0f7a..5c7df99c 100644 --- a/design/studio/lib/studio-pages.ts +++ b/design/studio/lib/studio-pages.ts @@ -147,6 +147,16 @@ export const STUDIO_PAGES: StudioPage[] = [ source: ["packages/web/client/scout/inspector/AgentsInspector.tsx"], blurb: "Info-dense agent tile — identity · state · task · project · capabilities.", }, + { + href: "/studies/agent-inspector-card", + label: "Agent Inspector Card", + bucket: "studies", + surface: "macos", + family: "agent-cards", + status: "draft", + source: ["apps/macos/Sources/Scout/ScoutRootView.swift"], + blurb: "Per-agent sidebar card — no AVAILABLE tag, header→profile, Observe top-right, New-session CTA explored three ways.", + }, { href: "/studies/session-search", label: "Session Search", diff --git a/docs/eng/sco-056-managed-process-adapter-contract.md b/docs/eng/sco-056-managed-process-adapter-contract.md index 7c9cf08e..b2acf7fe 100644 --- a/docs/eng/sco-056-managed-process-adapter-contract.md +++ b/docs/eng/sco-056-managed-process-adapter-contract.md @@ -52,6 +52,12 @@ exists. Scout should still define its own adapter boundary so ACP, MCP stdio, OpenAI-compatible local processes, and Scout-native JSONL processes can be handled consistently. +Current implementation note: `@openscout/agent-sessions` includes a concrete +ACP stdio client adapter for launching ACP agents as subprocess-backed sessions. +This proposal still defines the broader managed-process profile and runtime +contract that should make ACP, MCP stdio, and Scout-native process adapters +inspectable through one common configuration surface. + ## Principles 1. The broker does not spawn arbitrary processes directly; runtime adapters do. @@ -215,8 +221,8 @@ approval. 5. Map protocol updates into SCO-042 observed events. 6. Add permission prompt ingress into durable unblock requests where the protocol supports it. -7. Add ACP stdio as an adapter profile only after the generic contract is - proven. +7. Align the existing ACP stdio adapter with the managed-process profile once + the generic contract is proven. ## Acceptance Criteria diff --git a/landing/public/mac/hud-agents-roster.png b/landing/public/mac/hud-agents-roster.png new file mode 100644 index 00000000..dc8b499f Binary files /dev/null and b/landing/public/mac/hud-agents-roster.png differ diff --git a/landing/src/app/globals.css b/landing/src/app/globals.css index 2b0cdad8..83a610bc 100644 --- a/landing/src/app/globals.css +++ b/landing/src/app/globals.css @@ -1,66 +1,95 @@ @import "tailwindcss"; :root { - --background: #08090a; - --foreground: #f0f0f0; - --muted: #919191; - --secondary: #b5b5b5; - --accent: #3dacff; - --accent-soft: #3dacff18; - --border: #1e1f22; - --border-strong: #303136; - --surface: #0e0f11; - --surface-elevated: #171819; - --shell-bg: #f4f6f8; - --shell-panel: rgba(247, 249, 252, 0.9); - --shell-ink: #252a31; - --shell-copy: #464c55; - --shell-dim: #7e8793; - --shell-muted: #b6bcc5; - --shell-line: rgba(15, 23, 42, 0.09); - --shell-line-strong: rgba(15, 23, 42, 0.16); - --shell-accent: #5b84ff; - --site-page-bg: #f5f1ea; - --site-page-bg-strong: rgba(245, 241, 234, 0.95); - --site-docs-bg: #f7f3ec; - --site-docs-bg-strong: rgba(247, 243, 236, 0.95); - --site-surface: rgba(255, 253, 248, 0.96); - --site-surface-strong: #fffdf8; - --site-panel: #faf6ee; - --site-panel-muted: #ede7db; - --site-panel-rail: #f4eee2; - --site-border: #d9cfb9; - --site-border-strong: #c8bea4; - --site-border-soft: rgba(26, 22, 18, 0.10); - --site-ink: #1a1612; - --site-copy: #3b342b; - --site-muted: #6b6356; - --site-muted-soft: #968b79; - --site-accent: #b5421c; - --site-accent-soft: rgba(181, 66, 28, 0.08); - --site-accent-soft-strong: #f7e9d6; - --site-accent-border: #e8d2b1; - --site-ink-contrast: #f5f4ef; - --site-ink-hover: #2a2a28; - --site-overlay: rgba(17, 17, 16, 0.72); - --site-progress: rgba(17, 17, 16, 0.2); - --site-grid: #c8c4ba; - --site-glow-a: rgba(168, 85, 28, 0.05); - --site-glow-b: rgba(168, 85, 28, 0.08); - --site-glow-c: rgba(212, 140, 70, 0.06); - --site-status-online: #2f8a4f; - --site-status-online-soft: rgba(47, 138, 79, 0.18); - --site-card-shadow: - 0 2px 8px rgba(17, 17, 16, 0.03); - --site-card-shadow-hover: - 0 4px 12px rgba(17, 17, 16, 0.06), - 0 20px 48px rgba(17, 17, 16, 0.08); - --site-panel-shadow: - 0 1px 2px rgba(17, 17, 16, 0.04), - 0 24px 80px rgba(17, 17, 16, 0.08); - --site-toggle-shadow: - 0 16px 40px rgba(17, 17, 16, 0.16); - --site-danger: #b2442e; + /* ── Basel — reductive system: near-monochrome on white, one grotesque, + a single rationed red. Token names are kept stable so the whole site + re-themes from this block; every legacy "warm/RFC" value is gone. ── */ + + /* Monochrome ramp — paper → near-black ink */ + --paper: oklch(0.993 0 0); + --paper-2: oklch(0.972 0 0); + --paper-3: oklch(0.940 0 0); + --ink: oklch(0.205 0 0); + --ink-2: oklch(0.400 0 0); + --ink-3: oklch(0.560 0 0); + --ink-faint: oklch(0.720 0 0); + --line: oklch(0.885 0 0); /* hairline divider */ + --line-soft: oklch(0.925 0 0); /* faintest divider */ + --rule: oklch(0.205 0 0); /* heavy structural rule = ink */ + + /* The one chromatic colour — spent once per view */ + --red: oklch(0.575 0.218 27); + --red-soft: oklch(0.575 0.218 27 / 0.10); + + /* One family. All historical font slots resolve to Archivo. */ + --font-geist-sans: var(--font-archivo), system-ui, sans-serif; + --font-geist-mono: var(--font-archivo), system-ui, sans-serif; + --font-display: var(--font-archivo), system-ui, sans-serif; + --font-mono-display: var(--font-archivo), system-ui, sans-serif; + --font-spectral: var(--font-archivo), system-ui, sans-serif; + + /* Global / Tailwind theme tokens */ + --background: var(--paper); + --foreground: var(--ink); + --muted: var(--ink-3); + --secondary: var(--ink-2); + --accent: var(--red); + --accent-soft: var(--red-soft); + --border: var(--line); + --border-strong: var(--rule); + --surface: var(--paper); + --surface-elevated: var(--paper-2); + + /* App-shell demo surfaces (broker console, etc.) */ + --shell-bg: var(--paper-2); + --shell-panel: var(--paper); + --shell-ink: var(--ink); + --shell-copy: var(--ink-2); + --shell-dim: var(--ink-3); + --shell-muted: var(--ink-faint); + --shell-line: var(--line); + --shell-line-strong: var(--rule); + --shell-accent: var(--red); + + /* Marketing-site surfaces */ + --site-page-bg: var(--paper); + --site-page-bg-strong: var(--paper); + --site-docs-bg: var(--paper-2); + --site-docs-bg-strong: var(--paper-2); + --site-surface: var(--paper); + --site-surface-strong: var(--paper); + --site-panel: var(--paper-2); + --site-panel-muted: var(--paper-2); + --site-panel-rail: var(--paper-2); + --site-border: var(--line); + --site-border-strong: var(--rule); + --site-border-soft: var(--line-soft); + --site-ink: var(--ink); + --site-copy: var(--ink-2); + --site-muted: var(--ink-3); + --site-muted-soft: var(--ink-faint); + --site-accent: var(--red); + --site-accent-soft: var(--red-soft); + --site-accent-soft-strong: oklch(0.575 0.218 27 / 0.16); + --site-accent-border: oklch(0.575 0.218 27 / 0.40); + --site-ink-contrast: var(--paper); + --site-ink-hover: oklch(0 0 0); + --site-overlay: oklch(0.205 0 0 / 0.72); + --site-progress: oklch(0.205 0 0 / 0.20); + --site-grid: var(--line); + /* No glows in Basel */ + --site-glow-a: transparent; + --site-glow-b: transparent; + --site-glow-c: transparent; + /* Idle status is neutral ink — red is reserved for the active mark */ + --site-status-online: var(--ink-3); + --site-status-online-soft: oklch(0.560 0 0 / 0.18); + /* No shadows in Basel — separation comes from hairlines + whitespace */ + --site-card-shadow: none; + --site-card-shadow-hover: none; + --site-panel-shadow: none; + --site-toggle-shadow: none; + --site-danger: var(--red); } html[data-site-theme="light"] { @@ -150,8 +179,8 @@ body { } ::selection { - background: #2a57cb; - color: white; + background: var(--red); + color: #fff; } /* ── entrance animations ── */ @@ -479,7 +508,7 @@ html[data-site-theme="dark"] .dot-grid { color: var(--site-ink); background: var(--site-panel-muted); border: 1px solid var(--site-border); - border-radius: 6px; + border-radius: 9px; cursor: pointer; transition: border-color 0.18s ease, background-color 0.18s ease; } @@ -532,7 +561,7 @@ html[data-site-theme="dark"] .dot-grid { .rfc-hero__viewer-row { display: flex; justify-content: flex-end; - margin-bottom: 1.5rem; + margin-bottom: 0.5rem; } .rfc-hero__title--full { @@ -609,6 +638,62 @@ html[data-site-theme="dark"] .dot-grid { color: var(--site-ink); } +/* Hero masthead — RFC-style header fields grounding the editorial column so it + carries weight down the page beside the taller live console. Definition list: + uppercase key column, dot-delimited values, hairline-ruled rows. */ +.rfc-hero__masthead { + margin: 2.25rem 0 0; + padding-top: 1.15rem; + border-top: 1px solid var(--site-border); + display: flex; + flex-direction: column; + max-width: 46ch; +} + +.rfc-hero__masthead-row { + display: grid; + grid-template-columns: 7rem minmax(0, 1fr); + gap: 1rem; + align-items: baseline; + padding: 0.62rem 0; + border-bottom: 1px solid var(--site-border-soft); +} + +.rfc-hero__masthead-row:last-child { + border-bottom: 0; +} + +.rfc-hero__masthead-key { + margin: 0; + font-family: var(--font-geist-mono), ui-monospace, monospace; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--site-muted); +} + +.rfc-hero__masthead-val { + margin: 0; + font-family: var(--font-geist-mono), ui-monospace, monospace; + font-size: 12.5px; + letter-spacing: 0.01em; + line-height: 1.5; + color: var(--site-ink); + display: flex; + flex-wrap: wrap; + align-items: baseline; +} + +.rfc-hero__masthead-token { + white-space: nowrap; +} + +.rfc-hero__masthead-sep { + color: var(--site-muted-soft); + margin: 0 0.5rem; +} + /* Hero split: editorial left, console right, console top-aligned with the headline for that "page-of-the-spec next to the live system" feel. */ .rfc-hero__split { @@ -624,12 +709,27 @@ html[data-site-theme="dark"] .dot-grid { .rfc-hero__console-col { min-width: 0; + position: relative; +} + +/* The "are you an agent?" toggle perches on the console's top-left shoulder — + a small playful control on the live system, not a chip floating over the + right margin. Absolutely placed so it never pushes the headline down. */ +.rfc-hero__viewer-perch { + position: absolute; + left: 2px; + bottom: calc(100% + 0.55rem); + z-index: 4; } @media (max-width: 920px) { .rfc-hero__split { grid-template-columns: 1fr; - gap: 2rem; + gap: 3.25rem; + } + /* On the stacked layout the perch sits in the gap above the console. */ + .rfc-hero__viewer-perch { + bottom: calc(100% + 0.65rem); } } @@ -671,7 +771,7 @@ html[data-site-theme="dark"] .viewer-toggle-floater { margin: 0; cursor: pointer; font-family: var(--font-geist-mono), ui-monospace, monospace; - font-size: 12px; + font-size: 11px; letter-spacing: 0.04em; color: var(--site-muted); } @@ -1128,11 +1228,65 @@ main { counter-reset: rfc-section; } color: var(--site-copy); } +/* §4 host-integration cards — boxed, equal-height, softened corners. */ +.integration-block { + display: flex; + flex-direction: column; + gap: 0.7rem; + height: 100%; + padding: 1.15rem 1.2rem 1.05rem; + background: var(--site-panel-muted); + border: 1px solid var(--site-border); + border-radius: 14px; + transition: border-color 0.18s ease, background-color 0.18s ease; +} + +.integration-block:hover { + border-color: var(--site-accent-border); + background: var(--site-surface); +} + .integration-block__header { display: flex; align-items: center; gap: 0.75rem; - min-height: 2.25rem; +} + +.integration-block__heading { + display: flex; + flex-direction: column; + gap: 0.18rem; + min-width: 0; +} + +.integration-block__name { + font-family: var(--font-spectral), ui-serif, Georgia, serif; + font-weight: 600; + font-size: 1rem; + line-height: 1.2; + letter-spacing: -0.005em; + color: var(--site-ink); +} + +.integration-block__install { + display: block; + font-family: var(--font-geist-mono), ui-monospace, monospace; + font-size: 11px; + line-height: 1.5; + color: var(--site-copy); + background: var(--site-page-bg); + border: 1px solid var(--site-border-soft); + border-radius: 8px; + padding: 0.45rem 0.6rem; + overflow-wrap: anywhere; +} + +.integration-block__links { + margin-top: auto; + padding-top: 0.15rem; + display: flex; + flex-wrap: wrap; + gap: 0.35rem 1.1rem; } .integration-block__mark { @@ -2279,16 +2433,19 @@ html[data-site-theme="dark"] .docs-markdown :is(h1, h2, h3, h4) > a[title="Link ────────────────────────────────────────────────────────── */ .scout-console { - --term-bg: #15110C; - --term-bg-2: #1B1611; - --term-rule: #2E261C; - --term-ink: #D7CCB6; - --term-dim: #8E826B; - --term-rust: #E2854E; - --term-rust-soft: rgba(226, 133, 78, 0.10); - --term-rust-soft-strong: rgba(226, 133, 78, 0.18); + /* Tuned to match the native menu-bar HUD: neutral near-black, green accent. */ + --term-bg: #0E100D; + --term-bg-2: #14171260; + --term-rule: #23271F; + --term-ink: #D7DACE; + --term-dim: #7F8674; + /* "rust" is the historical accent token — repointed to the HUD green so the + active tab, live dot, send action, and operator mark all read green. */ + --term-rust: #86BC63; + --term-rust-soft: rgba(134, 188, 99, 0.12); + --term-rust-soft-strong: rgba(134, 188, 99, 0.20); --term-blue: #7AA4C9; - --term-green: #8FB36A; + --term-green: #86BC63; --term-amber: #D5B070; background: var(--term-bg); @@ -2296,7 +2453,7 @@ html[data-site-theme="dark"] .docs-markdown :is(h1, h2, h3, h4) > a[title="Link font-family: var(--font-geist-mono), ui-monospace, monospace; font-size: 12px; border: 1px solid #0a0805; - border-radius: 10px; + border-radius: 16px; overflow: hidden; display: flex; flex-direction: column; @@ -2324,10 +2481,10 @@ html[data-site-theme="dark"] .scout-console { gap: 12px; padding: 10px 14px; border-bottom: 1px solid #000; - background: linear-gradient(to bottom, #2A2118 0%, #1F1812 55%, #1A140E 100%); + background: linear-gradient(to bottom, #1B1E18 0%, #15171300 55%, #101210 100%); box-shadow: - inset 0 -1px 0 rgba(255, 240, 210, 0.04), - inset 0 1px 0 rgba(255, 240, 210, 0.08); + inset 0 -1px 0 rgba(230, 240, 220, 0.04), + inset 0 1px 0 rgba(230, 240, 220, 0.07); color: var(--term-dim); font-size: 10.5px; letter-spacing: 0.04em; @@ -2434,12 +2591,19 @@ html[data-site-theme="dark"] .scout-console { content: ""; position: absolute; left: 0; - top: 0; + right: 0; bottom: -1px; - width: 2px; + height: 2px; background: var(--term-rust); } +/* Numbered slot marks, like the HUD's "1 agents · 2 activity ·" tab strip. */ +.scout-console__tab-mark { + font-variant-numeric: tabular-nums; + font-weight: 600; + opacity: 0.85; +} + .scout-console__tab.is-on .scout-console__tab-mark { color: var(--term-rust); } @@ -2449,6 +2613,96 @@ html[data-site-theme="dark"] .scout-console { border-bottom: 0; } +/* Composer bar pinned to the bottom of the console — reads like a modern + message input (rounded field + send affordance) rather than a flat strip. */ +.scout-console__cmdbar { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-top: 1px solid var(--term-rule); + background: linear-gradient(to top, rgba(0, 0, 0, 0.30), transparent); + flex-shrink: 0; +} + +.scout-console__cmdbar-field { + flex: 1; + min-width: 0; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 11px; + background: rgba(0, 0, 0, 0.32); + border: 1px solid var(--term-rule); + border-radius: 9px; + box-shadow: + inset 0 1px 2px rgba(0, 0, 0, 0.35), + inset 0 0 0 0.5px rgba(255, 240, 210, 0.02); +} + +.scout-console__cmdbar-glyph { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--term-green); + box-shadow: 0 0 6px rgba(134, 188, 99, 0.7); + flex-shrink: 0; +} + +.scout-console__cmdbar-hint { + color: var(--term-dim); + font-size: 11px; + letter-spacing: 0.02em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.scout-console__cmdbar-cue { + color: var(--term-ink); +} + +/* Blinking insertion caret — sells the "live input" read. */ +.scout-console__cmdbar-caret { + width: 1.5px; + height: 13px; + margin-left: auto; + background: var(--term-green); + border-radius: 1px; + flex-shrink: 0; + animation: scout-caret 1.1s steps(1) infinite; +} + +@keyframes scout-caret { + 0%, 50% { opacity: 0.9; } + 51%, 100% { opacity: 0; } +} + +.scout-console__cmdbar-send { + display: inline-flex; + align-items: center; + gap: 5px; + flex-shrink: 0; + padding: 6px 9px 6px 11px; + border-radius: 9px; + background: var(--term-rust-soft); + border: 1px solid rgba(134, 188, 99, 0.30); + color: var(--term-rust); + font-size: 10.5px; + letter-spacing: 0.04em; + text-transform: lowercase; +} + +.scout-console__cmdbar-send kbd { + font-family: inherit; + font-size: 10px; + color: var(--term-rust); + background: rgba(134, 188, 99, 0.16); + border-radius: 5px; + padding: 0 5px; + line-height: 1.5; +} + /* stage = rail + body */ .scout-console__stage { flex: 1; @@ -2964,3 +3218,123 @@ html[data-site-theme="dark"] .scout-console { display: block; color: var(--site-copy); } + +/* ══════════════════════════════════════════════════════════ + BASEL — reductive enforcement layer + Scoped to the marketing landing (.site-marketing). Sharp corners, no + shadows, no glows, no frosted chrome — separation comes from hairlines + and whitespace. One grotesque (Archivo); hierarchy from scale + weight, + kept deliberately light rather than poster-black. + ══════════════════════════════════════════════════════════ */ + +/* Basel stays flat and quiet — no shadows, no glows, no frosted chrome — but + corners are no longer hard-zeroed. Each component keeps its own modest radius + so the system reads as intentionally soft, not brittle. */ +.site-marketing, +.site-marketing *, +.site-marketing *::before, +.site-marketing *::after { + box-shadow: none !important; + text-shadow: none !important; +} + +/* The broker console is the one surface allowed depth — it reads as a live + window floating on the page, not a flat panel pinned to the grid. */ +.site-marketing .scout-console { + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.5), + 0 18px 40px -24px rgba(28, 22, 16, 0.32), + 0 6px 16px -12px rgba(28, 22, 16, 0.20) !important; +} + +/* No frosted chrome — surfaces sit on the grid, not floating above it. */ +.site-marketing .rfc-doc-strip, +.site-marketing .viewer-toggle-floater { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + background: var(--site-page-bg) !important; +} + +/* When the doc-strip sticks, mark it with the heavy black structural rule + that opens every Basel section, not a soft tinted hairline. */ +.site-marketing .rfc-doc-strip.is-stuck { + border-bottom-color: var(--rule) !important; +} + +/* Display type — Archivo, kept on the lighter side of the ramp. The headline + reads as a calm grotesque statement; weight is a nudge, never a slab. */ +.site-marketing .rfc-hero__title--full { + font-weight: 400; + letter-spacing: -0.03em; + /* A touch smaller — calm grotesque, lets the hero breathe. */ + font-size: clamp(1.6rem, 3vw, 2.7rem); +} +.site-marketing + .rfc-hero__title--full + .rfc-hero__title-line + + .rfc-hero__title-line { + font-weight: 400; /* red mono kicker line stays light */ +} +.site-marketing .rfc-section-title { + font-weight: 500; +} +.site-marketing .rfc-block__title, +.site-marketing .rfc-figure__caption-title { + font-weight: 500; +} +.site-marketing .integration-block__mark { + font-weight: 600; +} + +/* Whitespace does the separating — keep the ambient dot-grid barely there. */ +.site-marketing .dot-grid { + opacity: 0.2; +} + +/* Registration / corner crosses — a small Swiss "design control" placed just + inside the corners of framed surfaces (commands, figures, the terminal). + Eight thin accent strokes form a "+" at each corner; subtle by size + alpha. */ +.basel-crosses { + position: relative; +} + +/* Overlay holding the four corner marks; sits above the framed surface. */ +.basel-crosses__marks { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 3; +} + +/* Each mark is a small "+" built from two 1px bars in the accent. */ +.basel-cross { + position: absolute; + width: 9px; + height: 9px; + opacity: 0.6; + color: var(--site-accent); +} +.basel-cross::before, +.basel-cross::after { + content: ""; + position: absolute; + background: currentColor; +} +.basel-cross::before { + top: 50%; + left: 0; + width: 100%; + height: 1px; + transform: translateY(-50%); +} +.basel-cross::after { + left: 50%; + top: 0; + width: 1px; + height: 100%; + transform: translateX(-50%); +} +.basel-cross--tl { top: 6px; left: 6px; } +.basel-cross--tr { top: 6px; right: 6px; } +.basel-cross--bl { bottom: 6px; left: 6px; } +.basel-cross--br { bottom: 6px; right: 6px; } diff --git a/landing/src/app/layout.tsx b/landing/src/app/layout.tsx index 30108213..15914b59 100644 --- a/landing/src/app/layout.tsx +++ b/landing/src/app/layout.tsx @@ -1,39 +1,19 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import { Instrument_Serif, IBM_Plex_Mono, Spectral } from "next/font/google"; +import { Archivo } from "next/font/google"; import { GoogleAnalyticsTag } from "@/components/google-analytics-tag"; -import { SITE_THEME_INIT_SCRIPT } from "@/lib/site-theme"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", +// Basel — one grotesque across the whole system. Every legacy font variable +// (--font-spectral / --font-mono-display / --font-geist-* / --font-display) is +// aliased to this single family in globals.css, so hierarchy comes from weight +// and scale alone, never from a second face. +const archivo = Archivo({ + variable: "--font-archivo", subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - -const instrumentSerif = Instrument_Serif({ - variable: "--font-display", - subsets: ["latin"], - weight: "400", + weight: ["300", "400", "500", "600", "700", "800", "900"], style: ["normal", "italic"], }); -const plexMono = IBM_Plex_Mono({ - variable: "--font-mono-display", - subsets: ["latin"], - weight: ["400", "500", "600", "700"], -}); - -const spectral = Spectral({ - variable: "--font-spectral", - subsets: ["latin"], - weight: ["400", "500", "600"], -}); - export const metadata: Metadata = { title: "OpenScout — Local Agent Broker", metadataBase: new URL("https://openscout.app"), @@ -78,19 +58,9 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - -