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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions Sources/AgentWorkbench/ConversationNotice.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
50 changes: 35 additions & 15 deletions Sources/AgentWorkbench/ConversationTranscriptView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -1289,6 +1296,7 @@ private struct ConversationEntryAppearance: Equatable {
reduceMotion = environment.accessibilityReduceMotion || environment.conversationReduceMotion
isEnabled = environment.isEnabled
actionContext = environment.conversationActionContext
locale = environment.locale
}
}

Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -2089,15 +2106,15 @@ 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)
.accessibilityIdentifier("process-previous-page")
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,
Expand All @@ -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 ? "已展开" : "已收起"))
}
}
Expand All @@ -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)
}
}

Expand All @@ -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())
}
}

Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading