mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
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.
530 lines
18 KiB
Swift
530 lines
18 KiB
Swift
import HapiClient
|
|
import HapiProtocol
|
|
import PhotosUI
|
|
import SwiftUI
|
|
|
|
/// Per-session scratchlist workbench (A-M4b): notes/drafts parked until the
|
|
/// operator promotes them — presented as a sheet off the chat toolbar's note
|
|
/// icon. Entry cards (text preview, age, attachment thumbs) open an edit
|
|
/// sheet; the toolbar + drafts a new note; "To composer" inserts an entry's
|
|
/// text into the chat composer and closes the sheet.
|
|
///
|
|
/// Placement divergence from Android (deliberate, A-M3f owns the composer
|
|
/// UI): "Park current draft" lives in this screen's header instead of the
|
|
/// composer bar. The seam itself (`ChatInteractor.parkComposerDraft`) matches
|
|
/// Android — the composer clears only after the hub accepts, and an at-cap or
|
|
/// failed park keeps the draft.
|
|
struct ScratchlistView: View {
|
|
@State private var model: ScratchlistScreenModel
|
|
private let attachments: ScratchlistAttachmentLoader
|
|
private let interactor: ChatInteractor?
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var viewerAttachment: ScratchlistAttachment?
|
|
|
|
init(
|
|
store: ScratchlistStore,
|
|
sessionId: String,
|
|
attachments: ScratchlistAttachmentLoader,
|
|
interactor: ChatInteractor? = nil
|
|
) {
|
|
_model = State(initialValue: ScratchlistScreenModel(sessionId: sessionId, store: store))
|
|
self.attachments = attachments
|
|
self.interactor = interactor
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
content
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarLeading) {
|
|
Button("Done") {
|
|
dismiss()
|
|
}
|
|
}
|
|
ToolbarItem(placement: .principal) {
|
|
VStack(spacing: 0) {
|
|
Text("Scratchlist")
|
|
.font(.headline)
|
|
Text(countLine)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
if interactor != nil {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
parkButton
|
|
}
|
|
}
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button {
|
|
model.openEditor(nil)
|
|
} label: {
|
|
Image(systemName: "plus")
|
|
}
|
|
.accessibilityLabel("New note")
|
|
}
|
|
}
|
|
.overlay(alignment: .bottom) {
|
|
noticeToast
|
|
}
|
|
}
|
|
.sheet(isPresented: editorPresented) {
|
|
ScratchlistEditorSheet(
|
|
model: model,
|
|
attachments: attachments,
|
|
onOpenAttachment: { viewerAttachment = $0 }
|
|
)
|
|
}
|
|
.fullScreenCover(item: $viewerAttachment) { attachment in
|
|
ScratchlistAttachmentViewer(attachment: attachment, loader: attachments)
|
|
}
|
|
.onAppear {
|
|
model.start()
|
|
}
|
|
.onDisappear {
|
|
model.stop()
|
|
}
|
|
}
|
|
|
|
// MARK: - Content states
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
let state = model.state
|
|
VStack(spacing: 0) {
|
|
if !state.uploadsInFlight.isEmpty {
|
|
ProgressView()
|
|
.progressViewStyle(.linear)
|
|
}
|
|
if model.isLoading {
|
|
Spacer()
|
|
ProgressView()
|
|
Spacer()
|
|
} else if state.loadFailed {
|
|
loadFailedState
|
|
} else if state.entries.isEmpty {
|
|
emptyState
|
|
} else {
|
|
entryList(state.entries)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func entryList(_ entries: [ScratchlistEntry]) -> some View {
|
|
ScrollView {
|
|
LazyVStack(spacing: 10) {
|
|
ForEach(entries) { entry in
|
|
ScratchlistEntryCard(
|
|
entry: entry,
|
|
attachments: attachments,
|
|
onOpen: { model.openEditor(entry) },
|
|
onSendToComposer: sendToComposerAction(entry),
|
|
onOpenAttachment: { viewerAttachment = $0 }
|
|
)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 10)
|
|
}
|
|
}
|
|
|
|
/// "To composer": insert the entry's text into the chat composer and
|
|
/// close the sheet (the entry itself stays on the scratchlist). Nil when
|
|
/// no interactor is wired (previews) — the affordance is hidden.
|
|
private func sendToComposerAction(_ entry: ScratchlistEntry) -> (() -> Void)? {
|
|
guard let interactor else { return nil }
|
|
return {
|
|
interactor.insertComposerText(entry.text)
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
private var loadFailedState: some View {
|
|
ContentUnavailableView {
|
|
Label("Couldn't load the scratchlist", systemImage: "wifi.slash")
|
|
} description: {
|
|
Text("Check the connection to your hub and try again.")
|
|
} actions: {
|
|
Button("Retry") {
|
|
model.retry()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
}
|
|
|
|
private var emptyState: some View {
|
|
ContentUnavailableView {
|
|
Label("No notes yet", systemImage: "note.text")
|
|
} description: {
|
|
Text("Park drafts from the header, or tap + to jot a note for this session.")
|
|
}
|
|
}
|
|
|
|
// MARK: - Chrome
|
|
|
|
private var countLine: String {
|
|
let state = model.state
|
|
if !model.isLoading, state.entries.isEmpty {
|
|
return String(localized: "No notes")
|
|
}
|
|
return state.entries.count == 1
|
|
? String(localized: "1 note")
|
|
: String(format: String(localized: "%lld notes"), Int64(state.entries.count))
|
|
}
|
|
|
|
/// "Park current draft": the composer draft becomes an entry (disabled
|
|
/// while the composer is blank or the list is at cap; the new row appears
|
|
/// optimistically once the hub accepts).
|
|
@ViewBuilder
|
|
private var parkButton: some View {
|
|
if let interactor {
|
|
let blank = interactor.composer.text
|
|
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
Button {
|
|
interactor.parkComposerDraft()
|
|
} label: {
|
|
Image(systemName: "tray.and.arrow.down")
|
|
}
|
|
.disabled(blank || model.state.atCap)
|
|
.accessibilityLabel("Park current draft")
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var noticeToast: some View {
|
|
if let notice = model.notice {
|
|
Text(notice)
|
|
.font(.footnote)
|
|
.lineLimit(3)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 8)
|
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
.shadow(radius: 4, y: 2)
|
|
.padding(.horizontal, 24)
|
|
.padding(.bottom, 8)
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
}
|
|
}
|
|
|
|
private var editorPresented: Binding<Bool> {
|
|
Binding(
|
|
get: { model.editor != nil },
|
|
set: { open in
|
|
if !open {
|
|
model.dismissEditor()
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Entry card
|
|
|
|
/// One note: attachment strip, 4-line text preview, relative age, and the
|
|
/// optional "To composer" promote action.
|
|
private struct ScratchlistEntryCard: View {
|
|
let entry: ScratchlistEntry
|
|
let attachments: ScratchlistAttachmentLoader
|
|
let onOpen: () -> Void
|
|
let onSendToComposer: (() -> Void)?
|
|
let onOpenAttachment: (ScratchlistAttachment) -> Void
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
if !entry.attachments.isEmpty {
|
|
ScratchlistAttachmentStrip(
|
|
attachments: entry.attachments,
|
|
loader: attachments,
|
|
thumbSize: 64,
|
|
onOpen: onOpenAttachment,
|
|
onRemove: nil
|
|
)
|
|
}
|
|
if entry.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
Text("Attachment only")
|
|
.font(.subheadline)
|
|
.italic()
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
Text(entry.text)
|
|
.font(.subheadline)
|
|
.lineLimit(4)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
HStack {
|
|
Text(scratchlistRelativeAge(
|
|
nowMs: Int(Date().timeIntervalSince1970 * 1000),
|
|
thenMs: entry.updatedAt
|
|
))
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
Spacer()
|
|
if let onSendToComposer {
|
|
Button("To composer", action: onSendToComposer)
|
|
.font(.caption.weight(.medium))
|
|
.buttonStyle(.borderless)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 10)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Color(uiColor: .secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
|
.contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
|
.onTapGesture(perform: onOpen)
|
|
}
|
|
}
|
|
|
|
/// Relative age like the session list's Android `formatRelativeAge`.
|
|
private func scratchlistRelativeAge(nowMs: Int, thenMs: Int) -> String {
|
|
let delta = nowMs - thenMs
|
|
if delta < 60_000 { return String(localized: "now") }
|
|
let minutes = delta / 60_000
|
|
if minutes < 60 { return "\(minutes)m" }
|
|
let hours = minutes / 60
|
|
if hours < 24 { return "\(hours)h" }
|
|
let days = hours / 24
|
|
if days < 7 { return "\(days)d" }
|
|
let weeks = days / 7
|
|
if weeks < 5 { return "\(weeks)w" }
|
|
let months = days / 30
|
|
if months < 12 { return "\(months)mo" }
|
|
return "\(days / 365)y"
|
|
}
|
|
|
|
// MARK: - Attachment strip
|
|
|
|
/// Horizontal thumbnails: images render through the authed loader, other
|
|
/// mime types (pdf/text) degrade to filename chips; an optional ✕ badge
|
|
/// removes (editor strip).
|
|
private struct ScratchlistAttachmentStrip: View {
|
|
let attachments: [ScratchlistAttachment]
|
|
let loader: ScratchlistAttachmentLoader
|
|
let thumbSize: CGFloat
|
|
let onOpen: (ScratchlistAttachment) -> Void
|
|
let onRemove: ((ScratchlistAttachment) -> Void)?
|
|
|
|
var body: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 6) {
|
|
ForEach(attachments) { attachment in
|
|
ScratchlistAttachmentThumb(
|
|
attachment: attachment,
|
|
loader: loader,
|
|
size: thumbSize,
|
|
onOpen: { onOpen(attachment) },
|
|
onRemove: removeAction(for: attachment)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func removeAction(for attachment: ScratchlistAttachment) -> (() -> Void)? {
|
|
guard let onRemove else { return nil }
|
|
return { onRemove(attachment) }
|
|
}
|
|
}
|
|
|
|
/// One thumbnail (image via the authed loader, otherwise a filename chip).
|
|
private struct ScratchlistAttachmentThumb: View {
|
|
let attachment: ScratchlistAttachment
|
|
let loader: ScratchlistAttachmentLoader
|
|
let size: CGFloat
|
|
let onOpen: () -> Void
|
|
let onRemove: (() -> Void)?
|
|
|
|
@State private var image: UIImage?
|
|
@State private var failed = false
|
|
|
|
private var isImage: Bool {
|
|
attachment.mimeType.hasPrefix("image/")
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .topTrailing) {
|
|
Button(action: onOpen) {
|
|
tile
|
|
}
|
|
.buttonStyle(.plain)
|
|
if let onRemove {
|
|
Button(action: onRemove) {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.font(.system(size: 16))
|
|
.foregroundStyle(.white, .black.opacity(0.6))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.padding(2)
|
|
.accessibilityLabel("Remove \(attachment.filename)")
|
|
}
|
|
}
|
|
.task(id: attachment.id) {
|
|
guard isImage, image == nil, !failed else { return }
|
|
if let loaded = await loader.image(for: attachment.id) {
|
|
image = loaded
|
|
} else {
|
|
failed = true
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var tile: some View {
|
|
if isImage, !failed {
|
|
Group {
|
|
if let image {
|
|
Image(uiImage: image)
|
|
.resizable()
|
|
.scaledToFill()
|
|
} else {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
.frame(width: size, height: size)
|
|
.background(Color(uiColor: .tertiarySystemFill))
|
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
|
} else {
|
|
Text("📎 \(attachment.filename)")
|
|
.font(.caption2)
|
|
.lineLimit(3)
|
|
.multilineTextAlignment(.center)
|
|
.padding(4)
|
|
.frame(width: size, height: size)
|
|
.background(Color(uiColor: .tertiarySystemFill), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Editor sheet
|
|
|
|
/// Edit sheet: text field + attachment strip (photo picker → guard →
|
|
/// downscale → upload spinner tile, remove) + Delete for existing entries +
|
|
/// Save.
|
|
private struct ScratchlistEditorSheet: View {
|
|
let model: ScratchlistScreenModel
|
|
let attachments: ScratchlistAttachmentLoader
|
|
let onOpenAttachment: (ScratchlistAttachment) -> Void
|
|
|
|
@State private var pickedItem: PhotosPickerItem?
|
|
|
|
var body: some View {
|
|
let editor = model.editor ?? ScratchlistEditorState()
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(editor.entryId == nil ? String(localized: "New note") : String(localized: "Edit note"))
|
|
.font(.headline)
|
|
TextField(
|
|
"Note, draft, parking-lot idea…",
|
|
text: Binding(
|
|
get: { model.editor?.text ?? "" },
|
|
set: { model.setEditorText($0) }
|
|
),
|
|
axis: .vertical
|
|
)
|
|
.lineLimit(3...8)
|
|
.textFieldStyle(.roundedBorder)
|
|
attachmentRow(editor)
|
|
HStack {
|
|
if let entryId = editor.entryId {
|
|
Button("Delete", role: .destructive) {
|
|
model.deleteEntry(entryId)
|
|
}
|
|
}
|
|
Spacer()
|
|
Button("Cancel") {
|
|
model.dismissEditor()
|
|
}
|
|
Button("Save") {
|
|
model.saveEditor()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(editor.isUploading)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(16)
|
|
.presentationDetents([.medium, .large])
|
|
.presentationDragIndicator(.visible)
|
|
.onChange(of: pickedItem) {
|
|
guard let item = pickedItem else { return }
|
|
pickedItem = nil
|
|
model.addAttachment(item)
|
|
}
|
|
}
|
|
|
|
private func attachmentRow(_ editor: ScratchlistEditorState) -> some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 6) {
|
|
ForEach(editor.attachments) { attachment in
|
|
ScratchlistAttachmentThumb(
|
|
attachment: attachment,
|
|
loader: attachments,
|
|
size: 72,
|
|
onOpen: { onOpenAttachment(attachment) },
|
|
onRemove: { model.removeAttachment(attachment) }
|
|
)
|
|
}
|
|
if editor.isUploading {
|
|
ProgressView()
|
|
.frame(width: 72, height: 72)
|
|
.background(Color(uiColor: .tertiarySystemFill), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
|
} else {
|
|
PhotosPicker(selection: $pickedItem, matching: .images) {
|
|
Image(systemName: "plus")
|
|
.frame(width: 72, height: 72)
|
|
.background(Color(uiColor: .tertiarySystemFill), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
|
}
|
|
.accessibilityLabel("Add photo")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Viewer
|
|
|
|
/// Full-screen attachment viewer (the generated-image viewer pattern): dark
|
|
/// backdrop, fit-scaled image via the authed loader, tap or the close button
|
|
/// dismisses. Non-image attachments show a filename placeholder.
|
|
private struct ScratchlistAttachmentViewer: View {
|
|
let attachment: ScratchlistAttachment
|
|
let loader: ScratchlistAttachmentLoader
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var image: UIImage?
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .topTrailing) {
|
|
Color.black.ignoresSafeArea()
|
|
if let image {
|
|
Image(uiImage: image)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.padding(8)
|
|
} else {
|
|
Text(attachment.filename)
|
|
.foregroundStyle(.white)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
Button {
|
|
dismiss()
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.font(.title2)
|
|
.foregroundStyle(.white.opacity(0.8))
|
|
.padding(16)
|
|
}
|
|
}
|
|
.onTapGesture {
|
|
dismiss()
|
|
}
|
|
.task {
|
|
guard attachment.mimeType.hasPrefix("image/") else { return }
|
|
image = await loader.image(for: attachment.id)
|
|
}
|
|
}
|
|
}
|