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
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,21 @@ on:
pull_request:
paths:
- ".github/workflows/*.yml"
- "Cargo.lock"
- "Cargo.toml"
- "apps/cloud/**"
- "apps/desktop/**"
- "apps/mesh-front-door/**"
- "crates/**"
- "packages/agent-sessions/**"
- "packages/cli/**"
- "packages/protocol/**"
- "packages/runtime/**"
- "packages/session-trace/**"
- "packages/session-trace-react/**"
- "packages/web/**"
- "scripts/cargo.sh"
- "scripts/supervisor-smoke.sh"
- "scripts/**"
- "package.json"
- "bun.lock"
Expand Down Expand Up @@ -69,3 +74,18 @@ jobs:
bun run --cwd packages/web test:happy
bun test packages/session-trace/src/*.test.ts packages/session-trace-react/src/*.test.tsx
fi

rust-supervisor:
name: Rust supervisor
runs-on: macos-latest
timeout-minutes: 10

steps:
- name: Checkout
uses: actions/checkout@v5

- name: Set up Rust
uses: dtolnay/rust-toolchain@stable

- name: Smoke
run: bash scripts/supervisor-smoke.sh
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[workspace]
members = [
"crates/openscout-supervisor",
]
resolver = "2"
4 changes: 3 additions & 1 deletion apps/desktop/src/core/mcp/scout-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ type InboxStreamEvent = {
items?: InboxItem[];
};

const scoutChannelStartedAt = Date.now();

async function resolveAgentId(
currentDirectory: string,
env: NodeJS.ProcessEnv,
Expand Down Expand Up @@ -231,7 +233,7 @@ function buildScoutChannelEndpoint(input: {
metadata: {
source: "scout-channel",
processId: process.pid,
startedAt: now,
startedAt: scoutChannelStartedAt,
lastSeenAt: now,
},
};
Expand Down
80 changes: 59 additions & 21 deletions apps/ios/ScoutNext/HomeSurface.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import ScoutIOSCore
/// then drill to its agents. The flat agent list still lives on the Agents tab.
struct HomeSurface: View {
let model: AppModel
@Environment(\.scoutNextLayout) private var layout
/// Opens the connection detail for a tapped machine — switching / probing
/// lives there, not on the rail itself.
var onSelectMachine: (AppModel.PairedMachine) -> Void = { _ in }
Expand All @@ -41,7 +42,7 @@ struct HomeSurface: View {

var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: HudSpacing.xxl) {
VStack(alignment: .leading, spacing: layout.surfaceSectionSpacing) {
if isLoading {
HudEmptyState(title: "Loading fleet", subtitle: "Reading agents from the broker.", icon: "antenna.radiowaves.left.and.right")
} else if isFleetEmpty {
Expand All @@ -54,8 +55,9 @@ struct HomeSurface: View {
if !recentActivity.isEmpty { activitySection }
}
}
.padding(.horizontal, HudSpacing.xxl)
.padding(.vertical, HudSpacing.xxl)
.padding(.horizontal, layout.surfacePadding)
.padding(.top, layout.surfaceTopPadding)
.padding(.bottom, layout.surfaceBottomPadding)
}
.refreshable { await load() }
.task(id: reloadToken) { await load() }
Expand Down Expand Up @@ -552,29 +554,35 @@ private struct ProjectRow: View {
let group: ProjectGroup
let isExpanded: Bool
let onToggle: () -> Void
@Environment(\.scoutNextLayout) private var layout

var body: some View {
Button(action: onToggle) {
HStack(spacing: HudSpacing.md) {
HStack(alignment: layout.isMiniPhone ? .top : .center, spacing: HudSpacing.md) {
Glyphic.chevron(isExpanded ? .bottom : .trailing, size: 13)
.foregroundStyle(HudPalette.muted)
.frame(width: 12)
Text(group.name)
.font(HudFont.ui(HudTextSize.md, weight: .semibold))
.foregroundStyle(HudPalette.ink)
.lineLimit(1)
if group.liveCount > 0 {
HudStatusDot(color: HudPalette.accent, size: 6, pulses: true)
}
Spacer(minLength: HudSpacing.md)
Text("\(group.agents.count) agents")
.font(HudFont.mono(HudTextSize.xs))
.foregroundStyle(HudPalette.muted)
if let age = relativeAgeString(group.lastActiveAt) {
Text(age)
.font(HudFont.mono(HudTextSize.xs))
.foregroundStyle(HudPalette.muted)
.monospacedDigit()
.padding(.top, layout.isMiniPhone ? 2 : 0)
if layout.isMiniPhone {
VStack(alignment: .leading, spacing: HudSpacing.xxs) {
HStack(spacing: HudSpacing.sm) {
projectName
if group.liveCount > 0 { liveDot }
}
HStack(spacing: HudSpacing.sm) {
agentCount
if let age = lastActiveAge { ageText(age) }
}
}
.frame(maxWidth: .infinity, alignment: .leading)
} else {
projectName
if group.liveCount > 0 { liveDot }
Spacer(minLength: HudSpacing.md)
agentCount
if let age = lastActiveAge {
ageText(age)
}
}
}
.padding(.horizontal, HudSpacing.xl)
Expand All @@ -583,6 +591,35 @@ private struct ProjectRow: View {
}
.buttonStyle(.plain)
}

private var projectName: some View {
Text(group.name)
.font(HudFont.ui(HudTextSize.md, weight: .semibold))
.foregroundStyle(HudPalette.ink)
.lineLimit(1)
.truncationMode(.tail)
}

private var liveDot: some View {
HudStatusDot(color: HudPalette.accent, size: 6, pulses: true)
}

private var agentCount: some View {
Text("\(group.agents.count) agents")
.font(HudFont.mono(HudTextSize.xs))
.foregroundStyle(HudPalette.muted)
}

private var lastActiveAge: String? {
relativeAgeString(group.lastActiveAt)
}

private func ageText(_ age: String) -> some View {
Text(age)
.font(HudFont.mono(HudTextSize.xs))
.foregroundStyle(HudPalette.muted)
.monospacedDigit()
}
}

// MARK: - AgentFleetRow
Expand All @@ -600,6 +637,7 @@ private struct AgentFleetRow: View {
/// its name aligns with the expandable projects around it.
var leadingLeaf: Bool = false
let onTap: (() -> Void)?
@Environment(\.scoutNextLayout) private var layout

var body: some View {
Button(action: { onTap?() }) {
Expand Down Expand Up @@ -642,7 +680,7 @@ private struct AgentFleetRow: View {
.foregroundStyle(HudPalette.ink)
.lineLimit(1)
.layoutPriority(1)
if let locator = locator, !leadingLeaf {
if let locator = locator, !leadingLeaf, !layout.isMiniPhone {
Text(locator)
.font(HudFont.mono(HudTextSize.xs))
// Subordinate to the name: the mono locator was reading at full
Expand Down
102 changes: 79 additions & 23 deletions apps/ios/ScoutNext/ResponsiveFrame.swift
Original file line number Diff line number Diff line change
@@ -1,52 +1,108 @@
import SwiftUI
import HudsonUI

/// ScoutNext is authored against a single reference width — the standard iPhone
/// (393pt portrait). `DesignFrame` is the responsive envelope that honors that
/// contract so every surface can be tuned once, for the optimized (larger)
/// canvas, and still render correctly on the small one.
/// ScoutNext layout metrics derived from the real phone width. The app keeps
/// standard iPhone as the roomy baseline, but the 13 mini gets native text/hit
/// sizes with tighter chrome instead of a blanket downscale.
struct ScoutNextLayoutMetrics: Equatable {
let physicalWidth: CGFloat
let designWidth: CGFloat
let scale: CGFloat

var isMiniPhone: Bool { physicalWidth > 0 && physicalWidth <= 380 }
var isNarrowPhone: Bool { physicalWidth > 0 && physicalWidth < 390 }

var titleHorizontalPadding: CGFloat { isNarrowPhone ? HudSpacing.xl : HudSpacing.xxl }
var titleTopPadding: CGFloat { isNarrowPhone ? HudSpacing.md : HudSpacing.lg }
var titleBottomPadding: CGFloat { isNarrowPhone ? HudSpacing.md : HudSpacing.xl }
var wordmarkSize: CGFloat { isNarrowPhone ? HudTextSize.xl : HudTextSize.xxl }
var nextBadgeSize: CGFloat { isNarrowPhone ? HudTextSize.xxs : HudTextSize.xs }
var nextBadgeTracking: CGFloat { isNarrowPhone ? 1.5 : 2 }

var surfacePadding: CGFloat { isNarrowPhone ? HudSpacing.xl : HudSpacing.xxl }
var surfaceTopPadding: CGFloat { isNarrowPhone ? HudSpacing.lg : HudSpacing.xxl }
var surfaceBottomPadding: CGFloat { isNarrowPhone ? HudSpacing.xl : HudSpacing.xxl }
var surfaceSectionSpacing: CGFloat { isNarrowPhone ? HudSpacing.xl : HudSpacing.xxl }

var tabBarTopPadding: CGFloat { isNarrowPhone ? HudSpacing.xs : HudSpacing.sm }
var tabBarHorizontalPadding: CGFloat { isNarrowPhone ? HudSpacing.md : HudSpacing.lg }
var tabButtonHeight: CGFloat { isNarrowPhone ? 44 : 48 }
var tabGlyphSize: CGFloat { isNarrowPhone ? 19 : 21 }
var tabLabelSize: CGFloat { isNarrowPhone ? HudTextSize.micro : HudTextSize.xxs }

var statusSideInset: CGFloat { isNarrowPhone ? HudSpacing.xxxl : 42 }
var statusCenterGap: CGFloat { isNarrowPhone ? HudSpacing.md : HudSpacing.lg }
var statusMachineMaxLabelWidth: CGFloat { isNarrowPhone ? 72 : 120 }
}

private struct ScoutNextLayoutMetricsKey: EnvironmentKey {
static let defaultValue = ScoutNextLayoutMetrics(physicalWidth: 393, designWidth: 393, scale: 1)
}

extension EnvironmentValues {
var scoutNextLayout: ScoutNextLayoutMetrics {
get { self[ScoutNextLayoutMetricsKey.self] }
set { self[ScoutNextLayoutMetricsKey.self] = newValue }
}
}

/// ScoutNext is authored against the standard iPhone width (393pt portrait), but
/// compact phones are real layout targets rather than compatibility-scaled
/// previews. `DesignFrame` publishes responsive metrics so app chrome can tighten
/// on the 13 mini while keeping native text size and 44pt tap targets.
///
/// - **Larger screens (≥ reference).** The optimized native target. No scaling:
/// the layout fills the available width fluidly at 1.0×. Standard, Plus, Pro,
/// and Pro Max all land here.
/// - **The 13 mini (375pt) — and any narrower device.** Graceful degradation:
/// the whole UI is laid out at the 393pt reference and uniformly scaled down
/// to fit (≈0.95×). Proportions stay pixel-identical — nothing is re-tuned
/// per device. Because the mini shares the standard aspect ratio almost
/// exactly (375×812 ≈ 2.165 vs 393×852 ≈ 2.168), the single width-ratio
/// scale fits both dimensions, so the bottom-docked chrome stays flush.
/// - **The 13 mini (375pt).** Native rendering with compact chrome metrics:
/// slightly tighter padding, shorter tab bar, and narrower status readouts.
/// - **Anything narrower than the mini.** Graceful degradation: lay out at the
/// mini width and uniformly shrink from there.
///
/// Implementation: lay the content out at the design width and a height that,
/// once scaled, exactly fills the available height (no letterbox); apply the
/// uniform `scaleEffect`; then claim the real available footprint so siblings
/// (the full-bleed canvas) cover the physical edges.
/// Implementation: lay the content out at either the real width or the mini
/// minimum, publish metrics, then apply shrink-only scaling for ultra-narrow
/// widths and claim the real footprint so the full-bleed canvas covers the edges.
struct DesignFrame<Content: View>: View {
/// Width every surface is designed against — standard iPhone portrait.
/// Devices at or above this render natively; narrower ones scale down.
/// Devices at or above this render with roomy metrics.
var referenceWidth: CGFloat = 393
/// Smallest native phone target. The 13 mini is 375pt wide.
var nativeMinimumWidth: CGFloat = 375

@ViewBuilder var content: () -> Content
private let content: (ScoutNextLayoutMetrics) -> Content

init(@ViewBuilder content: @escaping () -> Content) {
self.content = { _ in content() }
}

init(@ViewBuilder content: @escaping (ScoutNextLayoutMetrics) -> Content) {
self.content = content
}

var body: some View {
GeometryReader { proxy in
let avail = proxy.size
let scale = scale(forWidth: avail.width)
// At ≥ reference we lay out at the device's own width (fluid fill);
// below it we lay out at the fixed reference and shrink to fit.
let designWidth = scale < 1 ? referenceWidth : avail.width
// between mini and reference we lay out natively with compact metrics;
// below mini we lay out at the mini width and shrink to fit.
let designWidth = scale < 1 ? nativeMinimumWidth : avail.width
let designHeight = scale > 0 ? avail.height / scale : avail.height
let metrics = ScoutNextLayoutMetrics(physicalWidth: avail.width, designWidth: designWidth, scale: scale)

content()
content(metrics)
.environment(\.scoutNextLayout, metrics)
.frame(width: designWidth, height: designHeight, alignment: .top)
.scaleEffect(scale, anchor: .top)
.frame(width: avail.width, height: avail.height, alignment: .top)
}
}

/// Shrink-only: `1.0` for the optimized large canvas, the width ratio below
/// the reference. Floored so a hypothetical ultra-narrow device can't shrink
/// the UI into illegibility.
/// Shrink-only: `1.0` for mini and larger, the width ratio below the mini.
/// Floored so a hypothetical ultra-narrow device can't shrink the UI into
/// illegibility.
private func scale(forWidth width: CGFloat) -> CGFloat {
guard width > 0 else { return 1 }
return max(0.8, min(1, width / referenceWidth))
return max(0.84, min(1, width / nativeMinimumWidth))
}
}
Loading