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

143 lines
5.3 KiB
Swift

import HapiProtocol
import HapiUI
import SwiftUI
/// Codex `/review` verdict card (web `CodexReviewCard`): header with the
/// overall-correctness badge (+ confidence), the explanation as markdown,
/// and the findings list collapsed behind a count row.
struct CodexReviewBlockView: View {
let block: CodexReviewBlock
@State private var findingsOpen = false
@Environment(\.hapiTheme) private var theme
var body: some View {
let review = block.review
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 6) {
Text("Code review")
.font(.subheadline.weight(.semibold))
Spacer(minLength: 8)
if let verdict = review.overallCorrectness {
VerdictBadge(verdict: verdict)
}
if let confidence = formatPercent(review.overallConfidenceScore) {
Text(confidence)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
if let explanation = review.overallExplanation,
!explanation.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Divider()
MarkdownView(markdown: explanation)
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
if !review.findings.isEmpty {
Divider()
Button {
withAnimation(.easeInOut(duration: 0.15)) {
findingsOpen.toggle()
}
} label: {
Label(
review.findings.count == 1
? String(localized: "1 finding")
: String(format: String(localized: "%lld findings"), Int64(review.findings.count)),
systemImage: findingsOpen ? "chevron.down" : "chevron.right"
)
.font(.footnote)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if findingsOpen {
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(review.findings.enumerated()), id: \.offset) { _, finding in
FindingRow(finding: finding)
}
}
.padding(.horizontal, 12)
.padding(.bottom, 12)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(theme.surface)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
private struct VerdictBadge: View {
let verdict: String
var body: some View {
let lowered = verdict.lowercased()
let tint: Color = lowered.contains("incorrect")
? .red
: (lowered.contains("correct") ? .green : .secondary)
Text(verdict)
.font(.caption2)
.foregroundStyle(tint)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(tint.opacity(0.15), in: RoundedRectangle(cornerRadius: 6))
}
}
private struct FindingRow: View {
let finding: CodexReviewFinding
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
if let priority = finding.priority {
let value = Int(priority.rounded())
Text("P\(value)")
.font(.caption2.weight(.semibold))
.foregroundStyle(value <= 1 ? Color.red : Color.secondary)
.padding(.horizontal, 4)
.padding(.vertical, 1)
.background(
(value <= 1 ? Color.red : Color.secondary).opacity(0.14),
in: RoundedRectangle(cornerRadius: 4)
)
}
Text(finding.title)
.font(.subheadline.weight(.semibold))
}
Text(finding.body)
.font(.footnote)
if let location = formatLocation(finding) {
Text(location)
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(.background.opacity(0.6), in: RoundedRectangle(cornerRadius: 8))
}
}
private func formatPercent(_ value: Double?) -> String? {
guard let value, value.isFinite else { return nil }
return "\(Int((value * 100).rounded()))%"
}
private func formatLocation(_ finding: CodexReviewFinding) -> String? {
guard let filePath = finding.filePath else { return nil }
guard let start = finding.lineStart.map({ Int($0.rounded()) }) else { return filePath }
if let end = finding.lineEnd.map({ Int($0.rounded()) }), end != start {
return "\(filePath):\(start)-\(end)"
}
return "\(filePath):\(start)"
}