From b5a4f7458f17209192f92abf3707ae9ae6787d10 Mon Sep 17 00:00:00 2001 From: chenweigao <19328388+chenweigao@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:38:36 +0800 Subject: [PATCH 1/3] fix: quieter notes, matching fold toggle, wide content cues, window-true math MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Quiet tone: `ReplyTone` / `\.replyTone` lets secondary Markdown (compaction notes, runtime context) render at 12 pt on an 18 pt line in secondary ink. Before, the outer `.font` / `.secondary` never reached `ReplyText`. List markers follow the tone; the unused private `replyInk` is removed. - The native 展开完整问题 toggle matches SwiftUI: borderless, left-aligned at the text inset, secondary, 6 pt below the prompt on a 24 pt row. Row heights are unchanged. user-rows asserts it (UserMessageStyle.toggle* tokens). - Wide content: tables no longer reserve a legacy scroller band. Code cards (both renderers) and tables fade their trailing 12 pt while content overflows; the fade is visual only, and no glyph fades at the scrolled end. The table header rule is stronger than row rules; padding and rule opacities are `ReplyStyle.Table` tokens. - Math draws in the window's appearance: each formula is typeset in both colors and an image picks one at draw time, so a per-window light/dark override and live switches recolor inline and display math. Sizes are unchanged. - The warning alert title uses a darker amber in light mode; the bar and icon keep yellow. Co-Authored-By: Claude Opus 5.5 --- .../NativeAssistantBlocks.swift | 24 +++ Sources/AgentWorkbench/ReplyAlertBlock.swift | 15 ++ .../AgentWorkbench/ReplyMarkdownView.swift | 189 ++++++++++++++---- Sources/AgentWorkbench/ReplyMathView.swift | 61 ++++-- .../ParagraphExperiment.swift | 19 ++ 5 files changed, 250 insertions(+), 58 deletions(-) diff --git a/Sources/AgentWorkbench/NativeAssistantBlocks.swift b/Sources/AgentWorkbench/NativeAssistantBlocks.swift index 559467f..33187ea 100644 --- a/Sources/AgentWorkbench/NativeAssistantBlocks.swift +++ b/Sources/AgentWorkbench/NativeAssistantBlocks.swift @@ -228,6 +228,9 @@ private final class NativeCodeCardView: NativeAssistantBlockView { private let separator = NSView() private let scroll = NSScrollView() private let document = NSView() + /// Masks the viewport while the code overflows, like the SwiftUI card's + /// `ReplyOverflowFade`: opaque, then clear across the trailing fade width. + private let fade = CAGradientLayer() private var codeText: ReplyTextView? private var gutter: ReplyTextView? private var source = "" @@ -249,6 +252,10 @@ private final class NativeCodeCardView: NativeAssistantBlockView { scroll.borderType = .noBorder scroll.verticalScrollElasticity = .none scroll.documentView = document + scroll.wantsLayer = true + fade.startPoint = CGPoint(x: 0, y: 0.5) + fade.endPoint = CGPoint(x: 1, y: 0.5) + fade.colors = [NSColor.black.cgColor, NSColor.black.cgColor, NSColor.clear.cgColor] for view in [label, copy, separator, scroll] { addSubview(view) } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -318,6 +325,7 @@ private final class NativeCodeCardView: NativeAssistantBlockView { if scroll.frame != scrollFrame { scroll.frame = scrollFrame } let documentFrame = CGRect(x: 0, y: 0, width: codeX + text.width + Card.inset, height: text.height + Card.inset * 2) if document.frame != documentFrame { document.frame = documentFrame } + updateFade(overflows: documentFrame.width > scrollFrame.width + 0.5) if let gutterView = self.gutter { let frame = CGRect(x: Card.gutterLeading, y: Card.inset, width: gutter.width, height: gutter.height) if gutterView.frame != frame { gutterView.frame = frame } @@ -327,6 +335,22 @@ private final class NativeCodeCardView: NativeAssistantBlockView { if codeText.frame != frame { codeText.frame = frame } } } + /// A layer mask on the viewport: frames and hit testing stay as they are. + private func updateFade(overflows: Bool) { + guard let layer = scroll.layer else { return } + let size = scroll.bounds.size + guard overflows, size.width > ReplyStyle.overflowFadeWidth else { + if layer.mask != nil { layer.mask = nil } + return + } + // A standalone layer animates implicitly; the fade must track the frame. + CATransaction.begin() + CATransaction.setDisableActions(true) + fade.frame = CGRect(origin: .zero, size: size) + fade.locations = [0, NSNumber(value: Double(1 - ReplyStyle.overflowFadeWidth / size.width)), 1] + if layer.mask !== fade { layer.mask = fade } + CATransaction.commit() + } override func teardown() { if let gutter { gutter.update(NSAttributedString(string: "")) diff --git a/Sources/AgentWorkbench/ReplyAlertBlock.swift b/Sources/AgentWorkbench/ReplyAlertBlock.swift index cf9b57a..edd97a0 100644 --- a/Sources/AgentWorkbench/ReplyAlertBlock.swift +++ b/Sources/AgentWorkbench/ReplyAlertBlock.swift @@ -1,6 +1,14 @@ +import AppKit import SwiftUI import WorkbenchCore +/// Plain yellow text is hard to read on a light background: the warning title is +/// amber there and keeps system yellow in dark. Its bar and icon keep the tint. +private let warningTitleInk = NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + ? .systemYellow : NSColor(srgbRed: 0.60, green: 0.40, blue: 0.00, alpha: 1) +} + extension ReplyAlertKind { var title: String { switch self { @@ -29,4 +37,11 @@ extension ReplyAlertKind { case .caution: return .red } } + /// The title's ink; the bar and icon use `tint`. + var titleInk: Color { + switch self { + case .warning: return Color(nsColor: warningTitleInk) + case .note, .tip, .important, .caution: return tint + } + } } diff --git a/Sources/AgentWorkbench/ReplyMarkdownView.swift b/Sources/AgentWorkbench/ReplyMarkdownView.swift index fadfb85..3de616f 100644 --- a/Sources/AgentWorkbench/ReplyMarkdownView.swift +++ b/Sources/AgentWorkbench/ReplyMarkdownView.swift @@ -37,6 +37,15 @@ enum ReplyStyle { appearance.performAsCurrentDrawingAppearance { ink = NSColor.labelColor.withAlphaComponent(0.9) } return ink } + /// `ReplyTone.quiet`: compaction notes and runtime context read one step down, + /// in secondary ink. Weight still marks headings and strong text. + static let quietSize: CGFloat = 12 + static let quietLineHeight: CGFloat = 18 + static let quietInk = NSColor.secondaryLabelColor + /// Code and tables wider than the column fade out over this trailing width. + /// No wider than their 12 pt trailing inset (`Code.inset`, table cell padding), + /// so scrolled to the end no glyph fades. + static let overflowFadeWidth: CGFloat = 12 static let tableCellSize: CGFloat = 13 /// Column totals are arithmetic over estimated content widths, so a table /// never probes TextKit for widths and cannot feed the transcript layout. @@ -88,6 +97,26 @@ enum ReplyStyle { static let verticalInset: CGFloat = 4 static let barWidth: CGFloat = 2 } + /// Table cells and rules; horizontal cell padding lives in `tableGeometry`. + enum Table { + static let cellVerticalPadding: CGFloat = 8 + /// The rule under the header reads stronger than the rules between rows. + static let headerRuleOpacity: Double = 0.12 + static let rowRuleOpacity: Double = 0.06 + } +} + +/// How prose in a reply reads: the body, or quiet secondary notes (compaction notes, runtime context). +enum ReplyTone: Equatable { case body, quiet } +private struct ReplyToneKey: EnvironmentKey { static let defaultValue = ReplyTone.body } +extension EnvironmentValues { + /// `.quiet` sets `ReplyText` (and list markers) at `ReplyStyle.quietSize` on + /// `quietLineHeight` in `quietInk`. SwiftUI rows only: native rows never admit + /// quiet content. Set with `.environment(\.replyTone, .quiet)`. + var replyTone: ReplyTone { + get { self[ReplyToneKey.self] } + set { self[ReplyToneKey.self] = newValue } + } } extension ReplyStyle.BlockKind { @@ -113,6 +142,11 @@ enum UserMessageStyle { static let maxWidthRatio: CGFloat = 0.85 static let topSpacing: CGFloat = 8 static let paragraphSpacing: CGFloat = ReplyStyle.paragraphSpacing + /// The fold toggle under a long prompt, as `LongUserPrompt` sets it: 12 pt + /// secondary text on a 24 pt row, 6 pt below the prompt. + static let toggleFontSize: CGFloat = 12 + static let toggleSpacing: CGFloat = 6 + static let toggleHeight: CGFloat = 24 } enum ReplyParagraphLayout { @@ -311,10 +345,6 @@ private struct ReplyBlockContent: View, Equatable { ForEach(Array(items.enumerated()), id: \.offset) { _, item in HStack(alignment: .firstTextBaseline, spacing: ReplyStyle.ListLayout.markerGap) { ReplyListMarker(marker: item.marker) - .fixedSize() - .alignmentGuide(.firstTextBaseline) { _ in - ceil(NSFont.systemFont(ofSize: ReplyStyle.bodySize).ascender) - } .frame(width: markerWidth, alignment: .trailing) // Type erasure breaks the recursive SwiftUI view type, not the document hierarchy. AnyView(ReplyBlocks(blocks: item.blocks, compact: true, coalescesParagraphs: coalescesParagraphs)) @@ -344,8 +374,12 @@ private struct ReplyAlertBlock: View { var coalescesParagraphs = true var body: some View { VStack(alignment: .leading, spacing: 6) { - Label(kind.title, systemImage: kind.symbol) - .font(.system(size: 12, weight: .semibold)).foregroundStyle(kind.tint) + // The bar and icon carry the kind's tint; the title has its own ink. + Label { + Text(kind.title).foregroundStyle(kind.titleInk) + } icon: { + Image(systemName: kind.symbol).foregroundStyle(kind.tint) + }.font(.system(size: 12, weight: .semibold)) AnyView(ReplyBlocks(blocks: blocks, compact: true, coalescesParagraphs: coalescesParagraphs)) } .padding(.leading, ReplyStyle.Quote.leadingInset).padding(.vertical, ReplyStyle.Quote.verticalInset) @@ -385,17 +419,36 @@ private struct ReplyAlertBlock: View { /// Use the same marker metrics as native transcript rows. SwiftUI Text's rounded /// baseline can otherwise add a pixel to every list item at some font sizes. -private struct ReplyListMarker: NSViewRepresentable { +/// Quiet prose sets its markers like its text: quiet size, secondary ink. +private struct ReplyListMarker: View { let marker: String + @Environment(\.replyTone) private var tone + var body: some View { + let quiet = tone == .quiet + let size = quiet ? ReplyStyle.quietSize : ReplyStyle.bodySize + ReplyListMarkerLabel(marker: marker, size: size, ink: quiet ? ReplyStyle.quietInk : ReplyStyle.nativeInk) + .fixedSize() + .alignmentGuide(.firstTextBaseline) { _ in ceil(NSFont.systemFont(ofSize: size).ascender) } + } +} + +private struct ReplyListMarkerLabel: NSViewRepresentable { + let marker: String + let size: CGFloat + let ink: NSColor func makeNSView(context: Context) -> NSTextField { let field = NSTextField(labelWithString: marker) - field.font = .systemFont(ofSize: ReplyStyle.bodySize) - field.textColor = ReplyStyle.nativeInk + field.font = .systemFont(ofSize: size) + field.textColor = ink field.alignment = .right field.lineBreakMode = .byClipping return field } - func updateNSView(_ field: NSTextField, context: Context) { field.stringValue = marker } + func updateNSView(_ field: NSTextField, context: Context) { + field.stringValue = marker + if field.font?.pointSize != size { field.font = .systemFont(ofSize: size) } + if field.textColor !== ink { field.textColor = ink } + } func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSTextField, context: Context) -> CGSize { nsView.fittingSize } @@ -445,15 +498,6 @@ private struct ReplyListLayout: Layout { } } -private struct ReplyInkKey: EnvironmentKey { - static let defaultValue = ReplyStyle.nativeInk -} -private extension EnvironmentValues { - var replyInk: NSColor { - get { self[ReplyInkKey.self] } - set { self[ReplyInkKey.self] = newValue } - } -} private struct ReplyLineHeightKey: EnvironmentKey { static let defaultValue = ReplyStyle.lineHeight } @@ -620,7 +664,7 @@ enum CodeLineNumbers { } private struct ReplyText: View { - @Environment(\.replyInk) private var ink + @Environment(\.replyTone) private var tone @Environment(\.replyLineHeight) private var defaultLineHeight let runs: [ReplyInline] var size: CGFloat = ReplyStyle.bodySize @@ -632,9 +676,15 @@ private struct ReplyText: View { var preservesSelectionOnAppend = false /// Live reveal cap in UTF-16 units; nil renders the complete text. var truncateAt: Int? = nil - private var resolvedLineHeight: CGFloat { lineHeight ?? defaultLineHeight } + /// Quiet prose sets every run, headings and table cells included, at the quiet + /// size and line height in secondary ink; weight still marks the hierarchy. + private var resolvedSize: CGFloat { tone == .quiet ? ReplyStyle.quietSize : size } + private var resolvedLineHeight: CGFloat { + tone == .quiet ? ReplyStyle.quietLineHeight : (lineHeight ?? defaultLineHeight) + } + private var ink: NSColor { tone == .quiet ? ReplyStyle.quietInk : ReplyStyle.nativeInk } private var attributed: NSAttributedString { - let full = ReplyTextAttributes.paragraphs([runs] + followingParagraphs, size: size, weight: weight, + let full = ReplyTextAttributes.paragraphs([runs] + followingParagraphs, size: resolvedSize, weight: weight, alignment: alignment, lineHeight: resolvedLineHeight, paragraphSpacing: paragraphSpacing, ink: ink) guard let truncateAt, truncateAt < full.length else { return full } @@ -643,7 +693,7 @@ private struct ReplyText: View { var body: some View { SelectableReplyText(attributed: attributed, preservesSelectionOnAppend: preservesSelectionOnAppend) .alignmentGuide(.firstTextBaseline) { _ in - let font = NSFont.systemFont(ofSize: size) + let font = NSFont.systemFont(ofSize: resolvedSize) let naturalHeight = ceil(font.ascender - font.descender + font.leading) // TextKit adds minimum-line-height leading above the glyph baseline. return ceil(font.ascender) + max(0, resolvedLineHeight - naturalHeight) @@ -836,8 +886,11 @@ final class NativeUserMessageView: NSView { promptClip.wantsLayer = true promptClip.layer?.masksToBounds = true addSubview(promptClip) - expandButton.bezelStyle = .inline - expandButton.font = .systemFont(ofSize: 12) + // LongUserPrompt's plain button: secondary text at the prompt's text inset, + // no bezel. `place` sets the title in secondary ink. + expandButton.isBordered = false + expandButton.alignment = .left + expandButton.font = .systemFont(ofSize: UserMessageStyle.toggleFontSize) expandButton.target = self expandButton.action = #selector(togglePrompt) expandButton.identifier = NSUserInterfaceItemIdentifier("user-prompt-toggle") @@ -916,7 +969,8 @@ final class NativeUserMessageView: NSView { let natural = textHeight + gaps let folds = natural > UserMessageStyle.foldThreshold let visible = folds && !expanded ? UserMessageStyle.collapsedHeight : natural - return CGSize(width: width, height: UserMessageStyle.topSpacing + UserMessageStyle.verticalPadding * 2 + visible + (folds ? 30 : 0)) + let toggle = folds ? UserMessageStyle.toggleSpacing + UserMessageStyle.toggleHeight : 0 + return CGSize(width: width, height: UserMessageStyle.topSpacing + UserMessageStyle.verticalPadding * 2 + visible + toggle) } func place(width: CGFloat) { let layout = measurements(width: width) @@ -927,10 +981,26 @@ final class NativeUserMessageView: NSView { promptClip.frame = CGRect(x: x, y: UserMessageStyle.topSpacing + UserMessageStyle.verticalPadding, width: layout.bubbleWidth, height: visible) expandButton.isHidden = !folds - expandButton.title = L(expanded ? "收起问题" : "展开完整问题") - expandButton.setAccessibilityLabel(expandButton.title) - expandButton.frame = CGRect(x: x + UserMessageStyle.horizontalPadding, - y: promptClip.frame.maxY + 4, width: max(1, layout.bubbleWidth - UserMessageStyle.horizontalPadding * 2), height: 24) + if folds { + let title = L(expanded ? "收起问题" : "展开完整问题") + if expandButton.title != title { + let paragraph = NSMutableParagraphStyle(); paragraph.alignment = .left + expandButton.attributedTitle = NSAttributedString(string: title, attributes: [ + .font: NSFont.systemFont(ofSize: UserMessageStyle.toggleFontSize), + .foregroundColor: NSColor.secondaryLabelColor, .paragraphStyle: paragraph + ]) + expandButton.setAccessibilityLabel(title) + } + // At the text inset, toggleSpacing below the prompt, on the row + // LongUserPrompt gives its button. It hugs its title like that button, + // so the bubble's blank corner is no hit target. + let textWidth = max(1, layout.bubbleWidth - UserMessageStyle.horizontalPadding * 2) + let titleWidth = expandButton.intrinsicContentSize.width + expandButton.frame = CGRect(x: x + UserMessageStyle.horizontalPadding, + y: promptClip.frame.maxY + UserMessageStyle.toggleSpacing, + width: titleWidth > 0 ? min(titleWidth, textWidth) : textWidth, + height: UserMessageStyle.toggleHeight) + } var y: CGFloat = 0 for (text, size) in zip(texts, layout.sizes) { let frame = CGRect(x: UserMessageStyle.horizontalPadding, y: y, @@ -1398,12 +1468,7 @@ private struct ReplyCode: View { ReplyCopyButton(text: source, label: "复制代码") }.padding(.horizontal, ReplyStyle.Code.headerHorizontalPadding).padding(.vertical, ReplyStyle.Code.headerVerticalPadding) Rectangle().fill(.primary.opacity(0.055)).frame(height: 1) - // The settled native card uses an overlay scroller that takes no height. - // When macOS shows legacy scrollers (a mouse is attached), a SwiftUI - // scroll view reserves a scroller band below an overflowing line once it - // is displayed, so this card would be taller than the native card it - // settles into. Never reserve it; trackpad and Shift-wheel still scroll. - ScrollView(.horizontal) { + ReplyHorizontalScroll { HStack(alignment: .top, spacing: 0) { if lineCount > 1 { SelectableReplyText(attributed: ReplyTextAttributes.codeGutter(lineCount), selectable: false) @@ -1418,12 +1483,49 @@ private struct ReplyCode: View { bottom: ReplyStyle.Code.inset, trailing: ReplyStyle.Code.inset)) } } - .scrollIndicators(.never, axes: .horizontal) }.background(ReplyStyle.paper, in: RoundedRectangle(cornerRadius: ReplyStyle.Code.cornerRadius)) .overlay(RoundedRectangle(cornerRadius: ReplyStyle.Code.cornerRadius).strokeBorder(.primary.opacity(0.055))) } } +/// Sideways scrolling for code cards and tables wider than the column. While the +/// content is wider than the viewport, its trailing edge fades out so a cut-off +/// line reads as continuing; `NativeCodeCardView` draws the same fade. The fade is +/// a mask, so layout and hit testing are untouched, and the flag it keeps feeds no +/// layout: it cannot start a geometry loop. +private struct ReplyHorizontalScroll: View { + @ViewBuilder let content: Content + @State private var overflows = false + var body: some View { + // The settled native card uses an overlay scroller that takes no height. + // When macOS shows legacy scrollers (a mouse is attached), a SwiftUI + // scroll view reserves a scroller band below an overflowing line once it + // is displayed, so a card would be taller than the native card it settles + // into. Never reserve it; trackpad and Shift-wheel still scroll. + ScrollView(.horizontal) { + content.onGeometryChange(for: Bool.self) { proxy in + proxy.size.width > (proxy.bounds(of: .scrollView)?.width ?? proxy.size.width) + 0.5 + } action: { overflows = $0 } + } + .scrollIndicators(.never, axes: .horizontal) + .mask { ReplyOverflowFade(active: overflows) } + } +} + +/// Opaque, then clear across the trailing `overflowFadeWidth`; fully opaque while inactive. +private struct ReplyOverflowFade: View { + let active: Bool + var body: some View { + HStack(spacing: 0) { + Rectangle().fill(Color.black) + LinearGradient(colors: [Color.black, active ? Color.clear : Color.black], + startPoint: .leading, endPoint: .trailing) + .frame(width: ReplyStyle.overflowFadeWidth) + .flipsForRightToLeftLayoutDirection(true) + } + } +} + private struct ReplyTable: View { let headers: [[ReplyInline]] let rows: [[[ReplyInline]]] @@ -1443,7 +1545,7 @@ private struct ReplyTable: View { var body: some View { // Fit the viewport first; only the minimum column widths can cause overflow. - ScrollView(.horizontal) { + ReplyHorizontalScroll { ReplyTableLayout(contents: contents, geometry: ReplyStyle.tableGeometry, columnCount: headers.count) { cells } @@ -1455,13 +1557,15 @@ private struct ReplyTable: View { } /// Header cells first, then one separator and one row of cells per data row: - /// the order `ReplyTableLayout` walks. + /// the order `ReplyTableLayout` walks. The first separator is the header rule. @ViewBuilder private var cells: some View { ForEach(Array(headers.indices), id: \.self) { column in cell(headers[column], column: column, header: true) } - ForEach(Array(rows.enumerated()), id: \.offset) { _, row in - Rectangle().fill(.primary.opacity(0.06)).frame(height: 1) + ForEach(Array(rows.enumerated()), id: \.offset) { index, row in + Rectangle() + .fill(.primary.opacity(index == 0 ? ReplyStyle.Table.headerRuleOpacity : ReplyStyle.Table.rowRuleOpacity)) + .frame(height: 1) ForEach(Array(headers.indices), id: \.self) { column in cell(column < row.count ? row[column] : [], column: column, header: false) } @@ -1471,7 +1575,8 @@ private struct ReplyTable: View { private func cell(_ runs: [ReplyInline], column: Int, header: Bool) -> some View { ReplyText(runs: runs, size: ReplyStyle.tableCellSize, weight: header ? .medium : .regular, alignment: alignments[column] == .trailing ? .trailing : alignments[column] == .center ? .center : .leading) - .padding(.horizontal, ReplyStyle.tableGeometry.cellPadding).padding(.vertical, 8) + .padding(.horizontal, ReplyStyle.tableGeometry.cellPadding) + .padding(.vertical, ReplyStyle.Table.cellVerticalPadding) } } diff --git a/Sources/AgentWorkbench/ReplyMathView.swift b/Sources/AgentWorkbench/ReplyMathView.swift index 13a300b..bdfed7c 100644 --- a/Sources/AgentWorkbench/ReplyMathView.swift +++ b/Sources/AgentWorkbench/ReplyMathView.swift @@ -4,13 +4,16 @@ import SwiftMath import WorkbenchCore /// Display math ($$…$$) typeset natively by SwiftMath — no web view, no KaTeX. -/// The image renders once per (latex, mode, appearance) and is cached; the -/// reveal cursor treats the block as an atomic checkpoint. +/// Each formula typesets once per (latex, size, mode), in light and dark ink, and +/// is cached; the reveal cursor treats the block as an atomic checkpoint. struct ReplyMathBlock: View { let latex: String + @Environment(\.colorScheme) private var colorScheme var body: some View { - // Display math is set at the body size, as in typeset text. - if let image = ReplyMathImage.image(latex: latex, size: ReplyStyle.bodySize, display: true) { + // Display math is set at the body size, as in typeset text, in the row's + // color scheme rather than the app's appearance. + if let image = ReplyMathImage.image(latex: latex, size: ReplyStyle.bodySize, display: true, + dark: colorScheme == .dark) { Image(nsImage: image) .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 6) @@ -37,34 +40,60 @@ enum ReplyMathAttachment { } } +/// Both inks typeset the same glyphs, so the light and dark renderings share one +/// size: native and SwiftUI rows measure the same formula in either appearance. private enum ReplyMathImage { private struct Key: Hashable { let latex: String let size: CGFloat let display: Bool - let dark: Bool } - private static var cache: [Key: NSImage] = [:] + private struct Entry { + let light: NSImage + let dark: NSImage + /// Draws `light` or `dark` for the appearance it is drawn in. + let adaptive: NSImage + } + private static var cache: [Key: Entry] = [:] private static var order: [Key] = [] private static var failed: Set = [] + /// For text attachments. TextKit draws an attachment in its text view's + /// appearance, so inline math follows the row's appearance rather than the + /// app's, and a cached attributed string stays right after either changes. static func image(latex: String, size: CGFloat, display: Bool) -> NSImage? { - let dark = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua - let key = Key(latex: latex, size: size, display: display, dark: dark) + entry(latex: latex, size: size, display: display)?.adaptive + } + + /// One appearance's rendering, for SwiftUI views that know their color scheme. + static func image(latex: String, size: CGFloat, display: Bool, dark: Bool) -> NSImage? { + entry(latex: latex, size: size, display: display).map { dark ? $0.dark : $0.light } + } + + private static func entry(latex: String, size: CGFloat, display: Bool) -> Entry? { + let key = Key(latex: latex, size: size, display: display) if let hit = cache[key] { return hit } if failed.contains(latex) { return nil } - let math = MTMathImage(latex: latex, fontSize: size, - textColor: dark ? .white : .black, - labelMode: display ? .display : .text, - textAlignment: .left) - let (error, image) = math.asImage() - guard error == nil, let image else { + func typeset(_ ink: NSColor) -> NSImage? { + let math = MTMathImage(latex: latex, fontSize: size, textColor: ink, + labelMode: display ? .display : .text, + textAlignment: .left) + let (error, image) = math.asImage() + return error == nil ? image : nil + } + guard let light = typeset(.black), let dark = typeset(.white) else { failed.insert(latex) return nil } + let adaptive = NSImage(size: light.size, flipped: false) { rect in + let isDark = NSAppearance.currentDrawing().bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + (isDark ? dark : light).draw(in: rect) + return true + } if order.count >= 128 { cache.removeValue(forKey: order.removeFirst()) } - cache[key] = image + let entry = Entry(light: light, dark: dark, adaptive: adaptive) + cache[key] = entry order.append(key) - return image + return entry } } diff --git a/Tests/NavigationPreview/ParagraphExperiment.swift b/Tests/NavigationPreview/ParagraphExperiment.swift index 23bf532..02e58ce 100644 --- a/Tests/NavigationPreview/ParagraphExperiment.swift +++ b/Tests/NavigationPreview/ParagraphExperiment.swift @@ -172,6 +172,7 @@ extension NavigationRunner { ] } var observations: [[String: Any]] = [] + var foldToggles = 0 for width in [CGFloat(700), 340] { for dark in [false, true] { for (source, markers) in cases { @@ -241,6 +242,20 @@ extension NavigationRunner { throw NavigationError("Native user bubble lost its compact line height or symmetric 8pt insets") } } + // The fold toggle reads like LongUserPrompt's plain button: borderless, + // left-aligned at the text inset, toggleSpacing below the prompt. The + // height it adds is covered by the height comparison below. + if let toggle = native.subviews.first(where: { $0.identifier?.rawValue == "user-prompt-toggle" }) as? NSButton, + !toggle.isHidden { + guard let text = texts(native).last, let prompt = text.superview, + !toggle.isBordered, toggle.alignment == .left, + abs(toggle.frame.minX - text.convert(text.bounds, to: native).minX) <= 0.5, + abs(toggle.frame.minY - prompt.convert(prompt.bounds, to: native).maxY + - UserMessageStyle.toggleSpacing) <= 0.5 else { + throw NavigationError("Native fold toggle is not a borderless, left-aligned label \(UserMessageStyle.toggleSpacing) pt below the prompt") + } + foldToggles += 1 + } } heights.append(size.height) var positions: [String: CGPoint] = [:] @@ -277,10 +292,14 @@ extension NavigationRunner { throw NavigationError("Rich blocks were admitted with the A/B switch off") } } + // Long fixtures fold at both widths; a vacuous toggle check must not pass. + guard assistant || foldToggles > 0 else { throw NavigationError("No folded user fixture exercised the native fold toggle") } var report: [String: Any] = ["cases": observations, "complex_markdown_retains_swiftui": true, "links_and_unicode_copy": true] if !assistant { report["user_line_height"] = UserMessageStyle.lineHeight report["single_line_bubble_height"] = UserMessageStyle.lineHeight + UserMessageStyle.verticalPadding * 2 + report["fold_toggle"] = ["checked": foldToggles, "borderless": true, "left_aligned": true, + "spacing": UserMessageStyle.toggleSpacing] as [String: Any] } return report } From 2aa7a8990c0b9307e91b87338999c35dcb6cbac1 Mon Sep 17 00:00:00 2001 From: chenweigao <19328388+chenweigao@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:38:38 +0800 Subject: [PATCH 2/3] fix: process rows stay in place when a turn settles; rows follow the language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Live thought and tool rows had no inset while the folded 思考与工具 row and commentary used the disclosure's default 8 pt, so a running turn jumped sideways as it folded. `ReplyStyle.Process` puts every process chevron at the reading column's edge. Nested content uses one 20 pt indent (it was 10, 14, 20 or 24). The fold label is secondary and regular. The hover wash keeps its reach through `leadingWashOutset`. Disclosure stays atomic. - Compaction notes and runtime context use the quiet reply tone. - The plan review preview shows plain text instead of raw Markdown, and its notes are 11 pt. - A language change now reaches transcript rows: the locale is part of `ConversationEntryAppearance`, so rows are reconciled and hosted rows get `.environment(\.locale, …)`. Native rows re-update, and views that build labels in code depend on the locale. - Build lists (navigation, reading, tool-visibility, workflow and performance previews, check-scroll-following) and the performance export test compile the new style file. Co-Authored-By: Claude Opus 5.5 --- .../ConversationTranscriptView.swift | 50 +++++++++++++------ Sources/AgentWorkbench/PlanReviewView.swift | 19 +++++-- .../AgentWorkbench/ReplyProcessStyle.swift | 34 +++++++++++++ Sources/AgentWorkbench/ToolActivityView.swift | 26 ++++++---- Sources/AgentWorkbench/WorkbenchGlass.swift | 5 ++ Tests/Performance/test_preview_sources.py | 2 +- scripts/build-navigation-preview.sh | 2 +- scripts/build-performance-preview.sh | 2 +- scripts/build-reading-preview.sh | 2 +- scripts/build-tool-visibility-preview.sh | 2 +- scripts/build-workflow-preview.sh | 2 +- scripts/check-scroll-following.py | 2 +- 12 files changed, 112 insertions(+), 36 deletions(-) create mode 100644 Sources/AgentWorkbench/ReplyProcessStyle.swift diff --git a/Sources/AgentWorkbench/ConversationTranscriptView.swift b/Sources/AgentWorkbench/ConversationTranscriptView.swift index 4be2474..9f2f19b 100644 --- a/Sources/AgentWorkbench/ConversationTranscriptView.swift +++ b/Sources/AgentWorkbench/ConversationTranscriptView.swift @@ -49,8 +49,8 @@ private struct CompactionSummaryRow: View { .accessibilityValue(Text(expanded ? "已展开" : "已收起")) if expanded { KimiMarkdown(text: CompactionSummaryDisplay.humanText(text)) - .font(.system(size: 12)).foregroundStyle(.secondary) - .padding(.horizontal, 24) + .environment(\.replyTone, .quiet) + .padding(.horizontal, ReplyStyle.Process.nestedIndent) } } } @@ -98,7 +98,12 @@ struct KimiMessageView: View { .trimmingCharacters(in: .whitespacesAndNewlines) } private func runtimeContext(_ text: String) -> some View { - DisclosureGroup("运行上下文") { KimiMarkdown(text: text) }.disclosureGroupStyle(WorkbenchDisclosureStyle(horizontalPadding: 0)).font(.system(size: 12)).foregroundStyle(.secondary) + DisclosureGroup { + KimiMarkdown(text: text).environment(\.replyTone, .quiet) + .padding(.leading, ReplyStyle.Process.nestedIndent) + } label: { + Text("运行上下文").font(.system(size: ReplyStyle.Process.labelSize)).foregroundStyle(.secondary) + }.disclosureGroupStyle(ProcessDisclosureStyle()) } private func compactionSummary(_ text: String) -> some View { CompactionSummaryRow(text: text) @@ -1281,6 +1286,8 @@ private struct ConversationEntryAppearance: Equatable { let reduceMotion: Bool let isEnabled: Bool let actionContext: ConversationActionContext? + /// Hosted rows are separate SwiftUI roots; the app language reaches them only here. + let locale: Locale init(_ environment: EnvironmentValues) { colorScheme = environment.colorScheme layoutDirection = environment.layoutDirection @@ -1289,6 +1296,7 @@ private struct ConversationEntryAppearance: Equatable { reduceMotion = environment.accessibilityReduceMotion || environment.conversationReduceMotion isEnabled = environment.isEnabled actionContext = environment.conversationActionContext + locale = environment.locale } } @@ -1313,6 +1321,7 @@ private struct HostedConversationEntry: View { .environment(\.conversationReduceMotion, appearance.reduceMotion) .environment(\.isEnabled, appearance.isEnabled) .environment(\.conversationActionContext, appearance.actionContext) + .environment(\.locale, appearance.locale) // Match Perch's ink and monochrome disclosure/control tint in both appearances. .foregroundStyle(WorkbenchTheme.ink) .tint(WorkbenchTheme.accent) @@ -1562,6 +1571,12 @@ private final class ConversationEntryController: NSViewController { if contentChanged { NavigationRenderMetrics.count("host_content_update") } if appearanceChanged { NavigationRenderMetrics.count("host_appearance_update") } #endif + // A settled reply skips equal blocks, so its looked-up labels would keep + // the previous language. Rebuild it from the pool; user rows re-place. + if appearance.locale != nextAppearance.locale, let nativeAssistant { + NativeAssistantMessageView.recycle(nativeAssistant) + self.nativeAssistant = nil + } content = next appearance = nextAppearance generation += 1 @@ -2020,8 +2035,9 @@ private struct ConversationEntryView: View, Equatable { KimiMessageView(message: message, tools: tools, api: api, sessionId: sessionId, markdownPreparation: markdownPreparation) } - }.padding(.top, 10) } - }.disclosureGroupStyle(WorkbenchDisclosureStyle()).font(.system(size: 12)).foregroundStyle(.secondary) + }.padding(.top, 10).padding(.leading, ReplyStyle.Process.nestedIndent) } + }.disclosureGroupStyle(ProcessDisclosureStyle()) + .font(.system(size: ReplyStyle.Process.labelSize)).foregroundStyle(.secondary) case .thinkingDetails: ThoughtDisclosure(text: entry.messages.flatMap(\.content).compactMap(\.thinking).joined(separator: "\n\n")) case .thinkingPreview, .thinkingRecord: @@ -2068,6 +2084,7 @@ struct ProcessFoldRow: View { let rows: [ConversationPresentationModel.Row] let api: KimiAPI? let sessionId: String + @UILocalization private var L @Environment(\.conversationMemoryKey) private var memoryKey @RememberedExpansion("process-fold") private var expanded @Environment(\.conversationDisclosureWillChange) private var disclosureWillChange @@ -2089,7 +2106,7 @@ struct ProcessFoldRow: View { var body: some View { DisclosureGroup(isExpanded: $expanded) { if expanded { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: ReplyStyle.Process.rowSpacing) { if page.count > 1 { HStack(spacing: 12) { Button("上一页") { changePage(-1) }.disabled(page.number == 1) @@ -2097,7 +2114,7 @@ struct ProcessFoldRow: View { Text(L("第 \(page.number) / \(page.count) 页")).monospacedDigit() Button("下一页") { changePage(1) }.disabled(page.number == page.count) .accessibilityIdentifier("process-next-page") - }.buttonStyle(.borderless).font(.system(size: 12)) + }.buttonStyle(.borderless).font(.system(size: ReplyStyle.Process.labelSize)) } ForEach(Array(rows[page.range]), id: \.entry.id) { row in ConversationEntryView(entry: row.entry, tools: row.tools, api: api, sessionId: sessionId, @@ -2106,19 +2123,19 @@ struct ProcessFoldRow: View { .environment(\.isConversationBodyText, true) .background(ProcessFindAnchor(id: row.entry.id)) } - }.padding(.top, 12).padding(.leading, 14) + }.padding(.top, ReplyStyle.Process.rowSpacing).padding(.leading, ReplyStyle.Process.nestedIndent) .overlay(alignment: .leading) { - Rectangle().fill(.primary.opacity(0.12)).frame(width: 1).padding(.top, 12) + Rectangle().fill(.primary.opacity(0.12)).frame(width: 1).padding(.top, ReplyStyle.Process.rowSpacing) } } } label: { + // Secondary ink at regular weight, like the live process rows it replaces. HStack(spacing: 7) { Image(systemName: "list.bullet.rectangle").accessibilityHidden(true) - Text("思考与工具").fontWeight(.medium).foregroundStyle(Color.primary.opacity(0.75)) + Text("思考与工具") Text(toolCalls > 0 ? L("\(toolCalls) 次调用") : L("\(rows.count) 条记录")) - .foregroundStyle(.secondary) - }.font(.system(size: 12)).foregroundStyle(Color.secondary) - }.disclosureGroupStyle(WorkbenchDisclosureStyle()) + }.font(.system(size: ReplyStyle.Process.labelSize)).foregroundStyle(.secondary) + }.disclosureGroupStyle(ProcessDisclosureStyle()) .accessibilityValue(Text(expanded ? "已展开" : "已收起")) } } @@ -2133,11 +2150,13 @@ private enum ThoughtTextStyle { static let attributes: [NSAttributedString.Ke }() } +/// Expanded thoughts sit under their toggle's label, like every nested process row. private struct ThoughtText: View { let text: String var body: some View { DisclosureReplyText(text: text, attributes: ThoughtTextStyle.attributes) .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, ReplyStyle.Process.nestedIndent) } } @@ -2147,8 +2166,8 @@ private struct ThoughtToggle: View { @Binding var expanded: Bool var body: some View { DisclosureGroup(isExpanded: $expanded) { EmptyView() } label: { - title.font(.system(size: 12)).foregroundStyle(.secondary) - }.disclosureGroupStyle(WorkbenchDisclosureStyle(horizontalPadding: 0)) + title.font(.system(size: ReplyStyle.Process.labelSize)).foregroundStyle(.secondary) + }.disclosureGroupStyle(ProcessDisclosureStyle()) } } @@ -2184,6 +2203,7 @@ private struct ThoughtOutput: View { ThoughtText(text: fullText) } else if expanded { ThinkingTextViewport(text: fullText, overflows: $previewOverflows) + .padding(.leading, ReplyStyle.Process.nestedIndent) } } .popover(isPresented: $showFullText, arrowEdge: .top) { diff --git a/Sources/AgentWorkbench/PlanReviewView.swift b/Sources/AgentWorkbench/PlanReviewView.swift index d637a09..dd2651c 100644 --- a/Sources/AgentWorkbench/PlanReviewView.swift +++ b/Sources/AgentWorkbench/PlanReviewView.swift @@ -60,6 +60,15 @@ struct NativePlanReviewDock: View { } } +/// The plan's first lines as plain text, without Markdown syntax or blank lines. +/// Equatable, so typing review feedback does not parse the plan again. +private struct PlanPreviewText: View, Equatable { + let content: String + var body: some View { + Text(verbatim: ReplyPlainText.text(from: content).split(whereSeparator: \.isNewline).joined(separator: "\n")) + } +} + struct PlanReviewPanel: View { @Bindable var state: PlanReviewState let online: Bool @@ -77,8 +86,10 @@ struct PlanReviewPanel: View { Spacer() if state.submitting { ProgressView().controlSize(.small) } } - Text(plan.content.isEmpty ? L("尚未收到完整计划,请要求 Agent 补充后再批准。") : plan.content) - .font(.callout).foregroundStyle(.secondary).lineLimit(3).textSelection(.enabled) + Group { + if plan.content.isEmpty { Text("尚未收到完整计划,请要求 Agent 补充后再批准。") } + else { PlanPreviewText(content: plan.content).equatable() } + }.font(.callout).foregroundStyle(.secondary).lineLimit(3).textSelection(.enabled) HStack { Button("查看完整计划") { showPlan = true }.disabled(plan.content.isEmpty) ReplyCopyButton(text: plan.content, label: "复制计划").disabled(plan.content.isEmpty) @@ -89,10 +100,10 @@ struct PlanReviewPanel: View { if !state.canDecide { Button("关闭提示") { state.dismissNotice() } } } if plan.deferred { - Text("Agent 仍在等待,继续审批后再决定。").font(.caption).foregroundStyle(.secondary) + Text("Agent 仍在等待,继续审批后再决定。").font(.subheadline).foregroundStyle(.secondary) Button("继续审批") { submit("resume") }.disabled(!state.canDecide || !online) } else { - Text("批准本次计划,工具权限沿用当前设置。").font(.caption).foregroundStyle(.secondary) + Text("批准本次计划,工具权限沿用当前设置。").font(.subheadline).foregroundStyle(.secondary) if state.editing { TextField("填写修改意见", text: $state.feedback, axis: .vertical) .lineLimit(3...8).textFieldStyle(.roundedBorder).focused($focused) diff --git a/Sources/AgentWorkbench/ReplyProcessStyle.swift b/Sources/AgentWorkbench/ReplyProcessStyle.swift new file mode 100644 index 0000000..56e15fd --- /dev/null +++ b/Sources/AgentWorkbench/ReplyProcessStyle.swift @@ -0,0 +1,34 @@ +import SwiftUI + +extension ReplyStyle { + /// Process rows: thoughts, tools, activity groups, progress notes, runtime + /// context, compaction notes and the fold a settled turn collapses them into. + /// Live rows and the fold share this geometry, so a running turn folds in + /// place instead of moving sideways. + enum Process { + /// From a row's leading edge to its chevron (and from the header's last + /// label to its trailing edge). Zero puts top-level chevrons on the reading + /// column's leading edge, live and folded alike. + static let leadingInset: CGFloat = 0 + /// The hover wash reaches this far before the chevron. Negative padding + /// keeps the header in place; a top-level row clips it at the column edge. + static let washOutset: CGFloat = 8 + /// Nested content (rows, tool details, thoughts, notes) starts under its + /// header's label: the disclosure's 12 pt chevron column and its 8 pt gap. + static let nestedIndent: CGFloat = 20 + /// Header labels, a step below the 14 pt body. + static let labelSize: CGFloat = 12 + /// Between the rows of an expanded fold, and above its first row. + static let rowSpacing: CGFloat = 12 + } +} + +/// The disclosure every process row uses: `WorkbenchDisclosureStyle`'s full-width +/// header and content mechanics, at the `ReplyStyle.Process` inset. +struct ProcessDisclosureStyle: DisclosureGroupStyle { + func makeBody(configuration: Configuration) -> some View { + WorkbenchDisclosureStyle(horizontalPadding: ReplyStyle.Process.leadingInset, + leadingWashOutset: ReplyStyle.Process.washOutset) + .makeBody(configuration: configuration) + } +} diff --git a/Sources/AgentWorkbench/ToolActivityView.swift b/Sources/AgentWorkbench/ToolActivityView.swift index 738ce17..2987b11 100644 --- a/Sources/AgentWorkbench/ToolActivityView.swift +++ b/Sources/AgentWorkbench/ToolActivityView.swift @@ -11,7 +11,7 @@ struct ActivityNarrativeHistoryView: View { .foregroundStyle(narrative.phase == .blocked ? Color.orange : Color.secondary) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 3) { - Text(narrative.headline).font(.system(size: 12, weight: .medium)) + Text(narrative.headline).font(.system(size: ReplyStyle.Process.labelSize, weight: .medium)) .foregroundStyle(.primary) .fixedSize(horizontal: false, vertical: true) if let detail = narrative.detail, !detail.isEmpty { @@ -31,6 +31,9 @@ struct ActivityNarrativeHistoryView: View { /// failed and uncertain tools remain visible even when the stage is collapsed. struct KimiActivityView: View { @Environment(\.conversationMemoryKey) private var memoryKey + /// Core lookups (recent targets) read the app language; depending on the + /// locale re-runs them when the language changes. + @Environment(\.locale) private var locale @RememberedExpansion("expanded") private var expanded let entry: ConversationTimelineEntry let tools: [String: VisibleTool] @@ -62,10 +65,10 @@ struct KimiActivityView: View { Spacer(minLength: 0) let attention = items.filter { $0.staysVisible && $0.status != .running }.count if attention > 0 { Text("\(attention) 项需关注").foregroundStyle(.orange).fixedSize() } - }.font(.system(size: 12)).foregroundStyle(.secondary) - }.disclosureGroupStyle(WorkbenchDisclosureStyle(horizontalPadding: 0)) + }.font(.system(size: ReplyStyle.Process.labelSize)).foregroundStyle(.secondary) + }.disclosureGroupStyle(ProcessDisclosureStyle()) if expanded, let narrative { - ActivityNarrativeHistoryView(narrative: narrative).padding(.leading, 20) + ActivityNarrativeHistoryView(narrative: narrative).padding(.leading, ReplyStyle.Process.nestedIndent) } // Each phase contains one part. Offset identity also handles thoughts // and tools originating in the same provider message without collisions. @@ -74,12 +77,12 @@ struct KimiActivityView: View { if expanded || tool.staysVisible || tool.status == .failed { KimiToolCard(tool: tool) .environment(\.conversationMemoryKey, memoryKey + ":tool:" + tool.id) - .padding(.leading, expanded ? 20 : 0) + .padding(.leading, expanded ? ReplyStyle.Process.nestedIndent : 0) } } else if expanded { KimiMessageView(message: message, tools: tools, api: api, sessionId: sessionId) .environment(\.conversationMemoryKey, memoryKey + ":part:\(index)") - .padding(.leading, 20) + .padding(.leading, ReplyStyle.Process.nestedIndent) } } } @@ -89,6 +92,9 @@ struct KimiActivityView: View { struct KimiToolCard: View { let tool: VisibleTool var onExpand: (() -> Void)? = nil + /// Action and status names come from core lookups in the app language; + /// depending on the locale re-runs them when the language changes. + @Environment(\.locale) private var locale @RememberedExpansion("expanded") private var expanded // A failed tool's structured output is serialized by exitCodeReport. // Resolve it once per body evaluation and share it across the label. @@ -141,22 +147,22 @@ struct KimiToolCard: View { }.font(.system(size: 11, design: .monospaced)).frame(maxWidth: .infinity, alignment: .leading) .padding(10) .background(ReplyStyle.paper, in: RoundedRectangle(cornerRadius: 6)) - .padding(.leading, 10).padding(.top, 5) + .padding(.leading, ReplyStyle.Process.nestedIndent).padding(.top, 5) } } label: { HStack(spacing: 8) { - Text(ToolPresentation.action(tool.name)).font(.system(size: 12)).lineLimit(1) + Text(ToolPresentation.action(tool.name)).font(.system(size: ReplyStyle.Process.labelSize)).lineLimit(1) if summary != tool.name { Text(summary).lineLimit(1).truncationMode(.middle) } Spacer(minLength: 0) if showsAttention || tool.status == .running || exitReport != nil { Image(systemName: symbol(exitReport: exitReport)).font(.system(size: 11)).accessibilityHidden(true) Text(statusLabel).font(.system(size: 11)).fixedSize() } - }.font(.system(size: 12)).foregroundStyle(showsAttention ? Color.orange : Color.secondary) + }.font(.system(size: ReplyStyle.Process.labelSize)).foregroundStyle(showsAttention ? Color.orange : Color.secondary) .accessibilityElement(children: .combine) .accessibilityLabel(Text(verbatim: "\(tool.name) · \(summary) · \(statusLabel)")) .help(Text(verbatim: [tool.input?["command"].string ?? ToolPresentation.path(tool), statusLabel].compactMap { $0 }.joined(separator: "\n"))) - }.disclosureGroupStyle(WorkbenchDisclosureStyle(horizontalPadding: 0)) + }.disclosureGroupStyle(ProcessDisclosureStyle()) .onChange(of: expanded) { _, value in if value { onExpand?() } } } } diff --git a/Sources/AgentWorkbench/WorkbenchGlass.swift b/Sources/AgentWorkbench/WorkbenchGlass.swift index 8e6c356..6ca57b1 100644 --- a/Sources/AgentWorkbench/WorkbenchGlass.swift +++ b/Sources/AgentWorkbench/WorkbenchGlass.swift @@ -29,6 +29,9 @@ private struct WorkbenchGlassSurface: ViewModifier { struct WorkbenchDisclosureStyle: DisclosureGroupStyle { var minHeight: CGFloat = 28 var horizontalPadding: CGFloat = 8 + /// Extends the hover wash past the header's leading edge without moving the + /// header: padding inside the wash, the same negative padding outside it. + var leadingWashOutset: CGFloat = 0 func makeBody(configuration: Configuration) -> some View { VStack(alignment: .leading, spacing: 0) { @@ -40,9 +43,11 @@ struct WorkbenchDisclosureStyle: DisclosureGroupStyle { configuration.label Spacer(minLength: 0) }.padding(.horizontal, horizontalPadding) + .padding(.leading, leadingWashOutset) .frame(maxWidth: .infinity, minHeight: minHeight, alignment: .leading) .contentShape(Rectangle()) }.buttonStyle(WorkbenchDisclosureButtonStyle()) + .padding(.leading, -leadingWashOutset) .accessibilityValue(configuration.isExpanded ? Text("已展开") : Text("已收起")) WorkbenchDisclosureContent(expanded: configuration.isExpanded) { configuration.content } } diff --git a/Tests/Performance/test_preview_sources.py b/Tests/Performance/test_preview_sources.py index feaf074..25c48b1 100644 --- a/Tests/Performance/test_preview_sources.py +++ b/Tests/Performance/test_preview_sources.py @@ -46,7 +46,7 @@ def setUp(self): (self.repo / PRESENTATION).write_text("// presentation environment\n") (app / "RevealFrameDriver.swift").write_text("// working tree reveal driver\n") # Math, alert and selection sources exist only in the working tree: historical refs skip them. - for name in ("ReplyMathView", "ReplyAlertBlock", "ReplySelection"): + for name in ("ReplyMathView", "ReplyAlertBlock", "ReplySelection", "ReplyProcessStyle"): (app / f"{name}.swift").write_text("// working tree reply block\n") stubs = self.repo / "stubs" stubs.mkdir() diff --git a/scripts/build-navigation-preview.sh b/scripts/build-navigation-preview.sh index 4d1361a..d08a652 100755 --- a/scripts/build-navigation-preview.sh +++ b/scripts/build-navigation-preview.sh @@ -12,7 +12,7 @@ bin_dir="$(swift build --build-system native -c release --show-bin-path)" mkdir -p "$app_dir/Contents/MacOS" "$app_dir/Contents/Resources" app_files=( Sources/AgentWorkbench/WorkbenchTheme.swift - Sources/AgentWorkbench/ToolActivityView.swift + Sources/AgentWorkbench/ToolActivityView.swift Sources/AgentWorkbench/ReplyProcessStyle.swift Sources/AgentWorkbench/ConversationPresentationEnvironment.swift Sources/AgentWorkbench/ConversationTranscriptView.swift Sources/AgentWorkbench/RevealFrameDriver.swift Sources/AgentWorkbench/ConversationReadingMemory.swift Sources/AgentWorkbench/ConversationActions.swift Sources/AgentWorkbench/ConversationBookmarksStore.swift Sources/AgentWorkbench/ImagePreviewPanel.swift Sources/AgentWorkbench/ActivitySummarySettings.swift Sources/AgentWorkbench/ActivitySummaryController.swift Sources/AgentWorkbench/ConversationScrollControls.swift Sources/AgentWorkbench/ReplyMarkdownView.swift Sources/AgentWorkbench/ReplySelection.swift diff --git a/scripts/build-performance-preview.sh b/scripts/build-performance-preview.sh index 5419b7b..7b46bcf 100755 --- a/scripts/build-performance-preview.sh +++ b/scripts/build-performance-preview.sh @@ -45,7 +45,7 @@ if [[ -z "$theme_ref" ]] || git cat-file -e "$theme_ref:Sources/AgentWorkbench/N app_files+=(Sources/AgentWorkbench/NativeAssistantBlocks.swift) fi # Revisions before message actions/bookmarks/image preview, math or alerts do not carry these. -for introduced in ConversationActions ConversationBookmarksStore ImagePreviewPanel ReplyMathView ReplyAlertBlock ReplySelection; do +for introduced in ConversationActions ConversationBookmarksStore ImagePreviewPanel ReplyMathView ReplyAlertBlock ReplySelection ReplyProcessStyle; do if [[ -z "$theme_ref" ]] || git cat-file -e "$theme_ref:Sources/AgentWorkbench/${introduced}.swift" 2>/dev/null; then app_files+=("Sources/AgentWorkbench/${introduced}.swift") fi diff --git a/scripts/build-reading-preview.sh b/scripts/build-reading-preview.sh index 7fd85f8..35dfb10 100755 --- a/scripts/build-reading-preview.sh +++ b/scripts/build-reading-preview.sh @@ -14,7 +14,7 @@ swiftc -target "$(uname -m)-apple-macosx26.0" -O -swift-version 5 -parse-as-libr -I .build/checkouts/swift-markdown/Sources/CAtomic/include \ -I .build/checkouts/swift-cmark/src/include -I .build/checkouts/swift-cmark/extensions/include \ Sources/AgentWorkbench/ReplyMarkdownView.swift Sources/AgentWorkbench/ReplySelection.swift Sources/AgentWorkbench/ReplyMathView.swift Sources/AgentWorkbench/ReplyAlertBlock.swift Sources/AgentWorkbench/WorkbenchTheme.swift Sources/AgentWorkbench/ConversationPresentationEnvironment.swift Sources/AgentWorkbench/ConversationTranscriptView.swift Sources/AgentWorkbench/RevealFrameDriver.swift Sources/AgentWorkbench/NativeAssistantBlocks.swift Sources/AgentWorkbench/ConversationReadingMemory.swift Sources/AgentWorkbench/ConversationActions.swift Sources/AgentWorkbench/ConversationBookmarksStore.swift Sources/AgentWorkbench/ImagePreviewPanel.swift Sources/AgentWorkbench/ActivitySummarySettings.swift Sources/AgentWorkbench/ActivitySummaryController.swift \ - Sources/AgentWorkbench/ToolActivityView.swift Sources/AgentWorkbench/KimiAttachmentView.swift Sources/AgentWorkbench/ModelPicker.swift Sources/AgentWorkbench/ThinkingPicker.swift Sources/AgentWorkbench/AutoCompactStore.swift Sources/AgentWorkbench/UILocalization.swift \ + Sources/AgentWorkbench/ToolActivityView.swift Sources/AgentWorkbench/ReplyProcessStyle.swift Sources/AgentWorkbench/KimiAttachmentView.swift Sources/AgentWorkbench/ModelPicker.swift Sources/AgentWorkbench/ThinkingPicker.swift Sources/AgentWorkbench/AutoCompactStore.swift Sources/AgentWorkbench/UILocalization.swift \ Sources/AgentWorkbench/ConversationScrollControls.swift Sources/AgentWorkbench/WorkbenchGlass.swift \ Sources/AgentWorkbench/ConversationActivityBar.swift Tests/ReadingPreview/App.swift \ "${objects[@]}" -o "$app_dir/Contents/MacOS/ReplyReadingPreview" diff --git a/scripts/build-tool-visibility-preview.sh b/scripts/build-tool-visibility-preview.sh index 3278826..70b2a45 100755 --- a/scripts/build-tool-visibility-preview.sh +++ b/scripts/build-tool-visibility-preview.sh @@ -23,7 +23,7 @@ swiftc -target "$(uname -m)-apple-macosx26.0" -O -swift-version 5 -parse-as-libr -I .build/checkouts/swift-markdown/Sources/CAtomic/include \ -I .build/checkouts/swift-cmark/src/include -I .build/checkouts/swift-cmark/extensions/include \ Sources/AgentWorkbench/ReplyMarkdownView.swift Sources/AgentWorkbench/ReplySelection.swift Sources/AgentWorkbench/ReplyMathView.swift Sources/AgentWorkbench/ReplyAlertBlock.swift Sources/AgentWorkbench/WorkbenchTheme.swift Sources/AgentWorkbench/ConversationPresentationEnvironment.swift Sources/AgentWorkbench/ConversationTranscriptView.swift Sources/AgentWorkbench/RevealFrameDriver.swift Sources/AgentWorkbench/NativeAssistantBlocks.swift Sources/AgentWorkbench/ConversationReadingMemory.swift Sources/AgentWorkbench/ConversationActions.swift Sources/AgentWorkbench/ConversationBookmarksStore.swift Sources/AgentWorkbench/ImagePreviewPanel.swift Sources/AgentWorkbench/ActivitySummarySettings.swift Sources/AgentWorkbench/ActivitySummaryController.swift \ - Sources/AgentWorkbench/ToolActivityView.swift Sources/AgentWorkbench/KimiAttachmentView.swift Sources/AgentWorkbench/ModelPicker.swift Sources/AgentWorkbench/ThinkingPicker.swift Sources/AgentWorkbench/AutoCompactStore.swift \ + Sources/AgentWorkbench/ToolActivityView.swift Sources/AgentWorkbench/ReplyProcessStyle.swift Sources/AgentWorkbench/KimiAttachmentView.swift Sources/AgentWorkbench/ModelPicker.swift Sources/AgentWorkbench/ThinkingPicker.swift Sources/AgentWorkbench/AutoCompactStore.swift \ Sources/AgentWorkbench/ConversationScrollControls.swift Sources/AgentWorkbench/WorkbenchGlass.swift \ Sources/AgentWorkbench/UILocalization.swift Sources/AgentWorkbench/ConversationActivityBar.swift "$fixture" \ "${objects[@]}" -o "$app_dir/Contents/MacOS/ToolVisibilityPreview" diff --git a/scripts/build-workflow-preview.sh b/scripts/build-workflow-preview.sh index e8ea721..77f6492 100755 --- a/scripts/build-workflow-preview.sh +++ b/scripts/build-workflow-preview.sh @@ -14,7 +14,7 @@ swiftc -target "$(uname -m)-apple-macosx26.0" -O -swift-version 5 -parse-as-libr -I .build/checkouts/swift-markdown/Sources/CAtomic/include \ -I .build/checkouts/swift-cmark/src/include -I .build/checkouts/swift-cmark/extensions/include \ Sources/AgentWorkbench/ReplyMarkdownView.swift Sources/AgentWorkbench/ReplySelection.swift Sources/AgentWorkbench/ReplyMathView.swift Sources/AgentWorkbench/ReplyAlertBlock.swift Sources/AgentWorkbench/WorkbenchTheme.swift Sources/AgentWorkbench/ConversationPresentationEnvironment.swift Sources/AgentWorkbench/ConversationTranscriptView.swift Sources/AgentWorkbench/RevealFrameDriver.swift Sources/AgentWorkbench/NativeAssistantBlocks.swift Sources/AgentWorkbench/ConversationReadingMemory.swift Sources/AgentWorkbench/ConversationActions.swift Sources/AgentWorkbench/ConversationBookmarksStore.swift Sources/AgentWorkbench/ImagePreviewPanel.swift Sources/AgentWorkbench/ActivitySummarySettings.swift Sources/AgentWorkbench/ActivitySummaryController.swift \ - Sources/AgentWorkbench/ToolActivityView.swift Sources/AgentWorkbench/KimiAttachmentView.swift Sources/AgentWorkbench/ModelPicker.swift Sources/AgentWorkbench/ThinkingPicker.swift Sources/AgentWorkbench/AutoCompactStore.swift Sources/AgentWorkbench/UILocalization.swift \ + Sources/AgentWorkbench/ToolActivityView.swift Sources/AgentWorkbench/ReplyProcessStyle.swift Sources/AgentWorkbench/KimiAttachmentView.swift Sources/AgentWorkbench/ModelPicker.swift Sources/AgentWorkbench/ThinkingPicker.swift Sources/AgentWorkbench/AutoCompactStore.swift Sources/AgentWorkbench/UILocalization.swift \ Sources/AgentWorkbench/ConversationScrollControls.swift Sources/AgentWorkbench/WorkbenchGlass.swift \ Sources/AgentWorkbench/ConversationActivityBar.swift Sources/AgentWorkbench/RemoteFileView.swift Tests/WorkflowPreview/App.swift \ "${objects[@]}" -o "$app_dir/Contents/MacOS/WorkflowPreview" diff --git a/scripts/check-scroll-following.py b/scripts/check-scroll-following.py index 258fdb8..b984c64 100644 --- a/scripts/check-scroll-following.py +++ b/scripts/check-scroll-following.py @@ -15,7 +15,7 @@ "ConversationActions", "ConversationBookmarksStore", "ImagePreviewPanel", "ActivitySummarySettings", "ActivitySummaryController", "ConversationScrollControls", "ReplyMarkdownView", "NativeAssistantBlocks", "KimiAttachmentView", - "ReplyMathView", "ReplyAlertBlock", "ReplySelection", + "ReplyMathView", "ReplyAlertBlock", "ReplySelection", "ReplyProcessStyle", "ConversationActivityBar", "UILocalization", "WorkbenchGlass", "WorkbenchTheme"] includes = ["-I", str(BIN / "Modules")] for path in ["swift-markdown/Sources/CAtomic/include", "swift-cmark/src/include", "swift-cmark/extensions/include"]: From 330ed4d476bfa716ec522078321a2e1c54789384 Mon Sep 17 00:00:00 2001 From: chenweigao <19328388+chenweigao@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:38:40 +0800 Subject: [PATCH 3/3] fix: one notice style for every agent; queued prompts look sent - Native agents showed errors, a lost connection and stale sync as 10 pt orange captions centered across the pane; Kimi used a 12 pt banner. Both now use `ConversationNotice`: a 12 pt line with a symbol and at most one action, in the reading column. Text, actions and when each shows are unchanged. - A queued prompt was full-width left-aligned 13 pt text, then jumped to the right-aligned bubble when the agent echoed it. It now renders as that bubble (UserMessageStyle, same Markdown, top spacing and trailing edge). Its mode, status and controls sit on one 11 pt line below it. The editing sheet is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../AgentWorkbench/ConversationNotice.swift | 90 ++++++++++++++++ .../AgentWorkbench/KimiWorkspaceView.swift | 100 ++++++++---------- Sources/AgentWorkbench/NativeAgentView.swift | 73 ++++++------- .../PendingMessageContent.swift | 71 +++++++++++-- 4 files changed, 231 insertions(+), 103 deletions(-) create mode 100644 Sources/AgentWorkbench/ConversationNotice.swift diff --git a/Sources/AgentWorkbench/ConversationNotice.swift b/Sources/AgentWorkbench/ConversationNotice.swift new file mode 100644 index 0000000..eda6a3e --- /dev/null +++ b/Sources/AgentWorkbench/ConversationNotice.swift @@ -0,0 +1,90 @@ +import SwiftUI + +extension ReplyStyle { + /// Conversation notices (an error, a lost connection, a stale sync) look the same + /// for every agent: a 12 pt line with a symbol and at most one action, set in the + /// reading column. + enum Notice { + static let textSize: CGFloat = 12 + /// Between the symbol, the text and the action. + static let spacing: CGFloat = 8 + static let horizontalPadding: CGFloat = 12 + static let verticalPadding: CGFloat = 8 + static let cornerRadius: CGFloat = 8 + static let fillOpacity: Double = 0.06 + /// Long upstream errors scroll past this height instead of displacing the conversation. + static let maxTextHeight: CGFloat = 128 + /// The transcript column's horizontal inset (`ConversationScrollView`), which the + /// activity bar and the composer also use, so a notice lines up with all three. + static let columnInset: CGFloat = 36 + /// Above and below a notice. + static let margin: CGFloat = 8 + } +} + +/// One notice in a conversation: a symbol, localized text and an optional action +/// (重新连接, Retry). Native agents and Kimi both use it, so the two families report +/// problems the same way, in the reading column. It sets that column itself: place +/// it in the pane, not inside the transcript's scroll view. +struct ConversationNotice: View { + struct Action { + /// Already localized. + let title: String + var isDisabled = false + let perform: () -> Void + } + /// Already localized; upstream errors are shown verbatim. + let text: String + var symbol = "exclamationmark.circle" + var tint: Color = .orange + var action: Action? = nil + + var body: some View { + HStack(spacing: ReplyStyle.Notice.spacing) { + NoticeTextFrame(maxHeight: ReplyStyle.Notice.maxTextHeight) { + // Short text sizes the notice; only text past the cap scrolls. + ViewThatFits(in: .vertical) { + message + ScrollView { message } + } + } + if let action { + Button(action.title, action: action.perform) + .buttonStyle(.bordered).controlSize(.small).tint(tint) + .disabled(action.isDisabled) + } + } + .font(.system(size: ReplyStyle.Notice.textSize)) + .foregroundStyle(tint) + .padding(.horizontal, ReplyStyle.Notice.horizontalPadding) + .padding(.vertical, ReplyStyle.Notice.verticalPadding) + .background(tint.opacity(ReplyStyle.Notice.fillOpacity), + in: RoundedRectangle(cornerRadius: ReplyStyle.Notice.cornerRadius)) + .frame(maxWidth: ReplyStyle.readingWidth) + .padding(.horizontal, ReplyStyle.Notice.columnInset) + .frame(maxWidth: .infinity) + .padding(.vertical, ReplyStyle.Notice.margin) + } + + private var message: some View { + HStack(alignment: .firstTextBaseline, spacing: ReplyStyle.Notice.spacing) { + Image(systemName: symbol).accessibilityHidden(true) + Text(verbatim: text).textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +/// Offers its content at most `maxHeight` and keeps the content's own size. +/// `.frame(maxHeight:)` would instead stretch a one-line notice to the cap. +private struct NoticeTextFrame: Layout { + let maxHeight: CGFloat + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + guard let content = subviews.first else { return .zero } + return content.sizeThatFits(ProposedViewSize(width: proposal.width, + height: min(proposal.height ?? maxHeight, maxHeight))) + } + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + subviews.first?.place(at: bounds.origin, anchor: .topLeading, proposal: ProposedViewSize(bounds.size)) + } +} diff --git a/Sources/AgentWorkbench/KimiWorkspaceView.swift b/Sources/AgentWorkbench/KimiWorkspaceView.swift index 0a1ed7c..f80430e 100644 --- a/Sources/AgentWorkbench/KimiWorkspaceView.swift +++ b/Sources/AgentWorkbench/KimiWorkspaceView.swift @@ -23,7 +23,9 @@ struct KimiWorkspaceView: View { KimiGoalHeader(connection: connection, sessionID: conversation.snapshot.session.id) .id(conversation.snapshot.session.id) KimiTimeline(connection: connection, activityReview: activityReview, onResultDisplayed: onResultDisplayed) - if let problem = connection.actionError ?? connection.commandErrors[conversation.snapshot.session.id] ?? conversation.error { errorBanner(problem, canRetry: !connection.snapshotReady) } + if let problem = connection.actionError ?? connection.commandErrors[conversation.snapshot.session.id] ?? conversation.error { + ConversationNotice(text: problem, action: connection.snapshotReady ? nil : reloadAction) + } let pending = conversation.snapshot.pendingApprovals.count + conversation.snapshot.pendingQuestions.count let recapKey = conversation.taskRecapRevision.map { "\(connection.host.id):kimi:\(conversation.snapshot.session.id):\($0)" @@ -67,15 +69,19 @@ struct KimiWorkspaceView: View { Text(connection.loading ? "正在读取会话…" : "继续你的 Kimi 对话").font(.system(size: 23, weight: .semibold)).padding(.top, 16) Text("选择已有会话,或从一个项目目录开始。") .foregroundStyle(.secondary).padding(.top, 3) Button("新建会话") { onNew() }.buttonStyle(.borderedProminent).padding(.top, 18).disabled(!connection.online) - if let problem = connection.actionError { errorBanner(problem) } + if let problem = connection.actionError { ConversationNotice(text: problem) } Spacer() } // An open conversation reports disconnection, its detail and the // reconnect action in the activity bar; do not repeat it below. if let problem = connection.error, connection.online || connection.conversation == nil { - errorBanner(L("连接中断,显示的是最近同步的内容。") + "\n" + problem) + ConversationNotice(text: L("连接中断,显示的是最近同步的内容。") + "\n" + problem, symbol: "wifi.exclamationmark") + } + // An upgraded package with an older service still running. Informational: + // the session keeps working, and restarting that service stays the user's call. + if let notice = connection.staleRuntimeNotice { + ConversationNotice(text: notice, symbol: "info.circle", tint: .secondary) } - if let notice = connection.staleRuntimeNotice { runtimeNotice(notice) } }.frame(maxWidth: .infinity, maxHeight: .infinity).background(WorkbenchTheme.contentBackground) .tint(kimiAccent) .onChange(of: connection.selectedId) { _, _ in @@ -88,26 +94,11 @@ struct KimiWorkspaceView: View { } - private func errorBanner(_ text: String, canRetry: Bool = false) -> some View { - HStack(alignment: .top) { - Image(systemName: "exclamationmark.circle") - // Long upstream errors scroll instead of displacing the conversation. - ScrollView { Text(text).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxHeight: 128) - if canRetry { - Button("Retry") { connection.reloadSelected() }.disabled(!connection.online || connection.loading) - } - }.font(.system(size: 12)).foregroundStyle(.orange).padding(12).background(.orange.opacity(0.06)).padding(.horizontal, 28) - } - /// An upgraded package with an older service still running. Informational: the - /// session keeps working, and restarting that service stays the user's call. - private func runtimeNotice(_ text: String) -> some View { - HStack(alignment: .top) { - Image(systemName: "info.circle") - Text(text).textSelection(.enabled) - Spacer() - }.font(.system(size: 12)).foregroundStyle(.secondary).padding(12) - .background(Color.secondary.opacity(0.06)).padding(.horizontal, 28) + /// Offered with an error while the selected session's snapshot is not ready. + private var reloadAction: ConversationNotice.Action { + ConversationNotice.Action(title: L("Retry"), isDisabled: !connection.online || connection.loading) { + connection.reloadSelected() + } } } @@ -288,37 +279,33 @@ private struct KimiQueuedMessages: View { .disabled(!connection.online) } ForEach(items) { message in - VStack(alignment: .leading, spacing: 8) { - PendingMessageContent(text: message.text, status: message.state.label, - mode: message.mode == .steer ? "Steer" : "Queue") - HStack(spacing: 10) { - if message.state.isEditable { - // Reorder like ccgui's queue rows: arrows stay visible - // at both ends and disable instead of disappearing. - let editable = items.filter { $0.state.isEditable } - if editable.count > 1, let position = editable.firstIndex(where: { $0.id == message.id }) { - Button { connection.moveQueued(message.id, up: true) } label: { - Image(systemName: "chevron.up").frame(width: 22, height: 22).contentShape(Rectangle()) - }.buttonStyle(.plain).disabled(position == 0) - .help("上移,更早发送").accessibilityLabel("上移") - Button { connection.moveQueued(message.id, up: false) } label: { - Image(systemName: "chevron.down").frame(width: 22, height: 22).contentShape(Rectangle()) - }.buttonStyle(.plain).disabled(position == editable.count - 1) - .help("下移,更晚发送").accessibilityLabel("下移") - } - Button("Edit") { editing = message }.font(.caption2) - Button("Remove") { connection.removeQueued(message.id) }.font(.caption2) - } - if case .unknown = message.state { - Button("核对并重试") { connection.retryQueued(message.id) }.font(.caption2) - Text("先核对原请求的接收状态,避免重复执行。") - .font(.caption2).foregroundStyle(.secondary) - } - if case .failed = message.state { - Button("Move to draft") { connection.restoreQueued(message) }.font(.caption2) + PendingMessageContent(text: message.text, status: message.state.label, + mode: message.mode == .steer ? "Steer" : "Queue") { + if message.state.isEditable { + // Reorder like ccgui's queue rows: arrows stay visible + // at both ends and disable instead of disappearing. + let editable = items.filter { $0.state.isEditable } + if editable.count > 1, let position = editable.firstIndex(where: { $0.id == message.id }) { + Button { connection.moveQueued(message.id, up: true) } label: { + Image(systemName: "chevron.up").frame(width: 22, height: 22).contentShape(Rectangle()) + }.buttonStyle(.plain).disabled(position == 0) + .help("上移,更早发送").accessibilityLabel("上移") + Button { connection.moveQueued(message.id, up: false) } label: { + Image(systemName: "chevron.down").frame(width: 22, height: 22).contentShape(Rectangle()) + }.buttonStyle(.plain).disabled(position == editable.count - 1) + .help("下移,更晚发送").accessibilityLabel("下移") } + Button("Edit") { editing = message } + Button("Remove") { connection.removeQueued(message.id) } + } + if case .unknown = message.state { + Button("核对并重试") { connection.retryQueued(message.id) } + Text("先核对原请求的接收状态,避免重复执行。") } - }.padding(.vertical, 8) + if case .failed = message.state { + Button("Move to draft") { connection.restoreQueued(message) } + } + } } .sheet(item: $editing) { message in PendingMessageEditor(message: message) { connection.editQueued(message.id, text: $0) } @@ -378,14 +365,13 @@ private struct KimiTimeline: View { .environment(\.conversationActionContext, ConversationActionContext(hostID: connection.host.id, kind: .kimi, sessionID: c.snapshot.session.id)) ForEach(connection.pendingPrompts[c.snapshot.session.id] ?? []) { prompt in - VStack(alignment: .leading, spacing: 8) { - PendingMessageContent(text: prompt.text, status: prompt.label) + PendingMessageContent(text: prompt.text, status: prompt.label) { if ["queued", "blocked"].contains(prompt.status) && c.snapshot.session.isTurnRunning { Button("Steer") { Task { await connection.steerPrompt(prompt.id, for: c.snapshot.session.id) } - }.font(.caption).disabled(!connection.online || connection.isStopping) + }.disabled(!connection.online || connection.isStopping) } - }.padding(.vertical, 8) + } } KimiQueuedMessages(connection: connection, sessionID: c.snapshot.session.id) if let notice = c.notice { Text(notice).font(.system(size: 12)).foregroundStyle(.orange).textSelection(.enabled) } diff --git a/Sources/AgentWorkbench/NativeAgentView.swift b/Sources/AgentWorkbench/NativeAgentView.swift index 614293b..04b7334 100644 --- a/Sources/AgentWorkbench/NativeAgentView.swift +++ b/Sources/AgentWorkbench/NativeAgentView.swift @@ -42,7 +42,8 @@ struct NativeAgentView: View { // with the same reconnect action; keep this line for every other state. if !connection.online { if !(connection.conversation.presentationSnapshot.map(isCurrent) ?? false) { - HStack { Text(connection.error ?? L("正在连接远端服务")); Button("重新连接") { connection.connect() } }.font(.caption).foregroundStyle(.orange).padding(10) + ConversationNotice(text: connection.error ?? L("正在连接远端服务"), symbol: "wifi.exclamationmark", + action: ConversationNotice.Action(title: L("重新连接")) { connection.connect() }) } } else { NativeStaleSyncHint(connection: connection) } } @@ -153,11 +154,8 @@ struct NativeAgentView: View { onReview: { activityReview += 1 }, onReconnect: { connection.connect() }) .id(s.id).frame(maxWidth: ReplyStyle.readingWidth).padding(.horizontal, 36) .frame(maxWidth: .infinity).padding(.vertical, 6) - if let error = connection.actionError ?? s.error { - // Long upstream errors scroll instead of displacing the conversation. - ScrollView { Text(error).font(.caption).foregroundStyle(.orange).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxHeight: 128).padding(10) - } + // Long upstream errors scroll inside the notice instead of displacing the conversation. + if let error = connection.actionError ?? s.error { ConversationNotice(text: error) } if let command = s.commandResult { HStack(spacing: 6) { if command.status == "running" { ProgressView().controlSize(.small) } @@ -175,14 +173,13 @@ struct NativeAgentView: View { /// the connection still looks live. Past the idle cadence's slowest gap, show how /// stale the screen is instead of presenting it as current. private struct NativeStaleSyncHint: View { + @UILocalization private var L let connection: NativeAgentConnection var body: some View { TimelineView(.periodic(from: .now, by: 5)) { context in if let last = connection.lastSyncAt, context.date.timeIntervalSince(last) > 15 { - HStack { - Text("数据同步中断,界面可能不是最新。") - Button("重新连接") { connection.connect() } - }.font(.caption).foregroundStyle(.orange).padding(10) + ConversationNotice(text: L("数据同步中断,界面可能不是最新。"), symbol: "exclamationmark.arrow.circlepath", + action: ConversationNotice.Action(title: L("重新连接")) { connection.connect() }) } } } @@ -386,38 +383,34 @@ struct NativeRunControls: View { .disabled(!connection.online || connection.sessions.first(where: { $0.id == sessionID })?.busy != false || !phase.isSettled) } ForEach(pending) { message in - VStack(alignment: .leading, spacing: 8) { - PendingMessageContent(text: message.text, - status: message.mode == .steer && message.state == .accepted - ? "Accepted · waiting for context" : message.state.label, - mode: message.mode == .steer ? "Steer" : "Queue") - HStack(spacing: 10) { - if message.state.isEditable { - let editable = pending.filter { $0.state.isEditable } - if editable.count > 1, let position = editable.firstIndex(where: { $0.id == message.id }) { - Button { _ = connection.queue.move(message.id, up: true) } label: { - Image(systemName: "chevron.up").frame(width: 22, height: 22).contentShape(Rectangle()) - }.buttonStyle(.plain).disabled(position == 0) - .help("上移,更早发送").accessibilityLabel("上移") - Button { _ = connection.queue.move(message.id, up: false) } label: { - Image(systemName: "chevron.down").frame(width: 22, height: 22).contentShape(Rectangle()) - }.buttonStyle(.plain).disabled(position == editable.count - 1) - .help("下移,更晚发送").accessibilityLabel("下移") - } - Button("Edit") { editingMessage = message }.font(.caption2) - Button("Remove") { _ = connection.queue.remove(message.id) }.font(.caption2) - } - switch message.state { - case .unknown: - Button("核对并重试") { connection.retry(message.id) }.font(.caption2) - Text("先核对原请求的接收状态,避免重复执行。") - .font(.caption2).foregroundStyle(.secondary) - case .failed: - Button("Move to draft") { connection.restoreFailed(message) }.font(.caption2) - default: EmptyView() + PendingMessageContent(text: message.text, + status: message.mode == .steer && message.state == .accepted + ? "Accepted · waiting for context" : message.state.label, + mode: message.mode == .steer ? "Steer" : "Queue") { + if message.state.isEditable { + let editable = pending.filter { $0.state.isEditable } + if editable.count > 1, let position = editable.firstIndex(where: { $0.id == message.id }) { + Button { _ = connection.queue.move(message.id, up: true) } label: { + Image(systemName: "chevron.up").frame(width: 22, height: 22).contentShape(Rectangle()) + }.buttonStyle(.plain).disabled(position == 0) + .help("上移,更早发送").accessibilityLabel("上移") + Button { _ = connection.queue.move(message.id, up: false) } label: { + Image(systemName: "chevron.down").frame(width: 22, height: 22).contentShape(Rectangle()) + }.buttonStyle(.plain).disabled(position == editable.count - 1) + .help("下移,更晚发送").accessibilityLabel("下移") } + Button("Edit") { editingMessage = message } + Button("Remove") { _ = connection.queue.remove(message.id) } } - }.padding(.vertical, 8) + switch message.state { + case .unknown: + Button("核对并重试") { connection.retry(message.id) } + Text("先核对原请求的接收状态,避免重复执行。") + case .failed: + Button("Move to draft") { connection.restoreFailed(message) } + default: EmptyView() + } + } } if let warning = connection.queue.unsentWarning(for: reference) { Text(warning).font(.caption2).foregroundStyle(.secondary) diff --git a/Sources/AgentWorkbench/PendingMessageContent.swift b/Sources/AgentWorkbench/PendingMessageContent.swift index 40611ca..25ab69a 100644 --- a/Sources/AgentWorkbench/PendingMessageContent.swift +++ b/Sources/AgentWorkbench/PendingMessageContent.swift @@ -1,22 +1,81 @@ import SwiftUI import WorkbenchCore +extension UserMessageStyle { + /// A message still sending, queued or steering. It is drawn as the sent + /// prompt's bubble, so the echo takes its place without moving the text. + enum Pending { + /// Its state and controls under the bubble, in secondary ink. + static let detailSize: CGFloat = 11 + /// Between the bubble and that line, as the fold toggle under a long prompt. + static let detailSpacing: CGFloat = 6 + /// Between the items of that line. + static let detailItemSpacing: CGFloat = 8 + /// Below that line, before the next row. + static let bottomSpacing: CGFloat = 8 + } +} + /// Pending text stays in the transcript at full length until the runtime echoes it. -struct PendingMessageContent: View { +/// It reads as the sent prompt: the same right-aligned bubble, text, padding and +/// fill, so the echo replaces it in place. Its state and the caller's controls +/// share one line under it. +struct PendingMessageContent: View { @UILocalization private var L let text: String /// A localization key or already localized text; both display through lookup. let status: String /// A localization key ("Steer", "Queue"). var mode: String? = nil + /// Actions on this message, set in the line's type; buttons are borderless. + @ViewBuilder let controls: Controls var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(text).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading) - HStack(spacing: 8) { + VStack(alignment: .trailing, spacing: UserMessageStyle.Pending.detailSpacing) { + // An attachment-only send has no text to set. + if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + PendingBubbleLayout { + // Set as `KimiMessageView` sets a sent prompt; plain prompts also + // measure the same in the settled native row. + KimiMarkdown(text: text) + .environment(\.replyLineHeight, UserMessageStyle.lineHeight) + .padding(.horizontal, UserMessageStyle.horizontalPadding) + .padding(.vertical, UserMessageStyle.verticalPadding) + // The sent bubble's fill, without a stroke. + .background(ReplyStyle.paper, in: RoundedRectangle(cornerRadius: UserMessageStyle.cornerRadius)) + } + } + HStack(spacing: UserMessageStyle.Pending.detailItemSpacing) { if let mode { Text(L(key: mode)) } Text(L(key: status)).textSelection(.enabled) - }.font(.caption).foregroundStyle(.secondary) - }.padding(14).background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 12)) + controls + } + .font(.system(size: UserMessageStyle.Pending.detailSize)) + .foregroundStyle(.secondary) + .buttonStyle(.borderless) + } + .frame(maxWidth: .infinity, alignment: .trailing) + // A sent prompt's row starts its bubble `topSpacing` down; so does this one. + .padding(.top, UserMessageStyle.topSpacing) + .padding(.bottom, UserMessageStyle.Pending.bottomSpacing) + } +} + +/// Places the bubble as `UserMessageLayout` places a sent prompt: its ideal width +/// up to `UserMessageStyle.maxWidthRatio` of the column, against the trailing edge. +private struct PendingBubbleLayout: Layout { + private func measure(width: CGFloat, subview: LayoutSubview) -> CGSize { + let idealWidth = subview.sizeThatFits(.unspecified).width + return subview.sizeThatFits(ProposedViewSize(width: min(idealWidth, width * UserMessageStyle.maxWidthRatio), height: nil)) + } + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + guard let bubble = subviews.first else { return .zero } + let width = max(1, proposal.width.flatMap { $0.isFinite ? $0 : nil } ?? ReplyStyle.readingWidth) + return CGSize(width: width, height: measure(width: width, subview: bubble).height) + } + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + guard let bubble = subviews.first else { return } + bubble.place(at: CGPoint(x: bounds.maxX, y: bounds.minY), anchor: .topTrailing, + proposal: ProposedViewSize(measure(width: bounds.width, subview: bubble))) } }