Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Double> = 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -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<String> = [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.
Expand Down Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -447,14 +453,18 @@ 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
activityLabel.stringValue = word.map { WorkspaceHoverCardContent.withElapsed($0, since: content.activitySince, now: now) } ?? ""
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
Expand All @@ -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()
}
Expand Down Expand Up @@ -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)
}
}
Loading
Loading