Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,29 @@ import Observation
/// Dynamic answers/search hits are force-mounted, as in cmdk. Navigation/actions
/// use cmdk's localized label + URL fuzzy score; groups/items keep stable ties.
public func groups(strings: any Localizing) -> [NativeCommandPaletteGroup] {
var result: [NativeCommandPaletteGroup] = []
func group(_ id: String, _ key: String, _ entries: [NativeCommandPaletteEntry]) {
if !entries.isEmpty { result.append(.init(id: id, title: strings.t(key), entries: entries)) }
// cmdk scores every item, force-mounted ones included, and orders the groups by their
// best item (ties keep mount order); force-mounted groups only stay visible at 0. So the
// ticker, answer, search and Payees groups take part in the ordering too, scored on the
// `value` strings the web items carry.
var filtered: [(group: NativeCommandPaletteGroup, score: Double, index: Int)] = []
func group(
_ id: String, _ key: String, _ entries: [NativeCommandPaletteEntry],
value: (NativeCommandPaletteEntry) -> String
) {
guard !entries.isEmpty else { return }
typealias Scored = (entry: NativeCommandPaletteEntry, score: Double, index: Int)
var scored: [Scored] = []
for (index, entry) in entries.enumerated() {
let score: Double =
query.isEmpty ? 1 : NativeCommandPaletteQueries.score(value(entry), query: query)
scored.append((entry: entry, score: score, index: index))
}
scored.sort { (lhs: Scored, rhs: Scored) -> Bool in
lhs.score == rhs.score ? lhs.index < rhs.index : lhs.score > rhs.score
}
let ordered: [NativeCommandPaletteEntry] = scored.map { (item: Scored) in item.entry }
let built = NativeCommandPaletteGroup(id: id, title: strings.t(key), entries: ordered)
filtered.append((group: built, score: scored[0].score, index: filtered.count))
}
if let tickerSymbol, quote != nil || tickerLoading {
let symbol = quote?.symbol ?? tickerSymbol
Expand All @@ -140,7 +160,8 @@ import Observation
action: .navigate(
.init(.marketLookup, query: [.init(name: "symbol", value: symbol)])),
quote: quote)
])
]
) { _ in "market \(symbol) \(self.quote?.name ?? "")" }
}
if let answer {
group(
Expand All @@ -150,7 +171,8 @@ import Observation
id: "answer", title: answer.text,
symbol: answer.kind == .fx ? "arrow.left.arrow.right" : "plus.forwardslash.minus",
hint: strings.t("commandPalette.copyHint"), action: .copy(answer.text))
])
]
) { _ in "result \(query)" }
}
if trimmedQuery.utf16.count >= 2 {
group(
Expand All @@ -162,7 +184,8 @@ import Observation
symbol: "list.bullet.rectangle.portrait", hint: nil,
action: .navigate(
.init(.transactions, query: [.init(name: "search", value: trimmedQuery)])))
])
]
) { _ in "tx-search \(query)" }
}
group(
"recipients", "nav.recipients",
Expand All @@ -176,25 +199,26 @@ import Observation
.init(name: "recipient_id", value: String($0.id.rawValue)),
.init(name: "filter_label", value: $0.name),
])))
})
}
) { "recipient \($0.title) \(query)" }
let pages = Self.pageGroups(adminMode: settings.adminMode).flatMap(\.pages)
if trimmedQuery.isEmpty {
group(
"recent", "commandPalette.recent",
recents.enumerated().compactMap { index, url in
guard let page = pages.first(where: { $0.url == url }) else { return nil }
return pageEntry(page, strings: strings, prefix: "recent-\(index)-")
})
}
) { "recent \($0.title) \(navigationURL($0.action))" }
}
var filtered: [(group: NativeCommandPaletteGroup, score: Double, index: Int)] = []
for (index, section) in Self.pageGroups(adminMode: settings.adminMode).enumerated() {
for section in Self.pageGroups(adminMode: settings.adminMode) {
let entries = section.pages.map { pageEntry($0, strings: strings) }
let ranked = ranked(entries) { $0.title + " " + navigationURL($0.action) }
if !ranked.isEmpty {
filtered.append(
(
.init(id: section.key, title: strings.t(section.key), entries: ranked.map(\.entry)),
ranked[0].score, index
ranked[0].score, filtered.count
))
}
}
Expand All @@ -221,12 +245,11 @@ import Observation
(
.init(
id: "actions", title: strings.t("commandPalette.actions"), entries: rankedActions.map(\.entry)),
rankedActions[0].score, filtered.count + 5
rankedActions[0].score, filtered.count
))
}
if !query.isEmpty { filtered.sort { $0.score == $1.score ? $0.index < $1.index : $0.score > $1.score } }
result += filtered.map(\.group)
return result
return filtered.map(\.group)
}

