diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/HoverCards/WorkspaceHoverCardOptions.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/HoverCards/WorkspaceHoverCardOptions.swift index c0b37025e1f1..d5f046793793 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/HoverCards/WorkspaceHoverCardOptions.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/HoverCards/WorkspaceHoverCardOptions.swift @@ -7,10 +7,12 @@ import Foundation public nonisolated struct WorkspaceHoverCardOptions: Sendable, Hashable { /// The card's design. public enum Layout: String, Sendable, Hashable, CaseIterable { - /// The default: the title, status and a stats line, the workspace's facts, then the - /// scrollable history (prompts, starts, waits, finished turns), newest first. + /// The title, status and a stats line, the workspace's facts, then the scrollable history + /// (prompts, starts, waits, finished turns), newest first (u3320). case timeline - /// The facts card with the last three moments under it, no scrolling. + /// The default (Leo 2026-10-20: "more useful, no scroll, crisp"): the title with its age + /// and unread dot, the status, where it runs and its pull request, the agent's last + /// message rendered, then the last three moments, one line each. It never scrolls. case compact /// The history alone, taller and wider, each prompt up to three lines. case log @@ -20,7 +22,7 @@ public nonisolated struct WorkspaceHoverCardOptions: Sendable, Hashable { case minimal } - public var layout: Layout = .timeline + public var layout: Layout = .compact /// Running times: the status's and a command's elapsed time, and the stats line. public var showsTimings = true /// Prompts the person sent, and starts of work, in the history. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/HoverCardSettings+Options.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/HoverCardSettings+Options.swift index b3b01fda456f..b30dede20d28 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/HoverCardSettings+Options.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/HoverCardSettings+Options.swift @@ -4,7 +4,7 @@ extension HoverCardSettings { /// The workspace card's options as the sidebar reads them (`DesignSettings.workspaceHoverCard`). public var workspaceCardOptions: WorkspaceHoverCardOptions { var options = WorkspaceHoverCardOptions() - options.layout = WorkspaceHoverCardOptions.Layout(rawValue: layout.rawValue) ?? .timeline + options.layout = WorkspaceHoverCardOptions.Layout(rawValue: layout.rawValue) ?? WorkspaceHoverCardOptions().layout options.showsTimings = showsTimings options.showsPrompts = showsPrompts options.showsWaiting = showsWaiting diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+HoverCards.generated.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+HoverCards.generated.swift index 4f261944ae73..c42c88cf0dda 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+HoverCards.generated.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+HoverCards.generated.swift @@ -9,9 +9,9 @@ extension HoverCardSettings { public nonisolated static let delayPath = ["sidebar", "hoverCards", "delay"] public nonisolated static let delayDefault: Double = 250 public nonisolated static let delayRange: ClosedRange = 0...5000 - /// `sidebar.hoverCards.layout`: u3320 (Lawrence 2026-10-19): the workspace card's design (plans/cmux-next/hover-card-history/DESIGN.md). timeline (default): stats and a scrollable history of prompts, waits and finished turns; compact: the facts and the last three moments; log: the history alone; classic: the card before the history; minimal: title, status, last prompt. + /// `sidebar.hoverCards.layout`: u3320 (Lawrence 2026-10-19): the workspace card's design (plans/cmux-next/hover-card-history/DESIGN.md). compact (default since Leo 2026-10-20, "no scroll, crisp"): the facts, the last message rendered and the last three moments, never scrolling; timeline: stats and a scrollable history of prompts, waits and finished turns; log: the history alone; classic: the card before the history; minimal: title, status, last prompt. public nonisolated static let layoutPath = ["sidebar", "hoverCards", "layout"] - public nonisolated static let layoutDefault: HoverCardLayoutSetting = .timeline + public nonisolated static let layoutDefault: HoverCardLayoutSetting = .compact /// `sidebar.hoverCards.sections.timings`: u3320 (Lawrence 2026-10-19): which parts a workspace hover card shows. On by default. public nonisolated static let timingsPath = ["sidebar", "hoverCards", "sections", "timings"] public nonisolated static let timingsDefault = true diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+HoverCardsChoices.generated.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+HoverCardsChoices.generated.swift index fdd51d11c117..15ed961a3b8d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+HoverCardsChoices.generated.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+HoverCardsChoices.generated.swift @@ -1,7 +1,7 @@ // GENERATED by scripts/cmux-next/gen-settings-sources.py from // schemas/settings/source/hover-cards.json. Do not edit; edit the JSON and rerun. -/// u3320 (Lawrence 2026-10-19): the workspace card's design (plans/cmux-next/hover-card-history/DESIGN.md). timeline (default): stats and a scrollable history of prompts, waits and finished turns; compact: the facts and the last three moments; log: the history alone; classic: the card before the history; minimal: title, status, last prompt. +/// u3320 (Lawrence 2026-10-19): the workspace card's design (plans/cmux-next/hover-card-history/DESIGN.md). compact (default since Leo 2026-10-20, "no scroll, crisp"): the facts, the last message rendered and the last three moments, never scrolling; timeline: stats and a scrollable history of prompts, waits and finished turns; log: the history alone; classic: the card before the history; minimal: title, status, last prompt. public nonisolated enum HoverCardLayoutSetting: String, Sendable, Hashable, CaseIterable { case timeline case compact diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/WorkspaceHoverCardContent.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/WorkspaceHoverCardContent.swift index 426bd5734d13..5e46efda6eab 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/WorkspaceHoverCardContent.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/WorkspaceHoverCardContent.swift @@ -35,6 +35,9 @@ struct WorkspaceHoverCardContent: Equatable { var ink: HoverCardTone? /// The machine a machine row names: its icon draws that machine's color pick, if any. var machine: String? + /// The markdown `text` was rendered from (an agent's last message): the card draws its + /// bold, italic and code, never the raw marks (`renderedMarkdown`). + var markdown: String? /// The row the shared hover card row draws (`HoverCardRowView`). var row: HoverCardRowContent { @@ -56,6 +59,8 @@ struct WorkspaceHoverCardContent: Equatable { var icon: IconName /// How long ago the workspace was last active ("4w"); nil without activity. var age: String? + /// The workspace has something unread: a dot after the age, as on ChatGPT's session card. + var unread = false var facts: [Fact] /// The workspace's merged status and its style hint (the row's indicator). var activity: StatusIndicatorState = .idle @@ -114,6 +119,10 @@ struct WorkspaceHoverCardContent: Equatable { case .classic: break case .compact: + // The default (Leo 2026-10-20: "more useful, no scroll, crisp"): what the workspace is, + // where it runs and what it said last, then the last three moments, one line each. + card.facts.removeAll { !Self.compactKinds.contains($0.kind) } + for index in card.facts.indices where card.facts[index].kind == Fact.lastReplyKind { card.facts[index].lines = 3 } card.timeline = Array(shown.prefix(options.historyRows)) case .timeline: card.timeline = Array(shown.prefix(options.historyRows)) @@ -133,6 +142,37 @@ struct WorkspaceHoverCardContent: Equatable { return card } + /// The facts the compact card keeps: what its agents did since you looked, why it waits or what + /// it works on, where it runs, its pull request, and the agent's last message. + static let compactKinds: Set = [WorkspaceHoverCardContent.sinceLookedKind, "needsInput", "task", "folder", "branch", + "pullRequest", "ports", "machine", "machineStatus", Fact.lastReplyKind] + + /// `markdown` as one paragraph of inline markdown: code fences, heading, quote and list marks + /// dropped, its lines joined by a space, so a few lines of the card read as prose. + static func inlineMarkdown(_ markdown: String) -> String { + markdown.split(whereSeparator: \.isNewline).compactMap { line -> String? in + var text = line.trimmingCharacters(in: .whitespaces) + if text.hasPrefix("```") { return nil } + let heading = text.drop { $0 == "#" } + if heading.count < text.count, heading.first == " " { text = heading.trimmingCharacters(in: .whitespaces) } + while text.hasPrefix(">") { text = text.dropFirst().trimmingCharacters(in: .whitespaces) } + if let mark = ["- ", "* ", "+ "].first(where: { text.hasPrefix($0) }) { text = String(text.dropFirst(mark.count)) } + let number = text.prefix { $0.isNumber } + if !number.isEmpty, text.dropFirst(number.count).hasPrefix(". ") { text = String(text.dropFirst(number.count + 2)) } + return text.isEmpty ? nil : text + }.joined(separator: " ") + } + + /// `markdown`'s inline styles (bold, italic, code, links) parsed; the raw text when it is not markdown. + static func renderedMarkdown(_ markdown: String) -> AttributedString { + let inline = inlineMarkdown(markdown) + return (try? AttributedString(markdown: inline, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) + ?? AttributedString(inline) + } + + /// `markdown` as the words it reads as, without its marks. + static func plainMarkdown(_ markdown: String) -> String { String(renderedMarkdown(markdown).characters) } + /// "Prompts: 4 · Worked 12m" over `timeline` (newest first): the time from each prompt or /// start to the next moment after it that is a wait, finish, failure or later prompt, a running /// turn until `now`. Nil with neither prompts nor worked time. @@ -231,11 +271,15 @@ struct WorkspaceHoverCardContent: Equatable { // The agent chat's last reply, when no task says more: the card's last line, under the // metadata, two lines at most (Leo 2026-10-19, Claude desktop's session card). if detail.task == nil, let message = workspace.lastMessage, !message.isEmpty { - facts.append(Fact(icon: .agentChatList, text: message, lines: 2, kind: Fact.lastReplyKind)) + let text = Self.plainMarkdown(message) + if !text.isEmpty { + facts.append(Fact(icon: .agentChatList, text: text, lines: 2, kind: Fact.lastReplyKind, markdown: message)) + } } return WorkspaceHoverCardContent( title: workspace.title, icon: remote.map(hostIcon) ?? workspace.kind.iconName, - age: workspace.lastActivity.flatMap { age(since: $0, now: now) }, facts: facts, + age: workspace.lastActivity.flatMap { age(since: $0, now: now) }, unread: workspace.unread.isUnread, + facts: facts, activity: workspace.activity, activityStyle: workspace.activityStyle, activitySince: detail.activitySince) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceHoverCard.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceHoverCard.swift index 5e496ad033ac..eb2bc017c726 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceHoverCard.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceHoverCard.swift @@ -282,6 +282,8 @@ final class WorkspaceHoverCardView: NSView { private let activityLabel = ChromeLabel(labelWithString: "") private let activityRow = NSStackView() private let ageLabel = ChromeLabel(labelWithString: "") + /// After the age while the workspace has something unread (ChatGPT's session card). + private let unreadDot = UnreadDotView() private let factsStack = NSStackView() /// CPU and memory with the last seconds of CPU as bars (`WorkspaceHoverCardContent.resourceRow`). private let resourceRow = HoverCardRowView() @@ -329,6 +331,8 @@ final class WorkspaceHoverCardView: NSView { kindIcon.setContentHuggingPriority(.required, for: .horizontal) kindIcon.line = titleLabel kindIcon.side = Self.iconSize + unreadDot.line = ageLabel + unreadDot.isHidden = true // Digits of one width: a running time that ticks keeps its width ("1m 11s", "1m 18s"). activityLabel.font = Self.liveFont activityLabel.lineBreakMode = .byTruncatingTail @@ -340,7 +344,7 @@ final class WorkspaceHoverCardView: NSView { // Every icon sits on one column at the leading inset, its text beside it (the fact rows' // grid), so the kind icon leads the name instead of floating after it. - let titleRow = NSStackView(views: [kindIcon, titleLabel, ageLabel]) + let titleRow = NSStackView(views: [kindIcon, titleLabel, ageLabel, unreadDot]) titleRow.orientation = .horizontal titleRow.alignment = .firstBaseline titleRow.spacing = Metrics.space2 @@ -386,6 +390,8 @@ final class WorkspaceHoverCardView: NSView { resourceRow.widthAnchor.constraint(equalTo: stack.widthAnchor), kindIcon.widthAnchor.constraint(equalToConstant: Self.iconSize), kindIcon.heightAnchor.constraint(equalToConstant: Self.iconSize), + unreadDot.widthAnchor.constraint(equalToConstant: UnreadDotView.side), + unreadDot.heightAnchor.constraint(equalToConstant: UnreadDotView.side), activitySlot.widthAnchor.constraint(equalToConstant: Self.iconSize), activitySlot.heightAnchor.constraint(equalToConstant: Self.iconSize), activity.widthAnchor.constraint(equalToConstant: Self.iconSize), @@ -447,6 +453,9 @@ final class WorkspaceHoverCardView: NSView { kindIcon.image = NSImage.icon(content.icon, size: Self.iconSize) ageLabel.stringValue = content.age ?? "" ageLabel.isHidden = content.age == nil + unreadDot.isHidden = !content.unread + // The compact card keeps a long name to two lines; the others show it whole. + titleLabel.maximumNumberOfLines = content.options.layout == .compact ? 2 : 6 activity.configure(content.activity, style: content.activityStyle) let word = Strings.activity(content.activity) activityWord = word @@ -454,7 +463,8 @@ final class WorkspaceHoverCardView: NSView { activityRow.isHidden = word == nil // The width the title wraps at (the panel sizes the card in one // pass): the row less its icon and the age. - let age = ageLabel.isHidden ? 0 : ageLabel.intrinsicContentSize.width + Metrics.space4 + let age = (ageLabel.isHidden ? 0 : ageLabel.intrinsicContentSize.width + Metrics.space4) + + (unreadDot.isHidden ? 0 : UnreadDotView.side + Metrics.space2) titleLabel.preferredMaxLayoutWidth = width - 2 * Self.padding - Self.iconSize - Metrics.space2 - age while factViews.count < content.facts.count { addFactRow() } let wrap = width - 2 * Self.padding - Self.iconSize - Metrics.space2 @@ -478,8 +488,10 @@ final class WorkspaceHoverCardView: NSView { let log = content.options.layout == .log timeline.isHidden = content.timeline.isEmpty if !content.timeline.isEmpty { + // The compact card's last moments never scroll (Leo 2026-10-20: "no scroll, crisp"). timeline.configure(content.timeline, width: width - 2 * Self.padding, - maxHeight: log ? Self.logMaxHeight : Self.timelineMaxHeight, textLines: log ? 3 : 2, now: now) + maxHeight: log ? Self.logMaxHeight : Self.timelineMaxHeight, textLines: log ? 3 : 2, now: now, + scrolls: content.options.layout != .compact) } applyColors() } @@ -608,5 +620,60 @@ extension WorkspaceHoverCardView { // the shared overlay glass shows. for row in factViews { row.applyColors() } resourceRow.applyColors() + performWithTheme { + // The notification blue, as on the sidebar's own unread dots: the default theme's + // accent is a grey, and a grey dot read as missing (2026-10-21). + // glass-opaque-allow: a 6 pt unread dot on the title row, not a body or surface over the glass. + unreadDot.layer?.backgroundColor = Palette.notificationBlue.cgColor + // The last message in its own styles (bold, italic, code), never its raw marks. + for (index, row) in factViews.enumerated() where index < (content?.facts.count ?? 0) && !row.isHidden { + guard let markdown = content?.facts[index].markdown else { continue } + row.textLabel.attributedStringValue = Self.styled(markdown, color: Palette.textPrimary) + } + } + } + + /// `markdown`'s inline styles in the card's fonts: bold in the emphasized body, italic, + /// code in the monospaced body, all in `color`. + static func styled(_ markdown: String, color: NSColor) -> NSAttributedString { + let rendered = WorkspaceHoverCardContent.renderedMarkdown(markdown) + let body = Typography.body + let out = NSMutableAttributedString() + for run in rendered.runs { + let intent = run.inlinePresentationIntent ?? [] + let strong = intent.contains(.stronglyEmphasized) + // Bold code stays monospaced, in the emphasized body's weight. + var font = intent.contains(.code) + ? Typography.system(.monospaced, size: body.pointSize, weight: strong ? .medium : .regular) + : strong ? Typography.bodyEmphasized : body + if intent.contains(.emphasized) { font = NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) } + out.append(NSAttributedString(string: String(rendered[run.range].characters), attributes: [.font: font, .foregroundColor: color])) + } + return out + } +} + +/// The unread dot on the title row, centered on `line`'s capitals like the card's icons +/// (`HoverCardIconView`, `NSFont.iconBaselineOffsetFromTop`). +private final class UnreadDotView: NSView { + static var side: CGFloat { 6 } + weak var line: ChromeLabel? + + init() { + super.init(frame: .zero) + wantsLayer = true + layer?.cornerRadius = Self.side / 2 + setContentHuggingPriority(.required, for: .horizontal) + setAccessibilityElement(false) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + /// With no line to follow, the dot sits on the baseline like an image. No `super` call: NSView's + /// getter is not in the probed selector table (scripts/cmux-next/check-objc-selectors.py). + override var firstBaselineOffsetFromTop: CGFloat { + guard let font = line?.font else { return Self.side } + return font.iconBaselineOffsetFromTop(iconHeight: Self.side) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceHoverCardTimelineView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceHoverCardTimelineView.swift index 6ae668c479c8..55afbe671b76 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceHoverCardTimelineView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceHoverCardTimelineView.swift @@ -5,7 +5,8 @@ import CmuxNextIcons /// The workspace hover card's history (u3320): one line per moment, newest first, a time column /// and an icon column, the prompt or what happened beside them. It scrolls past `maxHeight`; the /// card window ignores the mouse, so the hover card coordinator hands it the wheel -/// (`HoverCardPanel.scrollCard`). +/// (`HoverCardPanel.scrollCard`). The compact card's few lines never scroll: no header, one line +/// each, as tall as they are (Leo 2026-10-20: "no scroll, crisp"). /// /// The lines are laid out by frame, each text measured once at its wrap width: a card slides /// to every row the pointer crosses while it shows, and the history's lines as stack views @@ -21,6 +22,8 @@ final class WorkspaceHoverCardTimelineView: NSView { private let header = ChromeLabel(labelWithString: "") private let document = FlippedDocumentView() private var heightConstraint: NSLayoutConstraint? + private var underHeader: NSLayoutConstraint? + private var atTop: NSLayoutConstraint? private var rows: [Line] = [] private var entries: [SidebarTimelineEntry] = [] /// What the lines were last laid out for: the same entries, width and line limit only @@ -50,11 +53,14 @@ final class WorkspaceHoverCardTimelineView: NSView { addSubview(scrollView) let height = scrollView.heightAnchor.constraint(equalToConstant: 0) heightConstraint = height + let under = scrollView.topAnchor.constraint(equalTo: header.bottomAnchor, constant: Metrics.space2) + underHeader = under + atTop = scrollView.topAnchor.constraint(equalTo: topAnchor) NSLayoutConstraint.activate([ header.topAnchor.constraint(equalTo: topAnchor), header.leadingAnchor.constraint(equalTo: leadingAnchor), header.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), - scrollView.topAnchor.constraint(equalTo: header.bottomAnchor, constant: Metrics.space2), + under, scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), @@ -75,9 +81,23 @@ final class WorkspaceHoverCardTimelineView: NSView { /// Shows `entries` at `width`; the list is at most `maxHeight` tall and scrolls past it. /// `textLines` is how many lines a prompt may wrap to. A new set of entries scrolls to the top. - func configure(_ entries: [SidebarTimelineEntry], width: CGFloat, maxHeight: CGFloat, textLines: Int, now: Date) { + /// Without `scrolls`, the list has no header and no scroller, one line per entry, all shown. + func configure(_ entries: [SidebarTimelineEntry], width: CGFloat, maxHeight: CGFloat, textLines: Int, now: Date, + scrolls: Bool = true) { let changed = entries != self.entries self.entries = entries + header.isHidden = !scrolls + underHeader?.isActive = scrolls + atTop?.isActive = !scrolls + scrollView.hasVerticalScroller = scrolls + // A list that does not scroll starts at its top, also when the same entries scrolled + // before under another layout (the laid-out cache below keeps the clip view as it was). + if !scrolls, scrollView.contentView.bounds.origin != .zero { + scrollView.contentView.scroll(to: .zero) + scrollView.reflectScrolledClipView(scrollView.contentView) + } + let textLines = scrolls ? textLines : 1 + let maxHeight = scrolls ? maxHeight : .greatestFiniteMagnitude while rows.count < entries.count { addRow() } for (index, row) in rows.enumerated() { let entry = index < entries.count ? entries[index] : nil diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WorkspaceHoverCardScrollTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WorkspaceHoverCardScrollTests.swift index e0c1c6467b32..9db91a1938fc 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WorkspaceHoverCardScrollTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WorkspaceHoverCardScrollTests.swift @@ -82,7 +82,11 @@ import Testing } /// The history is longer than the card: the wheel on it scrolls the history, and the card stays. + /// The timeline layout scrolls; the default compact card never does (Leo 2026-10-20). @Test func aScrollOnALongHistoryScrollsTheHistory() async throws { + let layout = DesignSettings.shared.workspaceHoverCard.layout + DesignSettings.shared.workspaceHoverCard.layout = .timeline + defer { DesignSettings.shared.workspaceHoverCard.layout = layout } let clock = ManualClock() let (list, window, coordinator, rows) = Self.sidebar(clock: clock) let now = Date() diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WorkspaceHoverCardSummaryTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WorkspaceHoverCardSummaryTests.swift new file mode 100644 index 000000000000..6beca7c6e5d7 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WorkspaceHoverCardSummaryTests.swift @@ -0,0 +1,57 @@ +import AppKit +@testable import CmuxNextDesign +@testable import CmuxNextSidebar +import Foundation +import Testing + +/// Leo 2026-10-20: the session hover card was a scrolling "History" of truncated prompts and +/// "claude-sr2 finished" lines, mushy, with an awkward inner scroll. "Make it more useful, no +/// scroll, crisp" (ChatGPT's session card is the floor). The default card is the compact one: the +/// title with its machine icon, age and unread dot; folder, branch, machine and pull request; the +/// status with how long it has held; the agent's last message rendered, not raw markdown; then +/// the last three moments, one line each. It never scrolls. +@MainActor @Suite(.serialized) struct WorkspaceHoverCardSummaryTests { + static let now = WorkspaceHoverCardTimelineTests.now + + static func workspace() -> SidebarWorkspace { + var workspace = WorkspaceHoverCardTimelineTests.workspace() + workspace.title = "GUI push · coordinator (this chat)" + workspace.unread = .dot + workspace.lastActivity = now.addingTimeInterval(-2 * 3600) + workspace.activity = .working + workspace.detail.activitySince = now.addingTimeInterval(-4 * 60) + workspace.lastMessage = "**Merged** #22670. Next: `fix` the [hover card](https://example.com/pr).\n\n- one\n- two" + return workspace + } + + @Test func theDefaultCardIsTheCompactOne() { + #expect(WorkspaceHoverCardOptions().layout == .compact) + } + + @Test func theCompactCardSaysWhatMatters() throws { + let card = WorkspaceHoverCardContent.make(Self.workspace(), machine: nil, now: Self.now, options: WorkspaceHoverCardOptions(), + timeline: WorkspaceHoverCardTimelineTests.history(10)) + #expect(card.unread, "the unread workspace shows no unread dot") + #expect(card.age == "2h") + let kinds = card.facts.map(\.kind) + for kind in ["folder", "branch", "pullRequest"] { #expect(kinds.contains(kind), "no \(kind) in \(kinds)") } + let reply = try #require(card.facts.first { $0.kind == WorkspaceHoverCardContent.Fact.lastReplyKind }) + #expect(reply.text == "Merged #22670. Next: fix the hover card. one two", "the last message reads raw: \(reply.text)") + #expect(reply.lines == 3) + #expect(card.timeline.count == 3) + #expect(card.stats == nil) + } + + @Test func theCompactCardNeverScrolls() throws { + let view = WorkspaceHoverCardView() + let card = WorkspaceHoverCardContent.make(Self.workspace(), machine: nil, now: Self.now, options: WorkspaceHoverCardOptions(), + timeline: WorkspaceHoverCardTimelineTests.history(40)) + view.configure(card, now: Self.now) + view.layoutSubtreeIfNeeded() + #expect(!view.timeline.scrollView.hasVerticalScroller, "the history scrolls") + let document = try #require(view.timeline.scrollView.documentView) + #expect(document.frame.height <= view.timeline.scrollView.frame.height + 0.5, "the history is taller than its view") + #expect(view.fittingSize.height < 360, "the card is \(view.fittingSize.height) pt tall") + #expect(!view.lines.contains { $0.contains("**") || $0.contains("`") }, "lines: \(view.lines)") + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WorkspaceHoverCardTimelineTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WorkspaceHoverCardTimelineTests.swift index 69706ca5dbb1..c5924e28f145 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WorkspaceHoverCardTimelineTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WorkspaceHoverCardTimelineTests.swift @@ -37,8 +37,8 @@ import Testing return options } - @Test func theDefaultCardListsTheHistoryNewestFirstWithStats() { - let card = WorkspaceHoverCardContent.make(Self.workspace(), machine: nil, now: Self.now, options: WorkspaceHoverCardOptions(), timeline: Self.history()) + @Test func theTimelineCardListsTheHistoryNewestFirstWithStats() { + let card = WorkspaceHoverCardContent.make(Self.workspace(), machine: nil, now: Self.now, options: Self.options(.timeline), timeline: Self.history()) #expect(card.options.layout == .timeline) #expect(card.timeline.map(\.kind) == [.finished, .prompt, .finished, .prompt, .finished, .prompt]) #expect(card.timeline.first?.date == Self.now) @@ -92,7 +92,7 @@ import Testing @Test func theCardViewListsTheHistoryAndCapsItsHeight() throws { let view = WorkspaceHoverCardView() - let card = WorkspaceHoverCardContent.make(Self.workspace(), machine: nil, now: Self.now, options: WorkspaceHoverCardOptions(), timeline: Self.history(40)) + let card = WorkspaceHoverCardContent.make(Self.workspace(), machine: nil, now: Self.now, options: Self.options(.timeline), timeline: Self.history(40)) view.configure(card, now: Self.now) view.layoutSubtreeIfNeeded() let lines = view.lines diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextSettingsTests/HoverCardContentSettingsTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextSettingsTests/HoverCardContentSettingsTests.swift index d8aecb79e69d..7abee9e21f55 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextSettingsTests/HoverCardContentSettingsTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextSettingsTests/HoverCardContentSettingsTests.swift @@ -6,14 +6,15 @@ import Testing /// (`sidebar.hoverCards.layout`, `.sections.*`, `.maxRows`): Settings rows, cmux.json, the /// palette's settings rows and `cmux settings set` all read the same schema rows. struct HoverCardContentSettingsTests { - @Test func defaultsAreTheStatsAndHistoryCardWithEveryPart() throws { + /// Leo 2026-10-20 ("more useful, no scroll, crisp"): the compact card is the default. + @Test func defaultsAreTheCompactCardWithEveryPart() throws { let snapshot = CmuxConfigSnapshot.parse(.object([:]), validDensities: [], validMetrics: []) let options = snapshot.hoverCards.workspaceCardOptions #expect(options == WorkspaceHoverCardOptions()) - #expect(options.layout == .timeline) + #expect(options.layout == .compact) #expect(options.maxRows == 20) let layout = try #require(SettingsSchema.descriptor(for: HoverCardSettings.layoutPath)) - #expect(layout.defaultValue == .string("timeline")) + #expect(layout.defaultValue == .string("compact")) for path in [HoverCardSettings.timingsPath, HoverCardSettings.promptsPath, HoverCardSettings.waitingPath, HoverCardSettings.gitPath, HoverCardSettings.portsPath] { let row = try #require(SettingsSchema.descriptor(for: path), "no Settings row for \(path)") @@ -41,7 +42,7 @@ struct HoverCardContentSettingsTests { @Test func aBadLayoutKeepsTheDefaultWithADiagnostic() { let root = JSONValue.object(["sidebar": .object(["hoverCards": .object(["layout": .string("huge"), "maxRows": .number(500)])])]) let snapshot = CmuxConfigSnapshot.parse(root, validDensities: [], validMetrics: []) - #expect(snapshot.hoverCards.workspaceCardOptions.layout == .timeline) + #expect(snapshot.hoverCards.workspaceCardOptions.layout == .compact) #expect(snapshot.hoverCards.workspaceCardOptions.maxRows == 100) #expect(snapshot.diagnostics.contains { $0.path == "sidebar.hoverCards.layout" }) } diff --git a/docs/mdm/com.manaflow.cmux.json b/docs/mdm/com.manaflow.cmux.json index 118b46a73f2c..b864c81988ae 100644 --- a/docs/mdm/com.manaflow.cmux.json +++ b/docs/mdm/com.manaflow.cmux.json @@ -3345,7 +3345,7 @@ "type" : "boolean" }, "sidebar.hoverCards.layout" : { - "default" : "timeline", + "default" : "compact", "description" : "What a workspace's hover card shows: its history of prompts and waits, its details, or both.", "enum" : [ "timeline", diff --git a/docs/mdm/com.manaflow.cmux.plist b/docs/mdm/com.manaflow.cmux.plist index 90bebdf3d489..929f1df464bf 100644 --- a/docs/mdm/com.manaflow.cmux.plist +++ b/docs/mdm/com.manaflow.cmux.plist @@ -4674,7 +4674,7 @@ pfm_default - timeline + compact pfm_description What a workspace's hover card shows: its history of prompts and waits, its details, or both. pfm_name diff --git a/docs/mdm/managed-preferences.md b/docs/mdm/managed-preferences.md index 993afd3595a2..b7ac198086bd 100644 --- a/docs/mdm/managed-preferences.md +++ b/docs/mdm/managed-preferences.md @@ -323,7 +323,7 @@ Files: `com.manaflow.cmux.plist` (ProfileManifests: iMazing Profile Editor, Prof | `sidebar.steppingWraps` | boolean | `true` | | Wrap Around. Past the last item, the next item is the first again. | | `sidebar.hoverCards.enabled` | boolean | `true` | | Show Hover Cards. A card with details opens when the pointer rests on a workspace or a tab. | | `sidebar.hoverCards.delay` | real | `250` | 0 to 5000 | Hover Card Delay. How long the pointer rests on a workspace or a tab before its card opens. While a card shows, the next one opens at once. | -| `sidebar.hoverCards.layout` | string | `"timeline"` | `timeline`, `compact`, `log`, `classic`, `minimal` | Workspace Card Layout. What a workspace's hover card shows: its history of prompts and waits, its details, or both. | +| `sidebar.hoverCards.layout` | string | `"compact"` | `timeline`, `compact`, `log`, `classic`, `minimal` | Workspace Card Layout. What a workspace's hover card shows: its history of prompts and waits, its details, or both. | | `sidebar.hoverCards.sections.timings` | boolean | `true` | | Show Running Times in Workspace Cards. How long the workspace has worked or waited, and how long its command has run. | | `sidebar.hoverCards.sections.prompts` | boolean | `true` | | Show Prompts in Workspace Cards. Each prompt you sent and when, in the card's history. | | `sidebar.hoverCards.sections.waiting` | boolean | `true` | | Show Waits in Workspace Cards. When an agent waited for your input or finished its turn, in the card's history. | diff --git a/plans/cmux-next/hover-card-history/DESIGN.md b/plans/cmux-next/hover-card-history/DESIGN.md index 3115f1ad3817..fbba834ea49b 100644 --- a/plans/cmux-next/hover-card-history/DESIGN.md +++ b/plans/cmux-next/hover-card-history/DESIGN.md @@ -82,7 +82,7 @@ cmux.json, the palette's settings rows (Cmd+Shift+P, the `,` scope) and `cmux se | Key | Default | | | --- | --- | --- | -| `sidebar.hoverCards.layout` | `timeline` | `timeline`, `compact`, `log`, `classic`, `minimal` | +| `sidebar.hoverCards.layout` | `compact` | `timeline`, `compact`, `log`, `classic`, `minimal` | | `sidebar.hoverCards.sections.timings` | `true` | running times and the stats line | | `sidebar.hoverCards.sections.prompts` | `true` | prompts and starts in the history | | `sidebar.hoverCards.sections.waiting` | `true` | waits, finishes, failures in the history | diff --git a/schemas/defaults/cmux-next.json b/schemas/defaults/cmux-next.json index 9aab92fa5cf8..6a8879144426 100644 --- a/schemas/defaults/cmux-next.json +++ b/schemas/defaults/cmux-next.json @@ -597,7 +597,7 @@ "setting.sidebar.hoverCards.groups.sections.status": true, "setting.sidebar.hoverCards.groups.tabGroups": true, "setting.sidebar.hoverCards.groups.workspaceGroups": true, - "setting.sidebar.hoverCards.layout": "timeline", + "setting.sidebar.hoverCards.layout": "compact", "setting.sidebar.hoverCards.maxRows": 20, "setting.sidebar.hoverCards.sections.git": true, "setting.sidebar.hoverCards.sections.ports": true, diff --git a/schemas/settings-export/settings-schema.sources.sha256 b/schemas/settings-export/settings-schema.sources.sha256 index 2189e79dfec7..4b11f330cdfe 100644 --- a/schemas/settings-export/settings-schema.sources.sha256 +++ b/schemas/settings-export/settings-schema.sources.sha256 @@ -178,7 +178,7 @@ ff3a5b0e5389f96e5bddebb79f1843820eac55f5e331dfba9fc0e0898543e1f1 Packages/macOS 54f4b23550b6c0ac0e22efcf4b524b1ae004eb834db83ba11ab82e1f209cf88e Packages/macOS/CmuxNext/Sources/CmuxNextSettings/HomeServerSetting.swift -83f88735839c4190d9cebd97220398282f5899062e1b59c6f4b199d64d3f5c7d Packages/macOS/CmuxNext/Sources/CmuxNextSettings/HoverCardSettings+Options.swift +cf37449aa85fb6ca698e4956248d7c827120870c6cb7098a76be369ead40f37a Packages/macOS/CmuxNext/Sources/CmuxNextSettings/HoverCardSettings+Options.swift ae856418ce22482ef4262df8fb664e18e76cc91a98c72a19e6feb55f70ea4541 Packages/macOS/CmuxNext/Sources/CmuxNextSettings/HoverCardSettings.swift @@ -386,9 +386,9 @@ ca17a18722a553f6963edee26becc1d91c1027afdf2ad80bbe713286e0d0899f Packages/macOS 2e03c002892fb8c6e36c4cdf57c180909d104b4babf211f2fe189d59b629dca8 Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+Feed.generated.swift -e88319190f1a93f49e9da53a50e5e3399175e86e913a10fa4796e6e4f680cfad Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+HoverCards.generated.swift +d6d582fa578b09865055d4a27c009faa501f6fee71afb5b70dc703a182970044 Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+HoverCards.generated.swift -04e7117b360aa6602c37c4de79fd04d4f2e48e81b0a588ef918ac01270837ed9 Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+HoverCardsChoices.generated.swift +c5c0e356f8eed4d35ea606c6284851427e1c989eb3291d04bb2deceb0642206b Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+HoverCardsChoices.generated.swift 783ebcbcdc1c4435e2efb0e7e665fc4dcef37937dd1b367e60ca5b43ced483e9 Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+Labs.generated.swift diff --git a/schemas/settings/settings-schema.json b/schemas/settings/settings-schema.json index 8a3060fb7b03..3c40cf202530 100644 --- a/schemas/settings/settings-schema.json +++ b/schemas/settings/settings-schema.json @@ -22902,7 +22902,7 @@ "consumers" : [ "cmux-next" ], - "default" : "timeline", + "default" : "compact", "default_label" : null, "group" : { "key" : "settings.group.sidebar", diff --git a/schemas/settings/source/hover-cards.json b/schemas/settings/source/hover-cards.json index 76aed578327d..89a9a48bbb4f 100644 --- a/schemas/settings/source/hover-cards.json +++ b/schemas/settings/source/hover-cards.json @@ -61,7 +61,7 @@ }, { "key": "sidebar.hoverCards.layout", - "note": "u3320 (Lawrence 2026-10-19): the workspace card's design (plans/cmux-next/hover-card-history/DESIGN.md). timeline (default): stats and a scrollable history of prompts, waits and finished turns; compact: the facts and the last three moments; log: the history alone; classic: the card before the history; minimal: title, status, last prompt.", + "note": "u3320 (Lawrence 2026-10-19): the workspace card's design (plans/cmux-next/hover-card-history/DESIGN.md). compact (default since Leo 2026-10-20, \"no scroll, crisp\"): the facts, the last message rendered and the last three moments, never scrolling; timeline: stats and a scrollable history of prompts, waits and finished turns; log: the history alone; classic: the card before the history; minimal: title, status, last prompt.", "title": { "key": "settings.sidebar.hoverCards.layout", "text": "Workspace Card Layout" @@ -108,7 +108,7 @@ } } ], - "default": "timeline", + "default": "compact", "keywords": [ "hover", "card",