Files
hapi/ios/Hapi/Features/Chat/SessionConfigView.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

146 lines
5.1 KiB
Swift

import HapiClient
import HapiProtocol
import SwiftUI
/// Session config sheet (A-M3b): permission mode / model / effort sections,
/// catalog-driven per flavor — the SwiftUI twin of the Android
/// `SessionConfigSheet`. Pickers apply optimistically through the interactor
/// (`setPermissionMode` & co., server truth reloaded on error); a flavor
/// without a known model catalog simply hides that section.
struct SessionConfigView: View {
let interactor: ChatInteractor
@Environment(\.dismiss) private var dismiss
var body: some View {
let config = interactor.config
NavigationStack {
List {
if !config.active {
notice("Session is offline — changes apply after it resumes or may be rejected.")
} else if config.controlledByUser {
notice("Session is controlled from the terminal — config changes will be rejected.")
}
if !config.permissionModes.isEmpty {
Section("Permission mode") {
ForEach(config.permissionModes, id: \.mode) { option in
OptionRow(
label: option.label,
selected: option.mode == (config.permissionMode ?? .default),
tone: option.tone
) {
interactor.setPermissionMode(option.mode)
}
}
}
}
if config.modelOptions != nil || config.modelOptionsLoading {
Section("Model") {
modelSection(config)
}
}
if let effortOptions = config.effortOptions {
Section("Effort") {
let current = normalizedCurrentEffort(config)
ForEach(effortOptions, id: \.self) { option in
OptionRow(label: option.label, selected: option.value == current) {
interactor.setEffort(option.value)
}
}
}
}
}
.navigationTitle("Session Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
dismiss()
}
}
}
}
.presentationDetents([.medium, .large])
.onAppear {
interactor.loadModelOptions()
}
}
@ViewBuilder
private func modelSection(_ config: SessionConfigState) -> some View {
if config.modelOptionsLoading {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
Text("Loading models…")
.font(.subheadline)
.foregroundStyle(.secondary)
}
} else if config.modelOptions?.isEmpty != false {
Text("Model list unavailable for this session.")
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
let current = normalizedCurrentModel(config)
ForEach(config.modelOptions ?? [], id: \.self) { option in
OptionRow(label: option.label, selected: option.value == current) {
interactor.setModel(option.value)
}
}
}
}
/// Claude models normalize `auto`/`default` to the nil Default row.
private func normalizedCurrentModel(_ config: SessionConfigState) -> String? {
config.flavor == "claude" ? ModelCatalog.normalizeClaudeModel(config.model) : config.model
}
private func normalizedCurrentEffort(_ config: SessionConfigState) -> String? {
config.flavor == "claude" ? ModelCatalog.normalizeClaudeEffort(config.effort) : config.effort
}
private func notice(_ text: LocalizedStringKey) -> some View {
Section {
Label(text, systemImage: "info.circle")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
/// One catalog row: label + trailing checkmark, tinted by the mode's tone.
private struct OptionRow: View {
let label: String
let selected: Bool
var tone: PermissionModeTone = .neutral
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack {
Text(label)
.foregroundStyle(toneColor)
Spacer()
if selected {
Image(systemName: "checkmark")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.tint)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private var toneColor: Color {
switch tone {
case .danger: .red
case .warning: .orange
case .neutral, .info: .primary
}
}
}