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
14 changes: 8 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ to light.
by navigation and the toolbar. Its tint comes from the desktop; do not add a
fixed colored fill or lower its opacity. The sidebar has no separate glass card,
outline, or shadow in the wide layout; its selection is a light row card.
- The opaque conversation sheet floats below the toolbar; the shorter inspector
floats beside it, sharing its top edge. Keep their radius concentric with the
window corner (`WorkbenchTheme.Radius.framePanel`), light shadows and visible
backdrop gutters. Preserve native Liquid Glass on the composer and controls.
These surfaces follow the user's unified-glass direction of 2026-10-27 and the
Apple concentric-corner direction of 2026-10-28.
- The opaque workspace sheet floats below the toolbar. The full-height inspector
is an internal auxiliary lane separated by a hairline, without its own rounded
card or shadow; opening it resizes the reader and composer together. Keep the
outer sheet concentric with the window corner (`WorkbenchTheme.Radius.framePanel`)
and its elevation light. Sidebar width remains user-adjustable and persisted;
navigation uses compact, secondary controls. Preserve native Liquid Glass on the
composer and controls. This follows the user's integrated-inspector direction
of 2026-10-28.
- Keep credentials in the user's existing SSH configuration and agent.
- Disconnecting a client must not close a remote pane or stop an agent.
- Do not attach with --takeover or enable agent permission bypass implicitly.
Expand Down
37 changes: 21 additions & 16 deletions Sources/AgentWorkbench/KimiWorkspaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -179,28 +179,33 @@ private struct KimiComposerView: View {
connection.thinkingChoices[sessionID] = model.resolve(connection.thinkingChoices[sessionID])
}, onSelectThinking: { connection.thinkingChoices[sessionID] = $0 })
}
ComposerEnvironmentMenu(host: connection.host, provider: "Kimi",
directory: connection.conversation?.snapshot.session.cwd ?? "")
PermissionPicker(provider: .kimi, capability: connection.permissionCapability(for: sessionID),
disabled: connection.sending) { mode in
connection.setPermission(mode, for: sessionID)
}
ComposerTemplatesMenu(draft: connection.drafts[sessionID] ?? "") { template in
onInput()
let existing = connection.drafts[sessionID] ?? ""
connection.drafts[sessionID] = existing.isEmpty ? template : existing + "\n\n" + template
NotificationCenter.default.post(name: .init("PerchFocusComposer"), object: nil)
}
ContextMeter(budget: connection.conversation?.snapshot.session.budget,
isStale: !connection.online || !connection.snapshotReady,
autoCompactHost: connection.host.id, autoCompactSession: sessionID)
HStack(spacing: 6) {
ComposerEnvironmentMenu(host: connection.host, provider: "Kimi",
directory: connection.conversation?.snapshot.session.cwd ?? "")
PermissionPicker(provider: .kimi, capability: connection.permissionCapability(for: sessionID),
disabled: connection.sending) { mode in
connection.setPermission(mode, for: sessionID)
}
ComposerTemplatesMenu(draft: connection.drafts[sessionID] ?? "") { template in
onInput()
let existing = connection.drafts[sessionID] ?? ""
connection.drafts[sessionID] = existing.isEmpty ? template : existing + "\n\n" + template
NotificationCenter.default.post(name: .init("PerchFocusComposer"), object: nil)
}
ContextMeter(budget: connection.conversation?.snapshot.session.budget,
isStale: !connection.online || !connection.snapshotReady,
autoCompactHost: connection.host.id, autoCompactSession: sessionID)
}.foregroundStyle(.secondary).padding(.leading, 10)
.overlay(alignment: .leading) {
Rectangle().fill(.primary.opacity(WorkbenchTheme.Stroke.hairline)).frame(width: 1, height: 16)
}
ComposerActionButton(isRunning: isRunning,
isStopping: connection.isStopping, canSend: canSend, canStop: connection.canStop,
queuedSendTitle: isCommandDraft ? "Run command" : "Steer",
onSend: { connection.sendPrompt() }, onStop: { connection.abort() },
onQueue: nextTurnSend)
}
}.padding(ReplyStyle.composerInset).workbenchControlSurface()
}.padding(ReplyStyle.composerInset).workbenchComposerSurface()
ComposerDeliveryHint(sending: connection.sending, saveError: connection.draftSaveError,
draftLength: (connection.drafts[sessionID] ?? "").count)
}.dropDestination(for: URL.self) { files, _ in
Expand Down
43 changes: 24 additions & 19 deletions Sources/AgentWorkbench/NativeAgentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -264,32 +264,37 @@ private struct NativeComposerView: View {
ComposerToolbarLayout {
ComposerAddButton(supportsFiles: supportsAttachments, disabled: connection.sending) { chooseFiles = true }
NativeModelControls(connection: connection, snapshot: s)
ComposerEnvironmentMenu(host: connection.host, provider: s.provider.label, directory: s.cwd)
PermissionPicker(
provider: s.provider,
capability: connection.sessions.first { $0.id == s.id }?.permission
?? s.permission
?? PermissionCatalog.capability(for: s.provider),
disabled: !connection.online,
allowsSelection: [.qoder, .qoderintl, .claude].contains(s.provider)
) { mode in
connection.setPermission(mode, for: s.id)
}
ComposerTemplatesMenu(draft: connection.drafts[s.id] ?? "") { template in
let existing = connection.drafts[s.id] ?? ""
connection.drafts[s.id] = existing.isEmpty ? template : existing + "\n\n" + template
NotificationCenter.default.post(name: .init("PerchFocusComposer"), object: nil)
}
ContextMeter(budget: s.provider == .codex && s.context?.reportedAt == nil ? nil : s.budget,
reportedAt: s.context?.reportedAt, isStale: !connection.online)
HStack(spacing: 6) {
ComposerEnvironmentMenu(host: connection.host, provider: s.provider.label, directory: s.cwd)
PermissionPicker(
provider: s.provider,
capability: connection.sessions.first { $0.id == s.id }?.permission
?? s.permission
?? PermissionCatalog.capability(for: s.provider),
disabled: !connection.online,
allowsSelection: [.qoder, .qoderintl, .claude].contains(s.provider)
) { mode in
connection.setPermission(mode, for: s.id)
}
ComposerTemplatesMenu(draft: connection.drafts[s.id] ?? "") { template in
let existing = connection.drafts[s.id] ?? ""
connection.drafts[s.id] = existing.isEmpty ? template : existing + "\n\n" + template
NotificationCenter.default.post(name: .init("PerchFocusComposer"), object: nil)
}
ContextMeter(budget: s.provider == .codex && s.context?.reportedAt == nil ? nil : s.budget,
reportedAt: s.context?.reportedAt, isStale: !connection.online)
}.foregroundStyle(.secondary).padding(.leading, 10)
.overlay(alignment: .leading) {
Rectangle().fill(.primary.opacity(WorkbenchTheme.Stroke.hairline)).frame(width: 1, height: 16)
}
ComposerActionButton(isRunning: s.busy, isStopping: connection.isStopping,
canSend: canSend(s), canStop: connection.canStop,
queuedSendTitle: defaultMode(s) == .steer ? "Steer" : "Queue",
onSend: { send(defaultMode(s)) },
onStop: { if isCurrent { connection.stop() } },
onQueue: nextTurnSend)
}
}.padding(ReplyStyle.composerInset).workbenchControlSurface()
}.padding(ReplyStyle.composerInset).workbenchComposerSurface()
ComposerDeliveryHint(sending: connection.sending, saveError: connection.draftSaveError,
draftLength: (connection.drafts[s.id] ?? "").count)
}.frame(maxWidth: ReplyStyle.composerWidth).padding(.horizontal, ReplyStyle.composerPadding).frame(maxWidth: .infinity, alignment: .center).padding(.bottom, 16)
Expand Down
13 changes: 10 additions & 3 deletions Sources/AgentWorkbench/NativeAssistantBlocks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ private final class NativeCodeCardView: NativeAssistantBlockView {
private var codeText: ReplyTextView?
private var gutter: ReplyTextView?
private var source = ""
private var compact = true

override init(links: SelectableReplyText.Coordinator) {
copy = NSHostingView(rootView: ReplyCopyButton(text: "", label: "复制代码"))
Expand Down Expand Up @@ -292,6 +293,7 @@ private final class NativeCodeCardView: NativeAssistantBlockView {
codeText.update(text)
codeText.highlightCode(language: language)
let lineCount = CodeLineNumbers.count(in: source)
compact = lineCount == 1
if lineCount > 1 {
let gutter = self.gutter ?? {
let view = SelectableReplyText.acquire(delegate: links)
Expand All @@ -313,7 +315,7 @@ private final class NativeCodeCardView: NativeAssistantBlockView {
private func textSize() -> CGSize { codeText?.measure(width: nil) ?? .zero }
private func gutterSize() -> CGSize { gutter?.measure(width: nil) ?? .zero }
override func contentHeight(width: CGFloat) -> CGFloat {
ReplyStyle.Code.headerHeight + 1 + textSize().height + ReplyStyle.Code.inset * 2
(compact ? 0 : ReplyStyle.Code.headerHeight + 1) + textSize().height + ReplyStyle.Code.inset * 2
}
override func placeContent(width: CGFloat) {
typealias Card = ReplyStyle.Code
Expand All @@ -328,13 +330,18 @@ private final class NativeCodeCardView: NativeAssistantBlockView {
width: min(labelSize.width, max(0, width - Card.headerHorizontalPadding * 2 - Card.copyButtonSize - 8)),
height: labelSize.height)
if label.frame != labelFrame { label.frame = labelFrame }
let copyFrame = CGRect(x: width - Card.headerHorizontalPadding - Card.copyButtonSize, y: Card.headerVerticalPadding,
label.isHidden = compact
separator.isHidden = compact
let copyY = compact ? (text.height + Card.inset * 2 - Card.copyButtonSize) / 2 : Card.headerVerticalPadding
let copyFrame = CGRect(x: width - Card.headerHorizontalPadding - Card.copyButtonSize, y: copyY,
width: Card.copyButtonSize, height: Card.copyButtonSize)
if copy.frame != copyFrame { copy.frame = copyFrame }
let separatorFrame = CGRect(x: 0, y: Card.headerHeight, width: width, height: 1)
if separator.frame != separatorFrame { separator.frame = separatorFrame }
separator.layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(0.055).cgColor
let scrollFrame = CGRect(x: 0, y: Card.headerHeight + 1, width: width, height: text.height + Card.inset * 2)
let scrollFrame = CGRect(x: 0, y: compact ? 0 : Card.headerHeight + 1,
width: compact ? width - Card.copyButtonSize - Card.inset : width,
height: text.height + Card.inset * 2)
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 }
Expand Down
26 changes: 17 additions & 9 deletions Sources/AgentWorkbench/ReplyMarkdownView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1619,15 +1619,23 @@ struct ReplyCode: View {
let source: String
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
// NativeCodeCardView shows the same label once the reply settles.
Text(language.isEmpty ? L("代码") : language).font(.system(size: ReplyStyle.Code.labelSize, weight: .medium))
.foregroundStyle(.secondary)
Spacer()
ReplyCopyButton(text: source, label: "复制代码")
}.padding(.horizontal, ReplyStyle.Code.headerHorizontalPadding).padding(.vertical, ReplyStyle.Code.headerVerticalPadding)
Rectangle().fill(.primary.opacity(0.055)).frame(height: 1)
ReplyCodeBody(source: source, language: language)
if CodeLineNumbers.count(in: source) == 1 {
HStack(spacing: 0) {
ReplyCodeBody(source: source, language: language)
ReplyCopyButton(text: source, label: "复制代码")
.padding(.trailing, ReplyStyle.Code.inset)
}
} else {
HStack {
// NativeCodeCardView shows the same label once the reply settles.
Text(language.isEmpty ? L("代码") : language).font(.system(size: ReplyStyle.Code.labelSize, weight: .medium))
.foregroundStyle(.secondary)
Spacer()
ReplyCopyButton(text: source, label: "复制代码")
}.padding(.horizontal, ReplyStyle.Code.headerHorizontalPadding).padding(.vertical, ReplyStyle.Code.headerVerticalPadding)
Rectangle().fill(.primary.opacity(0.055)).frame(height: 1)
ReplyCodeBody(source: source, language: language)
}
}.background(ReplyStyle.paper, in: RoundedRectangle(cornerRadius: ReplyStyle.Code.cornerRadius))
.overlay(RoundedRectangle(cornerRadius: ReplyStyle.Code.cornerRadius).strokeBorder(.primary.opacity(0.055)))
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/AgentWorkbench/SessionRowChrome.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,12 @@ struct SessionRowChrome<Indicator: View>: View {
} else if updatedAt > 0 {
TimelineView(.periodic(from: .now, by: 60)) { context in
Text(SessionTime.compactLabel(since: updatedAt, now: context.date, locale: locale) ?? "")
.font(.system(size: 11)).monospacedDigit().foregroundStyle(.tertiary)
.font(.system(size: SidebarMetrics.detailSize)).monospacedDigit().foregroundStyle(WorkbenchTheme.quiet)
.lineLimit(1)
}
}
}.frame(width: 28, height: 18, alignment: .trailing).padding(.top, 1)
}.padding(.horizontal, SidebarMetrics.rowInset).padding(.vertical, 10)
}.padding(.horizontal, SidebarMetrics.rowInset).padding(.vertical, 8)
.frame(maxWidth: .infinity, minHeight: SidebarMetrics.sessionMinimumHeight, alignment: .leading)
.contentShape(Rectangle())
}.buttonStyle(.plain).disabled(!canOpen).focused($focus, equals: .open)
Expand Down
4 changes: 3 additions & 1 deletion Sources/AgentWorkbench/SidebarMetrics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import SwiftUI
/// with the project title. Section headers start at the icon column.
enum SidebarMetrics {
static let rowHeight: CGFloat = 36
static let navigationHeight: CGFloat = 32
static let navigationSize: CGFloat = 13
/// Session rows that carry a status line (offline, waiting for input).
static let subtitleRowHeight: CGFloat = 50
/// Conversation titles may occupy two lines without being squeezed by actions.
Expand Down Expand Up @@ -39,7 +41,7 @@ enum SidebarMetrics {
static let sectionTitleSize: CGFloat = 12
static let sectionTitleOpacity: Double = 0.62
static let sectionHeaderHeight: CGFloat = 24
static let sectionTopSpacing: CGFloat = 18
static let sectionTopSpacing: CGFloat = 12
static let sectionBottomSpacing: CGFloat = 2
static let sectionHeight: CGFloat = sectionTopSpacing + sectionHeaderHeight + sectionBottomSpacing

Expand Down
5 changes: 2 additions & 3 deletions Sources/AgentWorkbench/TaskContextPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ struct TaskContextView: View {
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header.fixedSize(horizontal: false, vertical: true).inspectorHeightContribution(.header)
header.fixedSize(horizontal: false, vertical: true)
ScrollView {
VStack(alignment: .leading, spacing: 24) {
if let error = state.error {
Expand All @@ -136,7 +136,6 @@ struct TaskContextView: View {
Text("暂无任务活动").foregroundStyle(.secondary)
}
}.frame(maxWidth: .infinity, alignment: .leading).padding(16)
.inspectorHeightContribution(.content)
}
if detached, let item {
VStack(spacing: 0) {
Expand All @@ -152,7 +151,7 @@ struct TaskContextView: View {
}.fixedSize(horizontal: false, vertical: true)
}
}.font(.system(size: 13)).frame(maxWidth: .infinity, maxHeight: .infinity)
// Embedded in the floating glass panel the workspace owns the surface;
// Embedded in the integrated lane, the workspace owns the surface;
// detached needs the window's own opaque background.
.background(detached ? Color(nsColor: .windowBackgroundColor) : Color.clear)
#if PERCH_ACCEPTANCE
Expand Down
4 changes: 4 additions & 0 deletions Sources/AgentWorkbench/WorkbenchGlass.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ extension View {
func workbenchControlSurface() -> some View {
modifier(WorkbenchGlassSurface(cornerRadius: 20))
}

func workbenchComposerSurface() -> some View {
modifier(WorkbenchGlassSurface(cornerRadius: WorkbenchTheme.Radius.composer))
}
}

private struct WorkbenchGlassSurface: ViewModifier {
Expand Down
6 changes: 3 additions & 3 deletions Sources/AgentWorkbench/WorkbenchHeader.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import SwiftUI
import WorkbenchCore

/// Title changes follow workspace selection, independently of streaming state.
/// A quiet, text-only title follows selection independently of streaming state.
struct WorkbenchHeader: View {
@UILocalization private var L
@Bindable var model: WorkbenchModel
Expand All @@ -11,9 +11,9 @@ struct WorkbenchHeader: View {
return item?.title ?? L("正在恢复会话…")
}
var body: some View {
Text(title).font(.system(size: 14, weight: .semibold))
Text(title).font(.system(size: 14, weight: .medium))
.lineLimit(1).truncationMode(.tail)
.frame(minWidth: 100, maxWidth: 440, alignment: .leading)
.frame(minWidth: 80, maxWidth: 360, alignment: .leading)
.help(item.map { "\($0.title)\n\($0.reference.kind.label) · \($0.hostName)\n\($0.directory)" } ?? title)
}
}
Expand Down
Loading
Loading