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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ A server is reached over SSH, a monitor agent, both, or is this device (`Spi.loc
### Tabs

- **A tab's single column is the subject**; the list moves behind a bar button (`view/page/benchmark/tab.dart`).
- Bar: `SessionSwitcherLabel` left, `Btn.icon` at **18pt** right, labels as in the terminal tab.
- **A narrow (one-column) tab bar is a `SwitcherBar`** (fl_lib): a `SessionSwitcherLabel` left, `BarAction`s right (folded into ⋮ when short; `BarAction.listen` for live state; `.button()` for a wide pane's own header). It keeps clear of the iPadOS window controls; a hand-built top bar must wrap itself in `WindowControlsInset`. Wide two-pane layouts keep their own headers.
- `detailId` is null at the root; give `leading` where the back button has nowhere to go; no `ref.watch`/`ref.listen` in `detailBuilder`; the subject is a widget, not a route.
- `AppTab` is positional; index 7 stays in `_retiredIndices`.

Expand Down
4 changes: 4 additions & 0 deletions ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
4A2DCD6B2E4B127100CF68B7 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */; };
4A2DCD712E4B127100CF68B7 /* DynamicIslandBrand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD722E4B127100CF68B7 /* DynamicIslandBrand.swift */; };
4A2DCD732E4B127100CF68B7 /* PrivacyBlur.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD742E4B127100CF68B7 /* PrivacyBlur.swift */; };
4A2DCD752E4B127100CF68B7 /* WindowControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD762E4B127100CF68B7 /* WindowControls.swift */; };
4A2DCD802E4B127100CF68B7 /* CrashDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCD812E4B127100CF68B7 /* CrashDiagnostics.swift */; };
4A2DCDB22E4B127100CF68B7 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCDB32E4B127100CF68B7 /* SceneDelegate.swift */; };
4A2DCDB02E4B127100CF68B7 /* IncomingShare.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2DCDB12E4B127100CF68B7 /* IncomingShare.swift */; };
Expand Down Expand Up @@ -135,6 +136,7 @@
4A2DCD692E4B127100CF68B7 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; };
4A2DCD722E4B127100CF68B7 /* DynamicIslandBrand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicIslandBrand.swift; sourceTree = "<group>"; };
4A2DCD742E4B127100CF68B7 /* PrivacyBlur.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyBlur.swift; sourceTree = "<group>"; };
4A2DCD762E4B127100CF68B7 /* WindowControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowControls.swift; sourceTree = "<group>"; };
4A2DCD812E4B127100CF68B7 /* CrashDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashDiagnostics.swift; sourceTree = "<group>"; };
4A2DCDB32E4B127100CF68B7 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
4A2DCDB12E4B127100CF68B7 /* IncomingShare.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingShare.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -323,6 +325,7 @@
4A2DCD6A2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift */,
4A2DCD722E4B127100CF68B7 /* DynamicIslandBrand.swift */,
4A2DCD742E4B127100CF68B7 /* PrivacyBlur.swift */,
4A2DCD762E4B127100CF68B7 /* WindowControls.swift */,
4A2DCD812E4B127100CF68B7 /* CrashDiagnostics.swift */,
4A2DCDB32E4B127100CF68B7 /* SceneDelegate.swift */,
4A2DCDB12E4B127100CF68B7 /* IncomingShare.swift */,
Expand Down Expand Up @@ -652,6 +655,7 @@
4A2DCD6C2E4B127100CF68B7 /* TerminalLiveActivityAttributes.swift in Sources */,
4A2DCD712E4B127100CF68B7 /* DynamicIslandBrand.swift in Sources */,
4A2DCD732E4B127100CF68B7 /* PrivacyBlur.swift in Sources */,
4A2DCD752E4B127100CF68B7 /* WindowControls.swift in Sources */,
4A2DCD802E4B127100CF68B7 /* CrashDiagnostics.swift in Sources */,
4A2DCDB22E4B127100CF68B7 /* SceneDelegate.swift in Sources */,
4A2DCDB02E4B127100CF68B7 /* IncomingShare.swift in Sources */,
Expand Down
16 changes: 16 additions & 0 deletions ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import UserNotifications
) { note in
guard let scene = note.object as? UIWindowScene else { return }
DynamicIslandBrand.shared.install(in: scene)
WindowControls.shared.install(in: scene)
PrivacyBlur.shared.hideIfUnlocked()
}

Expand Down Expand Up @@ -86,6 +87,21 @@ import UserNotifications
}
}

// Pushed on change, and readable once: the first layout can report
// before Dart has set its handler, and a push nobody hears is lost.
let windowControlsChannel = FlutterMethodChannel(name: "tech.lolli.toolbox/window_controls", binaryMessenger: binaryMessenger)
WindowControls.shared.onChange = { zones in
windowControlsChannel.invokeMethod("changed", arguments: zones)
}
windowControlsChannel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
switch call.method {
case "zones":
result(WindowControls.shared.zones)
default:
result(FlutterMethodNotImplemented)
}
})

let homeWidgetChannel = FlutterMethodChannel(name: "tech.lolli.toolbox/home_widget", binaryMessenger: binaryMessenger)
homeWidgetChannel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
switch call.method {
Expand Down
168 changes: 168 additions & 0 deletions ios/Runner/WindowControls.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
//
// WindowControls.swift
// Runner
//

import Flutter
import UIKit

/// Reports to Dart where the system's window controls cover the app.
///
/// A windowed scene on iPadOS 26 draws its close, minimise and tile buttons
/// over the top corner of the app's own content. UIKit's bars step around
/// them through the corner-adapted layout regions; `safeAreaInsets`, the one
/// number the Flutter engine hands on as `MediaQuery.padding`, leaves them
/// out, so a Flutter app bar put its back button under them.
///
/// The covered area is the corner where an inset adapted on one axis grows
/// past the plain safe area: the horizontal adaptation gives its width, the
/// vertical one its height. Sent as a list of rects in the Flutter view's
/// coordinates, which are its points — empty when nothing is covered, as on
/// every iPhone and on a full-screen iPad scene.
final class WindowControls {
static let shared = WindowControls()

/// `[left, top, right, bottom]` per covered corner.
private(set) var zones: [[Double]] = []

/// Called on the main thread whenever [zones] changes.
var onChange: (([[Double]]) -> Void)?

/// `UIView` and not the probe type, which a stored property of a class
/// that is not itself iOS 26-only cannot name.
private weak var probes: UIView?

private init() {}

/// Idempotent per view: a scene reconnecting gets a new Flutter view, and
/// the probes have to be measured against whichever one is live.
func install(in scene: UIWindowScene) {
guard #available(iOS 26.0, *) else { return }
guard let view = scene.windows
.lazy
.compactMap({ $0.rootViewController as? FlutterViewController })
.first?.view
else { return }
if let probes, probes.superview === view { return }
probes?.removeFromSuperview()

let container = ProbeContainer { [weak self] zones in
self?.update(zones)
}
view.addSubview(container)
container.pin(to: view)
probes = container
}

private func update(_ zones: [[Double]]) {
guard zones != self.zones else { return }
self.zones = zones
onChange?(zones)
}
}

/// Invisible views pinned to the corner-adapted safe areas.
///
/// Measuring from layout instead of asking once: the controls come and go as
/// the scene moves between full screen and a window, and the regions follow
/// them. A probe pinned to a region is resized whenever the region changes,
/// and a resize is a `layoutSubviews` — the one notification UIKit gives.
@available(iOS 26.0, *)
private final class ProbeContainer: UIView {
private let report: ([[Double]]) -> Void
private let horizontal = Probe()
private let vertical = Probe()

init(report: @escaping ([[Double]]) -> Void) {
self.report = report
super.init(frame: .zero)
isUserInteractionEnabled = false
isHidden = true
translatesAutoresizingMaskIntoConstraints = false
for probe in [horizontal, vertical] {
probe.onLayout = { [weak self] in self?.measure() }
probe.translatesAutoresizingMaskIntoConstraints = false
}
}

@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }

/// Spans the whole view; the probes inside follow its regions.
func pin(to view: UIView) {
NSLayoutConstraint.activate([
leadingAnchor.constraint(equalTo: view.leadingAnchor),
trailingAnchor.constraint(equalTo: view.trailingAnchor),
topAnchor.constraint(equalTo: view.topAnchor),
bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
for (probe, axis) in [
(horizontal, UIView.LayoutRegion.AdaptivityAxis.horizontal),
(vertical, .vertical),
] {
view.addSubview(probe)
let guide = view.layoutGuide(for: .safeArea(cornerAdaptation: axis))
NSLayoutConstraint.activate([
probe.leadingAnchor.constraint(equalTo: guide.leadingAnchor),
probe.trailingAnchor.constraint(equalTo: guide.trailingAnchor),
probe.topAnchor.constraint(equalTo: guide.topAnchor),
probe.bottomAnchor.constraint(equalTo: guide.bottomAnchor),
])
}
}

override func removeFromSuperview() {
horizontal.removeFromSuperview()
vertical.removeFromSuperview()
super.removeFromSuperview()
}

override func layoutSubviews() {
super.layoutSubviews()
measure()
}

private func measure() {
guard let view = superview else { return }
let safe = view.safeAreaInsets
let wide = view.edgeInsets(for: .safeArea(cornerAdaptation: .horizontal))
let tall = view.edgeInsets(for: .safeArea(cornerAdaptation: .vertical))
let width = Double(view.bounds.width)
let bottom = Double(max(tall.top, safe.top))

var zones: [[Double]] = []
// A window as large as the screen still rounds its corners, and the
// horizontal region steps around that too — but within the status
// bar, which the content is already below.
guard bottom > Double(safe.top) else {
report(zones)
return
}
if wide.left > safe.left {
zones.append([0, 0, Double(wide.left), bottom])
}
if wide.right > safe.right {
zones.append([width - Double(wide.right), 0, width, bottom])
}
report(zones)
}
}

@available(iOS 26.0, *)
private final class Probe: UIView {
var onLayout: (() -> Void)?

override init(frame: CGRect) {
super.init(frame: frame)
isUserInteractionEnabled = false
isHidden = true
}

@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }

override func layoutSubviews() {
super.layoutSubviews()
onLayout?()
}
}
35 changes: 35 additions & 0 deletions lib/core/chan.dart
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,41 @@ abstract final class MethodChans {
});
}

static const _windowControlsChannel = MethodChannel(
'${Miscs.pkgName}/window_controls',
);

/// Keeps [WindowControls.zones] at what the platform reports — iPadOS only,
/// where a windowed app has its window controls drawn over it.
///
/// Read once as well as listened to: the first report can come before this
/// handler is set.
static void watchWindowControls() {
if (!isIOS) return;
List<Rect> parse(Object? raw) => [
for (final z in (raw as List?) ?? const [])
if (z case [final num l, final num t, final num r, final num b])
Rect.fromLTRB(l.toDouble(), t.toDouble(), r.toDouble(), b.toDouble()),
];
// The reply to the read below can arrive after a push sent later, and
// would put back what was there before it.
var pushed = false;
_windowControlsChannel.setMethodCallHandler((call) async {
if (call.method == 'changed') {
pushed = true;
WindowControls.zones.value = parse(call.arguments);
}
});
_windowControlsChannel
.invokeMethod<List<Object?>>('zones')
.then((raw) {
if (!pushed) WindowControls.zones.value = parse(raw);
})
.catchError((Object e) {
Loggers.app.warning('windowControls', e);
});
}

/// Register a handler for native -> Flutter callbacks.
/// Currently handles:
/// - `disconnectSession` with argument map {id: string}
Expand Down
4 changes: 4 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@ Future<void> _initApp(ProviderContainer container) async {
// framework's animations — see [AppBinding].
AppBinding();

// As early as there is a binding: until the first report arrives, a bar is
// laid out as if nothing covered it.
MethodChans.watchWindowControls();

// Before anything that can fail, so that a failure during startup is at
// least recorded — the errors worth catching most are the ones that stop the
// app from reaching a screen anyone could copy a log from.
Expand Down
43 changes: 17 additions & 26 deletions lib/view/page/agent/agent.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,22 @@ class _AgentPageState extends ConsumerState<AgentPage>
@override
bool get wantKeepAlive => true;

/// Sends this conversation floating, so it stays reachable from the other
/// tabs, or stops it. On by default, but the window only comes along once
/// there is a chat to follow — see `AgentChats.engaged`.
BarAction _floatAction() {
final floating = ref.watch(agentShellProvider) != FloatShellMode.hidden;
return BarAction(
icon: floating
? Icons.picture_in_picture_alt
: Icons.picture_in_picture_alt_outlined,
label: context.l10n.floatOverTabs,
// On in the accent, as the server tab's globe toggle is.
color: floating ? Theme.of(context).colorScheme.primary : null,
onTap: ref.read(agentShellProvider.notifier).toggle,
);
}

@override
Widget build(BuildContext context) {
super.build(context);
Expand All @@ -53,32 +69,7 @@ class _AgentPageState extends ConsumerState<AgentPage>
sideBuilder: (_) => const AgentHistoryPanel(inSheet: false),
builder: (_, split) => AgentConversationView(
compact: !split,
headerTrailing: const _FloatToggle(),
),
);
}
}

/// Sends this conversation floating, so it stays reachable from the other
/// tabs, or stops it. On by default, but the window only comes along once
/// there is a chat to follow — see `AgentChats.engaged`.
class _FloatToggle extends ConsumerWidget {
const _FloatToggle();

@override
Widget build(BuildContext context, WidgetRef ref) {
final floating = ref.watch(agentShellProvider) != FloatShellMode.hidden;
// The tab's bar, so the 18pt `Btn.icon` the other tabs' bars use — on in
// the accent, as the server tab's globe toggle is.
return Btn.icon(
text: context.l10n.floatOverTabs,
onTap: ref.read(agentShellProvider.notifier).toggle,
icon: Icon(
floating
? Icons.picture_in_picture_alt
: Icons.picture_in_picture_alt_outlined,
size: 18,
color: floating ? Theme.of(context).colorScheme.primary : null,
headerAction: _floatAction(),
),
);
}
Expand Down
24 changes: 12 additions & 12 deletions lib/view/page/agent/history.dart
Original file line number Diff line number Diff line change
Expand Up @@ -206,30 +206,30 @@ class _AgentHistoryPanelState extends State<AgentHistoryPanel> {
search: _search,
actions: [
if (chats.isNotEmpty)
Btn.icon(
text: libL10n.clearHistory,
icon: const Icon(Icons.delete_sweep_outlined, size: 18),
BarAction(
icon: Icons.delete_sweep_outlined,
label: libL10n.clearHistory,
onTap: busy ? null : _clear,
),
Btn.icon(
text: libL10n.search,
icon: const Icon(Icons.search, size: 18),
BarAction(
icon: Icons.search,
label: libL10n.search,
onTap: _search.start,
),
Btn.icon(
text: context.l10n.askAiNewConversation,
icon: const Icon(Icons.add, size: 18),
BarAction(
icon: Icons.add,
label: context.l10n.askAiNewConversation,
onTap: () {
AgentChats.startNew(_scope);
_closeIfSheet();
},
),
// Over the sheet rather than in place of it: back returns
// to the list it was opened from.
Btn.icon(
BarAction(
key: const ValueKey('agent-settings'),
text: libL10n.setting,
icon: const Icon(Icons.settings_outlined, size: 18),
icon: Icons.settings_outlined,
label: libL10n.setting,
onTap: () => SettingsSectionPage.route.go(
context,
SettingsSection.ai,
Expand Down
Loading
Loading