diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift index 653c521..0e64aba 100644 --- a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -74,14 +74,17 @@ struct SplitRootView: View { @EnvironmentObject var store: RemoteStore @State private var selection: SidebarItem? = .home @State private var columnVisibility: NavigationSplitViewVisibility = .all + /// One-shot: a Cmd+N sets this, the Home composer consumes it to grab focus. + @State private var focusNewChat = false var body: some View { NavigationSplitView(columnVisibility: $columnVisibility) { - SplitSidebar(selection: $selection) + SplitSidebar(selection: $selection, focusNewChat: $focusNewChat) } detail: { - SplitDetail(selection: selection) + SplitDetail(selection: selection, focusNewChat: $focusNewChat) } .navigationSplitViewStyle(.balanced) + .background { keyboardShortcuts } // 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 @@ -96,6 +99,23 @@ struct SplitRootView: View { } } } + + /// Hardware-keyboard shortcuts (Magic Keyboard on iPad), the remote's echo of the Mac's menu + /// commands: ⌘N new chat, ⌘1–4 jump to a destination, ⌘R refresh. Carried by hidden buttons — + /// SwiftUI still routes a shortcut to a zero-opacity button that's in the hierarchy. + private var keyboardShortcuts: some View { + Group { + Button("New chat") { selection = .home; focusNewChat = true } + .keyboardShortcut("n", modifiers: .command) + Button("Home") { selection = .home }.keyboardShortcut("1", modifiers: .command) + Button("Projects") { selection = .projects }.keyboardShortcut("2", modifiers: .command) + Button("To-dos") { selection = .todos }.keyboardShortcut("3", modifiers: .command) + Button("Settings") { selection = .settings }.keyboardShortcut("4", modifiers: .command) + Button("Refresh") { store.refreshSessions() }.keyboardShortcut("r", modifiers: .command) + } + .opacity(0) + .accessibilityHidden(true) + } } /// The leading column: global destinations, then the live sessions grouped under their projects @@ -104,6 +124,7 @@ struct SplitRootView: View { private struct SplitSidebar: View { @EnvironmentObject var store: RemoteStore @Binding var selection: SidebarItem? + var focusNewChat: Binding = .constant(false) /// 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. @@ -173,11 +194,21 @@ private struct SplitSidebar: View { .listStyle(.sidebar) .navigationTitle(store.hostName.isEmpty ? "Nucleic" : store.hostName) .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + selection = .home + focusNewChat.wrappedValue = true + } label: { + Image(systemName: "square.and.pencil") + } + .help("New chat (⌘N)") + .disabled(!store.canControl) + } ToolbarItem(placement: .primaryAction) { Button { store.refreshSessions() } label: { Image(systemName: "arrow.clockwise") } - .help("Refresh") + .help("Refresh (⌘R)") } } .safeAreaInset(edge: .bottom) { @@ -192,11 +223,12 @@ private struct SplitSidebar: View { /// their own `NavigationStack`, so they're shown directly. private struct SplitDetail: View { let selection: SidebarItem? + var focusNewChat: Binding = .constant(false) var body: some View { switch selection { case .home, .none: - HomeView() + HomeView(focusNewChat: focusNewChat) case .projects: ProjectsView() case .todos: diff --git a/NucleicRemote/NucleicRemote/Views/Composer.swift b/NucleicRemote/NucleicRemote/Views/Composer.swift index 89b2157..44fef85 100644 --- a/NucleicRemote/NucleicRemote/Views/Composer.swift +++ b/NucleicRemote/NucleicRemote/Views/Composer.swift @@ -5,11 +5,16 @@ import NucleicProtocol /// composer). Picks a project, types a prompt, optionally toggles Auto. struct StartChatComposer: View { @EnvironmentObject var store: RemoteStore + /// A one-shot request from the iPad `Cmd+N` shortcut to focus the prompt so a keyboard user can + /// start typing a new chat immediately. Consumed (reset to false) once handled, so it never + /// steals focus on a later Home appearance. `.constant(false)` for the iPhone/compact path. + var focusNewChat: Binding = .constant(false) @State private var projectID: ProjectID? @State private var draft = "" @State private var auto = false @State private var model: String? @State private var effort = MobileEfforts.fallback + @FocusState private var draftFocused: Bool // Advanced options (the Mac new-session sheet's base branch + worktree fields). @State private var showOptions = false @State private var baseBranch = "" @@ -79,6 +84,7 @@ struct StartChatComposer: View { .lineLimit(1...5) .orchestraGlow(active: MobileEfforts.isOrchestra(effort) && controlled) .keyboardDismissable() + .focused($draftFocused) Button { if let project = selected { let branch = baseBranch.trimmingCharacters(in: .whitespaces) @@ -107,6 +113,18 @@ struct StartChatComposer: View { effort = levels.last ?? MobileEfforts.fallback } } + // A Cmd+N (iPad) focuses the prompt so the keyboard user can type a new chat immediately. + // onAppear handles the case where Cmd+N switched to Home from a session (composer just + // mounted); onChange handles Cmd+N while Home is already on screen. Consumed either way. + .onAppear { consumeFocusRequest() } + .onChange(of: focusNewChat.wrappedValue) { consumeFocusRequest() } + } + + private func consumeFocusRequest() { + guard focusNewChat.wrappedValue else { return } + if projectID == nil { projectID = store.dashboard.projects.first?.id } + draftFocused = true + focusNewChat.wrappedValue = false } } diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift index f1bd9c7..9f0e813 100644 --- a/NucleicRemote/NucleicRemote/Views/HomeView.swift +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -6,6 +6,8 @@ import NucleicProtocol /// start-chat composer (control scope). struct HomeView: View { @EnvironmentObject var store: RemoteStore + /// Set by the iPad `Cmd+N` shortcut to focus the start-chat composer below (consumed there). + var focusNewChat: Binding = .constant(false) private var counts: DashboardCounts { store.dashboard.counts } @@ -83,7 +85,7 @@ struct HomeView: View { QuickTodos() - StartChatComposer() + StartChatComposer(focusNewChat: focusNewChat) } .padding() // Cap + center the dashboard on a wide iPad so it reads like the Mac home rather @@ -138,6 +140,7 @@ private struct InProgressSessions: View { SessionRow(summary: summary) } .buttonStyle(.plain) + .hoverEffect(.highlight) } } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 9ccf742..0fcbf47 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -74,6 +74,19 @@ struct SessionDetailView: View { // 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) } + .background { interruptShortcut } + } + + /// ⌘. interrupts a running session (the Mac's "stop" convention) — the action is otherwise + /// only in the ⋯ menu. A hidden button carries the shortcut; present only when it applies. + @ViewBuilder + private var interruptShortcut: some View { + if store.canControl, summary?.status == .running { + Button("Interrupt") { store.interrupt(sessionID) } + .keyboardShortcut(".", modifiers: .command) + .opacity(0) + .accessibilityHidden(true) + } } /// Whether this session's project is under Nucleic Control (gates Orchestra + autoship). diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift index 3eda77d..ae953e6 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/ToolGroupRow.swift @@ -14,7 +14,9 @@ struct ToolCallCard: View { var body: some View { let content = VStack(alignment: .leading, spacing: 8) { - Button { expanded.toggle() } label: { header }.buttonStyle(.plain) + Button { expanded.toggle() } label: { header } + .buttonStyle(.plain) + .hoverEffect(.highlight) if expanded { details } } if inGroup { @@ -98,6 +100,7 @@ struct ToolBlockCard: View { VStack(alignment: .leading, spacing: 0) { header .contentShape(Rectangle()) + .hoverEffect(.highlight) .onTapGesture { withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() } } if expanded { ForEach(groups, id: \.toolCallID) { group in