Polish assistant sidebar interactions

This commit is contained in:
Kaede Akino committed 2026-08-28 16:19:32 +08:00
1 parent acbc488d51
commit e26f79fd65
6 files changed
+200 -331

No files matched your search

@@ -51,12 +51,14 @@ struct ConversationView: View {
SteeringQueueView(
conversation: conversation,
isCompact: isCompact,
isEmbedded: true
isEmbedded: true,
surfaceStyle: interactionSurfaceStyle
)
}
}
ComposerView(
conversation: conversation,
surfaceStyle: interactionSurfaceStyle,
maxContentWidth: isCompact ? nil : Metrics.Chat.contentMaxWidth,
sessionContext: sessionContext,
includesTerminalSuggestion: showsTerminalActions,
@@ -68,7 +70,22 @@ struct ConversationView: View {
}
}
@ViewBuilder
private var archivedSurface: some View {
if isCompact {
archivedSurfaceContent
.composerBlock(style: interactionSurfaceStyle)
.padding(.horizontal, Metrics.Space.l)
.padding(.top, Metrics.Space.s)
.padding(.bottom, Metrics.Space.s)
} else {
archivedSurfaceContent
.padding(Metrics.Space.l)
.background(.bar)
}
}
private var archivedSurfaceContent: some View {
HStack(spacing: Metrics.Space.m) {
Label("Archived conversations are read-only.", systemImage: "archivebox")
.font(.callout)
@@ -79,8 +96,10 @@ struct ConversationView: View {
}
.buttonStyle(.borderedProminent)
}
.padding(Metrics.Space.l)
.background(.bar)
}
private var interactionSurfaceStyle: AssistantInteractionSurfaceStyle {
isCompact ? .sidebar : .standard
}
private func chatColumn<Content: View>(
@@ -123,7 +142,7 @@ struct ConversationView: View {
),
alignment: .top
)
.composerBlock()
.composerBlock(style: interactionSurfaceStyle)
.padding(.horizontal, Metrics.Space.l)
.frame(maxWidth: isCompact ? .infinity : Metrics.Chat.contentMaxWidth)
.frame(maxWidth: .infinity)
@@ -1512,9 +1531,7 @@ private struct AgentUserInputCard: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
Text(
"Question \(conversation.currentUserInputQuestionIndex + 1) of \(request.questions.count)"
)
questionProgress
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
.fixedSize()
@@ -1534,6 +1551,16 @@ private struct AgentUserInputCard: View {
}
}
@ViewBuilder
private var questionProgress: some View {
let current = conversation.currentUserInputQuestionIndex + 1
if isCompact {
Text("\(current) / \(request.questions.count)")
} else {
Text("Question \(current) of \(request.questions.count)")
}
}
@ViewBuilder
private func questionBlock(_ question: AgentUserQuestion) -> some View {
@Bindable var conversation = conversation
@@ -1658,6 +1685,7 @@ struct SteeringQueueView: View {
let conversation: ConversationModel
var isCompact = false
var isEmbedded = false
var surfaceStyle: AssistantInteractionSurfaceStyle = .standard
/// The rows' own height, because a `ScrollView` will not report one — left
/// alone it takes every point it is offered, and the strip would stand at
@@ -1710,7 +1738,12 @@ struct SteeringQueueView: View {
.padding(.horizontal, Metrics.Space.l)
.padding(.vertical, Metrics.Space.m)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.bar, in: .rect(cornerRadius: Metrics.Chat.composerRadius))
.background(
surfaceStyle == .sidebar
? AnyShapeStyle(surfaceStyle.panelColor)
: AnyShapeStyle(.bar),
in: .rect(cornerRadius: Metrics.Chat.composerRadius)
)
.overlay {
RoundedRectangle(cornerRadius: Metrics.Chat.composerRadius)
.stroke(.quaternary, lineWidth: 1)
+70 -7
View File
@@ -269,20 +269,72 @@ struct FlowLayout: Layout {
/// meant to read as the same object. Two hand-written copies of the same six
/// lines drift the first time one radius changes, and the drift shows up as two
/// blocks in one column that are almost — but not quite — the same shape.
enum AssistantInteractionSurfaceStyle {
case standard
case sidebar
var panelColor: Color {
switch self {
case .standard:
.clear
case .sidebar:
Color(nsColor: .unemphasizedSelectedContentBackgroundColor)
}
}
}
private struct ComposerBlock: ViewModifier {
let style: AssistantInteractionSurfaceStyle
let isEmphasized: Bool
func body(content: Content) -> some View {
content
.padding(Metrics.Space.l)
.background(.bar, in: .rect(cornerRadius: Metrics.Chat.composerRadius))
.overlay {
RoundedRectangle(cornerRadius: Metrics.Chat.composerRadius)
.stroke(.quaternary, lineWidth: 1)
.background {
surfaceBackground
}
.overlay {
surfaceBorder
}
}
@ViewBuilder
private var surfaceBackground: some View {
switch style {
case .standard:
RoundedRectangle(cornerRadius: Metrics.Chat.composerRadius)
.fill(.bar)
case .sidebar:
RoundedRectangle(cornerRadius: Metrics.Chat.composerRadius)
.fill(style.panelColor)
}
}
@ViewBuilder
private var surfaceBorder: some View {
switch style {
case .standard:
RoundedRectangle(cornerRadius: Metrics.Chat.composerRadius)
.stroke(.quaternary, lineWidth: 1)
case .sidebar:
RoundedRectangle(cornerRadius: Metrics.Chat.composerRadius)
.stroke(Color.primary.opacity(isEmphasized ? 0.16 : 0.09), lineWidth: 1)
}
}
}
extension View {
func composerBlock() -> some View { modifier(ComposerBlock()) }
func composerBlock(
style: AssistantInteractionSurfaceStyle = .standard,
isEmphasized: Bool = false
) -> some View {
modifier(
ComposerBlock(
style: style,
isEmphasized: isEmphasized
)
)
}
}
/// The Plan chip, which exists only while plan mode is on.
@@ -347,6 +399,7 @@ struct ComposerView: View {
let conversation: ConversationModel
var prompt: LocalizedStringKey = "Ask anything…"
var surfaceStyle: AssistantInteractionSurfaceStyle = .standard
/// The full Assistant tab shares one centered column with its transcript.
/// `nil` keeps compact/sidebar composers fluid.
var maxContentWidth: CGFloat? = nil
@@ -449,7 +502,10 @@ struct ComposerView: View {
.frame(maxWidth: .infinity, minHeight: 32, maxHeight: 32)
.animation(Motion.panel(reduceMotion: reduceMotion), value: showsTranscriptionControls)
}
.composerBlock()
.composerBlock(
style: surfaceStyle,
isEmphasized: isFocused || isDropTargeted
)
.overlay {
if isDropTargeted {
RoundedRectangle(cornerRadius: Metrics.Radius.large)
@@ -572,7 +628,11 @@ struct ComposerView: View {
return .handled
}
.onKeyPress(.return, phases: .down) { press in
guard !press.modifiers.contains(.shift) else { return .ignored }
let editorHasMarkedText =
(NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false
guard !hasMarkedText, !editorHasMarkedText,
!press.modifiers.contains(.shift)
else { return .ignored }
let disallowed: EventModifiers = [.control, .option]
guard press.modifiers.intersection(disallowed).isEmpty else { return .ignored }
if acceptHighlightedSlashRow() { return .handled }
@@ -1034,6 +1094,7 @@ struct ComposerView: View {
completion: completion,
selection: min(slashSelection, completion.rows.count - 1),
scrollRequest: slashScrollRequest,
surfaceStyle: surfaceStyle,
contextShare: contextShare,
trailingText: slashTrailingText,
onHover: { slashSelection = $0 },
@@ -1410,6 +1471,7 @@ struct ComposerView: View {
.padding(.bottom, Metrics.Space.s)
}
.frame(width: 320)
.background(surfaceStyle.panelColor)
}
private func permissionOptionRow(_ mode: ChatPermissionMode) -> some View {
@@ -1581,6 +1643,7 @@ struct ComposerView: View {
.buttonStyle(.plain)
}
.frame(width: 264)
.background(surfaceStyle.panelColor)
}
private func modelPickerRow(
@@ -18,6 +18,7 @@ struct SlashCommandMenuView: View {
/// Advanced only by keyboard navigation. Hover still changes the highlight,
/// but must not recenter a list underneath a stationary pointer.
let scrollRequest: Int
var surfaceStyle: AssistantInteractionSurfaceStyle = .standard
var contextShare: Double?
/// The value a row currently holds — the resolved effort beside
/// `/reasoning`. Returning `nil` leaves the trailing column empty.
@@ -36,7 +37,7 @@ struct SlashCommandMenuView: View {
// what stops a short list from rubber-banding when the pointer crosses
// it while longer command and option lists remain scrollable.
.frame(height: listHeight)
.composerBlock()
.composerBlock(style: surfaceStyle)
.accessibilityElement(children: .contain)
.accessibilityLabel(Text("Commands"))
}
+66 -39
View File
@@ -74,16 +74,10 @@ struct AssistantPanelView: View {
.transition(.move(edge: .trailing).combined(with: .opacity))
} else if let conversation = window.sidebarAssistantConversation {
conversationDetail(conversation)
.transition(.move(edge: .trailing).combined(with: .opacity))
} else {
landing
.transition(.move(edge: .leading).combined(with: .opacity))
}
}
.animation(
Motion.tabSwitch,
value: window.sidebar.assistantConversationID
)
.animation(
Motion.tabSwitch,
value: window.sidebar.isAssistantArchivePresented
@@ -112,6 +106,7 @@ struct AssistantPanelView: View {
ComposerView(
conversation: window.sidebar.assistantDraft,
prompt: "Ask anything…",
surfaceStyle: .sidebar,
sessionContext: window.sidebar.sessionContext,
includesTerminalSuggestion: true,
onSend: {
@@ -163,11 +158,7 @@ struct AssistantPanelView: View {
.lineLimit(1)
Spacer(minLength: 0)
}
.padding(.horizontal, Metrics.Space.m)
.padding(.vertical, Metrics.Space.s)
.background(.bar)
Divider()
.compactAssistantHeader()
ConversationView(
conversation: conversation, isCompact: true,
sessionContext: window.sidebar.sessionContext,
@@ -551,33 +542,7 @@ private struct AssistantArchiveView: View {
var body: some View {
VStack(spacing: 0) {
HStack(spacing: Metrics.Space.s) {
if let onBack {
Button(action: onBack) {
Image(systemName: "arrow.left")
}
.buttonStyle(.borderless)
.keyboardShortcut(.cancelAction)
.help(Text("Back to Chats"))
}
Image(systemName: "archivebox")
.foregroundStyle(.secondary)
Text("Archived")
.font(isCompact ? .headline : .title2.weight(.semibold))
Text(window.assistantLibrary.archivedConversations.count, format: .number)
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
.padding(.horizontal, Metrics.Space.s)
.padding(.vertical, 2)
.background(.quaternary, in: .capsule)
Spacer(minLength: 0)
}
.padding(.horizontal, isCompact ? Metrics.Space.m : Metrics.Space.xl)
.padding(.vertical, isCompact ? Metrics.Space.s : Metrics.Space.l)
.background(.bar)
Divider()
archiveHeader
AssistantConversationList(
selectedID: nil,
@@ -595,7 +560,69 @@ private struct AssistantArchiveView: View {
}
}
}
.background(.background)
.background {
if !isCompact {
Rectangle()
.fill(.background)
}
}
}
@ViewBuilder
private var archiveHeader: some View {
if isCompact {
archiveHeaderContent
.compactAssistantHeader()
} else {
archiveHeaderContent
.padding(.horizontal, Metrics.Space.xl)
.padding(.vertical, Metrics.Space.l)
.background(.bar)
Divider()
}
}
private var archiveHeaderContent: some View {
HStack(spacing: Metrics.Space.s) {
if let onBack {
Button(action: onBack) {
Image(systemName: "arrow.left")
}
.buttonStyle(.borderless)
.keyboardShortcut(.cancelAction)
.help(Text("Back to Chats"))
}
Image(systemName: "archivebox")
.foregroundStyle(.secondary)
Text("Archived")
.font(isCompact ? .headline : .title2.weight(.semibold))
Text(window.assistantLibrary.archivedConversations.count, format: .number)
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
.padding(.horizontal, Metrics.Space.s)
.padding(.vertical, 2)
.background(.quaternary, in: .capsule)
Spacer(minLength: 0)
}
}
}
private struct CompactAssistantHeader: ViewModifier {
func body(content: Content) -> some View {
content
.padding(.horizontal, Metrics.Space.m)
.padding(.vertical, Metrics.Space.s)
.overlay(alignment: .bottom) {
Divider()
.opacity(0.55)
}
}
}
private extension View {
func compactAssistantHeader() -> some View {
modifier(CompactAssistantHeader())
}
}
@@ -12,7 +12,6 @@ struct SidebarView: View {
@Environment(WindowModel.self) private var window
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Namespace private var modeGlassNamespace
var body: some View {
@Bindable var window = window
@@ -68,67 +67,27 @@ struct SidebarView: View {
@ViewBuilder
private var modeSelector: some View {
if #available(macOS 26.0, *) {
GlassEffectContainer(spacing: Metrics.Space.xs) {
HStack(spacing: Metrics.Space.xs) {
ForEach(window.availableSidebarModes) { mode in
glassSidebarModeButton(mode)
}
}
}
.animation(reduceMotion ? nil : Motion.panel, value: window.sidebarMode)
} else {
HStack(spacing: Metrics.Space.xs) {
ForEach(window.availableSidebarModes) { mode in
fallbackSidebarModeButton(mode)
}
HStack(spacing: Metrics.Space.xs) {
ForEach(window.availableSidebarModes) { mode in
sidebarModeButton(mode)
}
}
.animation(reduceMotion ? nil : Motion.panel, value: window.sidebarMode)
}
@available(macOS 26.0, *)
private func glassSidebarModeButton(_ mode: WindowSidebarState.Mode) -> some View {
private func sidebarModeButton(_ mode: WindowSidebarState.Mode) -> some View {
let isSelected = window.sidebarMode == mode
let isEnabled = window.isSidebarModeEnabled(mode)
return Button {
window.sidebarMode = mode
} label: {
Image(systemName: mode.symbol)
.font(.callout.weight(.medium))
.frame(maxWidth: .infinity, minHeight: 28)
.foregroundStyle(isSelected ? Color.primary : Color.secondary)
.background {
if isSelected {
Color.clear
.glassEffect(
.regular.tint(Color.accentColor.opacity(0.14)).interactive(),
in: .rect(cornerRadius: Metrics.Sidebar.rowCornerRadius)
)
.glassEffectID("sidebar-mode-selection", in: modeGlassNamespace)
}
}
.contentShape(.rect)
}
.buttonStyle(.plain)
.disabled(!isEnabled)
.opacity(isEnabled ? 1 : 0.38)
.help(Text(mode.label))
.accessibilityLabel(Text(mode.label))
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
private func fallbackSidebarModeButton(_ mode: WindowSidebarState.Mode) -> some View {
let isSelected = window.sidebarMode == mode
let isEnabled = window.isSidebarModeEnabled(mode)
return Button {
window.sidebarMode = mode
} label: {
Image(systemName: mode.symbol)
.font(.callout.weight(.medium))
.frame(maxWidth: .infinity, minHeight: 28)
.font(.callout.weight(isSelected ? .semibold : .medium))
.foregroundStyle(isSelected ? Color.primary : Color.secondary)
.frame(maxWidth: .infinity)
.frame(height: 28)
.background(
Highlight.fill(isSelected: isSelected),
isSelected ? Color.primary.opacity(0.10) : Color.clear,
in: .rect(cornerRadius: Metrics.Sidebar.rowCornerRadius)
)
.contentShape(.rect)
+12 -226
View File
@@ -319,6 +319,18 @@
}
}
},
"%lld / %lld" : {
"comment" : "A step counter for a question.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$lld / %2$lld"
}
}
}
},
"%lld active transfers" : {
"localizations" : {
"zh-Hans" : {
@@ -607,17 +619,6 @@
}
}
},
"%lld results." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld结果。"
}
}
}
},
"%lld selected" : {
"localizations" : {
"zh-Hans" : {
@@ -708,17 +709,6 @@
}
}
},
"%lld." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld."
}
}
}
},
"%lldh" : {
"localizations" : {
"zh-Hans" : {
@@ -769,17 +759,6 @@
}
}
},
"•" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "•"
}
}
}
},
"• %@" : {
"localizations" : {
"zh-Hans" : {
@@ -1900,17 +1879,6 @@
}
}
},
"Backend" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "后端"
}
}
}
},
"Base URL" : {
"localizations" : {
"zh-Hans" : {
@@ -1951,17 +1919,6 @@
}
}
},
"Blocked link: %@" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "已屏蔽链接:%@"
}
}
}
},
"Blue" : {
"localizations" : {
"zh-Hans" : {
@@ -4232,17 +4189,6 @@
}
}
},
"Empty response body" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "空响应体"
}
}
}
},
"Enable" : {
"localizations" : {
"zh-Hans" : {
@@ -4343,17 +4289,6 @@
}
}
},
"Every model uses the backend below, which behaves the same on every endpoint." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "所有模型均使用下方后端,其在每个端点上的行为一致。"
}
}
}
},
"Exact model ID, such as gpt-5.4" : {
"localizations" : {
"zh-Hans" : {
@@ -5442,17 +5377,6 @@
}
}
},
"How the assistant finds pages before it reads them." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "助手读取页面前使用的搜索方式。"
}
}
}
},
"HTTP" : {
"localizations" : {
"zh-Hans" : {
@@ -6761,28 +6685,6 @@
}
}
},
"No account or key." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "无需账户或密钥。"
}
}
}
},
"No account or key. Reachable almost anywhere." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "无需账户或密钥,几乎可在任何网络访问。"
}
}
}
},
"No application is available to open this attachment." : {
"localizations" : {
"zh-Hans" : {
@@ -8253,17 +8155,6 @@
}
}
},
"Providers that run search on their own servers do it themselves, and read whole pages rather than snippets. Anything else falls back to the backend below. Whether an endpoint really does is inferred from the model, and a gateway can make that wrong — turn this off if search fails or the model reports an unknown tool." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "支持在自身服务器上运行网页搜索的提供商会自行执行搜索,并读取完整页面而非摘要。其他情况则使用下方后端。系统会根据模型推断端点是否支持此功能,但网关可能导致判断错误——如果搜索失败或模型报告未知工具,请关闭此选项。"
}
}
}
},
"Proxy" : {
"localizations" : {
"zh-Hans" : {
@@ -8510,17 +8401,6 @@
}
}
},
"Reached the backend, but it returned no results." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "已连接后端,但未返回结果。"
}
}
}
},
"read" : {
"localizations" : {
"zh-Hans" : {
@@ -9133,28 +9013,6 @@
}
}
},
"Response body" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "响应正文"
}
}
}
},
"Response headers" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "响应标头"
}
}
}
},
"Responses API" : {
"localizations" : {
"zh-Hans" : {
@@ -11985,17 +11843,6 @@
}
}
},
"The search query is empty." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "搜索查询为空。"
}
}
}
},
"The secret is not valid for this host, tool, or purpose." : {
"localizations" : {
"zh-Hans" : {
@@ -12256,56 +12103,6 @@
}
}
},
"The Web Search endpoint redirected somewhere Termind will not follow." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "网页搜索端点重定向到了Termind不会跟随的地址。"
}
}
}
},
"The Web Search endpoint returned %lld: %@" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "The Web Search endpoint returned %1$lld: %2$@"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "网页搜索端点返回%1$lld:%2$@"
}
}
}
},
"The Web Search endpoint returned an unsupported response format." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "网页搜索端点返回了不支持的响应格式。"
}
}
}
},
"The Web Search endpoint URL is invalid." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "网页搜索端点URL无效。"
}
}
}
},
"Theme" : {
"localizations" : {
"zh-Hans" : {
@@ -13538,17 +13335,6 @@
}
}
},
"Web Search has no API key configured." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "网页搜索未配置API密钥。"
}
}
}
},
"Web search run by the model provider." : {
"localizations" : {
"zh-Hans" : {