private static func pageGroups(adminMode: Bool) -> [(key: String, pages: [NavItem])] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,11 @@ struct AIInvestigationDocumentsView: View {
}
}.padding(.top, 12)
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(strings.t("aiResearch.evidenceOptions")).font(.callout.weight(.medium))
Text(summary).font(.caption).foregroundStyle(theme.secondaryLabel)
// `DisclosureSummary className="flex flex-wrap items-baseline gap-x-2"`: the title and
// its footnote summary on one line, wrapping only when the row is too narrow.
ViewThatFits(in: .horizontal) {
HStack(alignment: .firstTextBaseline, spacing: 8) { evidenceTitle; evidenceSummary }
VStack(alignment: .leading, spacing: 4) { evidenceTitle; evidenceSummary }
}
}
.padding(.vertical, 10)
Expand Down Expand Up @@ -84,6 +86,12 @@ struct AIInvestigationDocumentsView: View {
.accessibilityLabel(strings.t("aiResearch.deleteDocument") + ": " + document.title)
}.padding(.vertical, 10)
}
private var evidenceTitle: some View {
Text(strings.t("aiResearch.evidenceOptions")).font(.callout.weight(.medium))
}
private var evidenceSummary: some View {
Text(summary).font(.footnote).foregroundStyle(theme.secondaryLabel)
}
private func dateField(_ key: String, value: Binding<String>) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(strings.t(key)).font(.caption)
Expand Down
39 changes: 27 additions & 12 deletions apps/native/Sources/VisionNativeUI/AIInvestigationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,19 @@ struct AIInvestigationView: View {
.visionAccessibilityLabel(strings.t("aiResearch.researchMode"))
}.padding(.top, 12)
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(strings.t("aiResearch.configure")).font(.callout.weight(.medium))
Text(settingsSummary).font(.caption).foregroundStyle(theme.secondaryLabel)
// `flex flex-wrap items-baseline gap-x-2`: title and summary share one line.
ViewThatFits(in: .horizontal) {
HStack(alignment: .firstTextBaseline, spacing: 8) { configureTitle; configureSummary }
VStack(alignment: .leading, spacing: 4) { configureTitle; configureSummary }
}
}
}
private var configureTitle: some View {
Text(strings.t("aiResearch.configure")).font(.callout.weight(.medium))
}
private var configureSummary: some View {
Text(settingsSummary).font(.footnote).foregroundStyle(theme.secondaryLabel)
}
@ViewBuilder private var publicInputs: some View {
if model.researchMode == .publicWeb {
input(
Expand All @@ -110,19 +117,27 @@ struct AIInvestigationView: View {
Text(strings.t("aiResearch.publicSymbolsHelp")).font(.caption).foregroundStyle(theme.secondaryLabel)
}
}
/// The lifecycle row (`flex flex-wrap items-center gap-2`): the buttons, then the state note
/// in the same row, as a footnote.
private var lifecycle: some View {
VStack(alignment: .leading, spacing: 12) {
ViewThatFits(in: .horizontal) {
HStack(spacing: 12) { lifecycleActions }
VStack(alignment: .leading, spacing: 10) { lifecycleActions }
ViewThatFits(in: .horizontal) {
HStack(spacing: 8) {
lifecycleActions
lifecycleNote
}
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) { lifecycleActions }
lifecycleNote
}
Text(
model.job.map { strings.t("aiResearch.jobState", ["state": $0.state.rawValue]) }
?? strings.t("aiResearch.localDefault")
)
.font(.caption).foregroundStyle(theme.secondaryLabel)
}
}
private var lifecycleNote: some View {
Text(
model.job.map { strings.t("aiResearch.jobState", ["state": $0.state.rawValue]) }
?? strings.t("aiResearch.localDefault")
)
.font(.footnote).foregroundStyle(theme.secondaryLabel)
}
@ViewBuilder private var lifecycleActions: some View {
Button {
Task { await model.run() }
Expand Down
26 changes: 19 additions & 7 deletions apps/native/Sources/VisionNativeUI/AccountDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -378,13 +378,25 @@ struct AccountDetailContent: View {
Text(strings.t("accounts.detail.details"))
.font(Font.visionTitle3)
.accessibilityAddTraits(.isHeader)
Grid(alignment: .leading, horizontalSpacing: 24, verticalSpacing: 6) {
ForEach(model.details(account)) { entry in
GridRow {
Text(strings.t(entry.labelKey))
.font(.footnote)
.foregroundStyle(theme.secondaryLabel)
Text(entry.value)
// `<dl className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4">`: each entry is
// a footnote label over its body value.
VisionBreakpointReader(breakpoint: VisionBreakpoint.sm) { wide in
LazyVGrid(
columns: Array(
repeating: GridItem(.flexible(), spacing: 16, alignment: .topLeading), count: wide ? 4 : 2),
alignment: .leading, spacing: 12
) {
ForEach(model.details(account)) { entry in
VStack(alignment: .leading, spacing: 0) {
Text(strings.t(entry.labelKey))
.font(.footnote)
.foregroundStyle(theme.secondaryLabel)
Text(entry.value)
.monospacedDigit()
.foregroundStyle(theme.foreground)
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
}
}
}
Expand Down
16 changes: 7 additions & 9 deletions apps/native/Sources/VisionNativeUI/AnalysisWorkspaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,18 @@ public struct AnalysisWorkspaceView: View {
private var strings: any Localizing { shell.strings }

public var body: some View {
GeometryReader { geometry in
GeometryReader { _ in
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: VisionPageLayout.spacing) {
VisionPageHeader(
title: strings.t("analysis.title"), subtitle: strings.t("analysis.subtitle"),
symbol: VisionPageSymbol.of("/analysis", fallback: "chart.bar"))
workflow(proxy)
let wide = geometry.size.width >= 1140
let layout =
wide
? AnyLayout(HStackLayout(alignment: .top, spacing: 24))
: AnyLayout(VStackLayout(alignment: .leading, spacing: 24))
layout {
// The web page is one column: the editor and result cards, then
// `<aside className="grid items-start gap-4 lg:grid-cols-2">` with the
// Reusable analysis and Saved analyses cards side by side under them.
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 24) {
if model.dataset?.relation.hasPrefix("service:") == true { financialParameters }
templates(proxy)
Expand All @@ -55,15 +53,15 @@ public struct AnalysisWorkspaceView: View {
}
savedFeedback
}.frame(maxWidth: .infinity, alignment: .leading)
VStack(alignment: .leading, spacing: 24) {
VisionResponsiveColumns(weights: [1, 1], spacing: 16, loneChildSpans: false) {
AnalysisReusableView(model: model, shell: shell)
AnalysisSavedLibraryView(
model: model, shell: shell,
onSelect: {
templatesOpen = false
builderOpen = true
})
}.frame(width: wide ? 360 : nil)
}
}
}.padding(VisionPageLayout.padding).frame(maxWidth: .infinity, alignment: .leading)
}
Expand Down
39 changes: 26 additions & 13 deletions apps/native/Sources/VisionNativeUI/Components/NativeTopbar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ import SwiftUI
import VisionNativeCore

/// AppLayout's topbar over native screens, drawn in the transparent titlebar strip: the
/// `SidebarTrigger` (always, as on the web; right of the traffic lights while the sidebar is
/// hidden), the page header's title once the page has scrolled past 96 pt, and the bar veil
/// page header's title once the page has scrolled past 96 pt, and the bar veil
/// once it has scrolled past 8 pt. The web's search button is `md:hidden` (phones only); the
/// sidebar and its icon rail keep the search entry. It never takes clicks outside its buttons,
/// so the strip still drags the window.
Expand Down Expand Up @@ -34,16 +33,9 @@ struct NativeTopbar: View {

var body: some View {
HStack(spacing: 0) {
// `<SidebarTrigger className="mr-4" />`: an aria-label, no tooltip.
// `variant="ghost" size="icon"` at `h-7 w-7` with the 16 pt PanelLeft glyph.
Button {
model.toggleSidebar()
} label: {
Image(systemName: "sidebar.left").font(.system(size: 14, weight: .regular))
}
.buttonStyle(.vision(.ghost, size: .iconXs))
.accessibilityLabel(model.strings.t("menu.toggleSidebar"))
.padding(.trailing, 16)
// No SidebarTrigger here (Tor, 2026-11-14): beside the sidebar header's own collapse
// button it was a second sidebar icon. The sidebar's collapse button, the icon
// rail's toggle, ⌃⌘S and View › Toggle Sidebar keep every way to open and close it.
if let title {
Text(title)
.font(.visionDisplayHeadline)
Expand Down Expand Up @@ -90,6 +82,11 @@ struct NativeTopbar: View {
.opacity(veiled ? 1 : 0)
.allowsHitTesting(false)
}
// `<BackgroundQueryIndicator />` is the header's last child, `absolute inset-x-0 bottom-0`:
// the 2 pt line runs along the bar's bottom edge, not the window's top edge.
.overlay(alignment: .bottom) {
if let inspector = model.apiInspector { BackgroundQueryIndicator(inspector: inspector) }
}
.animation(reduceMotion ? nil : .easeOut(duration: DesignTokens.Duration.normal), value: scrolled)
// `transition: opacity var(--duration-normal) var(--ease-glide)`.
.animation(
Expand Down Expand Up @@ -142,14 +139,30 @@ struct PageScrollObserver: NSViewRepresentable {
private func clipDidScroll(_ clip: NSClipView) {
guard clip.window === window, let document = clip.documentView,
document.frame.height > clip.bounds.height,
clip.bounds.width >= bounds.width * 0.6
clip.bounds.width >= bounds.width * 0.6, !Self.isNestedInVerticalScroller(clip)
else { return }
let offset =
document.isFlipped
? clip.bounds.minY + clip.contentInsets.top
: document.frame.height - clip.bounds.maxY
onChange?(max(0, offset))
}

/// A table body that scrolls inside the page (`VirtualDataTable`'s `maxHeight`) is as wide
/// as the page but sits inside the page's own scroller; only the outermost vertical
/// scroller moves the topbar.
private static func isNestedInVerticalScroller(_ clip: NSClipView) -> Bool {
var view = clip.superview
while let current = view {
if let outer = current as? NSClipView, let document = outer.documentView,
document.frame.height > outer.bounds.height + 1
{
return true
}
view = current.superview
}
return false
}
}
}

Expand Down
Loading
Loading