Merge branch 'dev' into canary
This commit is contained in:
@@ -1,17 +1,16 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import NucleicProtocol
|
import NucleicProtocol
|
||||||
|
|
||||||
/// Start a new chat in a project, straight from Home (control scope — mirrors the Mac's home
|
/// Start a new chat, presented in the `NewChatSheet` that the floating "+" opens. Styled as the
|
||||||
/// composer). Picks a project, types a prompt, optionally toggles Auto.
|
/// same Liquid Glass composer bar the open session carries, for consistency: picks a project (or
|
||||||
|
/// is scoped to one), types a prompt, optionally toggles Auto.
|
||||||
struct StartChatComposer: View {
|
struct StartChatComposer: View {
|
||||||
@EnvironmentObject var store: RemoteStore
|
@EnvironmentObject var store: RemoteStore
|
||||||
/// A one-shot request from the iPad `Cmd+N` shortcut to focus the prompt so a keyboard user can
|
/// Called after a chat is kicked off, so the presenting sheet can dismiss itself.
|
||||||
/// 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<Bool> = .constant(false)
|
|
||||||
/// Called after a chat is kicked off, so a host that presents the composer modally (e.g. the
|
|
||||||
/// Sessions tab's floating New-chat button) can dismiss itself. Home leaves it nil and stays put.
|
|
||||||
var onStart: (() -> Void)? = nil
|
var onStart: (() -> Void)? = nil
|
||||||
|
/// When set, the composer is scoped to this project and the picker is hidden — used by a
|
||||||
|
/// project page's "+", so the chat always lands in that project.
|
||||||
|
var lockedProject: WireProject? = nil
|
||||||
@State private var projectID: ProjectID?
|
@State private var projectID: ProjectID?
|
||||||
@State private var draft = ""
|
@State private var draft = ""
|
||||||
@State private var auto = false
|
@State private var auto = false
|
||||||
@@ -25,22 +24,28 @@ struct StartChatComposer: View {
|
|||||||
|
|
||||||
private var projects: [WireProject] { store.dashboard.projects }
|
private var projects: [WireProject] { store.dashboard.projects }
|
||||||
private var selected: WireProject? {
|
private var selected: WireProject? {
|
||||||
projects.first { $0.id == projectID } ?? projects.first
|
lockedProject ?? (projects.first { $0.id == projectID } ?? projects.first)
|
||||||
}
|
}
|
||||||
private var controlled: Bool { selected?.isNucleicControlled ?? false }
|
private var controlled: Bool { selected?.isNucleicControlled ?? false }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("New chat").font(.headline)
|
|
||||||
HStack {
|
HStack {
|
||||||
|
if lockedProject == nil {
|
||||||
Menu {
|
Menu {
|
||||||
ForEach(projects) { project in
|
ForEach(projects) { project in
|
||||||
Button(project.name) { projectID = project.id }
|
Button(project.name) { projectID = project.id }
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
|
// The model also selects the backend, so the picker offers every project.
|
||||||
Label(selected?.name ?? "Select a project", systemImage: "folder")
|
Label(selected?.name ?? "Select a project", systemImage: "folder")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Scoped to this project's page — show it as context, not a picker.
|
||||||
|
Label(selected?.name ?? "", systemImage: "folder")
|
||||||
|
.font(.subheadline).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Toggle(isOn: $auto) {
|
Toggle(isOn: $auto) {
|
||||||
Label("Auto", systemImage: auto ? "bolt.fill" : "bolt.slash")
|
Label("Auto", systemImage: auto ? "bolt.fill" : "bolt.slash")
|
||||||
@@ -50,7 +55,6 @@ struct StartChatComposer: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
// The model also selects the backend, so the home composer offers every provider.
|
|
||||||
ModelMenu(model: $model, catalog: store.modelCatalog, backend: nil)
|
ModelMenu(model: $model, catalog: store.modelCatalog, backend: nil)
|
||||||
Spacer()
|
Spacer()
|
||||||
EffortMenu(effort: $effort, catalog: store.modelCatalog, modelSKU: model, controlled: controlled)
|
EffortMenu(effort: $effort, catalog: store.modelCatalog, modelSKU: model, controlled: controlled)
|
||||||
@@ -82,9 +86,12 @@ struct StartChatComposer: View {
|
|||||||
.transition(.move(edge: .top).combined(with: .opacity))
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
}
|
}
|
||||||
HStack(alignment: .bottom, spacing: 8) {
|
HStack(alignment: .bottom, spacing: 8) {
|
||||||
|
// Plain, borderless field over the glass — matching the open session's chat bar
|
||||||
|
// rather than a boxed form control.
|
||||||
TextField("Describe a task…", text: $draft, axis: .vertical)
|
TextField("Describe a task…", text: $draft, axis: .vertical)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.plain)
|
||||||
.lineLimit(1...5)
|
.lineLimit(1...5)
|
||||||
|
.padding(.vertical, 3)
|
||||||
.orchestraGlow(active: MobileEfforts.isOrchestra(effort) && controlled)
|
.orchestraGlow(active: MobileEfforts.isOrchestra(effort) && controlled)
|
||||||
.keyboardDismissable()
|
.keyboardDismissable()
|
||||||
.focused($draftFocused)
|
.focused($draftFocused)
|
||||||
@@ -108,7 +115,9 @@ struct StartChatComposer: View {
|
|||||||
Text("This device is view-only.").font(.caption2).foregroundStyle(.secondary)
|
Text("This device is view-only.").font(.caption2).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.card()
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.glassSurface(cornerRadius: 26)
|
||||||
// Switching to a model with a lower effort cap can't leave an unsupported level selected.
|
// Switching to a model with a lower effort cap can't leave an unsupported level selected.
|
||||||
.onChange(of: model) { _, newModel in
|
.onChange(of: model) { _, newModel in
|
||||||
let levels = store.modelCatalog.offeredEfforts(forModel: newModel)
|
let levels = store.modelCatalog.offeredEfforts(forModel: newModel)
|
||||||
@@ -117,18 +126,84 @@ struct StartChatComposer: View {
|
|||||||
effort = levels.last ?? MobileEfforts.fallback
|
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() }
|
/// The sheet the floating "+" opens: the glass `StartChatComposer` in a compact panel. `project`
|
||||||
.onChange(of: focusNewChat.wrappedValue) { consumeFocusRequest() }
|
/// scopes it to one project (a project page's "+"); nil offers the picker (Home, Sessions).
|
||||||
|
struct NewChatSheet: View {
|
||||||
|
var project: WireProject? = nil
|
||||||
|
var dismiss: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
StartChatComposer(onStart: dismiss, lockedProject: project)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationTitle("New chat")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The composer needs only a fraction of the screen — a compact height by default, still
|
||||||
|
// expandable if the advanced options are opened.
|
||||||
|
.presentationDetents([.height(360), .large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The floating "+" start-chat affordance: a Liquid Glass circle in the lower-right that opens the
|
||||||
|
/// `NewChatSheet`. Shared by Home, Sessions, and a project page (which scopes it to `project`), so
|
||||||
|
/// all three offer the same control instead of an inline composer card.
|
||||||
|
struct NewChatAffordance: ViewModifier {
|
||||||
|
@EnvironmentObject var store: RemoteStore
|
||||||
|
/// Scopes the composer to one project (project page); nil offers the picker (Home, Sessions).
|
||||||
|
var project: WireProject? = nil
|
||||||
|
/// Owned by the caller so it can also open the sheet programmatically (iPad ⌘N on Home).
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
|
||||||
|
/// The button only makes sense when this device can control a Mac and there's a project to
|
||||||
|
/// start a chat in; otherwise the composer it opens would be inert.
|
||||||
|
private var canStart: Bool {
|
||||||
|
store.canControl && (project != nil || !store.dashboard.projects.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func consumeFocusRequest() {
|
func body(content: Content) -> some View {
|
||||||
guard focusNewChat.wrappedValue else { return }
|
content
|
||||||
if projectID == nil { projectID = store.dashboard.projects.first?.id }
|
.overlay(alignment: .bottomTrailing) {
|
||||||
draftFocused = true
|
if canStart {
|
||||||
focusNewChat.wrappedValue = false
|
Button { isPresented = true } label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.font(.system(size: 22, weight: .semibold))
|
||||||
|
.foregroundStyle(Palette.accent)
|
||||||
|
.frame(width: 56, height: 56)
|
||||||
|
// The whole disc is the hit target — without this, taps on the
|
||||||
|
// translucent glass outside the glyph fall through to the list row
|
||||||
|
// behind the button.
|
||||||
|
.contentShape(Circle())
|
||||||
|
.glassCircle()
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.trailing, 20)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
.accessibilityLabel("New chat")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $isPresented) {
|
||||||
|
NewChatSheet(project: project) { isPresented = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// Add the floating "+" start-chat affordance (glass circle → glass composer sheet). See
|
||||||
|
/// `NewChatAffordance`.
|
||||||
|
func newChatAffordance(project: WireProject? = nil, isPresented: Binding<Bool>) -> some View {
|
||||||
|
modifier(NewChatAffordance(project: project, isPresented: isPresented))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import NucleicProtocol
|
|||||||
/// start-chat composer (control scope).
|
/// start-chat composer (control scope).
|
||||||
struct HomeView: View {
|
struct HomeView: View {
|
||||||
@EnvironmentObject var store: RemoteStore
|
@EnvironmentObject var store: RemoteStore
|
||||||
/// Set by the iPad `Cmd+N` shortcut to focus the start-chat composer below (consumed there).
|
/// Set by the iPad `Cmd+N` shortcut; opens the new-chat sheet (consumed here).
|
||||||
var focusNewChat: Binding<Bool> = .constant(false)
|
var focusNewChat: Binding<Bool> = .constant(false)
|
||||||
|
@State private var showNewChat = false
|
||||||
|
|
||||||
private var counts: DashboardCounts { store.dashboard.counts }
|
private var counts: DashboardCounts { store.dashboard.counts }
|
||||||
|
|
||||||
@@ -95,8 +96,6 @@ struct HomeView: View {
|
|||||||
if !running.isEmpty { InProgressSessions(sessions: running) }
|
if !running.isEmpty { InProgressSessions(sessions: running) }
|
||||||
|
|
||||||
QuickTodos()
|
QuickTodos()
|
||||||
|
|
||||||
StartChatComposer(focusNewChat: focusNewChat)
|
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
// Cap + center the dashboard on a wide iPad so it reads like the Mac home rather
|
// Cap + center the dashboard on a wide iPad so it reads like the Mac home rather
|
||||||
@@ -107,6 +106,12 @@ struct HomeView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.refreshable { store.refreshSessions() }
|
.refreshable { store.refreshSessions() }
|
||||||
|
// A floating "+" replaces the old inline start-chat card; opens the glass composer
|
||||||
|
// sheet. An iPad ⌘N (via focusNewChat) opens the same sheet.
|
||||||
|
.newChatAffordance(isPresented: $showNewChat)
|
||||||
|
.onChange(of: focusNewChat.wrappedValue) { _, want in
|
||||||
|
if want { showNewChat = true; focusNewChat.wrappedValue = false }
|
||||||
|
}
|
||||||
.safeAreaInset(edge: .bottom) { ConnectionChip().padding(.bottom, 8) }
|
.safeAreaInset(edge: .bottom) { ConnectionChip().padding(.bottom, 8) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,9 +46,7 @@ struct ProjectsView: View {
|
|||||||
struct ProjectDetailView: View {
|
struct ProjectDetailView: View {
|
||||||
@EnvironmentObject var store: RemoteStore
|
@EnvironmentObject var store: RemoteStore
|
||||||
let project: WireProject
|
let project: WireProject
|
||||||
@State private var draft = ""
|
@State private var showNewChat = false
|
||||||
@State private var model: String?
|
|
||||||
@State private var effort = MobileEfforts.fallback
|
|
||||||
|
|
||||||
private var sessions: [WireSessionSummary] {
|
private var sessions: [WireSessionSummary] {
|
||||||
store.liveSessions
|
store.liveSessions
|
||||||
@@ -58,27 +56,6 @@ struct ProjectDetailView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
Section {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
HStack {
|
|
||||||
ModelMenu(model: $model, catalog: store.modelCatalog, backend: nil)
|
|
||||||
Spacer()
|
|
||||||
EffortMenu(effort: $effort, catalog: store.modelCatalog, modelSKU: model,
|
|
||||||
controlled: project.isNucleicControlled)
|
|
||||||
}
|
|
||||||
HStack(alignment: .bottom, spacing: 8) {
|
|
||||||
TextField("Start a chat in \(project.name)…", text: $draft, axis: .vertical)
|
|
||||||
.textFieldStyle(.roundedBorder).lineLimit(1...4)
|
|
||||||
.orchestraGlow(active: MobileEfforts.isOrchestra(effort) && project.isNucleicControlled)
|
|
||||||
.keyboardDismissable()
|
|
||||||
Button {
|
|
||||||
store.startChat(in: project.id, message: draft, model: model, effort: effort)
|
|
||||||
draft = ""
|
|
||||||
} label: { Image(systemName: "arrow.up.circle.fill").font(.title2) }
|
|
||||||
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Section("Sessions") {
|
Section("Sessions") {
|
||||||
if sessions.isEmpty {
|
if sessions.isEmpty {
|
||||||
Text("No sessions yet.").foregroundStyle(.secondary)
|
Text("No sessions yet.").foregroundStyle(.secondary)
|
||||||
@@ -93,12 +70,8 @@ struct ProjectDetailView: View {
|
|||||||
.navigationTitle(project.name)
|
.navigationTitle(project.name)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.onChange(of: model) { _, newModel in
|
// A floating "+" scoped to this project — the glass composer sheet opens pre-locked to it,
|
||||||
let levels = store.modelCatalog.offeredEfforts(forModel: newModel)
|
// replacing the old inline start-chat card at the top of the page.
|
||||||
let sentinel = store.modelCatalog.orchestraSentinelOrFallback
|
.newChatAffordance(project: project, isPresented: $showNewChat)
|
||||||
if effort.caseInsensitiveCompare(sentinel) != .orderedSame, !levels.contains(effort) {
|
|
||||||
effort = levels.last ?? MobileEfforts.fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,6 @@ struct SessionsView: View {
|
|||||||
@State private var showNewChat = false
|
@State private var showNewChat = false
|
||||||
@State private var path = NavigationPath()
|
@State private var path = NavigationPath()
|
||||||
|
|
||||||
/// The floating New-chat button only makes sense when this device can control a Mac and there's
|
|
||||||
/// at least one project to start a chat in; otherwise the composer it opens would be inert.
|
|
||||||
private var canStartChat: Bool { store.canControl && !store.dashboard.projects.isEmpty }
|
|
||||||
|
|
||||||
private var grouped: [(title: String, rows: [WireSessionSummary])] {
|
private var grouped: [(title: String, rows: [WireSessionSummary])] {
|
||||||
let pool = (showArchived ? store.sessions : store.liveSessions)
|
let pool = (showArchived ? store.sessions : store.liveSessions)
|
||||||
.sorted(by: StatusStyle.attentionThenRecency)
|
.sorted(by: StatusStyle.attentionThenRecency)
|
||||||
@@ -90,52 +86,13 @@ struct SessionsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A floating "new chat" affordance in the lower-right, mirroring the Mac's per-project
|
// A floating "new chat" affordance in the lower-right, mirroring the Mac's per-project
|
||||||
// "New chat" button. Picks a project + prompt in a sheet, then kicks off the chat.
|
// "New chat" button. Picks a project + prompt in a glass sheet, then kicks off the chat.
|
||||||
.overlay(alignment: .bottomTrailing) {
|
// Applied before the chip inset so it floats clear above the connection chip.
|
||||||
if canStartChat { newChatButton }
|
.newChatAffordance(isPresented: $showNewChat)
|
||||||
}
|
|
||||||
.sheet(isPresented: $showNewChat) {
|
|
||||||
NavigationStack {
|
|
||||||
ScrollView {
|
|
||||||
StartChatComposer(onStart: { showNewChat = false })
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
.navigationTitle("New chat")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .topBarLeading) {
|
|
||||||
Button("Cancel") { showNewChat = false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The composer needs only a fraction of the screen — a compact height by default,
|
|
||||||
// still expandable if the advanced options are opened.
|
|
||||||
.presentationDetents([.height(360), .large])
|
|
||||||
.presentationDragIndicator(.visible)
|
|
||||||
}
|
|
||||||
.safeAreaInset(edge: .bottom) { ConnectionChip().padding(.bottom, 8) }
|
.safeAreaInset(edge: .bottom) { ConnectionChip().padding(.bottom, 8) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The circular plus button, on Liquid Glass rather than a solid teal disc — an accent-tinted
|
|
||||||
/// glyph over a translucent glass circle so it reads as a floating control, not a stamped
|
|
||||||
/// button. Sits low over the list, clear of the connection chip.
|
|
||||||
private var newChatButton: some View {
|
|
||||||
Button {
|
|
||||||
showNewChat = true
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "plus")
|
|
||||||
.font(.system(size: 22, weight: .semibold))
|
|
||||||
.foregroundStyle(Palette.accent)
|
|
||||||
.frame(width: 56, height: 56)
|
|
||||||
.glassCircle()
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.padding(.trailing, 20)
|
|
||||||
.padding(.bottom, 6)
|
|
||||||
.accessibilityLabel("New chat")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Push the session a notification tap asked for, then clear the request.
|
/// Push the session a notification tap asked for, then clear the request.
|
||||||
private func consumeRoute() {
|
private func consumeRoute() {
|
||||||
guard let route = store.pendingRoute else { return }
|
guard let route = store.pendingRoute else { return }
|
||||||
|
|||||||
Reference in New Issue
Block a user