diff --git a/Sources/InkletMac/Models/AnalysisCopy.swift b/Sources/InkletMac/Models/AnalysisCopy.swift index d9280c0..f008491 100644 --- a/Sources/InkletMac/Models/AnalysisCopy.swift +++ b/Sources/InkletMac/Models/AnalysisCopy.swift @@ -297,7 +297,7 @@ enum AnalysisTimeline { var parts: [String] = [] if failedSteps > 0 { parts.append("\(AnalysisCopy.plural(failedSteps, "step")) failed") } if deniedSteps > 0 { parts.append("\(AnalysisCopy.plural(deniedSteps, "step")) blocked") } - let suffix = parts.isEmpty ? "" : " · " + parts.joined(separator: " · ") + let suffix = parts.isEmpty ? "" : ". " + parts.joined(separator: ", ") let state = data["state"]?.stringValue ?? "active" let failed = state == "failed" @@ -315,7 +315,7 @@ enum AnalysisTimeline { guard let read = stats["notesRead"]?.numberValue.map({ Int($0) }) else { return done ? "Read your notes" : "Reading your notes" } - return done ? "Read \(AnalysisCopy.plural(read, "note"))" : "Reading your notes · \(read) read" + return done ? "Read \(AnalysisCopy.plural(read, "note"))" : "Reading your notes (\(read) read)" case "checking_display": return done ? "Checked the display" : "Checking the display" case "choosing_layout": @@ -326,11 +326,11 @@ enum AnalysisTimeline { } if done { let looked = "Looked at \(AnalysisCopy.plural(seen, "layout"))" - return chosen.map { "\(looked) · chose \($0)" } ?? looked + return chosen.map { "\(looked), chose \($0)" } ?? looked } // A layout it has settled on outranks the running count: it is the // answer the activity exists to produce. - return chosen.map { "Chose \($0)" } ?? "Looking at layouts · \(seen) so far" + return chosen.map { "Chose \($0)" } ?? "Looking at layouts (\(seen) so far)" case "submitting_plan": // "Checking" while it runs, because the backend is validating it // and it may yet come back; "Submitted" once it has gone through. @@ -339,7 +339,7 @@ enum AnalysisTimeline { return done ? "Tried another layout" : "Trying another layout" default: if steps <= 0 { return done ? "Worked through it" : "Working" } - return done ? "Worked through \(AnalysisCopy.plural(steps, "step"))" : "Working · \(AnalysisCopy.plural(steps, "step"))" + return done ? "Worked through \(AnalysisCopy.plural(steps, "step"))" : "Working through \(AnalysisCopy.plural(steps, "step"))" } } diff --git a/Sources/InkletMac/Models/AskModel.swift b/Sources/InkletMac/Models/AskModel.swift index 2a602f3..63cb917 100644 --- a/Sources/InkletMac/Models/AskModel.swift +++ b/Sources/InkletMac/Models/AskModel.swift @@ -135,6 +135,15 @@ final class AskModel { } } + func rename(_ id: String, title: String) async throws { + let expected = generation + let updated = try await InkletAPI.shared.renameConversation(id: id, title: title) + guard generation == expected else { return } + if let index = conversations.firstIndex(where: { $0.id == id }) { + conversations[index] = updated + } + } + func delete(_ id: String) async { let expected = generation do { diff --git a/Sources/InkletMac/Models/Models.swift b/Sources/InkletMac/Models/Models.swift index 6563ebf..6179f5e 100644 --- a/Sources/InkletMac/Models/Models.swift +++ b/Sources/InkletMac/Models/Models.swift @@ -213,7 +213,7 @@ struct KnowledgeItem: Identifiable, Hashable { } else if let firstLine { title = firstLine } else if let link = links.first?.url { - title = link + title = URL(string: link)?.host()?.replacingOccurrences(of: "www.", with: "") ?? link } else if !images.isEmpty { title = images.count == 1 ? (images.first?.filename ?? "Image") : "\(images.count) images" } else if !files.isEmpty { @@ -233,7 +233,10 @@ struct KnowledgeItem: Identifiable, Hashable { if !images.isEmpty { parts.append("\(images.count) image\(images.count == 1 ? "" : "s")") } if !files.isEmpty { parts.append("\(files.count) file\(files.count == 1 ? "" : "s")") } if let failure = dto.failure, processStatus == .failed { parts.append(failure.message) } - detail = parts.isEmpty ? nil : parts.joined(separator: " · ") + detail = parts.isEmpty ? nil : parts.joined(separator: ", ") + if dto.assets.count == 1, let raw = links.first?.url, let url = URL(string: raw) { + detail = url.path.isEmpty || url.path == "/" ? raw : (url.path.removingPercentEncoding ?? url.path) + } } } diff --git a/Sources/InkletMac/Services/InkletAPI.swift b/Sources/InkletMac/Services/InkletAPI.swift index e5285b3..11917a2 100644 --- a/Sources/InkletMac/Services/InkletAPI.swift +++ b/Sources/InkletMac/Services/InkletAPI.swift @@ -297,6 +297,14 @@ actor InkletAPI { return try await authed(path) } + func content(id: String) async throws -> ContentDTO { + try await authed("api/app/v1/contents/\(id)") + } + + func readContentAsset(contentID: String, index: Int, offset: Int = 0) async throws -> ContentAssetReading { + try await authed("api/app/v1/contents/\(contentID)/assets/\(index)/reading?offset=\(offset)") + } + // MARK: - Conversations (Ask inklet, `/api/app/v1/conversations`) func conversations(cursor: String? = nil, limit: Int = 50) async throws -> ConversationPageDTO { @@ -323,6 +331,10 @@ actor InkletAPI { return try await authed(path) } + func renameConversation(id: String, title: String) async throws -> ConversationDTO { + try await authed("api/app/v1/conversations/\(id)", method: "PATCH", jsonObject: ["title": title]) + } + func deleteConversation(id: String) async throws { try await authedVoid("api/app/v1/conversations/\(id)", method: "DELETE") } diff --git a/Sources/InkletMac/Theme/InkletTheme.swift b/Sources/InkletMac/Theme/InkletTheme.swift index 5e1a637..6f5aff2 100644 --- a/Sources/InkletMac/Theme/InkletTheme.swift +++ b/Sources/InkletMac/Theme/InkletTheme.swift @@ -199,16 +199,23 @@ struct InkCard: View { } } -/// Small caps eyebrow used above every section. +/// Shared type roles: serif page titles, readable section headings and quiet metadata. +enum InkType { + static let title = Font.brand(34) + static let section = Font.system(size: 16, weight: .semibold) + static let body = Font.system(size: 15) + static let metadata = Font.system(size: 12) +} + +/// A section heading, never a decorative all-caps eyebrow. struct SectionLabel: View { let text: String init(_ text: String) { self.text = text } var body: some View { - Text(text.uppercased()) - .font(.system(size: 11, weight: .medium)) - .tracking(1.3) - .foregroundStyle(Ink.muted) + Text(text) + .font(InkType.section) + .foregroundStyle(Ink.text) } } diff --git a/Sources/InkletMac/Views/AskView.swift b/Sources/InkletMac/Views/AskView.swift index 21151bb..acce305 100644 --- a/Sources/InkletMac/Views/AskView.swift +++ b/Sources/InkletMac/Views/AskView.swift @@ -1,201 +1,407 @@ import SwiftUI import InkletPresentationKit -/// Ask inklet: a conversation with the knowledge base. Conversations on the -/// left, the thread on the right, a composer at the bottom. The reply is drawn -/// as it is written; the agent's current step sits under it until it is done. +/// A shelf of conversations, with native navigation into a focused reading view. struct AskView: View { @Environment(AppModel.self) private var model + @AppStorage("ask.pinnedConversations") private var storedPins = "[]" + @Binding var path: NavigationPath + @State private var search = "" + @State private var renamingConversation: ConversationDTO? + + private var pins: Set { + Set((try? JSONDecoder().decode([String].self, from: Data(storedPins.utf8))) ?? []) + } + + private var matching: [ConversationDTO] { + model.ask.conversations.filter { + search.isEmpty || ($0.title ?? "New conversation").localizedStandardContains(search) + } + } var body: some View { @Bindable var ask = model.ask + NavigationStack(path: $path) { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 6) { + Text("Ask").font(.brand(34)).foregroundStyle(Ink.text) + Text("Ask questions about the things you’ve saved.") + .font(.system(size: 13)).foregroundStyle(Ink.secondary) + } + Spacer(minLength: 16) + Button("New conversation", systemImage: "plus") { newConversation() } + .buttonStyle(.borderedProminent) + } + .padding(.horizontal, 28) + .padding(.top, 26) + .padding(.bottom, 20) - HStack(spacing: 0) { - conversationList - .frame(width: 220) - Divider() - thread + HStack { + Spacer() + InkSearchField(text: $search, placeholder: "Find a conversation") + .frame(width: 240) + } + .padding(.horizontal, 28) + .padding(.bottom, 14) + + ScrollView { + VStack(alignment: .leading, spacing: 24) { + if model.ask.isLoadingList && model.ask.conversations.isEmpty { + ProgressView("Opening your conversations…").controlSize(.small) + } else if model.ask.conversations.isEmpty { + InkCard { + VStack(alignment: .leading, spacing: 12) { + Text("Start with a thought.").font(.brand(26)) + Text("Ask about something you saved, connect a few ideas, or bring a thought into view.") + .font(.system(size: 13)).foregroundStyle(Ink.secondary) + Button("Ask inklet", systemImage: "arrow.up.right") { newConversation() } + .buttonStyle(.bordered) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } else { + if !matching.filter({ pins.contains($0.id) }).isEmpty { + VStack(alignment: .leading, spacing: 12) { + SectionLabel("Pinned") + LazyVGrid(columns: [GridItem(.adaptive(minimum: 240), spacing: 14)], spacing: 14) { + ForEach(matching.filter { pins.contains($0.id) }) { conversation in + pinnedCard(conversation) + } + } + } + } + VStack(alignment: .leading, spacing: 12) { + SectionLabel("Conversations") + if matching.isEmpty { + Text("No conversations match your search.") + .font(.system(size: 13)).foregroundStyle(Ink.secondary).padding(.vertical, 20) + } else { + InkItemList(items: matching) { conversation in + conversationRow(conversation) + } + } + } + } + } + .padding(.horizontal, 28).padding(.bottom, 28) + } + } + .background(Ink.bg) + .navigationTitle("Ask") + .toolbar { ComposerToolbar(newConversation: newConversation) } + .navigationDestination(for: AskDestination.self) { destination in + AskThreadView(destination: destination, newConversation: newConversation) + .id(destination) + } } - .background(Ink.bg) - .navigationTitle("Ask") .task { await model.ask.load() } + .sheet(item: $renamingConversation) { conversation in + AskRenameSheet(conversationID: conversation.id, initialTitle: conversation.title ?? "") + } .alert("Ask inklet", isPresented: Binding(get: { ask.error != nil }, set: { if !$0 { ask.error = nil } })) { Button("OK") { ask.error = nil } - } message: { - Text(ask.error ?? "") - } + } message: { Text(ask.error ?? "") } } - // MARK: - Conversations + private func newConversation() { + model.ask.draft = "" + path = NavigationPath([AskDestination.new(UUID())]) + } - private var conversationList: some View { - VStack(alignment: .leading, spacing: 0) { - HStack { - SectionLabel("Conversations") - Spacer() - Button { - Task { await model.ask.select(nil) } - } label: { - Image(systemName: "square.and.pencil").font(.system(size: 12, weight: .medium)) - } - .buttonStyle(.plain) - .foregroundStyle(Ink.secondary) - .help("New conversation") - } - .padding(.horizontal, 16) - .padding(.top, 22) - .padding(.bottom, 8) - - if model.ask.isLoadingList && model.ask.conversations.isEmpty { - Text("Loading…").font(.system(size: 12)).foregroundStyle(Ink.muted).padding(.horizontal, 16) - } else if model.ask.conversations.isEmpty { - Text("Nothing asked yet.").font(.system(size: 12)).foregroundStyle(Ink.muted).padding(.horizontal, 16) - } + private func togglePin(_ id: String) { + var updated = pins + if updated.contains(id) { updated.remove(id) } else { updated.insert(id) } + if let data = try? JSONEncoder().encode(updated.sorted()), let value = String(data: data, encoding: .utf8) { + storedPins = value + } + } - ScrollView { - LazyVStack(alignment: .leading, spacing: 2) { - ForEach(model.ask.conversations) { conversation in - conversationRow(conversation) + private func pinnedCard(_ conversation: ConversationDTO) -> some View { + InkCard(padding: 0) { + Button { path.append(AskDestination.conversation(conversation.id)) } label: { + VStack(alignment: .leading, spacing: 14) { + Text(conversation.title ?? "New conversation") + .font(.brand(25)).foregroundStyle(Ink.text).lineLimit(2) + HStack { + Text(Self.relative(conversation.lastMessageAt ?? conversation.createdAt)) + Spacer() + Image(systemName: "arrow.up.right") } + .font(InkType.metadata).foregroundStyle(Ink.secondary) } - .padding(.horizontal, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(18) + .contentShape(.rect) } - Spacer(minLength: 0) + .buttonStyle(InkListRowButtonStyle()) + .contextMenu { conversationMenu(conversation) } } - .background(Ink.sidebar) } private func conversationRow(_ conversation: ConversationDTO) -> some View { - let selected = model.ask.selectedID == conversation.id - return Button { - Task { await model.ask.select(conversation.id) } - } label: { - VStack(alignment: .leading, spacing: 2) { - Text(conversation.title ?? "New conversation") - .font(.system(size: 13, weight: selected ? .medium : .regular)) - .foregroundStyle(Ink.text) - .lineLimit(1) - Text(Self.relative(conversation.lastMessageAt ?? conversation.createdAt)) - .font(.system(size: 11)) - .foregroundStyle(Ink.muted) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 8) - .padding(.vertical, 6) - .background(selected ? Ink.card : .clear, in: .rect(cornerRadius: Ink.controlCorner)) - .contentShape(.rect) + InkListRow(title: conversation.title ?? "New conversation") { + path.append(AskDestination.conversation(conversation.id)) + } leading: { + Image(systemName: "text.alignleft") + } metadata: { + Text(Self.relative(conversation.lastMessageAt ?? conversation.createdAt)) + .frame(width: 96, alignment: .trailing) } - .buttonStyle(.plain) - .contextMenu { - Button("Delete", role: .destructive) { Task { await model.ask.delete(conversation.id) } } + .contextMenu { conversationMenu(conversation) } + } + + @ViewBuilder + private func conversationMenu(_ conversation: ConversationDTO) -> some View { + Button(pins.contains(conversation.id) ? "Unpin" : "Pin", + systemImage: pins.contains(conversation.id) ? "pin.slash" : "pin") { + togglePin(conversation.id) } + Button("Rename…", systemImage: "pencil") { renamingConversation = conversation } + Button("Delete", role: .destructive) { Task { await model.ask.delete(conversation.id) } } } - // MARK: - Thread + static func relative(_ value: String) -> String { + let plain = ISO8601DateFormatter() + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + guard let date = plain.date(from: value) ?? fractional.date(from: value) else { return "" } + let minutes = Int(Date.now.timeIntervalSince(date) / 60) + if minutes < 1 { return "just now" } + if minutes < 60 { return "\(minutes) min ago" } + if minutes < 24 * 60 { return "\(minutes / 60) h ago" } + return date.formatted(.dateTime.month(.abbreviated).day()) + } +} - private var thread: some View { - VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .bottom) { - VStack(alignment: .leading, spacing: 6) { - SectionLabel("Ask your second brain") - Text("Ask") - .font(.brand(34)) - .foregroundStyle(Ink.text) - } - Spacer(minLength: 20) - } - .padding(.horizontal, 28) - .padding(.top, 26) - .padding(.bottom, 12) +enum AskDestination: Hashable { + case new(UUID) + case conversation(String) + + var conversationID: String? { + if case .conversation(let id) = self { return id } + return nil + } +} + +private struct AskThreadView: View { + @Environment(AppModel.self) private var model + let destination: AskDestination + let newConversation: () -> Void + @State private var materialsExpanded = false + @State private var opened = false + @State private var visible = false + @State private var showingRename = false + @FocusState private var composerFocused: Bool + + private var title: String { + model.ask.conversations.first { $0.id == model.ask.selectedID }?.title ?? "New conversation" + } + + private var citations: [MessageCitationDTO] { + var seen = Set() + return (model.ask.messages.flatMap { $0.citations ?? [] } + (model.ask.live?.citations ?? [])) + .filter { seen.insert($0.contentId).inserted } + } + private var actions: [MessageActionDTO] { + var result: [MessageActionDTO] = [] + for action in model.ask.messages.flatMap({ $0.actions ?? [] }) + (model.ask.live?.actions ?? []) { + if !result.contains(action) { result.append(action) } + } + return result + } + + var body: some View { + VStack(spacing: 0) { ScrollViewReader { proxy in ScrollView { - LazyVStack(alignment: .leading, spacing: 18) { - if model.ask.isLoadingThread { - HStack(spacing: 8) { ProgressView().controlSize(.small); Text("Opening…").font(.system(size: 12)).foregroundStyle(Ink.muted) } - } else if model.ask.messages.isEmpty { - emptyThread - } - ForEach(model.ask.messages) { message in - MessageRow(message: message, live: model.ask.live) - .id(message.id) + VStack(alignment: .leading, spacing: 26) { + header + if !opened || model.ask.isLoadingThread { + ProgressView("Opening…").controlSize(.small) + } else { + ForEach(model.ask.messages) { message in + AskMessageRow(message: message, live: model.ask.live) + .id(message.id) + } } Color.clear.frame(height: 1).id("end") } - .padding(.horizontal, 28) - .padding(.vertical, 8) + .frame(maxWidth: 720, alignment: .leading) + .padding(.horizontal, 28).padding(.top, 26).padding(.bottom, 12) + .frame(maxWidth: .infinity) + } + .onChange(of: model.ask.messages.count) { _, _ in + if opened { proxy.scrollTo("end", anchor: .bottom) } } - .onChange(of: model.ask.messages.count) { _, _ in proxy.scrollTo("end", anchor: .bottom) } .onChange(of: model.ask.live?.text) { _, _ in proxy.scrollTo("end", anchor: .bottom) } } - + if opened && !model.ask.isLoadingThread && model.ask.messages.isEmpty { + emptyThread + .frame(maxWidth: 720, alignment: .leading) + .padding(.horizontal, 28) + .frame(maxWidth: .infinity) + } composer } + .background(Ink.bg) + .navigationTitle(opened ? title : "Conversation") + .toolbar { ComposerToolbar(newConversation: newConversation) } + .toolbarBackground(.hidden, for: .windowToolbar) + .onAppear { visible = true } + .onDisappear { visible = false } + .onChange(of: model.ask.isSending) { wasSending, isSending in + if isSending { + composerFocused = false + } else if wasSending { + Task { @MainActor in + await Task.yield() + if visible && !showingRename { composerFocused = true } + } + } + } + .sheet(isPresented: $showingRename) { + if let id = model.ask.selectedID { + AskRenameSheet(conversationID: id, initialTitle: title) + } + } + .task { + await model.ask.select(destination.conversationID) + opened = true + if destination.conversationID == nil { composerFocused = true } + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 10) { + Button { showingRename = true } label: { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(opened ? title : "Conversation") + .font(.brand(34)).foregroundStyle(Ink.text) + .lineLimit(2).fixedSize(horizontal: false, vertical: true) + if opened && model.ask.selectedID != nil { + Image(systemName: "pencil").font(.system(size: 13)).foregroundStyle(Ink.muted) + } + } + .contentShape(.rect) + } + .buttonStyle(.plain) + .disabled(!opened || model.ask.selectedID == nil) + .accessibilityLabel("Rename conversation: \(title)") + .help("Rename conversation") + if opened && !model.ask.messages.isEmpty { + DisclosureGroup(isExpanded: $materialsExpanded) { + VStack(alignment: .leading, spacing: 16) { + if citations.isEmpty && actions.isEmpty { + Text("Sources and results will appear here when inklet uses your notes or takes an action.") + .font(.system(size: 12)).foregroundStyle(Ink.secondary) + } + if !citations.isEmpty { + VStack(alignment: .leading, spacing: 10) { + SectionLabel("Sources") + ForEach(citations) { citation in + Label(citation.title.isEmpty ? "Untitled note" : citation.title, systemImage: "doc.text") + .font(.system(size: 13)).foregroundStyle(Ink.text) + .textSelection(.enabled) + } + } + } + if !actions.isEmpty { + VStack(alignment: .leading, spacing: 10) { + SectionLabel("Results") + ForEach(Array(actions.enumerated()), id: \.offset) { _, action in + VStack(alignment: .leading, spacing: 6) { + Label(action.displayText, systemImage: "rectangle.on.rectangle") + .font(.system(size: 13)).foregroundStyle(Ink.text) + if let id = action.analysisId { + Button("View activity", systemImage: "arrow.up.right") { model.openRun(id) } + .buttonStyle(.link).font(.system(size: 12)) + } + } + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16).background(Ink.card, in: .rect(cornerRadius: Ink.cardCorner)) + .padding(.top, 10) + } label: { + HStack(spacing: 8) { + Text("Materials & results") + if !citations.isEmpty || !actions.isEmpty { + Text("\(citations.count) sources").foregroundStyle(Ink.secondary) + Text("\(actions.count) results").foregroundStyle(Ink.secondary).padding(.leading, 8) + } + } + .font(.system(size: 12)).foregroundStyle(Ink.secondary) + } + .disclosureGroupStyle(AskMaterialsDisclosureStyle()) + } + } } private var emptyThread: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Ask about anything you have saved.") - .font(.system(size: 14)) - .foregroundStyle(Ink.text) - Text("inklet searches and reads your notes, answers, and can put a card on a display when you say so. Try “When is my next dentist appointment?” or “Put yesterday’s shopping list back on the kitchen display.”") - .font(.system(size: 13)) - .foregroundStyle(Ink.secondary) + VStack(alignment: .leading, spacing: 16) { + Text("What would you like to explore?") + .font(.brand(28)).foregroundStyle(Ink.text) + Text("Find an answer in your notes, or make connections between them.") + .font(.system(size: 14)).foregroundStyle(Ink.secondary) + ViewThatFits(in: .horizontal) { + HStack(spacing: 10) { suggestions } + VStack(alignment: .leading, spacing: 10) { suggestions } + } + } + .padding(.top, 24).padding(.bottom, 8) + } + + @ViewBuilder private var suggestions: some View { + Button("Connect a few ideas") { + model.ask.draft = "Help me connect ideas from my recent notes." + composerFocused = true + } + Button("Bring a thought into view") { + model.ask.draft = "Help me find something worth keeping on my desk display." + composerFocused = true } - .padding(.top, 24) } private var composer: some View { @Bindable var ask = model.ask - return VStack(spacing: 0) { - Divider() - HStack(alignment: .bottom, spacing: 10) { - TextField("Ask about your notes…", text: $ask.draft, axis: .vertical) - .textFieldStyle(.plain) - .lineLimit(1...6) - .font(.system(size: 14)) - .padding(.horizontal, 12) - .padding(.vertical, 9) - .background(Ink.input, in: .rect(cornerRadius: Ink.controlCorner)) - .onSubmit { Task { await model.ask.send() } } - .disabled(model.ask.isSending) - Button { - Task { await model.ask.send() } - } label: { - if model.ask.isSending { - ProgressView().controlSize(.small).frame(width: 44, height: 20) + return VStack(alignment: .leading, spacing: 12) { + TextField("Ask…", text: $ask.draft, axis: .vertical) + .textFieldStyle(.plain).lineLimit(2...6).font(.system(size: 14)) + .focused($composerFocused) + .disabled(!opened || ask.isSending || ask.isLoadingThread) + .onSubmit { Task { await ask.send() } } + HStack { + Spacer() + Button { Task { await ask.send() } } label: { + if ask.isSending { + ProgressView().controlSize(.small).frame(width: 18, height: 18) } else { - Text("Ask").font(.system(size: 13, weight: .medium)).frame(width: 44, height: 20) + Image(systemName: "arrow.up") + .resizable() + .scaledToFit() + .fontWeight(.semibold) + .frame(width: 16, height: 16, alignment: .center) } } - .buttonStyle(.borderedProminent) + .buttonStyle(AskSendButtonStyle()) + .accessibilityLabel("Send question").help("Send question (⌘Return)") .keyboardShortcut(.return, modifiers: .command) - .disabled(model.ask.isSending || model.ask.isReplying || model.ask.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .disabled(!opened || ask.isLoadingThread || ask.isSending || ask.isReplying || ask.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } - .padding(.horizontal, 28) - .padding(.vertical, 14) } - } - - static func relative(_ value: String) -> String { - let plain = ISO8601DateFormatter() - let fractional = ISO8601DateFormatter() - fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - guard let date = plain.date(from: value) ?? fractional.date(from: value) else { return "" } - let minutes = Int(Date.now.timeIntervalSince(date) / 60) - if minutes < 1 { return "just now" } - if minutes < 60 { return "\(minutes) min ago" } - if minutes < 24 * 60 { return "\(minutes / 60) h ago" } - return date.formatted(.dateTime.month(.abbreviated).day()) + .padding(16).background(Ink.card, in: .rect(cornerRadius: Ink.cardCorner)) + .frame(maxWidth: 720).padding(.horizontal, 28).padding(.top, 12).padding(.bottom, 22) + .frame(maxWidth: .infinity) } } -/// One message. A user message is a filled bubble on the right; an assistant -/// message is plain text with the notes it drew on and what it did underneath. -private struct MessageRow: View { +private struct AskMessageRow: View { let message: ConversationMessageDTO let live: AskModel.LiveReply? - private var streaming: AskModel.LiveReply? { live?.messageID == message.id ? live : nil } var body: some View { @@ -218,49 +424,141 @@ private struct MessageRow: View { private var assistant: some View { let text = streaming?.text ?? message.text let citations = streaming?.citations ?? message.citations ?? [] - let actions = streaming?.actions ?? message.actions ?? [] let working = streaming != nil || message.isOpen - return VStack(alignment: .leading, spacing: 6) { + return VStack(alignment: .leading, spacing: 14) { if message.state == "failed" { Text(message.failure?.message ?? "inklet could not answer this one. Try asking again.") - .font(.system(size: 14)) - .foregroundStyle(Ink.danger) + .font(.system(size: 14)).foregroundStyle(Ink.danger) } else { if !text.isEmpty { - Text(text) - .font(.system(size: 14)) - .foregroundStyle(Ink.text) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) + Text(Self.formatted(text)) + .font(.system(size: 14)).lineSpacing(6).foregroundStyle(Ink.text) + .textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading) } if working { HStack(spacing: 8) { ProgressView().controlSize(.small) Text(streaming?.activity ?? (text.isEmpty ? "Thinking…" : "Writing…")) - .font(.system(size: 12)) - .foregroundStyle(Ink.muted) - .contentTransition(.opacity) + .font(.system(size: 12)).foregroundStyle(Ink.secondary) } } } if !citations.isEmpty { - HStack(spacing: 6) { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 180), alignment: .leading)], alignment: .leading, spacing: 8) { ForEach(citations) { citation in - Text(citation.title.isEmpty ? "a note" : citation.title) - .font(.system(size: 11)) - .foregroundStyle(Ink.secondary) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .overlay { Capsule().strokeBorder(Ink.border) } + Label(citation.title.isEmpty ? "Untitled note" : citation.title, systemImage: "doc.text") + .font(.system(size: 11)).foregroundStyle(Ink.secondary) + .padding(.horizontal, 10).padding(.vertical, 7) + .background(Ink.card, in: .rect(cornerRadius: Ink.controlCorner)) } } } - ForEach(Array(actions.enumerated()), id: \.offset) { _, action in - Text(action.displayText) - .font(.system(size: 12)) - .foregroundStyle(Ink.secondary) + ForEach(Array((streaming?.actions ?? message.actions ?? []).enumerated()), id: \.offset) { _, action in + Label(action.displayText, systemImage: "rectangle.on.rectangle") + .font(.system(size: 12)).foregroundStyle(Ink.secondary) } } .padding(.trailing, 80) } + + private static func formatted(_ text: String) -> AttributedString { + (try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) + ?? AttributedString(text) + } +} + +private struct AskSendButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .frame(width: 32, height: 32, alignment: .center) + .foregroundStyle(Ink.bg) + .background( + Ink.text.opacity(isEnabled ? (configuration.isPressed ? 0.8 : 1) : 0.25), + in: .rect(cornerRadius: Ink.controlCorner) + ) + .contentShape(.rect(cornerRadius: Ink.controlCorner)) + } +} + +private struct AskMaterialsDisclosureStyle: DisclosureGroupStyle { + func makeBody(configuration: Configuration) -> some View { + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation(.easeInOut(duration: 0.18)) { configuration.isExpanded.toggle() } + } label: { + HStack(spacing: 8) { + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .rotationEffect(.degrees(configuration.isExpanded ? 90 : 0)) + configuration.label + Spacer(minLength: 0) + } + .foregroundStyle(Ink.secondary) + .frame(maxWidth: .infinity, minHeight: 40, alignment: .leading) + .contentShape(.rect) + } + .buttonStyle(.plain) + .accessibilityValue(configuration.isExpanded ? "Expanded" : "Collapsed") + if configuration.isExpanded { configuration.content } + } + } +} + +private struct AskRenameSheet: View { + @Environment(AppModel.self) private var model + @Environment(\.dismiss) private var dismiss + let conversationID: String + @State private var title: String + @State private var saving = false + @State private var error: String? + @FocusState private var titleFocused: Bool + + init(conversationID: String, initialTitle: String) { + self.conversationID = conversationID + _title = State(initialValue: initialTitle) + } + + private var trimmedTitle: String { title.trimmingCharacters(in: .whitespacesAndNewlines) } + private var valid: Bool { !trimmedTitle.isEmpty && trimmedTitle.unicodeScalars.count <= 200 } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Rename conversation").font(.brand(25)).foregroundStyle(Ink.text) + TextField("Conversation title", text: $title) + .textFieldStyle(.roundedBorder).focused($titleFocused) + .disabled(saving) + .onSubmit { if valid && !saving { save() } } + if let error { + Text(error).font(.system(size: 12)).foregroundStyle(Ink.danger) + } else if trimmedTitle.unicodeScalars.count > 200 { + Text("Keep the title under 200 characters.").font(.system(size: 12)).foregroundStyle(Ink.secondary) + } + HStack { + Spacer() + Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction).disabled(saving) + Button(saving ? "Saving…" : "Save", action: save) + .keyboardShortcut(.defaultAction).disabled(!valid || saving) + } + } + .padding(24).frame(width: 400).background(Ink.bg) + .interactiveDismissDisabled(saving) + .onAppear { titleFocused = true } + } + + private func save() { + guard valid && !saving else { return } + saving = true + error = nil + Task { + do { + try await model.ask.rename(conversationID, title: trimmedTitle) + dismiss() + } catch { + self.error = error.localizedDescription + saving = false + } + } + } } diff --git a/Sources/InkletMac/Views/Components.swift b/Sources/InkletMac/Views/Components.swift index ed38b2c..ddc3ad0 100644 --- a/Sources/InkletMac/Views/Components.swift +++ b/Sources/InkletMac/Views/Components.swift @@ -136,9 +136,8 @@ struct DisplayFrame: View { } else if let title { VStack(spacing: width * 0.035) { if let subtitle { - Text(subtitle.uppercased()) + Text(subtitle) .font(.system(size: max(7, width * 0.026), weight: .medium)) - .tracking(1.2) .foregroundStyle(.black.opacity(0.42)) .lineLimit(1) } diff --git a/Sources/InkletMac/Views/DeviceDetailView.swift b/Sources/InkletMac/Views/DeviceDetailView.swift index 741d0da..e48a39e 100644 --- a/Sources/InkletMac/Views/DeviceDetailView.swift +++ b/Sources/InkletMac/Views/DeviceDetailView.swift @@ -137,7 +137,7 @@ struct DeviceDetailView: View { .font(.system(size: 14)) .foregroundStyle(Ink.text) .lineLimit(1) - Text("\(upNext.status.label) · added \(relativeTime(upNext.createdAt))") + Text("Added \(relativeTime(upNext.createdAt))") .font(.system(size: 12)) .foregroundStyle(Ink.muted) .lineLimit(1) diff --git a/Sources/InkletMac/Views/HistoryView.swift b/Sources/InkletMac/Views/HistoryView.swift index 3d23d9d..31bd10c 100644 --- a/Sources/InkletMac/Views/HistoryView.swift +++ b/Sources/InkletMac/Views/HistoryView.swift @@ -5,40 +5,42 @@ import InkletPresentationKit /// the daily ones they did not — and, for any one of them, what happened. /// /// The same page as the web Portal's Analyses, in two levels: the list, and a -/// run's timeline in its place. No navigation stack: the list keeps its scroll -/// and filters behind the detail, and the back button is the only way out. +/// run's timeline in a native navigation stack. The list retains its filters +/// while the system supplies the toolbar-level back navigation. struct HistoryView: View { @Environment(AppModel.self) private var model - @State private var openID: String? + @Binding var path: NavigationPath var body: some View { @Bindable var history = model.history - VStack(alignment: .leading, spacing: 0) { - if let openID { - if let analysis = history.analysis(openID) { - HistoryDetail(analysis: analysis, history: history, displayNames: displayNames) { - self.openID = nil - } - } else { - opening(history) - } - } else { + NavigationStack(path: $path) { + VStack(alignment: .leading, spacing: 0) { masthead filters($history) list(history) } + .background(Ink.bg) + .navigationTitle("History") + .toolbar { ComposerToolbar() } + .navigationDestination(for: String.self) { id in + Group { + if let analysis = history.analysis(id) { + HistoryDetail(analysis: analysis, history: history, displayNames: displayNames) + } else { + opening(history) + } + } + .background(Ink.bg) + .navigationTitle(history.analysis(id)?.historyTitle ?? "Activity") + .toolbar { ComposerToolbar() } + .toolbarBackground(.hidden, for: .windowToolbar) + .task(id: id) { await history.ensure(id) } + } } - .background(Ink.bg) - .navigationTitle("History") .task { await model.history.loadIfNeeded() } - // A run the list does not hold — opened from a device's history, or - // from a page the filters hide — is read on its own. - .task(id: openID) { - if let openID { await model.history.ensure(openID) } - } .onChange(of: history.requestedOpenID, initial: true) { _, id in guard let id else { return } - openID = id + path = NavigationPath([id]) model.history.requestedOpenID = nil } .onChange(of: history.stateFilter) { _, _ in Task { await model.history.load() } } @@ -48,12 +50,6 @@ struct HistoryView: View { /// The detail's frame while the run it was asked for is still being read. private func opening(_ history: HistoryModel) -> some View { VStack(alignment: .leading, spacing: 16) { - Button { openID = nil } label: { - Label("History", systemImage: "chevron.left") - } - .buttonStyle(.plain) - .font(.system(size: 13)) - .foregroundStyle(Ink.secondary) Text(history.openError ?? "Loading…") .font(.system(size: 13)) .foregroundStyle(history.openError == nil ? Ink.muted : Ink.danger) @@ -71,11 +67,10 @@ struct HistoryView: View { private var masthead: some View { VStack(alignment: .leading, spacing: 6) { - SectionLabel("Every run") Text("History") .font(.brand(34)) .foregroundStyle(Ink.text) - Text("Every run inklet has made for you — the ones you asked for, and the daily ones you did not.") + Text("Review your creations and display updates.") .font(.system(size: 13)) .foregroundStyle(Ink.secondary) } @@ -85,31 +80,10 @@ struct HistoryView: View { } private func filters(_ history: Bindable) -> some View { - HStack(spacing: 22) { - HStack(spacing: 10) { - Text("State") - .font(.system(size: 12)) - .foregroundStyle(Ink.muted) - Picker("State", selection: history.stateFilter) { - ForEach(HistoryModel.StateFilter.allCases) { Text($0.label).tag($0) } - } - .pickerStyle(.segmented) - .labelsHidden() - .fixedSize() - } - HStack(spacing: 10) { - Text("Started by") - .font(.system(size: 12)) - .foregroundStyle(Ink.muted) - Picker("Started by", selection: history.triggerFilter) { - ForEach(HistoryModel.TriggerFilter.allCases) { Text($0.label).tag($0) } - } - .pickerStyle(.segmented) - .labelsHidden() - .fixedSize() - } + HStack(spacing: 16) { + filterControls(history) - Spacer() + Spacer(minLength: 0) if history.wrappedValue.isLoading { ProgressView().controlSize(.small) @@ -129,6 +103,16 @@ struct HistoryView: View { .padding(.bottom, 14) } + @ViewBuilder + private func filterControls(_ history: Bindable) -> some View { + InkSegmentedPicker(title: "State", selection: history.stateFilter, showsLabel: true) { + ForEach(HistoryModel.StateFilter.allCases) { Text($0.label).tag($0) } + } + InkSegmentedPicker(title: "Started by", selection: history.triggerFilter, showsLabel: true) { + ForEach(HistoryModel.TriggerFilter.allCases) { Text($0.label).tag($0) } + } + } + @ViewBuilder private func list(_ history: HistoryModel) -> some View { if history.items.isEmpty { @@ -142,14 +126,8 @@ struct HistoryView: View { } else { ScrollView { VStack(spacing: 14) { - InkCard(padding: 0) { - LazyVStack(spacing: 0) { - ForEach(Array(history.items.enumerated()), id: \.element.id) { index, analysis in - HistoryRow(analysis: analysis, showsDivider: index < history.items.count - 1) { - openID = analysis.id - } - } - } + InkItemList(items: history.items) { analysis in + HistoryRow(analysis: analysis) { path.append(analysis.id) } } if history.hasMore { Button { @@ -186,57 +164,23 @@ struct HistoryView: View { } } -/// Fixed-height row, like Knowledge's, so the list keeps an even rhythm. private struct HistoryRow: View { let analysis: AnalysisDTO - let showsDivider: Bool let open: () -> Void - @State private var isHovering = false - var body: some View { - VStack(spacing: 0) { - Button(action: open) { - HStack(spacing: 12) { - StateMark(state: analysis.state) - .frame(width: 22) - - VStack(alignment: .leading, spacing: 2) { - Text(analysis.historyTitle) - .font(.system(size: 14)) - .foregroundStyle(Ink.text) - .lineLimit(1) - Text(analysis.resultLine()) - .font(.system(size: 12)) - .foregroundStyle(analysis.isFailure ? Ink.danger : Ink.muted) - .lineLimit(1) - } - - Spacer(minLength: 8) - - Text(analysis.stateLabel) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(analysis.isTerminal ? Ink.muted : Ink.text) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(Ink.input, in: .rect(cornerRadius: 5)) - - Text(historyWhen(analysis.createdDate)) - .font(.system(size: 12)) - .foregroundStyle(Ink.muted) - .frame(width: 96, alignment: .trailing) - } - .padding(.horizontal, 16) - .frame(height: 58) - .background(isHovering ? Ink.input.opacity(0.55) : .clear) - .contentShape(.rect) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - - if showsDivider { - Rectangle().fill(Ink.cardRule).frame(height: 1) - } + InkListRow( + title: analysis.historyTitle, + subtitle: analysis.resultLine(), + subtitleColor: analysis.isFailure ? Ink.danger : Ink.muted, + action: open + ) { + StateMark(state: analysis.state) + } metadata: { + Text(analysis.stateLabel) + .foregroundStyle(analysis.isTerminal ? Ink.secondary : Ink.text) + Text(historyWhen(analysis.createdDate)) + .frame(width: 96, alignment: .trailing) } } } @@ -267,30 +211,20 @@ private struct HistoryDetail: View { let analysis: AnalysisDTO let history: HistoryModel let displayNames: [String: String] - let back: () -> Void + @State private var showDetails = false private var events: [AnalysisEventDTO] { history.events[analysis.id] ?? [] } private var attempts: [TimelineAttempt] { AnalysisTimeline.build(events, displayNames: displayNames) } var body: some View { VStack(alignment: .leading, spacing: 0) { - Button(action: back) { - Label("History", systemImage: "chevron.left") - } - .buttonStyle(.plain) - .font(.system(size: 13)) - .foregroundStyle(Ink.secondary) - .keyboardShortcut(.escape, modifiers: []) - .padding(.horizontal, 28) - .padding(.top, 18) - ScrollView { VStack(alignment: .leading, spacing: 18) { header timeline } .padding(.horizontal, 28) - .padding(.top, 16) + .padding(.top, 26) .padding(.bottom, 24) } .scrollIndicators(.never) @@ -303,7 +237,7 @@ private struct HistoryDetail: View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { StateMark(state: analysis.liveState(events: events)) - SectionLabel(AnalysisCopy.stateLabel(analysis.liveState(events: events))) + Text(AnalysisCopy.stateLabel(analysis.liveState(events: events))).font(.system(size: 13)).foregroundStyle(Ink.secondary) } Text(analysis.historyTitle) .font(.brand(28)) @@ -317,18 +251,16 @@ private struct HistoryDetail: View { meta("Looked at", analysis.contextLabel) meta("When", historyWhen(analysis.createdDate)) } - .padding(.top, 2) + .padding(.top, 12) } } private func meta(_ label: String, _ value: String) -> some View { - HStack(spacing: 5) { - Text(label) - .foregroundStyle(Ink.muted) - Text(value) - .foregroundStyle(Ink.secondary) + VStack(alignment: .leading, spacing: 5) { + Text(label).font(.system(size: 12)).foregroundStyle(Ink.secondary) + Text(value).font(.system(size: 13)).foregroundStyle(Ink.text) } - .font(.system(size: 12)) + .frame(maxWidth: .infinity, alignment: .leading) } private var timeline: some View { @@ -338,6 +270,9 @@ private struct HistoryDetail: View { SectionLabel("Timeline") Spacer() statusLine + Toggle("Show details", isOn: $showDetails) + .toggleStyle(.checkbox).font(.system(size: 12)) + .padding(.leading, 12) } .padding(.bottom, 6) @@ -352,7 +287,7 @@ private struct HistoryDetail: View { attemptDivider(attempt.attempt) } ForEach(attempt.rows) { row in - TimelineRowView(row: row) + TimelineRowView(row: row, showDetails: showDetails) } } if let error = history.timelineErrors[analysis.id] { @@ -392,7 +327,6 @@ private struct HistoryDetail: View { HStack(spacing: 8) { Text("Attempt \(attempt)") .font(.system(size: 11, weight: .medium)) - .tracking(0.8) .foregroundStyle(Ink.muted) Rectangle().fill(Ink.cardRule).frame(height: 1) } @@ -403,6 +337,7 @@ private struct HistoryDetail: View { private struct TimelineRowView: View { let row: TimelineRow + var showDetails = false private var color: Color { switch row.tone { @@ -429,7 +364,7 @@ private struct TimelineRowView: View { Circle().fill(Ink.online).frame(width: 6, height: 6) } } - if let note = row.note { + if let note = row.note, showDetails || row.tone != .info { Text(note) .font(.system(size: 13)) .foregroundStyle(Ink.muted) @@ -441,7 +376,7 @@ private struct TimelineRowView: View { .font(.system(size: 12)) .foregroundStyle(Ink.muted) } - if !row.facts.isEmpty { + if showDetails && !row.facts.isEmpty { HStack(spacing: 12) { ForEach(row.facts, id: \.label) { fact in (Text("\(fact.label): ").foregroundStyle(factColor(fact.tone)) diff --git a/Sources/InkletMac/Views/HomeView.swift b/Sources/InkletMac/Views/HomeView.swift index c5246ec..b90b6e7 100644 --- a/Sources/InkletMac/Views/HomeView.swift +++ b/Sources/InkletMac/Views/HomeView.swift @@ -40,12 +40,11 @@ struct HomeView: View { private var greeting: some View { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 6) { - Text(Date.now.formatted(.dateTime.weekday(.wide).month(.wide).day()).uppercased()) + Text(Date.now.formatted(.dateTime.weekday(.wide).month(.wide).day())) .font(.system(size: 13, weight: .medium)) - .tracking(1.6) .foregroundStyle(Ink.muted) Text("Hi, \(model.account.username)!") - .font(.brand(40)) + .font(InkType.title) .foregroundStyle(Ink.text) } Spacer(minLength: 12) @@ -164,11 +163,13 @@ struct HomeView: View { private var activity: some View { VStack(alignment: .leading, spacing: 14) { HStack(alignment: .firstTextBaseline) { - SectionLabel("Building your second brain") + SectionLabel("Activity") Spacer() - Text("\(total) items · \(streak) day streak") - .font(.system(size: 13)) - .foregroundStyle(Ink.secondary) + HStack(spacing: 20) { + Text("\(total) items saved") + Text("\(streak) day streak") + } + .font(.system(size: 13)).foregroundStyle(Ink.secondary) } HeatmapView(counts: model.activityByDay) } diff --git a/Sources/InkletMac/Views/KnowledgeDetailView.swift b/Sources/InkletMac/Views/KnowledgeDetailView.swift new file mode 100644 index 0000000..7e3b111 --- /dev/null +++ b/Sources/InkletMac/Views/KnowledgeDetailView.swift @@ -0,0 +1,203 @@ +import SwiftUI +import InkletPresentationKit + +struct ContentAssetReading: Decodable, Sendable { + let assetIndex: Int + let text: String + let summary: String? + let status: String + let nextOffset: Int? + let downloadUrl: String? +} + +/// A reading surface for saved material. It loads only the selected item. +struct KnowledgeDetailView: View { + @Environment(AppModel.self) private var model + let item: KnowledgeItem + @State private var content: ContentDTO? + @State private var loading = true + @State private var error: String? + + private var title: String { content.map { KnowledgeItem(dto: $0).title ?? "Untitled" } ?? item.title ?? "Untitled" } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 28) { + VStack(alignment: .leading, spacing: 12) { + Text(title).font(InkType.title).foregroundStyle(Ink.text).lineLimit(3) + .fixedSize(horizontal: false, vertical: true).textSelection(.enabled) + HStack(spacing: 20) { + Label(item.kind?.rawValue ?? "Saved item", systemImage: item.kind?.symbol ?? "doc") + Text(item.createdAt.formatted(date: .abbreviated, time: .shortened)) + } + .font(.system(size: 13)).foregroundStyle(Ink.secondary) + } + if loading { + ProgressView("Opening item…").controlSize(.small) + } else if let error { + VStack(alignment: .leading, spacing: 12) { + Text(error).font(InkType.body).foregroundStyle(Ink.secondary) + Button("Try again") { Task { await load() } } + } + } else if let content { + if let failure = content.failure { + Label(failure.message, systemImage: "exclamationmark.circle") + .font(.system(size: 14)).foregroundStyle(Ink.danger) + } + if content.assets.isEmpty { + Text("No content was saved in this item.").foregroundStyle(Ink.secondary) + } + ForEach(content.assets, id: \.assetIndex) { asset in + KnowledgeAssetView(contentID: content.id, asset: asset, showHeading: content.assets.count > 1) + if asset.assetIndex != content.assets.last?.assetIndex { + Rectangle().fill(Ink.cardRule).frame(height: 1) + } + } + if let ids = content.analysisIds, !ids.isEmpty { + VStack(alignment: .leading, spacing: 12) { + SectionLabel("Related activity") + ForEach(Array(ids.enumerated()), id: \.element) { index, id in + Button("View activity \(index + 1)", systemImage: "arrow.up.right") { model.openRun(id) } + .buttonStyle(.link).font(.system(size: 13)) + } + } + .padding(.top, 8) + } + } + } + .frame(maxWidth: 720, alignment: .leading) + .padding(.horizontal, 32).padding(.vertical, 28) + .frame(maxWidth: .infinity, alignment: .center) + } + .background(Ink.bg) + .navigationTitle(title) + .toolbar { ComposerToolbar() } + .toolbarBackground(.hidden, for: .windowToolbar) + .task { await load() } + } + + private func load() async { + loading = true + error = nil + defer { loading = false } + do { + let result = try await InkletAPI.shared.content(id: item.id) + try Task.checkCancellation() + content = result + } catch is CancellationError { + } catch { + self.error = (error as? APIError)?.errorDescription ?? error.localizedDescription + } + } +} + +private struct KnowledgeAssetView: View { + let contentID: String + let asset: ContentAssetDTO + let showHeading: Bool + @State private var reading: ContentAssetReading? + @State private var text = "" + @State private var nextOffset: Int? + @State private var loading = false + @State private var failed = false + + private var source: URL? { Self.webURL(asset.url) } + private var download: URL? { Self.webURL(reading?.downloadUrl) } + private var bodyText: String { asset.type == "text" ? (asset.text ?? "") : text } + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + if showHeading { + SectionLabel(asset.filename ?? (asset.type == "text" ? "Note" : asset.type.capitalized)) + } + if asset.type == "link", let source { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "link").foregroundStyle(Ink.secondary) + VStack(alignment: .leading, spacing: 6) { + Text(source.absoluteString).font(.system(size: 13)).foregroundStyle(Ink.secondary) + .textSelection(.enabled).lineLimit(3) + Link("Open original", destination: source).font(.system(size: 13, weight: .medium)) + } + Spacer(minLength: 0) + } + } + if asset.type == "image", let download { + AsyncImage(url: download) { phase in + switch phase { + case .success(let image): image.resizable().scaledToFit().frame(maxHeight: 480) + case .failure: Label("Image preview unavailable", systemImage: "photo").foregroundStyle(Ink.secondary) + default: ProgressView().frame(height: 120) + } + } + .frame(maxWidth: .infinity) + .clipShape(.rect(cornerRadius: 8)) + } + if asset.type == "file" || asset.type == "image" { + HStack(spacing: 12) { + Image(systemName: asset.type == "image" ? "photo" : "doc") + .font(.system(size: 22)).foregroundStyle(Ink.secondary) + VStack(alignment: .leading, spacing: 4) { + Text(asset.filename ?? "Attachment").font(.system(size: 14, weight: .medium)) + if let size = asset.sizeBytes { + Text(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file)) + .font(.system(size: 12)).foregroundStyle(Ink.secondary) + } + } + Spacer() + if let download { Link("Open original", destination: download).font(.system(size: 13)) } + } + .padding(16).background(Ink.card, in: .rect(cornerRadius: Ink.cardCorner)) + } + if let summary = reading?.summary, !summary.isEmpty, asset.type != "text" { + DisclosureGroup("Summary") { + Text(summary).font(.system(size: 14)).lineSpacing(5) + .textSelection(.enabled).padding(.top, 10) + } + .font(.system(size: 13)).foregroundStyle(Ink.secondary) + } + if !bodyText.isEmpty { + Text(Self.formatted(bodyText)).font(InkType.body).lineSpacing(7) + .foregroundStyle(Ink.text).textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + if loading { + ProgressView("Loading content…").controlSize(.small) + } else if failed { + HStack(spacing: 12) { + Text("Preview unavailable.").foregroundStyle(Ink.secondary) + Button("Try again") { Task { await load(offset: nextOffset ?? 0) } } + } + .font(.system(size: 13)) + } else if let offset = nextOffset { + Button("Read more") { Task { await load(offset: offset) } }.font(.system(size: 13)) + } else if bodyText.isEmpty && reading != nil { + Text(reading?.status == "pending" ? "The text is still being prepared." : "No readable text was found in this item.") + .font(.system(size: 14)).foregroundStyle(Ink.secondary) + } + } + .task { if asset.type != "text" { await load(offset: 0) } } + } + + private func load(offset: Int) async { + loading = true + failed = false + defer { loading = false } + do { + let result = try await InkletAPI.shared.readContentAsset(contentID: contentID, index: asset.assetIndex, offset: offset) + try Task.checkCancellation() + if offset == 0 { reading = result; text = result.text } else { text += result.text } + nextOffset = result.nextOffset + } catch is CancellationError { + } catch { failed = true } + } + + private static func webURL(_ raw: String?) -> URL? { + guard let raw, let url = URL(string: raw), ["http", "https"].contains(url.scheme?.lowercased() ?? ""), + url.host() != nil, url.user == nil, url.password == nil else { return nil } + return url + } + + private static func formatted(_ value: String) -> AttributedString { + (try? AttributedString(markdown: value, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(value) + } +} diff --git a/Sources/InkletMac/Views/KnowledgeView.swift b/Sources/InkletMac/Views/KnowledgeView.swift index 6ce939c..6863c8b 100644 --- a/Sources/InkletMac/Views/KnowledgeView.swift +++ b/Sources/InkletMac/Views/KnowledgeView.swift @@ -2,6 +2,7 @@ import SwiftUI struct KnowledgeView: View { @Environment(AppModel.self) private var model + @Binding var path: NavigationPath enum Filter: String, CaseIterable, Identifiable { case organized = "Organized" @@ -18,12 +19,19 @@ struct KnowledgeView: View { @State private var searchError: String? var body: some View { - VStack(alignment: .leading, spacing: 0) { - masthead - list + NavigationStack(path: $path) { + VStack(alignment: .leading, spacing: 0) { + masthead + list + } + .background(Ink.bg) + .navigationTitle("Knowledge") + .toolbar { ComposerToolbar() } + .toolbarBackground(.hidden, for: .windowToolbar) + .navigationDestination(for: KnowledgeItem.self) { item in + KnowledgeDetailView(item: item).id(item.id) + } } - .background(Ink.bg) - .navigationTitle("Knowledge") // Settle for a moment, then ask the backend: it searches the whole // library — titles, note text, links, filenames, and what the ingest // worker read out of images and files — not just what is loaded here. @@ -48,49 +56,24 @@ struct KnowledgeView: View { } private var masthead: some View { - HStack(alignment: .bottom) { - VStack(alignment: .leading, spacing: 6) { - SectionLabel("Your second brain") - Text("Knowledge") - .font(.brand(34)) - .foregroundStyle(Ink.text) - } - Spacer(minLength: 20) - HStack(spacing: 22) { - stat("\(model.knowledge.count)", "items") - stat("\(organizedCount)", "organized") - stat("\(model.knowledge.count - organizedCount)", "pending") - } - .padding(.bottom, 4) - } - .padding(.horizontal, 28) - .padding(.top, 26) - .padding(.bottom, 20) - } - - private func stat(_ value: String, _ label: String) -> some View { - HStack(alignment: .firstTextBaseline, spacing: 5) { - Text(value) - .font(.brand(19)) - .foregroundStyle(Ink.text) - Text(label) - .font(.system(size: 12)) - .foregroundStyle(Ink.muted) + VStack(alignment: .leading, spacing: 6) { + Text("Knowledge").font(InkType.title).foregroundStyle(Ink.text) + Text("Browse and read the things you’ve saved.") + .font(.system(size: 13)).foregroundStyle(Ink.secondary) } + .padding(.horizontal, 28).padding(.top, 26).padding(.bottom, 20) } private var list: some View { VStack(spacing: 0) { HStack(spacing: 12) { - // Back to the tinted SwiftUI picker: the AppKit control keeps its - // glass but paints the selection with the system accent, and blue - // is worse here than losing the material. - Picker("Filter", selection: $filter) { + InkSegmentedPicker(title: "Filter", selection: $filter) { ForEach(Filter.allCases) { Text($0.rawValue).tag($0) } } - .pickerStyle(.segmented) - .labelsHidden() - .fixedSize() + + Text("\(model.knowledge.count) saved items") + .font(InkType.metadata).foregroundStyle(Ink.muted) + .fixedSize() Spacer() @@ -114,12 +97,9 @@ struct KnowledgeView: View { Spacer() } else { ScrollView { - InkCard(padding: 0) { - LazyVStack(spacing: 0) { - ForEach(Array(items.enumerated()), id: \.element.id) { index, item in - KnowledgeRow(item: item, showsDivider: index < items.count - 1) - } - } + InkItemList(items: items) { item in + KnowledgeRow(item: item) { path.append(item) } + .help("Read \(item.title ?? "item")") } .padding(.horizontal, 28) .padding(.bottom, resultsTruncated ? 8 : 24) @@ -157,53 +137,25 @@ struct KnowledgeView: View { } } -/// Fixed-height row so the list keeps an even rhythm regardless of subtitle. private struct KnowledgeRow: View { let item: KnowledgeItem - let showsDivider: Bool + let open: () -> Void var body: some View { - VStack(spacing: 0) { - HStack(spacing: 12) { - Image(systemName: item.kind?.symbol ?? "square.stack") - .font(.system(size: 14)) - .foregroundStyle(Ink.secondary) - .frame(width: 22) - - VStack(alignment: .leading, spacing: 2) { - // The title arrives with a second request, so an unresolved - // row shows a placeholder rather than collapsing in height. - Text(item.title ?? "Loading…") - .font(.system(size: 14)) - .foregroundStyle(item.title == nil ? Ink.muted : Ink.text) - .lineLimit(1) - Text(item.detail ?? item.kind?.rawValue ?? " ") - .font(.system(size: 12)) - .foregroundStyle(Ink.muted) - .lineLimit(1) - } - - Spacer(minLength: 8) - + InkListRow( + title: item.title ?? "Loading…", + subtitle: item.detail ?? item.kind?.rawValue, + titleColor: item.title == nil ? Ink.muted : Ink.text, + action: open + ) { + Image(systemName: item.kind?.symbol ?? "square.stack") + } metadata: { + if item.processStatus == .failed || item.processStatus == .uploading { Text(item.processStatus.label) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(item.processStatus == .ready ? Ink.muted : Ink.text) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(Ink.input, in: .rect(cornerRadius: 5)) - - Text(relativeTime(item.createdAt)) - .font(.system(size: 12)) - .foregroundStyle(Ink.muted) - .frame(width: 62, alignment: .trailing) - } - .padding(.horizontal, 16) - .frame(height: 58) - .contentShape(.rect) - - if showsDivider { - Rectangle().fill(Ink.cardRule).frame(height: 1) + .foregroundStyle(item.processStatus == .failed ? Ink.danger : Ink.secondary) } + Text(relativeTime(item.createdAt)) + .frame(width: 96, alignment: .trailing) } } } diff --git a/Sources/InkletMac/Views/ListComponents.swift b/Sources/InkletMac/Views/ListComponents.swift new file mode 100644 index 0000000..04524b6 --- /dev/null +++ b/Sources/InkletMac/Views/ListComponents.swift @@ -0,0 +1,116 @@ +import SwiftUI + +/// Shared card, separators and interaction style for the three content lists. +struct InkItemList: View { + let items: [Item] + @ViewBuilder var row: (Item) -> Row + + var body: some View { + InkCard(padding: 0) { + LazyVStack(spacing: 0) { + ForEach(items) { item in + row(item) + if item.id != items.last?.id { + Rectangle().fill(Ink.cardRule).frame(height: 1) + .padding(.horizontal, 16) + .accessibilityHidden(true) + } + } + } + } + } +} + +/// Padding belongs to the button label so every point in the row opens it. +struct InkListRow: View { + let title: String + var subtitle: String? = nil + var titleColor: Color = Ink.text + var subtitleColor: Color = Ink.muted + let action: () -> Void + @ViewBuilder var leading: Leading + @ViewBuilder var metadata: Metadata + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + leading + .font(.system(size: 14)) + .foregroundStyle(Ink.secondary) + .frame(width: 22) + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(titleColor) + .lineLimit(1) + if let subtitle { + Text(subtitle) + .font(InkType.metadata) + .foregroundStyle(subtitleColor) + .lineLimit(1) + } + } + Spacer(minLength: 8) + metadata + .font(InkType.metadata) + .foregroundStyle(Ink.muted) + .lineLimit(1) + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Ink.muted) + .accessibilityHidden(true) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, minHeight: 72, alignment: .leading) + .contentShape(.rect) + } + .buttonStyle(InkListRowButtonStyle()) + } +} + +/// Own the hover on the complete button, not on a text or an inner stack. +struct InkListRowButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + RowFeedback(configuration: configuration) + } + + private struct RowFeedback: View { + let configuration: ButtonStyle.Configuration + @Environment(\.isEnabled) private var isEnabled + @State private var isHovering = false + + var body: some View { + configuration.label + .frame(maxWidth: .infinity, alignment: .leading) + .background(Ink.text.opacity(isEnabled ? (configuration.isPressed ? 0.10 : (isHovering ? 0.05 : 0)) : 0)) + .contentShape(.rect) + .onHover { isHovering = $0 } + .onDisappear { isHovering = false } + } + } +} + +/// Keep native segmented-control behavior and sizing identical across filters. +struct InkSegmentedPicker: View { + let title: String + @Binding var selection: Selection + var showsLabel = false + @ViewBuilder var options: Options + + var body: some View { + HStack(spacing: 10) { + if showsLabel { + Text(title).font(InkType.metadata).foregroundStyle(Ink.secondary) + .fixedSize() + } + Picker(title, selection: $selection) { options } + .pickerStyle(.segmented) + .labelsHidden() + .controlSize(.small) + .fixedSize() + } + .fixedSize(horizontal: true, vertical: false) + .frame(height: 28) + } +} diff --git a/Sources/InkletMac/Views/NewDisplayView.swift b/Sources/InkletMac/Views/NewDisplayView.swift index 2eb76a6..0b4c5fb 100644 --- a/Sources/InkletMac/Views/NewDisplayView.swift +++ b/Sources/InkletMac/Views/NewDisplayView.swift @@ -8,7 +8,7 @@ private enum DisplayConnection: String, CaseIterable, Identifiable { var id: Self { self } var title: String { switch self { - case .hardware: "Hardware Display" + case .hardware: "inklet D1" case .virtual: "Virtual Display" case .quote0: "Quote/0" } @@ -57,7 +57,6 @@ struct NewDisplayView: View { ScrollView { VStack(alignment: .leading, spacing: 28) { VStack(alignment: .leading, spacing: 9) { - SectionLabel("Make room for your ideas") Text("New Display").font(.brand(36)).foregroundStyle(Ink.text) Text("Choose how you’d like to bring inklet into view.") .font(.system(size: 14)).foregroundStyle(Ink.secondary) @@ -142,7 +141,7 @@ private struct MacVirtualDisplaySetupView: View { } InkCard(padding: 20) { VStack(alignment: .leading, spacing: 12) { - SectionLabel("Display name") + Text("Display name").font(.system(size: 13, weight: .medium)).foregroundStyle(Ink.secondary) TextField("My inklet", text: $name) .textFieldStyle(.plain).font(.system(size: 16)) .padding(12).background(Ink.input, in: .rect(cornerRadius: Ink.controlCorner)) @@ -150,7 +149,7 @@ private struct MacVirtualDisplaySetupView: View { } }.disabled(virtuals.busy) VStack(alignment: .leading, spacing: 12) { - SectionLabel("Canvas size") + Text("Canvas size").font(.system(size: 13, weight: .medium)).foregroundStyle(Ink.secondary) LazyVGrid(columns: [GridItem(.flexible(), spacing: 14), GridItem(.flexible(), spacing: 14)], spacing: 14) { ForEach(VirtualDisplaySizeProfile.available) { value in Button { profile = value } label: { @@ -243,7 +242,7 @@ private struct Quote0SetupView: View { InkCard(padding: 20) { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 10) { - SectionLabel("Name") + Text("Name").font(.system(size: 13, weight: .medium)).foregroundStyle(Ink.secondary) TextField("Kitchen", text: $name) .textFieldStyle(.plain).font(.system(size: 15)) .padding(12).background(Ink.input, in: .rect(cornerRadius: Ink.controlCorner)) @@ -254,7 +253,7 @@ private struct Quote0SetupView: View { .font(.system(size: 12)).foregroundStyle(Ink.muted) } VStack(alignment: .leading, spacing: 10) { - SectionLabel("Dot. API key") + Text("Dot. API key").font(.system(size: 13, weight: .medium)).foregroundStyle(Ink.secondary) SecureField("dot_app_…", text: $apiKey) .textFieldStyle(.plain).font(.system(size: 15, design: .monospaced)) .padding(12).background(Ink.input, in: .rect(cornerRadius: Ink.controlCorner)) @@ -263,7 +262,7 @@ private struct Quote0SetupView: View { .font(.system(size: 12)).foregroundStyle(Ink.muted) } VStack(alignment: .leading, spacing: 10) { - SectionLabel("Serial number") + Text("Serial number").font(.system(size: 13, weight: .medium)).foregroundStyle(Ink.secondary) TextField("ABCD1234ABCD", text: $serial) .textFieldStyle(.plain).font(.system(size: 15, design: .monospaced)) .autocorrectionDisabled() diff --git a/Sources/InkletMac/Views/RootView.swift b/Sources/InkletMac/Views/RootView.swift index b2ecdcc..4bafde2 100644 --- a/Sources/InkletMac/Views/RootView.swift +++ b/Sources/InkletMac/Views/RootView.swift @@ -18,32 +18,34 @@ struct RootView: View { @Environment(WidgetRouter.self) private var widgetRouter @Environment(\.scenePhase) private var scenePhase @State private var selection: SidebarItem? = .home + @State private var askPath = NavigationPath() + @State private var historyPath = NavigationPath() + @State private var knowledgePath = NavigationPath() var body: some View { @Bindable var model = model NavigationSplitView { - Sidebar(selection: $selection) + Sidebar(selection: Binding( + get: { selection }, + set: { item in + askPath = NavigationPath() + historyPath = NavigationPath() + knowledgePath = NavigationPath() + selection = item + } + )) .navigationSplitViewColumnWidth(min: 208, ideal: 228, max: 300) } detail: { detail + // Keep each sidebar page's navigation column identity separate; + // SwiftUI cannot compare the different destination value types. + .id(selection) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Ink.bg.ignoresSafeArea()) .toolbar { - // Without a titlebar there is nothing to push trailing items - // over — neither `.primaryAction` nor the default placement - // moves them off the leading edge. A flexible spacer is what - // actually claims the gap. It is a macOS 26 API; on 15 the - // trailing placement below does the job on its own. - if #available(macOS 26.0, *) { - ToolbarSpacer(.flexible) - } - - // One composer entry point for the whole window, so the popover - // always has a stable anchor no matter which page is showing. - ToolbarItem(placement: .primaryAction) { - Button("Create", systemImage: "square.and.pencil") { model.startComposing() } - .help("Create an inklet Presentation (\(ShortcutStore.shared.shortcut.display))") + if selection != .ask && selection != .history && selection != .knowledge { + ComposerToolbar() } } .safeAreaInset(edge: .bottom, spacing: 0) { @@ -52,6 +54,13 @@ struct RootView: View { } } } + .onChange(of: selection) { previous, _ in + // Preserve an incoming History deep link while dismissing the page + // being left, including navigation initiated outside the sidebar. + if previous == .ask { askPath = NavigationPath() } + if previous == .history { historyPath = NavigationPath() } + if previous == .knowledge { knowledgePath = NavigationPath() } + } .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await model.virtualDisplays.refresh() } } } @@ -95,11 +104,11 @@ struct RootView: View { case .home: HomeView(selection: $selection) case .knowledge: - KnowledgeView() + KnowledgeView(path: $knowledgePath) case .ask: - AskView() + AskView(path: $askPath) case .history: - HistoryView() + HistoryView(path: $historyPath) case .virtualDisplayDetail(let id): NavigationStack { VirtualDisplayDetailView(id: id).id(id) } case .newDisplay: @@ -124,6 +133,33 @@ struct RootView: View { } } +/// Shared by the window's pages and each native navigation destination. +/// A pushed destination owns its toolbar, so it must also supply Create. +struct ComposerToolbar: ToolbarContent { + @Environment(AppModel.self) private var model + var newConversation: (() -> Void)? = nil + + var body: some ToolbarContent { + if #available(macOS 26.0, *) { + ToolbarSpacer(.flexible) + } + if let newConversation { + ToolbarItem(placement: .primaryAction) { + Button("New conversation", systemImage: "plus", action: newConversation) + .help("New conversation") + .disabled(model.ask.isSending) + } + if #available(macOS 26.0, *) { + ToolbarSpacer(.fixed) + } + } + ToolbarItem(placement: .primaryAction) { + Button("Create", systemImage: "square.and.pencil") { model.startComposing() } + .help("Create an inklet Presentation (\(ShortcutStore.shared.shortcut.display))") + } + } +} + private struct Sidebar: View { @Environment(AppModel.self) private var model @EnvironmentObject private var virtuals: VirtualDisplayController @@ -195,7 +231,9 @@ private struct GroupLabel: View { } var body: some View { - SectionLabel(title) + Text(title) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Ink.secondary) .padding(.leading, 10) .padding(.top, topPadding) .padding(.bottom, 3) @@ -316,9 +354,8 @@ private struct AccountBar: View { Text(model.account.username) .font(.system(size: 14)) .foregroundStyle(Ink.text) - Text(model.account.plan.uppercased()) - .font(.system(size: 10, weight: .medium)) - .tracking(0.8) + Text(model.account.plan.capitalized) + .font(.system(size: 12)) .foregroundStyle(Ink.muted) } diff --git a/Sources/InkletMac/Views/SettingsView.swift b/Sources/InkletMac/Views/SettingsView.swift index a85d6f7..e4385d9 100644 --- a/Sources/InkletMac/Views/SettingsView.swift +++ b/Sources/InkletMac/Views/SettingsView.swift @@ -119,7 +119,7 @@ private struct GeneralSettings: View { SettingsGroup("Composer") { SettingRow(title: "Global shortcut", - subtitle: "Opens the composer from any app · ⌫ restores the default") { + subtitle: "Opens the composer from any app. Press ⌫ to restore the default.") { ShortcutRecorder() } SettingRow(title: "Highlighted text", subtitle: selectionSubtitle) { @@ -178,9 +178,8 @@ private struct AccountSettings: View { .foregroundStyle(Ink.secondary) } Spacer(minLength: 8) - Text(model.account.plan.uppercased()) + Text(model.account.plan.capitalized) .font(.system(size: 11, weight: .medium)) - .tracking(0.8) .foregroundStyle(Ink.secondary) .padding(.horizontal, 9) .padding(.vertical, 4) diff --git a/Sources/InkletMac/Views/VirtualDisplayView.swift b/Sources/InkletMac/Views/VirtualDisplayView.swift index 2b1607a..a4700a3 100644 --- a/Sources/InkletMac/Views/VirtualDisplayView.swift +++ b/Sources/InkletMac/Views/VirtualDisplayView.swift @@ -9,7 +9,6 @@ struct VirtualDisplayView: View { ScrollView { VStack(alignment: .leading, spacing: 22) { VStack(alignment: .leading, spacing: 6) { - SectionLabel("On this Mac") Text("Virtual Display") .font(.brand(34)) Text("Your latest Presentation, at home on your desktop.") diff --git a/Sources/InkletMac/Views/VirtualDisplayViews.swift b/Sources/InkletMac/Views/VirtualDisplayViews.swift index eeb45e2..54419f5 100644 --- a/Sources/InkletMac/Views/VirtualDisplayViews.swift +++ b/Sources/InkletMac/Views/VirtualDisplayViews.swift @@ -25,7 +25,7 @@ struct VirtualDisplayDetailView: View { VirtualFramePreview(data: frame?.imageData) .aspectRatio(CGFloat(display.width) / CGFloat(display.height), contentMode: .fit) } caption: { - Text(display.revision > 0 ? "Published · Revision \(display.revision)" : "Nothing on screen yet") + Text(display.revision > 0 ? "Published revision \(display.revision)" : "Nothing on screen yet") } } information: { InkCard(stretches: true) { diff --git a/Sources/InkletPresentationKit/VirtualDisplayCore.swift b/Sources/InkletPresentationKit/VirtualDisplayCore.swift index 2644a28..c15695c 100644 --- a/Sources/InkletPresentationKit/VirtualDisplayCore.swift +++ b/Sources/InkletPresentationKit/VirtualDisplayCore.swift @@ -31,10 +31,10 @@ nonisolated public enum VirtualDisplaySizeProfile: String, CaseIterable, Codable public var height: Int { switch self { case .iosLarge, .legacy: 376; default: 360 } } public var title: String { switch self { - case .macLarge: "Mac · Large" - case .macExtraLarge: "Mac · Extra Large" - case .iosLarge: "iPhone / iPad · Large" - case .ipadExtraLarge: "iPad · Extra Large" + case .macLarge: "Large on Mac" + case .macExtraLarge: "Extra Large on Mac" + case .iosLarge: "Large on iPhone and iPad" + case .ipadExtraLarge: "Extra Large on iPad" case .legacy: "Original display" } } diff --git a/Tests/InkletMacTests/HistoryTimelineTests.swift b/Tests/InkletMacTests/HistoryTimelineTests.swift index 8bf1ce8..5ec6c89 100644 --- a/Tests/InkletMacTests/HistoryTimelineTests.swift +++ b/Tests/InkletMacTests/HistoryTimelineTests.swift @@ -75,7 +75,7 @@ private func events(_ json: String) -> [AnalysisEventDTO] { let attempts = AnalysisTimeline.build(stream) #expect(attempts.count == 1) let rows = attempts[0].rows - #expect(rows.map(\.text) == ["Analysis created", "Read 3 notes", "Looking at layouts · 2 so far"]) + #expect(rows.map(\.text) == ["Analysis created", "Read 3 notes", "Looking at layouts (2 so far)"]) #expect(rows[1].id == "activity-1-read") #expect(!rows[1].live) #expect(rows[2].live) @@ -127,11 +127,11 @@ private func events(_ json: String) -> [AnalysisEventDTO] { return AnalysisTimeline.activityText(event.data!) } #expect(text(#"{"activityId":"a","kind":"checking_display","state":"failed","steps":1,"stats":{"failedSteps":1}}"#) - == "Checked the display · 1 step failed") + == "Checked the display. 1 step failed") #expect(text(#"{"activityId":"a","kind":"checking_display","state":"failed","steps":1,"stats":{}}"#) == "Checking the display — failed") #expect(text(#"{"activityId":"a","kind":"choosing_layout","state":"done","steps":4,"stats":{"layoutsSeen":3,"chosen":"Small Panel Card"}}"#) - == "Looked at 3 layouts · chose Small Panel Card") + == "Looked at 3 layouts, chose Small Panel Card") #expect(text(#"{"activityId":"a","kind":"other","state":"active","steps":2,"stats":{"deniedSteps":1}}"#) - == "Working · 2 steps · 1 step blocked") + == "Working through 2 steps. 1 step blocked") }