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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ Git 性能测试增加等待时间。推送到 `main` 或手动运行时,再
组装通用 DMG。Windows PR 把前端验证与 Rust 测试放在两个独立 job 中并行
执行;Windows x64 NSIS 安装包只由 preview 和稳定版发布工作流生成。

Git 图形的离屏帧采样保留完整 1,000 行历史,但每次只绘制 40 行的可见区域,
分别覆盖开头、中间和末尾。绘制前把该区域的坐标映射到小位图,绘制后检查
实际有图形像素,避免中段被裁剪为空时得到虚假的快速结果。完整 1,000/5,000
行图结构、顺序和签名仍由独立用例与 Release 验证器检查;单帧中位数和整个
测试的时间上限保持不变。

正确做法:模拟滚动窗口的一帧,把可见区域作为绘制输入。不要把全部历史
重复绘制 220 次当作单帧采样:这会把画面之外的工作混进测试总耗时,在共享
runner 上可能出现断言都通过、计时工具仍因超过十秒而失败的情况。

CI 缓存 Cargo fingerprints、build script outputs 和依赖 outputs,不缓存
最终可执行文件。缓存覆盖 `rust/target/macos` 的 Rust Core 和 `rust/target`
的数据库辅助 crate;缓存键必须包含运行器架构、编译器、Xcode/SDK/macOS
Expand Down Expand Up @@ -110,11 +120,19 @@ Swift 测试已经编译完整 Lithe 目标。再生成两个 DMG 会在普通
任务结束。双架构验证因此保留给真正影响包内容和构建边界的改动;开发者需要
临时安装包时使用手动工作流。

### 提高 Git 帧采样的总时间上限

这能容纳重复绘制完整历史的耗时,却保留了与可见窗口不符的工作量,也削弱
其他测试的时间门禁。因此修正采样范围与位图坐标,继续使用原有上限;完整
图的正确性检查不减少,批量绘制不能替代可见区域的帧成本测量。

## 后果

- 普通 macOS Swift PR 更快得到必需检查结果;被选中的打包改动仍获得两个架构
的真实安装物。
- Git 专项验证不会再延长无关 Swift 改动的反馈时间。
- 帧采样只绘制可见区域,减少与屏幕无关的测试工作;它不衡量完整历史的一次
批量导出,也不等同于需要显式开启的真实窗口帧率采样。
- Windows 前端失败与 Rust 失败可以独立、并行反馈,不再等待 NSIS 安装包。
- 缓存命中时可减少 Rust 相关重复编译,同时通过完整缓存键避免跨环境误复用。
- artifact、校验和、合并提交与 gate 结果共同提供可追溯的测试交付物。
Expand All @@ -126,6 +144,7 @@ Swift 测试已经编译完整 Lithe 目标。再生成两个 DMG 会在普通

- `actionlint .github/workflows/ci-macos.yml .github/workflows/ci-windows.yml`
- `./scripts/test-macos.sh`
- `./scripts/test-git-performance-baseline.sh`
- `./scripts/build-macos.sh --configuration debug --triple arm64-apple-macosx`
- `./scripts/build-macos.sh --configuration debug --triple x86_64-apple-macosx`
- `./scripts/build-official-plugins.sh --configuration debug --triple arm64-apple-macosx`
Expand Down
5 changes: 4 additions & 1 deletion docs/ci-builds.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,10 @@ change should be evaluated separately if queueing continues to dominate.

普通 `./scripts/test-macos.sh` 和 `./scripts/test-git-performance-baseline.sh`
默认跳过两个 WindowServer/display-link 真实窗口采样用例,继续运行 Git 图布局、
离屏绘制和其他性能回归验证。真实窗口采样需要 macOS 14+ 和可用的桌面显示;
离屏绘制和其他性能回归验证。图形离屏帧采样保留完整 1,000 行历史,分别在
开头、中间、末尾采样 40 行可见区域,并检查位图确实绘制了图形。完整图的
结构和 Release 基线仍覆盖 1,000/5,000 行;单帧与测试总耗时上限保持不变。
真实窗口采样需要 macOS 14+ 和可用的桌面显示;
只在专门测量滚动帧率时显式开启:

