Files
hapi/ios/Hapi/Features/Chat/Blocks/ToolGroupBlockView.swift
T
weishu 6066f20c20 feat(ios): zh-CN localization + catalog dedupe (A-M5)
Part 1 — Simplified Chinese localization of the iOS app layer:
- ios/Hapi/Resources/Localizable.xcstrings: hand-authored String Catalog
  (395 keys, zh-Hans; en implicit as source). Terminology mined from
  web/src/lib/locales/zh-CN.ts (会话/新建会话/权限模式/允许/拒绝/工作树/
  机器/语音输入/用量/草稿夹 …).
- Mechanical edits only: merged multi-part string concatenations into
  single LocalizedStringKey literals, converted ternary/plain-String user
  copy to String(localized:), switched helper params (detailRow,
  DashboardCard, optionPicker, notice) to LocalizedStringKey.
- LocalizedNoticeMapper (app layer): display-point translation of the 27
  known HapiKit-emitted strings (ChatInteractor notices, dictation errors,
  window-sync warnings, files fallbacks, worktree-name validation) with
  verbatim passthrough for server-originated text; package stays
  language-free.
- Deliberately untranslated (web parity / non-copy): event rows
  (EventPresentation — web renders presentation.ts verbatim), catalog
  option labels (Default/Auto/Sonnet/permission modes — web shows them
  verbatim), tool names, code-like titles (grep(pattern:), MCP:, Skill,
  Task), unit suffixes (B/KB/MB, m/h/d/w), decorative separators.
- Language row wired: AppLanguage gains .system (follow system, new
  default); explicit picks write the AppleLanguages override, Follow
  system removes it; footer notes a relaunch applies it (no supported
  in-place SwiftUI locale swap).
- project.pbxproj: zh-Hans added to knownRegions.

Part 2 — #39 catalog dedupe (HapiProtocol):
- NewSessionCatalogs.claudeModels/claudeEfforts now derive from
  ClaudeModels/ClaudeEfforts (single source shared with ModelCatalog);
  codexReasoningEfforts stays own data (web CODEX_REASONING_EFFORT_OPTIONS
  minus max); effortLabel delegates to ModelCatalog.capitalizedFirst.
- New CatalogTests lock test for the derived option lists.

Gate: bash ios/scripts/linux-test.sh — 459 tests green.
2026-08-18 16:04:03 +08:00

115 lines
3.9 KiB
Swift

import HapiProtocol
import HapiUI
import SwiftUI
/// Run of adjacent groupable tools (web `ToolGroupCard`): a one-line summary
/// — count + first targets + error/running signals — expanding to the
/// individual `ToolCallBlockView`s. Codex exploration groups honor their
/// `defaultOpen`.
struct ToolGroupBlockView: View {
let block: ToolGroupBlock
let basePath: String?
@State private var expanded: Bool
@Environment(\.hapiTheme) private var theme
init(block: ToolGroupBlock, basePath: String?) {
self.block = block
self.basePath = basePath
_expanded = State(initialValue: block.defaultOpen)
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
headerRow
if expanded {
VStack(alignment: .leading, spacing: 6) {
ForEach(block.tools, id: \.id) { tool in
ToolCallBlockView(block: tool, basePath: basePath)
}
}
.padding(.horizontal, 10)
.padding(.bottom, 10)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(theme.surface)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
private var headerRow: some View {
Button {
withAnimation(.easeInOut(duration: 0.15)) {
expanded.toggle()
}
} label: {
HStack(spacing: 8) {
Image(systemName: "wrench.and.screwdriver")
.font(.footnote)
.foregroundStyle(.secondary)
.frame(width: 18)
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(.subheadline)
.foregroundStyle(theme.textPrimary)
.lineLimit(1)
if !summaryText.isEmpty {
Text(summaryText)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 8)
trailingIndicator
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private var title: String {
if let activityTitle = block.activityTitle {
return activityTitle
}
let count = block.summary.totalTools
return count == 1
? String(localized: "1 tool")
: String(format: String(localized: "%lld tools"), Int64(count))
}
/// "file, other-file +2" digest from the group summary targets.
private var summaryText: String {
let summary = block.summary
let targets = summary.fileTargets + summary.searchTargets
+ summary.commandTargets + summary.urlTargets + summary.otherTargets
guard !targets.isEmpty else { return "" }
let shown = targets.prefix(3)
.map { target -> String in
let tail = target.split(separator: "/").last.map(String.init) ?? target
return tail.isEmpty ? target : tail
}
.joined(separator: ", ")
let more = targets.count - 3
return more > 0 ? "\(shown) +\(more)" : shown
}
@ViewBuilder
private var trailingIndicator: some View {
if block.summary.runningCount > 0 {
ProgressView()
.controlSize(.small)
} else if block.summary.errorCount > 0 {
Text("\(block.summary.errorCount) ⚠")
.font(.caption2)
.foregroundStyle(.red)
} else {
Image(systemName: expanded ? "chevron.down" : "chevron.right")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}