Files
hapi/ios/Hapi/Features/NewSession/NewSessionView.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

394 lines
13 KiB
Swift

import HapiClient
import HapiProtocol
import SwiftUI
/// NEW SESSION (A-M3c): machine → directory → agent/options → spawn,
/// presented as a sheet off the session list's "+" button. State and
/// behavior live in `NewSessionModel`; this view renders it and forwards
/// intents. A successful spawn surfaces through `onCreated` (the presenter
/// dismisses the sheet and pushes the chat — navigate-replace).
struct NewSessionView: View {
@State private var model: NewSessionModel
@Environment(\.dismiss) private var dismiss
private let session: HubSession
init(session: HubSession, onCreated: @escaping @MainActor (String) -> Void) {
_model = State(initialValue: NewSessionModel(session: session, onCreated: onCreated))
self.session = session
}
var body: some View {
NavigationStack {
Form {
machineSection
directorySection
sessionTypeSection
agentSection
optionsSection
createSection
}
.navigationTitle("New Session")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
.task {
await model.start()
}
.onChange(of: session.machineStore.listRevision) {
model.machinesChanged()
}
}
}
// MARK: - Machine
private var machineSection: some View {
Section("Machine") {
if model.machines.isEmpty {
Text(model.machinesLoading
? String(localized: "Loading machines…")
: String(localized: "No machines online"))
.foregroundStyle(.secondary)
} else {
Picker("Machine", selection: machineBinding) {
ForEach(model.machines) { machine in
Text(machine.label).tag(machine.id)
}
}
if let health = selectedMachineHealth {
Text(health)
.font(.caption)
.foregroundStyle(.secondary)
}
}
if let error = model.runnerSpawnError {
Text("Runner last spawn error: \(error)")
.font(.footnote)
.foregroundStyle(.red)
}
}
.disabled(model.isSpawning)
}
private var selectedMachineHealth: String? {
model.machines.first { $0.id == model.form.machineId }?.healthLabel
}
private var machineBinding: Binding<String> {
Binding(
get: { model.form.machineId ?? "" },
set: { model.setMachine($0) }
)
}
// MARK: - Directory
private var directorySection: some View {
Section {
TextField(
"/path/to/project",
text: Binding(get: { model.form.directory }, set: { model.setDirectory($0) })
)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.font(.system(.callout, design: .monospaced))
// Server-side autocomplete (list-directory on the parent path).
ForEach(model.suggestions, id: \.self) { suggestion in
Button {
model.pickSuggestion(suggestion)
} label: {
Label {
Text(suggestion)
.lineLimit(1)
.truncationMode(.head)
} icon: {
Image(systemName: "folder")
}
.font(.callout)
}
.buttonStyle(.plain)
}
if !model.recentPaths.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(model.recentPaths, id: \.self) { path in
FilterChip(label: path, selected: false) {
model.pickRecentPath(path)
}
}
}
}
.listRowInsets(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12))
}
} header: {
Text("Directory")
} footer: {
if let status = model.directoryStatus {
Text(status.message)
.foregroundStyle(status.isError ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary))
}
}
.disabled(model.isSpawning)
}
// MARK: - Session type
private var sessionTypeSection: some View {
Section {
Picker("Session type", selection: sessionTypeBinding) {
Text("Simple").tag(SpawnSessionType.simple)
Text("Worktree").tag(SpawnSessionType.worktree)
}
.pickerStyle(.segmented)
.labelsHidden()
if model.form.sessionType == .worktree {
TextField(
"feature-x (optional)",
text: Binding(
get: { model.form.worktreeName },
set: { model.setWorktreeName($0) }
)
)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
}
} header: {
Text("Session Type")
} footer: {
if let error = model.worktreeNameError {
Text(LocalizedNoticeMapper.map(error)).foregroundStyle(.red)
} else {
Text(
model.form.sessionType == .worktree
? String(localized: "Create a new git worktree next to the repo")
: String(localized: "Use selected directory as-is")
)
}
}
.disabled(model.isSpawning)
}
private var sessionTypeBinding: Binding<SpawnSessionType> {
Binding(
get: { model.form.sessionType },
set: { model.setSessionType($0) }
)
}
// MARK: - Agent
private var agentSection: some View {
Section("Agent") {
Picker("Agent", selection: agentBinding) {
ForEach(model.agents, id: \.value) { agent in
Text(agent.label).tag(agent.value)
}
}
}
.disabled(model.isSpawning)
}
private var agentBinding: Binding<String> {
Binding(
get: { model.form.agent.rawValue },
set: { model.setAgent(AgentFlavor(rawValue: $0)) }
)
}
// MARK: - Options
@ViewBuilder
private var optionsSection: some View {
Section("Options") {
if let options = model.modelOptions {
optionPicker(
"Model",
options: options,
selection: Binding(get: { model.form.model }, set: { model.setModel($0) })
)
.disabled(model.modelsLoading || model.modelsError != nil)
if model.modelsLoading {
Text("Loading…")
.font(.caption)
.foregroundStyle(.secondary)
}
if let error = model.modelsError {
HStack {
Text(error)
.font(.footnote)
.foregroundStyle(.red)
Spacer()
Button("Retry") {
model.retryCodexModels()
}
.font(.footnote)
}
}
}
if let options = model.effortOptions {
optionPicker(
"Effort",
options: options,
selection: Binding(get: { model.form.effort }, set: { model.setEffort($0) })
)
}
if let options = model.reasoningEffortOptions {
optionPicker(
"Reasoning Effort",
options: options,
selection: Binding(
get: { model.form.modelReasoningEffort },
set: { model.setModelReasoningEffort($0) }
)
)
.disabled(model.modelsLoading)
}
permissionControl
if model.showCollaborationMode {
Picker("Collaboration Mode", selection: collaborationModeBinding) {
ForEach(CodexCollaborationMode.allCases, id: \.self) { mode in
Text(mode.label).tag(mode)
}
}
}
if model.showCopilotAgentMode {
Picker("Agent Mode", selection: copilotAgentModeBinding) {
ForEach(CopilotAgentMode.allCases, id: \.self) { mode in
Text(mode.label).tag(mode)
}
}
}
if model.showFastMode {
Picker("Fast Mode", selection: serviceTierBinding) {
Text("Standard").tag(ServiceTier.standard)
Text("Fast").tag(ServiceTier.fast)
}
}
}
.disabled(model.isSpawning)
}
@ViewBuilder
private var permissionControl: some View {
switch model.permission {
case .nativeSelect(let options):
Picker("Permission Mode", selection: permissionModeBinding) {
ForEach(options, id: \.mode) { option in
Text(option.label).tag(option.mode)
}
}
case .yoloToggle(let nativeModeLabel):
VStack(alignment: .leading, spacing: 2) {
Toggle(
"Bypass approvals and sandbox",
isOn: Binding(get: { model.form.yolo }, set: { model.setYolo($0) })
)
Text(
nativeModeLabel.map {
String(format: String(localized: "Uses dangerous agent flags when spawning. Applies native %@ mode."), $0)
}
?? String(localized: "Uses dangerous agent flags when spawning.")
)
.font(.caption)
.foregroundStyle(.secondary)
}
case .managed:
VStack(alignment: .leading, spacing: 2) {
LabeledContent("Permission Mode", value: String(localized: "Managed by agent"))
Text("This agent manages its own permissions. YOLO mode is not available.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private var permissionModeBinding: Binding<PermissionMode> {
Binding(
get: { model.form.permissionMode },
set: { model.setPermissionMode($0) }
)
}
private var collaborationModeBinding: Binding<CodexCollaborationMode> {
Binding(
get: { model.form.collaborationMode },
set: { model.setCollaborationMode($0) }
)
}
private var copilotAgentModeBinding: Binding<CopilotAgentMode> {
Binding(
get: { model.form.copilotAgentMode },
set: { model.setCopilotAgentMode($0) }
)
}
private var serviceTierBinding: Binding<ServiceTier> {
Binding(
get: { model.form.serviceTier },
set: { model.setServiceTier($0) }
)
}
private func optionPicker(
_ title: LocalizedStringKey,
options: [NewSessionOption],
selection: Binding<String>
) -> some View {
Picker(title, selection: selection) {
ForEach(options, id: \.value) { option in
Text(option.label).tag(option.value)
}
}
}
// MARK: - Create
private var createSection: some View {
Section {
Button {
model.create()
} label: {
HStack(spacing: 8) {
if model.isSpawning {
ProgressView()
.controlSize(.small)
}
Text(createLabel)
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
}
.disabled(!model.canCreate)
} footer: {
if let error = model.spawnError {
Text(error).foregroundStyle(.red)
}
}
}
private var createLabel: String {
if model.isSpawning {
return String(localized: "Creating…")
}
if model.confirmCreateDirectory {
return String(localized: "Create and Make Directory")
}
return String(localized: "Create")
}
}