```bash
Expand Down
5 changes: 4 additions & 1 deletion docs/development/platform-parity-matrix.csv

Large diffs are not rendered by default.

13 changes: 8 additions & 5 deletions docs/development/platform-parity-matrix.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions macos/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1286,6 +1286,7 @@
"Repository needs attention" = "Repository needs attention";

"Codex subscription" = "Codex subscription";
"Loading session settings…" = "Loading session settings…";

"Use your local ChatGPT account. No API key or URL is needed." = "Use your local ChatGPT account. No API key or URL is needed.";

Expand Down
1 change: 1 addition & 0 deletions macos/Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -2523,6 +2523,7 @@
"Repository needs attention" = "仓库需要处理";

"Codex subscription" = "Codex 官方订阅(本机账号)";
"Loading session settings…" = "正在加载会话设置…";

"Use your local ChatGPT account. No API key or URL is needed." = "使用本机已登录的 ChatGPT 账号,无需 API Key 或 URL。";

Expand Down
47 changes: 33 additions & 14 deletions macos/Sources/Lithe/Views/Agent/AgentComposerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ struct AgentComposerView: View {
let onError: (String?) -> Void
var configOptions: [AgentSessionConfigOption] = []
var sessionID: String?
var isPreparingSession = false
var isConfiguring = false
var isCancelling = false
var contextUsage: AgentContextUsage?
Expand Down Expand Up @@ -108,7 +109,14 @@ struct AgentComposerView: View {
.buttonStyle(AgentToolbarButtonStyle())
.help("Agent Settings")
agentMenu
if !configOptions.isEmpty {
if isPreparingSession {
Label("Loading session settings…", systemImage: "hourglass")
.font(.system(size: 11))
.foregroundStyle(AgentPanelStyle.secondary)
.lineLimit(1)
.padding(.horizontal, 4)
.accessibilityIdentifier("agent-session-settings-loading")
} else if !configOptions.isEmpty {
AgentSessionSelectors(
options: configOptions,
agentName: selectedAgent?.name,
Expand All @@ -117,10 +125,9 @@ struct AgentComposerView: View {
)
.id(sessionID ?? selectedAgent?.id)
if isConfiguring { ProgressView().controlSize(.mini) }
}
if configOptions.isEmpty, let model = selectedAgent?.modelName, !model.isEmpty {
} else if let model = selectedAgent?.modelName, !model.isEmpty {
HStack(spacing: 5) {
AgentBrandIcon(name: selectedAgent?.name, size: 12)
AgentBrandIcon(name: selectedAgent?.name, size: 12, style: .brand)
Text(model).lineLimit(1).truncationMode(.middle)
}
.font(.system(size: 11))
Expand All @@ -138,7 +145,7 @@ struct AgentComposerView: View {
}
.buttonStyle(.litheNoPress)
.lithePointer()
.disabled(isCancelling || (!isResponding && (!hasContent || isBlocked || isConfiguring)))
.disabled(isCancelling || (!isResponding && (!hasContent || isBlocked || isPreparingSession || isConfiguring)))
.help(isCancelling ? "Stopping…" : (isResponding ? "Stop" : "Send"))
}
.padding(.horizontal, 5)
Expand All @@ -149,20 +156,31 @@ struct AgentComposerView: View {

private var agentMenu: some View {
Menu {
if agents.isEmpty { Text("No Agent is set up yet") }
ForEach(agents) { agent in
Button { onSelectAgent(agent.id) } label: {
if agent.id == selectedAgent?.id {
Label(agent.name, systemImage: "checkmark")
} else {
Text(agent.name)
if agents.isEmpty {
Text("No Agent is set up yet")
} else {
// The native picker owns the selection checkmark separately
// from each item's brand image.
Picker("Choose an Agent", selection: Binding(
get: { selectedAgent?.id },
set: { if let id = $0 { onSelectAgent(id) } }
)) {
ForEach(agents) { agent in
Label {
Text(agent.name)
} icon: {
AgentBrandIcon(name: agent.name, size: 16, style: .brand)
}
.tag(Optional(agent.id))
}
}
.pickerStyle(.inline)
.labelsHidden()
}
Divider()
Button("Agent Settings…", action: onOpenSettings)
} label: {
AgentBrandIcon(name: selectedAgent?.name, size: 16)
AgentBrandIcon(name: selectedAgent?.name, size: 18, style: .brand)
.foregroundStyle(AgentPanelStyle.secondary)
.frame(width: 28, height: 28)
}
Expand All @@ -171,6 +189,7 @@ struct AgentComposerView: View {
.fixedSize()
.help(selectedAgent?.name ?? String(localized: "Choose an Agent"))
.accessibilityLabel("Switch Agent")
.accessibilityIdentifier("agent-composer-agent-selector")
}

private func addFiles(_ urls: [URL]) -> Bool {
Expand All @@ -188,7 +207,7 @@ struct AgentComposerView: View {

private func send() {
guard hasContent, !isResponding else { return }
if isBlocked {
if isBlocked || isPreparingSession {
onError(String(localized: "The conversation is still being prepared. Try again in a moment."))
return
}
Expand Down
57 changes: 50 additions & 7 deletions macos/Sources/Lithe/Views/Agent/AgentConversationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,16 @@ struct AgentConversationView: View {
feature: model.agentManagementFeature,
onDone: { showsSettings = false }
)
} else if let feature, let connection = feature.selectedConnection, let agentID = feature.selectedAgentID {
AgentConnectionView(
feature: connection,
history: feature.history(for: agentID),
agents: feature.agents,
selectedAgentID: feature.selectedAgentID,
} else if let feature {
AgentConfiguredConversationView(
feature: feature,
setupError: model.agentConversationSetupError,
onSelectAgent: { model.selectAgentConversationAgent($0) },
onConnect: { model.connectAgentConversation() },
onOpenSettings: { showsSettings = true },
onCopySessionID: { model.copyAgentSessionID($0) },
onOpenFile: { model.openAgentFile($0) }
)
.id(feature.selectedAgentID)
} else {
AgentUnconfiguredConversationView(
setupError: model.agentConversationSetupError,
Expand All @@ -44,6 +41,37 @@ struct AgentConversationView: View {
}
}

/// Observe selection where the selected connection is resolved. AppModel does
/// not forward this optional module's changes, and each connection owns its UI.
struct AgentConfiguredConversationView: View {
@ObservedObject var feature: AgentConversationFeatureModel
let setupError: AgentConversationError?
let onSelectAgent: (String) -> Void
let onConnect: () -> Void
let onOpenSettings: () -> Void
let onCopySessionID: (String) -> Void
let onOpenFile: (AgentToolDetails.Location) -> Void

var body: some View {
if let connection = feature.selectedConnection, let agentID = feature.selectedAgentID {
AgentConnectionView(
feature: connection,
history: feature.history(for: agentID),
agents: feature.agents,
selectedAgentID: agentID,
onSelectAgent: onSelectAgent,
onConnect: onConnect,
onOpenSettings: onOpenSettings,
onCopySessionID: onCopySessionID,
onOpenFile: onOpenFile
)
.id(agentID)
} else {
AgentUnconfiguredConversationView(setupError: setupError, onOpenSettings: onOpenSettings)
}
}
}

/// Title on the left and icon actions on the right, like a chat client's
/// session header: new conversation, history, settings.
private struct AgentPanelHeader<Actions: View>: View {
Expand Down Expand Up @@ -131,6 +159,20 @@ private struct AgentConnectionView: View {

private var selectedAgent: AgentOption? { agents.first { $0.id == selectedAgentID } }

/// A saved provider model is not the current session's confirmed model.
/// Include the ready-to-create gap, but stop waiting on failure or sign-in.
private var isPreparingSession: Bool {
switch feature.connectionState {
case .idle, .connecting:
return true
case .ready:
return feature.isCreatingSession || feature.selectedConversation?.isLoading == true
|| (feature.selectedSessionID == nil && feature.errorMessage == nil)
case .authenticationRequired, .authenticating, .failed:
return false
}
}

var body: some View {
ZStack {
conversation
Expand Down Expand Up @@ -218,6 +260,7 @@ private struct AgentConnectionView: View {
onError: { localError = $0 },
configOptions: feature.selectedConversation?.configOptions ?? [],
sessionID: feature.selectedSessionID,
isPreparingSession: isPreparingSession,
isConfiguring: feature.selectedConversation?.pendingConfigToken != nil,
isCancelling: feature.selectedConversation?.isCancelling == true,
contextUsage: feature.selectedConversation?.contextUsage,
Expand Down
48 changes: 42 additions & 6 deletions macos/Sources/Lithe/Views/Agent/AgentPanelStyle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ enum AgentPanelStyle {
static let versionText = adaptive(dark: 0xddd6fe, light: 0x6d28d9)
static let versionAccent = Color(red: 139 / 255, green: 92 / 255, blue: 246 / 255)

static func brandTint(for name: String?, isDark: Bool) -> UInt32? {
switch name?.lowercased() {
case "claude", "claude code": 0xd97757
case "codex": isDark ? 0xcccccc : 0x333333
default: nil
}
}

private static func adaptive(dark: UInt32, light: UInt32) -> Color {
Color(nsColor: NSColor(name: nil) { appearance in
let hex = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light
Expand All @@ -30,17 +38,25 @@ enum AgentPanelStyle {
}
}

/// Template SVGs use the same vendor silhouettes at welcome and toolbar sizes.
/// Reuse the vendor silhouettes; toolbar marks retain their color in native menus.
struct AgentBrandIcon: View {
enum Style { case template, brand }

let name: String?
var size: CGFloat = 16
var style: Style = .template
@Environment(\.colorScheme) private var colorScheme

private var tint: UInt32? {
style == .brand ? AgentPanelStyle.brandTint(for: name, isDark: colorScheme == .dark) : nil
}

var body: some View {
Group {
if let image = AgentBrandIconLoader.image(name: name, size: size) {
if let image = AgentBrandIconLoader.image(name: name, size: size, tint: tint) {
Image(nsImage: image)
.resizable()
.renderingMode(.template)
.renderingMode(tint == nil ? .template : .original)
.aspectRatio(contentMode: .fit)
} else {
Image(systemName: "sparkles")
Expand All @@ -59,11 +75,13 @@ enum AgentBrandIconLoader {
let bundleURL: URL
let filename: String
let size: Int
let tint: UInt32?
}
private static var images: [CacheKey: NSImage] = [:]

static func image(
name: String?, size: CGFloat = 64,
tint: UInt32? = nil,
resourceBundle: Bundle? = resolveResourceBundle()
) -> NSImage? {
let filename: String
Expand All @@ -73,15 +91,33 @@ enum AgentBrandIconLoader {
default: return nil
}
guard let resourceBundle else { return nil }
let key = CacheKey(bundleURL: resourceBundle.bundleURL, filename: filename, size: max(1, Int(size.rounded())))
let key = CacheKey(bundleURL: resourceBundle.bundleURL, filename: filename,
size: max(1, Int(size.rounded())), tint: tint)
if let image = images[key] { return image }
guard let url = resourceBundle.url(forResource: filename, withExtension: "svg", subdirectory: "AgentIcons"),
let image = NSImage(contentsOf: url) else { return nil }
image.isTemplate = true
// Native Menu labels read NSImage.size rather than the SwiftUI frame.
image.size = NSSize(width: key.size, height: key.size)
images[key] = image
return image
let rendered: NSImage
if let tint {
let color = NSColor(srgbRed: CGFloat((tint >> 16) & 255) / 255,
green: CGFloat((tint >> 8) & 255) / 255,
blue: CGFloat(tint & 255) / 255, alpha: 1)
// SwiftUI foreground styles can be lost when Menu converts its label
// to AppKit. Color the SVG's alpha mask in memory and keep it original.
rendered = NSImage(size: image.size, flipped: false) { rect in
image.draw(in: rect, from: .zero, operation: .copy, fraction: 1)
color.setFill()
rect.fill(using: .sourceIn)
return true
}
rendered.isTemplate = false
} else {
rendered = image
}
images[key] = rendered
return rendered
}

nonisolated static func resolveResourceBundle(
Expand Down
6 changes: 4 additions & 2 deletions macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ struct AgentSessionSelectors: View {
.help(AgentSessionSelectorPresentation.localized(mode.name))
.accessibilityLabel(Text("Approval mode"))
.accessibilityValue(AgentSessionSelectorPresentation.currentTitle(mode))
.accessibilityIdentifier("agent-session-mode-selector")
.popover(isPresented: $showsModes, arrowEdge: .top) {
AgentModePopover(option: mode) { value in select(mode.id, value) }
.onExitCommand { showsModes = false }
Expand All @@ -95,14 +96,15 @@ struct AgentSessionSelectors: View {
if let model {
Button { showsModels.toggle() } label: {
selectorLabel {
AgentBrandIcon(name: agentName, size: 12)
AgentBrandIcon(name: agentName, size: 12, style: .brand)
Text(modelSummary(model))
}
}
.buttonStyle(.litheNoPress)
.help(model.currentLabel)
.accessibilityLabel(Text("Model"))
.accessibilityValue(model.currentLabel)
.accessibilityIdentifier("agent-session-model-selector")
.popover(isPresented: $showsModels, arrowEdge: .top) {
AgentModelPopover(option: model, settings: settings, agentName: agentName, onSelect: select)
.onExitCommand { showsModels = false }
Expand Down Expand Up @@ -223,7 +225,7 @@ struct AgentModelPopover: View {
.padding(.horizontal, 12).padding(.vertical, 4)
}
AgentSelectorRow(isSelected: choice.id == option.currentValue, action: { onSelect(option.id, choice.id) }) {
AgentBrandIcon(name: agentName, size: 16)
AgentBrandIcon(name: agentName, size: 16, style: .brand)
Text(choice.name).lineLimit(1).truncationMode(.middle)
}
}
Expand Down
Loading
Loading