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/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/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/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/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))) } } 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/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/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/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 } 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"]: