Merge claude/elastic-haibt-f29115 into dev (iPad remote port: adaptive sidebar+detail shell, Mac-style two-pane diff, structured commit/command transcript cards, keyboard/pointer affordances, camera-free manual pairing)
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
import NucleicProtocol
|
import NucleicProtocol
|
||||||
|
#if canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
/// What the phone pins about its Mac at pairing (SYNC §4.2): the host's static key (for IK
|
/// What the phone pins about its Mac at pairing (SYNC §4.2): the host's static key (for IK
|
||||||
/// reconnect), a display name, and the transport + connection hint from the QR — LAN
|
/// reconnect), a display name, and the transport + connection hint from the QR — LAN
|
||||||
@@ -42,11 +45,22 @@ enum IdentityStore {
|
|||||||
static func deviceID() -> String {
|
static func deviceID() -> String {
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
if let existing = defaults.string(forKey: deviceIDKey) { return existing }
|
if let existing = defaults.string(forKey: deviceIDKey) { return existing }
|
||||||
let id = "iphone-" + UUID().uuidString.prefix(8).lowercased()
|
let id = deviceIDPrefix + UUID().uuidString.prefix(8).lowercased()
|
||||||
defaults.set(id, forKey: deviceIDKey)
|
defaults.set(id, forKey: deviceIDKey)
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Idiom-tagged prefix so the host lists a paired device with the right kind/icon
|
||||||
|
/// (`ipad-…` vs `iphone-…`). Only stamps *freshly generated* ids — an existing install
|
||||||
|
/// keeps whatever id it already persisted, so upgrading a phone never changes its identity.
|
||||||
|
private static var deviceIDPrefix: String {
|
||||||
|
#if canImport(UIKit)
|
||||||
|
return UIDevice.current.userInterfaceIdiom == .pad ? "ipad-" : "iphone-"
|
||||||
|
#else
|
||||||
|
return "iphone-"
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
static func loadPairedHost() -> PairedHost? {
|
static func loadPairedHost() -> PairedHost? {
|
||||||
guard let data = UserDefaults.standard.data(forKey: pairedHostKey) else { return nil }
|
guard let data = UserDefaults.standard.data(forKey: pairedHostKey) else { return nil }
|
||||||
return try? JSONDecoder().decode(PairedHost.self, from: data)
|
return try? JSONDecoder().decode(PairedHost.self, from: data)
|
||||||
|
|||||||
@@ -295,6 +295,17 @@ final class RemoteStore: ObservableObject {
|
|||||||
+ if (!token) throw new AuthError("missing bearer token")
|
+ if (!token) throw new AuthError("missing bearer token")
|
||||||
return verify(token)
|
return verify(token)
|
||||||
}
|
}
|
||||||
|
diff --git a/auth/session.ts b/auth/session.ts
|
||||||
|
new file mode 100644
|
||||||
|
--- /dev/null
|
||||||
|
+++ b/auth/session.ts
|
||||||
|
@@ -0,0 +1,6 @@
|
||||||
|
+export interface Session {
|
||||||
|
+ userId: string
|
||||||
|
+ issuedAt: number
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+export const SESSION_TTL = 3600
|
||||||
""")
|
""")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,6 +629,12 @@ final class RemoteStore: ObservableObject {
|
|||||||
AgentEvent(sessionID: sessionID, seq: seq, at: Date(), backend: .claudeCode,
|
AgentEvent(sessionID: sessionID, seq: seq, at: Date(), backend: .claudeCode,
|
||||||
nativeType: nil, kind: kind)
|
nativeType: nil, kind: kind)
|
||||||
}
|
}
|
||||||
|
// A realistic `git commit` (heredoc message) so the transcript's structured commit card
|
||||||
|
// is exercisable offline: expand the Bash call to see the subject + Markdown body.
|
||||||
|
let demoCommitCommand = "git commit -F - <<'EOF'\nfix: harden auth middleware\n\nRequire a Bearer token and reject a missing or blank one.\n\n- extract `requireSession`\n- add a `Bearer` prefix check\nEOF"
|
||||||
|
// A multi-step, destructive shell pipeline so the transcript's step list (with the delete
|
||||||
|
// flagged in red) is exercisable offline: expand the Bash call to see the breakdown.
|
||||||
|
let demoCleanupCommand = "cd ~/code/nucleic && rm -rf .worktrees/auth-old && git worktree prune && git branch -D nucleic/auth-old"
|
||||||
openEvents = [
|
openEvents = [
|
||||||
event(1, .sessionStarted(SessionStarted(
|
event(1, .sessionStarted(SessionStarted(
|
||||||
backendSessionID: "demo", model: "claude-opus-4-8[1m]", cwd: "~/code/nucleic", toolNames: []))),
|
backendSessionID: "demo", model: "claude-opus-4-8[1m]", cwd: "~/code/nucleic", toolNames: []))),
|
||||||
@@ -633,8 +650,14 @@ final class RemoteStore: ObservableObject {
|
|||||||
event(11, .toolCallStarted(ToolCall(toolCallID: "t3", name: "Task", input: ["description": "Audit other call sites", "prompt": "Find every caller of the old auth API."]))),
|
event(11, .toolCallStarted(ToolCall(toolCallID: "t3", name: "Task", input: ["description": "Audit other call sites", "prompt": "Find every caller of the old auth API."]))),
|
||||||
event(12, .toolCallCompleted(ToolCall(toolCallID: "t3", name: "Task", input: ["description": "Audit other call sites"]))),
|
event(12, .toolCallCompleted(ToolCall(toolCallID: "t3", name: "Task", input: ["description": "Audit other call sites"]))),
|
||||||
event(13, .toolResult(ToolResult(toolCallID: "t3", content: "Checked 7 files; 1 stale caller updated.", isError: false))),
|
event(13, .toolResult(ToolResult(toolCallID: "t3", content: "Checked 7 files; 1 stale caller updated.", isError: false))),
|
||||||
event(14, .usage(Usage(inputTokens: 84_300, outputTokens: 2_140, costUSD: 0.0421, contextInputTokens: 84_300))),
|
event(14, .toolCallStarted(ToolCall(toolCallID: "t4", name: "Bash", input: ["command": .string(demoCommitCommand)]))),
|
||||||
event(15, .runFinished(RunFinished(outcome: .completed, finalText: "Done."))),
|
event(15, .toolCallCompleted(ToolCall(toolCallID: "t4", name: "Bash", input: ["command": .string(demoCommitCommand)]))),
|
||||||
|
event(16, .toolResult(ToolResult(toolCallID: "t4", content: "[nucleic/auth-refactor 1a2b3c4] fix: harden auth middleware\n 2 files changed, 312 insertions(+), 40 deletions(-)", isError: false))),
|
||||||
|
event(17, .toolCallStarted(ToolCall(toolCallID: "t5", name: "Bash", input: ["command": .string(demoCleanupCommand)]))),
|
||||||
|
event(18, .toolCallCompleted(ToolCall(toolCallID: "t5", name: "Bash", input: ["command": .string(demoCleanupCommand)]))),
|
||||||
|
event(19, .toolResult(ToolResult(toolCallID: "t5", content: "Removed 1 worktree; deleted branch nucleic/auth-old.", isError: false))),
|
||||||
|
event(20, .usage(Usage(inputTokens: 84_300, outputTokens: 2_140, costUSD: 0.0421, contextInputTokens: 84_300))),
|
||||||
|
event(21, .runFinished(RunFinished(outcome: .completed, finalText: "Done."))),
|
||||||
]
|
]
|
||||||
// If this session is blocked on a human, surface a real approval card so the
|
// If this session is blocked on a human, surface a real approval card so the
|
||||||
// Allow/Deny loop is exercisable in the demo (the seeded `a1` session).
|
// Allow/Deny loop is exercisable in the demo (the seeded `a1` session).
|
||||||
@@ -653,11 +676,17 @@ final class RemoteStore: ObservableObject {
|
|||||||
ApprovalID(rawValue: "demo-appr-\(sessionID.rawValue)")
|
ApprovalID(rawValue: "demo-appr-\(sessionID.rawValue)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func closeOpen() {
|
/// Close a session's live subscription. `id` names *which* session is closing — the detail
|
||||||
if let id = openSessionID {
|
/// view passes its own. On iPad's split view, switching session A→B can mount B (which calls
|
||||||
send(.unsubscribe(id))
|
/// `open(B)`, setting `openSessionID = B`) *before* A's detail disappears; so we always
|
||||||
markOpened(id) // everything up to now has been seen
|
/// unsubscribe the named session but only tear down the shared open-state when it still
|
||||||
}
|
/// belongs to that session — otherwise we'd wipe B's freshly-loaded transcript. Called with
|
||||||
|
/// no argument it closes whatever is currently open (the iPhone push/pop path, unchanged).
|
||||||
|
func closeOpen(_ id: SessionID? = nil) {
|
||||||
|
guard let target = id ?? openSessionID else { return }
|
||||||
|
send(.unsubscribe(target))
|
||||||
|
markOpened(target) // everything up to now has been seen
|
||||||
|
guard openSessionID == target else { return }
|
||||||
openSessionID = nil
|
openSessionID = nil
|
||||||
openEvents = []
|
openEvents = []
|
||||||
openApprovals = []
|
openApprovals = []
|
||||||
|
|||||||
@@ -36,8 +36,6 @@ struct NucleicRemoteApp: App {
|
|||||||
|
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
@EnvironmentObject var store: RemoteStore
|
@EnvironmentObject var store: RemoteStore
|
||||||
// Initial tab; overridable via NUCLEIC_TAB for offline UI previews.
|
|
||||||
@State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_TAB"] ?? "") ?? 0
|
|
||||||
// Appearance preferences — same storage keys and semantics as the Mac's Settings →
|
// Appearance preferences — same storage keys and semantics as the Mac's Settings →
|
||||||
// Appearance, so both devices honor the same choices.
|
// Appearance, so both devices honor the same choices.
|
||||||
@AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.system.rawValue
|
@AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.system.rawValue
|
||||||
@@ -52,30 +50,15 @@ struct RootView: View {
|
|||||||
DemoBanner()
|
DemoBanner()
|
||||||
Group {
|
Group {
|
||||||
if store.isPaired {
|
if store.isPaired {
|
||||||
TabView(selection: $tab) {
|
// Width-adaptive shell: the iPhone's TabView on compact width, the Mac's
|
||||||
HomeView()
|
// sidebar+detail split on a regular-width iPad (see AdaptiveRootView).
|
||||||
.tabItem { Label("Home", systemImage: "house") }.tag(0)
|
AdaptiveRootView()
|
||||||
SessionsView()
|
|
||||||
.tabItem { Label("Sessions", systemImage: "square.stack.3d.up") }
|
|
||||||
.badge(store.needsYouCount).tag(1)
|
|
||||||
ProjectsView()
|
|
||||||
.tabItem { Label("Projects", systemImage: "folder") }.tag(2)
|
|
||||||
TodosView()
|
|
||||||
.tabItem { Label("To-dos", systemImage: "checklist") }.tag(3)
|
|
||||||
SettingsView()
|
|
||||||
.tabItem { Label("Settings", systemImage: "gearshape") }.tag(4)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
PairingIntroView()
|
PairingIntroView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.overlay(alignment: .bottom) { ErrorBubble() }
|
.overlay(alignment: .bottom) { ErrorBubble() }
|
||||||
// A notification tap routes to its session: jump to the Sessions tab, where
|
|
||||||
// `SessionsView` consumes `pendingRoute` and pushes the detail.
|
|
||||||
.onChange(of: store.pendingRoute) { _, route in
|
|
||||||
if route != nil { tab = 1 }
|
|
||||||
}
|
|
||||||
.tint(Palette.accent)
|
.tint(Palette.accent)
|
||||||
.preferredColorScheme((AppAppearance(rawValue: appearanceRaw) ?? .system).colorScheme)
|
.preferredColorScheme((AppAppearance(rawValue: appearanceRaw) ?? .system).colorScheme)
|
||||||
.dynamicTypeSize((AppTextSize(rawValue: textSizeRaw) ?? .medium).dynamicTypeSize)
|
.dynamicTypeSize((AppTextSize(rawValue: textSizeRaw) ?? .medium).dynamicTypeSize)
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import NucleicProtocol
|
||||||
|
|
||||||
|
/// Chooses the navigation shell by width so one universal binary serves both idioms:
|
||||||
|
/// • **compact** (iPhone, and iPad in Slide Over / narrow split) → the five-tab `TabView`.
|
||||||
|
/// • **regular** (iPad full-screen / Stage Manager) → the Mac's sidebar + detail split.
|
||||||
|
///
|
||||||
|
/// Both render the SAME `RemoteStore` projection — the iPad is a third renderer of the one host
|
||||||
|
/// authority (UX_MACOS §8), not a fork. The store holds all real state, so a size-class flip
|
||||||
|
/// (e.g. resizing a Stage Manager window) only swaps chrome; sessions, connection, and the open
|
||||||
|
/// transcript survive it.
|
||||||
|
struct AdaptiveRootView: View {
|
||||||
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if sizeClass == .regular {
|
||||||
|
SplitRootView()
|
||||||
|
} else {
|
||||||
|
CompactRootView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Compact (iPhone / narrow multitasking)
|
||||||
|
|
||||||
|
/// The original iPhone shell, relocated verbatim from `RootView`: a five-tab `TabView`. A
|
||||||
|
/// notification tap routes to the Sessions tab, where `SessionsView` consumes `pendingRoute`
|
||||||
|
/// and pushes the detail.
|
||||||
|
struct CompactRootView: View {
|
||||||
|
@EnvironmentObject var store: RemoteStore
|
||||||
|
// Initial tab; overridable via NUCLEIC_TAB for offline UI previews.
|
||||||
|
@State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_TAB"] ?? "") ?? 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TabView(selection: $tab) {
|
||||||
|
HomeView()
|
||||||
|
.tabItem { Label("Home", systemImage: "house") }.tag(0)
|
||||||
|
SessionsView()
|
||||||
|
.tabItem { Label("Sessions", systemImage: "square.stack.3d.up") }
|
||||||
|
.badge(store.needsYouCount).tag(1)
|
||||||
|
ProjectsView()
|
||||||
|
.tabItem { Label("Projects", systemImage: "folder") }.tag(2)
|
||||||
|
TodosView()
|
||||||
|
.tabItem { Label("To-dos", systemImage: "checklist") }.tag(3)
|
||||||
|
SettingsView()
|
||||||
|
.tabItem { Label("Settings", systemImage: "gearshape") }.tag(4)
|
||||||
|
}
|
||||||
|
// A notification tap routes to its session by jumping to the Sessions tab.
|
||||||
|
.onChange(of: store.pendingRoute) { _, route in
|
||||||
|
if route != nil { tab = 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Regular (iPad sidebar + detail)
|
||||||
|
|
||||||
|
/// What the sidebar can select. Mirrors the Mac sidebar: a few global destinations plus the
|
||||||
|
/// live sessions themselves (grouped by project). Projects are reached through the `projects`
|
||||||
|
/// destination (`ProjectsView`), which also hosts the scoped "start a chat here" composer.
|
||||||
|
enum SidebarItem: Hashable {
|
||||||
|
case home
|
||||||
|
case projects
|
||||||
|
case todos
|
||||||
|
case settings
|
||||||
|
case session(SessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Mac's information architecture on iPad: a two-column `NavigationSplitView` whose leading
|
||||||
|
/// column carries the destinations + the project→session tree, and whose detail is the selected
|
||||||
|
/// session (or a destination view). Selection lives here and binds the same `openSessionID`
|
||||||
|
/// lifecycle the iPhone push/pop path uses — the detail is keyed by session id so switching one
|
||||||
|
/// session for another fires `open`/`closeOpen` exactly as a push/pop would.
|
||||||
|
struct SplitRootView: View {
|
||||||
|
@EnvironmentObject var store: RemoteStore
|
||||||
|
@State private var selection: SidebarItem? = .home
|
||||||
|
@State private var columnVisibility: NavigationSplitViewVisibility = .all
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||||
|
SplitSidebar(selection: $selection)
|
||||||
|
} detail: {
|
||||||
|
SplitDetail(selection: selection)
|
||||||
|
}
|
||||||
|
.navigationSplitViewStyle(.balanced)
|
||||||
|
// Notification tap → select its session directly (the regular-width analogue of the
|
||||||
|
// compact path's jump-to-Sessions-tab). Then clear the request so it isn't re-consumed.
|
||||||
|
.onChange(of: store.pendingRoute) { _, route in
|
||||||
|
guard let route else { return }
|
||||||
|
selection = .session(route)
|
||||||
|
store.pendingRoute = nil
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// Offline UI previews (NUCLEIC_DEMO): open straight into one session.
|
||||||
|
if let raw = ProcessInfo.processInfo.environment["NUCLEIC_DEMO_SESSION"], !raw.isEmpty {
|
||||||
|
selection = .session(SessionID(rawValue: raw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The leading column: global destinations, then the live sessions grouped under their projects
|
||||||
|
/// (attention-first within each group), mirroring the Mac sidebar tree. The persistent
|
||||||
|
/// connection status sits in the footer — one home for it, versus the phone's per-tab inset.
|
||||||
|
private struct SplitSidebar: View {
|
||||||
|
@EnvironmentObject var store: RemoteStore
|
||||||
|
@Binding var selection: SidebarItem?
|
||||||
|
|
||||||
|
/// Live sessions grouped under their project (dashboard order), with anything whose project
|
||||||
|
/// isn't in the dashboard collected into a trailing "Other" group. Attention-first sort.
|
||||||
|
private var projectGroups: [(name: String, rows: [WireSessionSummary])] {
|
||||||
|
let live = store.liveSessions.sorted {
|
||||||
|
let a = StatusStyle.sortRank($0), b = StatusStyle.sortRank($1)
|
||||||
|
return a == b ? $0.updatedAt > $1.updatedAt : a < b
|
||||||
|
}
|
||||||
|
var groups: [(name: String, rows: [WireSessionSummary])] = []
|
||||||
|
var placed = Set<SessionID>()
|
||||||
|
for project in store.dashboard.projects {
|
||||||
|
let rows = live.filter { $0.projectID == project.id.rawValue }
|
||||||
|
guard !rows.isEmpty else { continue }
|
||||||
|
groups.append((project.name, rows))
|
||||||
|
rows.forEach { placed.insert($0.sessionID) }
|
||||||
|
}
|
||||||
|
let orphans = live.filter { !placed.contains($0.sessionID) }
|
||||||
|
if !orphans.isEmpty { groups.append(("Other", orphans)) }
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List(selection: $selection) {
|
||||||
|
Section {
|
||||||
|
Label("Home", systemImage: "house").tag(SidebarItem.home)
|
||||||
|
Label("Projects", systemImage: "folder").tag(SidebarItem.projects)
|
||||||
|
Label("To-dos", systemImage: "checklist").tag(SidebarItem.todos)
|
||||||
|
Label("Settings", systemImage: "gearshape").tag(SidebarItem.settings)
|
||||||
|
}
|
||||||
|
if projectGroups.isEmpty {
|
||||||
|
Section("Sessions") {
|
||||||
|
Text(store.connectivity.isLive
|
||||||
|
? "Start a session from Home or the Mac to see it here."
|
||||||
|
: "Waiting to connect to \(store.hostName)…")
|
||||||
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ForEach(projectGroups, id: \.name) { group in
|
||||||
|
Section(group.name) {
|
||||||
|
ForEach(group.rows, id: \.sessionID) { summary in
|
||||||
|
SessionRow(summary: summary, showProjectName: false)
|
||||||
|
.tag(SidebarItem.session(summary.sessionID))
|
||||||
|
.listRowBackground(SessionRowWash(summary: summary))
|
||||||
|
.swipeActions(edge: .leading) {
|
||||||
|
Button {
|
||||||
|
store.setFavorite(summary.sessionID, !summary.favorite)
|
||||||
|
} label: {
|
||||||
|
Label("Favorite", systemImage: summary.favorite ? "star.slash" : "star.fill")
|
||||||
|
}.tint(.yellow)
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .trailing) {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
store.deleteSession(summary.sessionID)
|
||||||
|
} label: { Label("Delete", systemImage: "trash") }
|
||||||
|
Button {
|
||||||
|
store.setArchived(summary.sessionID, !summary.archived)
|
||||||
|
} label: {
|
||||||
|
Label(summary.archived ? "Unarchive" : "Archive",
|
||||||
|
systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox")
|
||||||
|
}.tint(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.sidebar)
|
||||||
|
.navigationTitle(store.hostName.isEmpty ? "Nucleic" : store.hostName)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button { store.refreshSessions() } label: {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.help("Refresh")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.safeAreaInset(edge: .bottom) {
|
||||||
|
ConnectionChip().padding(.bottom, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The detail column. A selected session reuses the existing `SessionDetailView` unchanged
|
||||||
|
/// (it owns its own subscribe/unsubscribe on appear/disappear); it's keyed `.id(sessionID)` so
|
||||||
|
/// switching sessions remounts and drives that lifecycle. The destination views already embed
|
||||||
|
/// their own `NavigationStack`, so they're shown directly.
|
||||||
|
private struct SplitDetail: View {
|
||||||
|
let selection: SidebarItem?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch selection {
|
||||||
|
case .home, .none:
|
||||||
|
HomeView()
|
||||||
|
case .projects:
|
||||||
|
ProjectsView()
|
||||||
|
case .todos:
|
||||||
|
TodosView()
|
||||||
|
case .settings:
|
||||||
|
SettingsView()
|
||||||
|
case .session(let id):
|
||||||
|
NavigationStack {
|
||||||
|
SessionDetailView(sessionID: id)
|
||||||
|
}
|
||||||
|
.id(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,13 +33,17 @@ struct ApprovalCardView: View {
|
|||||||
.background(approval.risk.color.opacity(0.2), in: Capsule())
|
.background(approval.risk.color.opacity(0.2), in: Capsule())
|
||||||
.foregroundStyle(approval.risk.color)
|
.foregroundStyle(approval.risk.color)
|
||||||
}
|
}
|
||||||
// A host_exec gate escapes the sandbox onto the macOS host, so lay out what it's
|
// A git commit reads as a structured commit card (subject + Markdown body) so you can
|
||||||
// about to run — the inferred purpose and the program/actions/flags, parsed from the
|
// see exactly what you're granting; the literal command stays under "Show command".
|
||||||
// command itself (never from the agent) — then the exact command beneath it. Every
|
// Otherwise a host_exec gate lays out its parsed breakdown + exact command, and every
|
||||||
// other tool shows its untruncated detail in a single scrollable box.
|
// other tool shows its untruncated detail in a single scrollable box.
|
||||||
if approval.toolName == HostCommandSummary.hostExecToolName,
|
if let command = approval.input["command"]?.stringValue,
|
||||||
let command = approval.input["command"]?.stringValue,
|
let commit = GitCommitSummary.parse(command) {
|
||||||
let parsed = HostCommandSummary.summary(for: command) {
|
GitCommitCard(commit: commit, rawCommand: command)
|
||||||
|
} else if let command = approval.input["command"]?.stringValue,
|
||||||
|
let parsed = HostCommandSummary.summary(for: command),
|
||||||
|
approval.toolName == HostCommandSummary.hostExecToolName
|
||||||
|
|| parsed.invocations.count > 1 || parsed.isDestructive {
|
||||||
HostCommandBreakdown(summary: parsed)
|
HostCommandBreakdown(summary: parsed)
|
||||||
Text("Exact command").font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
|
Text("Exact command").font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
|
||||||
ApprovalDetailBox(text: command)
|
ApprovalDetailBox(text: command)
|
||||||
@@ -66,6 +70,9 @@ struct ApprovalCardView: View {
|
|||||||
Text("Deny").frame(maxWidth: .infinity)
|
Text("Deny").frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
|
// Hardware-keyboard shortcuts for the defining interaction: Esc denies, Return
|
||||||
|
// allows (the approval bar replaces the composer, so Return is unclaimed here).
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
store.respond(approval, .allow())
|
store.respond(approval, .allow())
|
||||||
@@ -74,6 +81,7 @@ struct ApprovalCardView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.disabled(!allowEnabled)
|
.disabled(!allowEnabled)
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A destructive action (rm, force-push, reset --hard, …) offers no remembered
|
// A destructive action (rm, force-push, reset --hard, …) offers no remembered
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ struct StartChatComposer: View {
|
|||||||
Image(systemName: "arrow.up.circle.fill").font(.title)
|
Image(systemName: "arrow.up.circle.fill").font(.title)
|
||||||
}
|
}
|
||||||
.disabled(selected == nil || draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl)
|
.disabled(selected == nil || draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl)
|
||||||
|
.keyboardShortcut(.return, modifiers: .command)
|
||||||
}
|
}
|
||||||
if !store.canControl {
|
if !store.canControl {
|
||||||
Text("This device is view-only.").font(.caption2).foregroundStyle(.secondary)
|
Text("This device is view-only.").font(.caption2).foregroundStyle(.secondary)
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ struct HomeView: View {
|
|||||||
StartChatComposer()
|
StartChatComposer()
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
// Cap + center the dashboard on a wide iPad so it reads like the Mac home rather
|
||||||
|
// than a stretched phone; a no-op at phone width.
|
||||||
|
.readableColumn()
|
||||||
}
|
}
|
||||||
.navigationTitle("Home")
|
.navigationTitle("Home")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Width tuning for the universal layout. In a wide iPad detail column, content designed for a
|
||||||
|
/// phone runs edge-to-edge and loses the Mac's comfortable reading measure; `readableColumn`
|
||||||
|
/// caps its width and centers it. It's a no-op on the phone and on iPad compact multitasking,
|
||||||
|
/// where the available width is already below the cap — so the iPhone layout is unchanged.
|
||||||
|
extension View {
|
||||||
|
func readableColumn(_ maxWidth: CGFloat = 820) -> some View {
|
||||||
|
frame(maxWidth: maxWidth, alignment: .leading)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,7 +71,9 @@ struct SessionDetailView: View {
|
|||||||
Button("Cancel", role: .cancel) {}
|
Button("Cancel", role: .cancel) {}
|
||||||
}
|
}
|
||||||
.onAppear { store.open(sessionID) }
|
.onAppear { store.open(sessionID) }
|
||||||
.onDisappear { store.closeOpen() }
|
// Pass our own id so an iPad split-view A→B switch (which may mount B before A
|
||||||
|
// disappears) unsubscribes A without tearing down B's just-opened state.
|
||||||
|
.onDisappear { store.closeOpen(sessionID) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this session's project is under Nucleic Control (gates Orchestra + autoship).
|
/// Whether this session's project is under Nucleic Control (gates Orchestra + autoship).
|
||||||
@@ -221,6 +223,9 @@ struct SessionDetailView: View {
|
|||||||
.font(.title2)
|
.font(.title2)
|
||||||
}
|
}
|
||||||
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.connectivity.isLive)
|
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.connectivity.isLive)
|
||||||
|
// Hardware-keyboard send (Magic Keyboard on iPad), mirroring the Mac —
|
||||||
|
// plain Return stays newline in the multiline field.
|
||||||
|
.keyboardShortcut(.return, modifiers: .command)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,6 +302,9 @@ struct TranscriptList: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
// Cap the transcript to a readable measure on a wide iPad so lines don't run
|
||||||
|
// edge-to-edge on a 13-inch screen; a no-op at phone width.
|
||||||
|
.readableColumn()
|
||||||
}
|
}
|
||||||
// A drag on the transcript dismisses the keyboard, so a tall multiline composer can
|
// A drag on the transcript dismisses the keyboard, so a tall multiline composer can
|
||||||
// be put away without leaving the session.
|
// be put away without leaving the session.
|
||||||
@@ -310,26 +318,27 @@ struct TranscriptList: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Diff tab with the real patch (the Mac Diff tab, phone form): a per-file summary list
|
/// The Diff tab, read-only over the on-demand `WireSessionDiff`. On a phone it's a per-file
|
||||||
/// over the unified patch, colored +/− per line, fetched on demand via `fetchDiff`.
|
/// summary over one scrolling unified patch; on a regular-width iPad it becomes the Mac's
|
||||||
|
/// two-pane diff — a selectable file list beside the selected file's patch — exploiting the
|
||||||
|
/// wide detail column. Same wire, no new protocol.
|
||||||
struct SessionDiffView: View {
|
struct SessionDiffView: View {
|
||||||
let diff: WireSessionDiff?
|
let diff: WireSessionDiff?
|
||||||
let loading: Bool
|
let loading: Bool
|
||||||
|
/// Below this available width the two-pane diff would leave the patch too cramped, so we
|
||||||
|
/// keep the phone stack. Picks two-pane on a wide iPad (landscape, or a large iPad) and the
|
||||||
|
/// stack on a phone or a narrow portrait split — based on real width, not just size class.
|
||||||
|
private let twoPaneMinWidth: CGFloat = 700
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if let diff, diff.stat.filesChanged > 0 {
|
if let diff, diff.stat.filesChanged > 0 {
|
||||||
ScrollView {
|
GeometryReader { geo in
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
if geo.size.width >= twoPaneMinWidth {
|
||||||
fileList(diff)
|
SplitDiffView(diff: diff)
|
||||||
PatchText(patch: diff.patch)
|
} else {
|
||||||
if diff.truncated {
|
stacked(diff)
|
||||||
Label("Patch truncated — open the Mac for the rest.",
|
|
||||||
systemImage: "scissors")
|
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
} else if loading {
|
} else if loading {
|
||||||
ProgressView("Fetching diff…")
|
ProgressView("Fetching diff…")
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
@@ -338,15 +347,111 @@ struct SessionDiffView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fileList(_ diff: WireSessionDiff) -> some View {
|
/// Phone form: the file summary over one scrolling unified patch.
|
||||||
|
private func stacked(_ diff: WireSessionDiff) -> some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("\(diff.stat.filesChanged) file\(diff.stat.filesChanged == 1 ? "" : "s") changed · +\(diff.stat.added) −\(diff.stat.removed)")
|
DiffStatHeader(stat: diff.stat)
|
||||||
.font(.subheadline.weight(.semibold))
|
ForEach(diff.files) { DiffFileRow(file: $0) }
|
||||||
|
}
|
||||||
|
.card()
|
||||||
|
PatchText(patch: diff.patch)
|
||||||
|
if diff.truncated { TruncatedPatchNote() }
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.readableColumn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// iPad form: the Mac's two-pane diff. A tappable file list on the left drives the selected
|
||||||
|
/// file's patch on the right. Row taps use a plain `Button` (not `List(selection:)`, whose
|
||||||
|
/// single-select tap handling is unreliable outside an edit-mode / split-view context).
|
||||||
|
private struct SplitDiffView: View {
|
||||||
|
let diff: WireSessionDiff
|
||||||
|
@State private var selected: String?
|
||||||
|
|
||||||
|
/// The combined patch split into per-file sections, keyed by file path.
|
||||||
|
private var sections: [String: String] { UnifiedPatch.sections(diff.patch, files: diff.files) }
|
||||||
|
private var current: String? { selected ?? diff.files.first?.path }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
fileList
|
||||||
|
.frame(width: 280)
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
Divider()
|
||||||
|
patchPane
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
.onAppear { if selected == nil { selected = diff.files.first?.path } }
|
||||||
|
// Keep the selection valid as the diff refetches (files can appear/vanish between turns).
|
||||||
|
.onChange(of: diff.files) { _, files in
|
||||||
|
if selected == nil || !files.contains(where: { $0.path == selected }) {
|
||||||
|
selected = files.first?.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fileList: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 0) {
|
||||||
|
DiffStatHeader(stat: diff.stat)
|
||||||
|
.padding(.horizontal, 12).padding(.vertical, 10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
Divider()
|
||||||
ForEach(diff.files) { file in
|
ForEach(diff.files) { file in
|
||||||
|
Button { selected = file.path } label: {
|
||||||
|
DiffFileRow(file: file)
|
||||||
|
.padding(.horizontal, 12).padding(.vertical, 9)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(current == file.path
|
||||||
|
? Palette.accent.opacity(0.15) : Color.clear)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.hoverEffect(.highlight)
|
||||||
|
}
|
||||||
|
if diff.truncated { TruncatedPatchNote().padding(12) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var patchPane: some View {
|
||||||
|
if let path = current, let patch = sections[path], !patch.isEmpty {
|
||||||
|
ScrollView { PatchText(patch: patch).padding() }
|
||||||
|
} else if current != nil {
|
||||||
|
// A file with no textual hunk (binary, or a section dropped by patch truncation).
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No preview", systemImage: "doc",
|
||||||
|
description: Text("This file has no textual diff\(diff.truncated ? " in the fetched patch" : "").")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ContentUnavailableView("Select a file", systemImage: "sidebar.left")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The "N files changed · +A −R" header shared by both diff forms.
|
||||||
|
private struct DiffStatHeader: View {
|
||||||
|
let stat: DiffStat
|
||||||
|
var body: some View {
|
||||||
|
Text("\(stat.filesChanged) file\(stat.filesChanged == 1 ? "" : "s") changed · +\(stat.added) −\(stat.removed)")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One file's row: status glyph, path, and its +/− counts. Shared by the phone summary and the
|
||||||
|
/// iPad file list.
|
||||||
|
struct DiffFileRow: View {
|
||||||
|
let file: WireFileDiff
|
||||||
|
var body: some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: statusIcon(file.status))
|
Image(systemName: DiffStatus.icon(file.status))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(statusColor(file.status))
|
.foregroundStyle(DiffStatus.color(file.status))
|
||||||
Text(file.path)
|
Text(file.path)
|
||||||
.font(.caption.monospaced())
|
.font(.caption.monospaced())
|
||||||
.lineLimit(1).truncationMode(.middle)
|
.lineLimit(1).truncationMode(.middle)
|
||||||
@@ -355,11 +460,10 @@ struct SessionDiffView: View {
|
|||||||
Text("−\(file.removed)").font(.caption2.monospacedDigit()).foregroundStyle(Palette.danger)
|
Text("−\(file.removed)").font(.caption2.monospacedDigit()).foregroundStyle(Palette.danger)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.card()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func statusIcon(_ status: String) -> String {
|
enum DiffStatus {
|
||||||
|
static func icon(_ status: String) -> String {
|
||||||
switch status {
|
switch status {
|
||||||
case "added", "untracked": "plus.circle"
|
case "added", "untracked": "plus.circle"
|
||||||
case "deleted": "minus.circle"
|
case "deleted": "minus.circle"
|
||||||
@@ -367,8 +471,7 @@ struct SessionDiffView: View {
|
|||||||
default: "pencil.circle"
|
default: "pencil.circle"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
static func color(_ status: String) -> Color {
|
||||||
private func statusColor(_ status: String) -> Color {
|
|
||||||
switch status {
|
switch status {
|
||||||
case "added", "untracked": Palette.success
|
case "added", "untracked": Palette.success
|
||||||
case "deleted": Palette.danger
|
case "deleted": Palette.danger
|
||||||
@@ -377,6 +480,57 @@ struct SessionDiffView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct TruncatedPatchNote: View {
|
||||||
|
var body: some View {
|
||||||
|
Label("Patch truncated — open the Mac for the rest.", systemImage: "scissors")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits a combined `git diff` patch into per-file sections for the iPad's two-pane view.
|
||||||
|
enum UnifiedPatch {
|
||||||
|
/// Map from file path to that file's slice of the unified patch. Sections begin at a
|
||||||
|
/// `diff --git a/… b/…` line. When the section count matches `files` we pair positionally
|
||||||
|
/// (the host emits patch and files together, in order); otherwise we key each section by the
|
||||||
|
/// new path parsed from its header. Callers fall back to a "no preview" state on a miss.
|
||||||
|
static func sections(_ patch: String, files: [WireFileDiff]) -> [String: String] {
|
||||||
|
guard patch.contains("diff --git ") else {
|
||||||
|
// A single-file patch with no git header: attribute the whole thing to the sole file.
|
||||||
|
return files.count == 1 ? [files[0].path: patch] : [:]
|
||||||
|
}
|
||||||
|
var chunks: [String] = []
|
||||||
|
var current: [Substring] = []
|
||||||
|
for line in patch.split(separator: "\n", omittingEmptySubsequences: false) {
|
||||||
|
if line.hasPrefix("diff --git ") {
|
||||||
|
if !current.isEmpty { chunks.append(current.joined(separator: "\n")) }
|
||||||
|
current = [line]
|
||||||
|
} else if !current.isEmpty {
|
||||||
|
current.append(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !current.isEmpty { chunks.append(current.joined(separator: "\n")) }
|
||||||
|
|
||||||
|
var result: [String: String] = [:]
|
||||||
|
if chunks.count == files.count {
|
||||||
|
for (file, chunk) in zip(files, chunks) { result[file.path] = chunk }
|
||||||
|
} else {
|
||||||
|
for chunk in chunks where newPath(chunk) != nil { result[newPath(chunk)!] = chunk }
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The new-side path from a section's `diff --git a/OLD b/NEW` header. Nil when the header is
|
||||||
|
/// absent or the path is quoted/spaced in a way we don't split cleanly.
|
||||||
|
private static func newPath(_ chunk: String) -> String? {
|
||||||
|
guard let header = chunk.split(separator: "\n", maxSplits: 1).first,
|
||||||
|
header.hasPrefix("diff --git "),
|
||||||
|
let bRange = header.range(of: " b/") else { return nil }
|
||||||
|
let path = header[bRange.upperBound...]
|
||||||
|
return path.isEmpty ? nil : String(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The unified patch, one `Text` per line with the classic +/− coloring. Lines scroll
|
/// The unified patch, one `Text` per line with the classic +/− coloring. Lines scroll
|
||||||
/// horizontally as a block so long lines don't wrap into noise.
|
/// horizontally as a block so long lines don't wrap into noise.
|
||||||
private struct PatchText: View {
|
private struct PatchText: View {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import NucleicProtocol
|
import NucleicProtocol
|
||||||
import NucleicTailnet
|
import NucleicTailnet
|
||||||
|
import UIKit
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@EnvironmentObject var store: RemoteStore
|
@EnvironmentObject var store: RemoteStore
|
||||||
@State private var showScanner = false
|
@State private var showScanner = false
|
||||||
|
@State private var showManualPair = false
|
||||||
@State private var tailscaleAuthKey: String = TailnetAuthStore.loadAuthKey() ?? ""
|
@State private var tailscaleAuthKey: String = TailnetAuthStore.loadAuthKey() ?? ""
|
||||||
@FocusState private var tailscaleKeyFocused: Bool
|
@FocusState private var tailscaleKeyFocused: Bool
|
||||||
@AppStorage("nucleic.showRawEvents") private var showRaw = false
|
@AppStorage("nucleic.showRawEvents") private var showRaw = false
|
||||||
@@ -133,6 +135,11 @@ struct SettingsView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Pair with a Mac", systemImage: "qrcode.viewfinder")
|
Label("Pair with a Mac", systemImage: "qrcode.viewfinder")
|
||||||
}
|
}
|
||||||
|
Button {
|
||||||
|
showManualPair = true
|
||||||
|
} label: {
|
||||||
|
Label("Enter code manually", systemImage: "keyboard")
|
||||||
|
}
|
||||||
if store.isPaired {
|
if store.isPaired {
|
||||||
Button(role: .destructive) { store.unpair() } label: {
|
Button(role: .destructive) { store.unpair() } label: {
|
||||||
Label("Unpair this device", systemImage: "minus.circle")
|
Label("Unpair this device", systemImage: "minus.circle")
|
||||||
@@ -149,6 +156,12 @@ struct SettingsView: View {
|
|||||||
store.pair(with: payload)
|
store.pair(with: payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showManualPair) {
|
||||||
|
ManualPairingView { payload in
|
||||||
|
showManualPair = false
|
||||||
|
store.pair(with: payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,12 +170,13 @@ struct SettingsView: View {
|
|||||||
struct PairingIntroView: View {
|
struct PairingIntroView: View {
|
||||||
@EnvironmentObject var store: RemoteStore
|
@EnvironmentObject var store: RemoteStore
|
||||||
@State private var showScanner = false
|
@State private var showScanner = false
|
||||||
|
@State private var showManualPair = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: 24) {
|
||||||
Image(systemName: "qrcode.viewfinder").font(.system(size: 72)).foregroundStyle(.tint)
|
Image(systemName: "qrcode.viewfinder").font(.system(size: 72)).foregroundStyle(.tint)
|
||||||
Text("Pair with your Mac").font(.title2.weight(.semibold))
|
Text("Pair with your Mac").font(.title2.weight(.semibold))
|
||||||
Text("On your Mac, open Nucleic ▸ Settings ▸ Add iPhone to show a QR code, then scan it here.")
|
Text("On your Mac, open Nucleic ▸ Settings ▸ Add device to show a QR code, then scan it here — or enter the pairing code beneath it manually.")
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.padding(.horizontal, 32)
|
.padding(.horizontal, 32)
|
||||||
@@ -173,6 +187,15 @@ struct PairingIntroView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
|
|
||||||
|
// A camera-free path — the iPad's scanner can't run while the app is mirrored to an
|
||||||
|
// external display or in some Stage Manager states; pasting the code always works.
|
||||||
|
Button {
|
||||||
|
showManualPair = true
|
||||||
|
} label: {
|
||||||
|
Label("Enter code manually", systemImage: "keyboard")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
|
||||||
// Feedback after a scan: the whole UI is still the intro until pairing completes, so
|
// Feedback after a scan: the whole UI is still the intro until pairing completes, so
|
||||||
// without this a failed or in-flight pair looks like "nothing happened."
|
// without this a failed or in-flight pair looks like "nothing happened."
|
||||||
pairingStatus
|
pairingStatus
|
||||||
@@ -201,6 +224,12 @@ struct PairingIntroView: View {
|
|||||||
store.pair(with: payload)
|
store.pair(with: payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showManualPair) {
|
||||||
|
ManualPairingView { payload in
|
||||||
|
showManualPair = false
|
||||||
|
store.pair(with: payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A status line reflecting an in-flight or failed pairing attempt. Silent in the resting
|
/// A status line reflecting an in-flight or failed pairing attempt. Silent in the resting
|
||||||
@@ -239,3 +268,72 @@ struct PairingIntroView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A camera-free pairing path (UX_IOS §7): paste the Mac's `nucleic://pair?d=…` code instead of
|
||||||
|
/// scanning its QR. The iPad's camera can be blocked while mirrored to an external display or in
|
||||||
|
/// some Stage Manager states, where the scanner's `couldNotStart` path leaves you stuck — this
|
||||||
|
/// always works. Parses with the same `PairingPayload(qrString:)` the scanner uses.
|
||||||
|
struct ManualPairingView: View {
|
||||||
|
let onEntered: (PairingPayload) -> Void
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var code = ""
|
||||||
|
@State private var error: String?
|
||||||
|
@FocusState private var fieldFocused: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
TextField("nucleic://pair?d=…", text: $code, axis: .vertical)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.keyboardType(.URL)
|
||||||
|
.font(.callout.monospaced())
|
||||||
|
.lineLimit(2...6)
|
||||||
|
.focused($fieldFocused)
|
||||||
|
.onChange(of: code) { error = nil }
|
||||||
|
} header: {
|
||||||
|
Text("Pairing code")
|
||||||
|
} footer: {
|
||||||
|
Text("On your Mac, open Nucleic ▸ Settings ▸ Add device and copy the pairing link shown beneath the QR code, then paste it here.")
|
||||||
|
}
|
||||||
|
if let error {
|
||||||
|
Label(error, systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.font(.callout).foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
submit()
|
||||||
|
} label: {
|
||||||
|
Label("Pair", systemImage: "link").frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
.navigationTitle("Enter pairing code")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button {
|
||||||
|
if let pasted = UIPasteboard.general.string { code = pasted }
|
||||||
|
} label: {
|
||||||
|
Label("Paste", systemImage: "doc.on.clipboard")
|
||||||
|
}
|
||||||
|
.disabled(!UIPasteboard.general.hasStrings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { fieldFocused = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submit() {
|
||||||
|
let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard let payload = try? PairingPayload(qrString: trimmed) else {
|
||||||
|
error = "That isn't a valid Nucleic pairing code. Copy the link beneath the QR on your Mac."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onEntered(payload)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// A `git commit` rendered as a structured card — the remote's echo of the desktop `GitBlockCard`:
|
||||||
|
/// the commit subject reads as a headline and the message body as Markdown, so a commit in the
|
||||||
|
/// transcript is legible at a glance instead of a raw `git commit -F - <<'EOF' …` blob. The literal
|
||||||
|
/// command stays one tap away under "Show command", so the card never hides what actually ran.
|
||||||
|
struct GitCommitCard: View {
|
||||||
|
let commit: GitCommitSummary.Commit
|
||||||
|
/// The literal command, revealed under "Show command". Omit to hide the disclosure.
|
||||||
|
var rawCommand: String? = nil
|
||||||
|
|
||||||
|
private var accent: Color { Palette.accent }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 9) {
|
||||||
|
Image(systemName: commit.amend ? "pencil.circle" : "checkmark.seal")
|
||||||
|
.font(.callout).foregroundStyle(accent).frame(width: 16)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(commit.amend ? "Amend commit" : "Commit")
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if !commit.subject.isEmpty {
|
||||||
|
Text(commit.subject)
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !commit.body.isEmpty {
|
||||||
|
// The body is real commit-message Markdown (paragraphs, bullet lists) — render it
|
||||||
|
// formatted, indented under the subject.
|
||||||
|
MarkdownText(markdown: commit.body)
|
||||||
|
.padding(.leading, 25)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
if let rawCommand, !rawCommand.isEmpty {
|
||||||
|
CommandDisclosure(command: rawCommand, accent: accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12).padding(.vertical, 10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(accent.opacity(0.06))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(accent.opacity(0.16), lineWidth: 1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A disclosure that reveals the literal shell command beneath a structured command card — so a
|
||||||
|
/// command rendered as a GUI element never hides what's actually being run. The remote's port of
|
||||||
|
/// the desktop `CommandDisclosure`.
|
||||||
|
struct CommandDisclosure: View {
|
||||||
|
/// The literal command to reveal.
|
||||||
|
let command: String
|
||||||
|
/// The card's accent, applied to the toggle and the revealed block.
|
||||||
|
let accent: Color
|
||||||
|
|
||||||
|
@State private var showCommand = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.15)) { showCommand.toggle() }
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.rotationEffect(.degrees(showCommand ? 90 : 0))
|
||||||
|
Text(showCommand ? "Hide command" : "Show command")
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
}
|
||||||
|
.foregroundStyle(accent)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
if showCommand {
|
||||||
|
Text(command)
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(8)
|
||||||
|
.background(accent.opacity(0.05), in: RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -577,6 +577,87 @@ private enum Lexer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Git commit summary
|
||||||
|
|
||||||
|
/// Pulls the commit message out of a command that runs `git commit`, so a commit reads as a
|
||||||
|
/// structured card (subject headline + Markdown body) instead of a raw `git commit -F - <<'EOF' …`
|
||||||
|
/// blob — the remote's echo of the desktop `GitCommandSummary`/`GitBlockCard` (which live in
|
||||||
|
/// NucleicCore, unavailable here). Handles the two shapes agents actually use: `-m`/`--message`
|
||||||
|
/// arguments and a `-F -`/`--file=-` heredoc body. Parsed from the command string alone, reusing
|
||||||
|
/// the same shell-aware ``Lexer`` the host-exec summary uses.
|
||||||
|
enum GitCommitSummary {
|
||||||
|
struct Commit: Equatable {
|
||||||
|
var subject: String
|
||||||
|
var body: String
|
||||||
|
var amend: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parse(_ command: String) -> Commit? {
|
||||||
|
let (flattened, bodies) = Lexer.stripHeredocs(command)
|
||||||
|
guard let args = commitArguments(in: flattened) else { return nil }
|
||||||
|
|
||||||
|
let amend = args.contains("--amend")
|
||||||
|
var messages: [String] = []
|
||||||
|
var usesStdinFile = false
|
||||||
|
var i = 0
|
||||||
|
while i < args.count {
|
||||||
|
let a = args[i]
|
||||||
|
if a == "-m" || a == "--message" {
|
||||||
|
if i + 1 < args.count { messages.append(args[i + 1]); i += 2; continue }
|
||||||
|
} else if a.hasPrefix("--message=") {
|
||||||
|
messages.append(String(a.dropFirst("--message=".count)))
|
||||||
|
} else if a.hasPrefix("-m"), a.count > 2 {
|
||||||
|
messages.append(String(a.dropFirst(2)))
|
||||||
|
} else if (a == "-F" || a == "--file"), i + 1 < args.count, args[i + 1] == "-" {
|
||||||
|
usesStdinFile = true
|
||||||
|
} else if a == "--file=-" || a == "-F-" {
|
||||||
|
usesStdinFile = true
|
||||||
|
}
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer explicit `-m` messages; otherwise a `-F -` heredoc body is the message.
|
||||||
|
let message: String
|
||||||
|
if !messages.isEmpty {
|
||||||
|
message = messages.joined(separator: "\n\n")
|
||||||
|
} else if usesStdinFile,
|
||||||
|
let body = bodies.first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) {
|
||||||
|
message = body
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subject = first non-blank line; body = everything after it.
|
||||||
|
let lines = message.components(separatedBy: "\n")
|
||||||
|
guard let subjectIndex = lines.firstIndex(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty }) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let subject = lines[subjectIndex].trimmingCharacters(in: .whitespaces)
|
||||||
|
let body = lines[(subjectIndex + 1)...].joined(separator: "\n")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return Commit(subject: subject, body: body, amend: amend)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The arguments after `git commit` in whichever segment runs it (skipping env assignments and
|
||||||
|
/// git's global flags like `-C <dir>` / `-c k=v`), or nil when no segment is a `git commit`.
|
||||||
|
private static func commitArguments(in flattened: String) -> [String]? {
|
||||||
|
for segment in Lexer.splitSegments(flattened) {
|
||||||
|
var tokens = Lexer.tokenize(segment)
|
||||||
|
while let first = tokens.first, Lexer.isEnvAssignment(first) { tokens.removeFirst() }
|
||||||
|
guard tokens.first == "git" else { continue }
|
||||||
|
var rest = Array(tokens.dropFirst())
|
||||||
|
while let flag = rest.first, flag.hasPrefix("-") {
|
||||||
|
let takesValue = (flag == "-C" || flag == "-c")
|
||||||
|
rest.removeFirst()
|
||||||
|
if takesValue, !rest.isEmpty { rest.removeFirst() }
|
||||||
|
}
|
||||||
|
guard rest.first == "commit" else { continue }
|
||||||
|
return Array(rest.dropFirst())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - ToolGroup convenience
|
// MARK: - ToolGroup convenience
|
||||||
|
|
||||||
extension ToolGroup {
|
extension ToolGroup {
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ struct HostExecToolCard: View {
|
|||||||
!$0.flags.isEmpty || !$0.arguments.isEmpty || !$0.env.isEmpty
|
!$0.flags.isEmpty || !$0.arguments.isEmpty || !$0.env.isEmpty
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private var canExpand: Bool { hasOutput || hasBreakdown }
|
/// A host-run `git commit` — surfaced as a structured commit card (subject + Markdown body),
|
||||||
|
/// like the Mac, instead of only the generic "Commit changes" purpose.
|
||||||
|
private var commit: GitCommitSummary.Commit? { GitCommitSummary.parse(command) }
|
||||||
|
private var canExpand: Bool { hasOutput || hasBreakdown || commit != nil }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
@@ -37,7 +40,11 @@ struct HostExecToolCard: View {
|
|||||||
label: { header }
|
label: { header }
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
if expanded {
|
if expanded {
|
||||||
if hasBreakdown, let parsed {
|
if let commit {
|
||||||
|
// The `$ command` is already shown in the header, so omit the disclosure here.
|
||||||
|
Divider().overlay(Color.primary.opacity(0.06))
|
||||||
|
GitCommitCard(commit: commit)
|
||||||
|
} else if hasBreakdown, let parsed {
|
||||||
Divider().overlay(Color.primary.opacity(0.06))
|
Divider().overlay(Color.primary.opacity(0.06))
|
||||||
HostCommandBreakdown(summary: parsed, showPurpose: false)
|
HostCommandBreakdown(summary: parsed, showPurpose: false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,9 +43,23 @@ struct ToolCallCard: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Only shell tools carry a literal command worth structuring (a git commit); everything else
|
||||||
|
/// keeps the plain input block, so a non-shell input can't be misread as a commit.
|
||||||
|
private var isShellTool: Bool { group.name == "Bash" || group.name == "Shell" }
|
||||||
|
|
||||||
@ViewBuilder private var details: some View {
|
@ViewBuilder private var details: some View {
|
||||||
let input = group.input.approvalDetail
|
let input = group.input.approvalDetail
|
||||||
if !input.isEmpty {
|
// A git commit reads as a structured commit card (subject + Markdown body); a multi-step or
|
||||||
|
// destructive shell pipeline reads as a compact step list (deletes flagged in red) — both
|
||||||
|
// like the Mac, with the literal command one tap away under "Show command". Everything else
|
||||||
|
// keeps the plain input block.
|
||||||
|
if isShellTool, let commit = GitCommitSummary.parse(input) {
|
||||||
|
GitCommitCard(commit: commit, rawCommand: input)
|
||||||
|
} else if isShellTool, let summary = HostCommandSummary.summary(for: input),
|
||||||
|
summary.invocations.count > 1 || summary.isDestructive {
|
||||||
|
HostCommandBreakdown(summary: summary, showPurpose: false)
|
||||||
|
CommandDisclosure(command: input, accent: Palette.accent)
|
||||||
|
} else if !input.isEmpty {
|
||||||
ToolBlock(label: "Input", text: input, mono: true)
|
ToolBlock(label: "Input", text: input, mono: true)
|
||||||
}
|
}
|
||||||
if let result = group.result {
|
if let result = group.result {
|
||||||
|
|||||||
Reference in New Issue
Block a user