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
10 changes: 6 additions & 4 deletions native/ios/PsycheApp/Sources/PsycheApp/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ final class AppModel: ObservableObject {
@Published private(set) var hostName: String?

let workspaceStore: WorkspaceStore
/// Shared host-owned action state. Fixture roots receive a disconnected
/// store so UI previews can exercise the state surface without a socket.
/// Shared host-owned action state. Fixture roots use the same deterministic
/// control client as the workspace store, so menu-driven remote actions
/// exercise the real action-sheet flows without a socket.
let remoteActionStore: RemoteActionStore
/// `nil` under a fixture launch — that absence is what makes the fixture
/// root incapable of talking to a host.
Expand Down Expand Up @@ -53,11 +54,12 @@ final class AppModel: ObservableObject {
}

composition = nil
workspaceStore = DemoStore.makeWorkspaceStore(
let fixtureWorkspace = DemoStore.makeFixtureWorkspace(
fixture: fixture,
inspectionFails: fixtureInspectionFails
)
remoteActionStore = RemoteActionStore()
workspaceStore = fixtureWorkspace.workspaceStore
remoteActionStore = RemoteActionStore(controlRequests: fixtureWorkspace.controlRequests)
// A fixture terminal client, so the fixture shell renders real output
// through the real registry without opening a socket.
terminalRegistry = TerminalSessionRegistry(
Expand Down
15 changes: 15 additions & 0 deletions native/ios/PsycheApp/Sources/PsycheApp/CockpitView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ enum RootTab: String, Hashable, CaseIterable {
/// throw away where you were.
struct CockpitView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@EnvironmentObject private var remoteActionStore: RemoteActionStore
@EnvironmentObject private var store: WorkspaceStore

@State private var tab: RootTab = .now
Expand All @@ -48,9 +49,23 @@ struct CockpitView: View {
}
}
.tint(PsycheTheme.mint)
.sheet(isPresented: remoteActionSheetBinding) {
ActionSheetView(store: remoteActionStore)
}
.accessibilityIdentifier("main-cockpit")
}

private var remoteActionSheetBinding: Binding<Bool> {
Binding(
get: { remoteActionStore.presentation != nil },
set: { isPresented in
if !isPresented {
remoteActionStore.dismiss()
}
}
)
}

// MARK: - Compact

private var compactShell: some View {
Expand Down
14 changes: 11 additions & 3 deletions native/ios/PsycheApp/Sources/PsycheApp/DemoStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,16 @@ import PsycheCore
/// turn a named scenario into deterministic state.
@MainActor
enum DemoStore {
struct FixtureWorkspaceComposition {
let workspaceStore: WorkspaceStore
let controlRequests: FixtureControlRequests
}

/// Builds the deterministic store a `-uiFixture` launch runs against.
static func makeWorkspaceStore(
static func makeFixtureWorkspace(
fixture name: String,
inspectionFails: Bool = false
) -> WorkspaceStore {
) -> FixtureWorkspaceComposition {
let workspace = WorkspaceFixtures.workspace(named: name)
// A fixture control client, so create/rename/stop actually run and
// republish the workspace the way a host broadcast would. Without it
Expand All @@ -32,6 +37,9 @@ enum DemoStore {
store.applySnapshot(workspace: update.workspace, sequence: update.sequence)
}
}
return store
return FixtureWorkspaceComposition(
workspaceStore: store,
controlRequests: requests
)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import PsycheCore

enum ActionSheetSection: Equatable {
enum ActionSheetSection: Equatable, Hashable {
case scope
case consequence
case content
Expand Down Expand Up @@ -58,6 +58,10 @@ enum ActionSheetPresentation {
1...min(max(requestedMaximum ?? 6, 1), 12)
}

static func prefersMultilineInput(_ requestedMaximum: Int?) -> Bool {
inputLineRange(requestedMaximum).upperBound > 1
}

static func editingDisabled(isSubmitting: Bool) -> Bool { isSubmitting }

static func status(for kind: RemoteActionTerminalKind) -> ActionSheetStatus {
Expand All @@ -84,42 +88,7 @@ enum ActionSheetPresentation {
}

static func actionLabel(for action: PaneAction) -> String {
switch action {
case .view:
"View"
case .setSource:
"Set Source"
case .close:
"Close"
case .merge:
"Merge"
case .createPR:
"Create Pull Request"
case .rename:
"Rename"
case .duplicate:
"Duplicate"
case .runTest:
"Run Tests"
case .runDev:
"Run Development Server"
case .openOutput:
"Open Output"
case .copyPath:
"Copy Path"
case .openInEditor:
"Open in Editor"
case .toggleAutopilot:
"Toggle Autopilot"
case .attachAgent:
"Attach Agent"
case .createChildWorktree:
"Create Child Worktree"
case .openTerminalInWorktree:
"Open Terminal in Worktree"
case .openFileBrowser:
"Open File Browser"
}
action.presentationLabel
}

static func defaultMarker(for option: MobileActionOption) -> String? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ struct ActionSheetView: View {
Form {
if let presentation = store.presentation {
headerSection(for: presentation)

// Keep context ahead of every control, especially destructive choices.
scopeSection(for: presentation)
consequenceSection(for: presentation)
contentSections(for: presentation)
relatedFilesSection(for: presentation)
controlsSection(for: presentation)
ForEach(
ActionSheetPresentation.sectionOrder(
hasScope: !presentation.scope.rows.isEmpty,
hasConsequence: presentation.scope.consequence != nil,
hasRelatedFiles: !presentation.relatedFiles.isEmpty
),
id: \.self
) { section in
sectionView(section, presentation: presentation)
}
} else {
Section {
ContentUnavailableView(
Expand Down Expand Up @@ -50,24 +53,22 @@ struct ActionSheetView: View {
@ViewBuilder
private func headerSection(for presentation: RemoteActionPresentation) -> some View {
Section {
Text(ActionSheetPresentation.actionLabel(for: presentation.action))
Text(presentation.actionLabel)
.font(.headline)
LabeledContent("Pane", value: presentation.paneID)
}
}

@ViewBuilder
private func scopeSection(for presentation: RemoteActionPresentation) -> some View {
if !presentation.scope.rows.isEmpty {
Section("Scope") {
ForEach(presentation.scope.rows) { row in
LabeledContent {
Text(row.value)
.multilineTextAlignment(.trailing)
.textSelection(.enabled)
} label: {
Text(row.label)
}
Section("Scope") {
ForEach(presentation.scope.rows) { row in
LabeledContent {
Text(row.value)
.multilineTextAlignment(.trailing)
.textSelection(.enabled)
} label: {
Text(row.label)
}
}
}
Expand All @@ -94,13 +95,7 @@ struct ActionSheetView: View {
case .input(let input):
Section("Details") {
Text(presentation.message)
TextField(
input.placeholder ?? "Response",
text: $draft,
axis: .vertical
)
.lineLimit(ActionSheetPresentation.inputLineRange(input.maxVisibleLines))
.disabled(ActionSheetPresentation.editingDisabled(isSubmitting: store.isSubmitting))
inputField(for: input)
}
case .pullRequestReview(let review):
pullRequestReviewSection(
Expand Down Expand Up @@ -129,6 +124,25 @@ struct ActionSheetView: View {
}
}

@ViewBuilder
private func inputField(for input: RemoteActionInput) -> some View {
if ActionSheetPresentation.prefersMultilineInput(input.maxVisibleLines) {
TextField(
input.placeholder ?? "Response",
text: $draft,
axis: .vertical
)
.lineLimit(ActionSheetPresentation.inputLineRange(input.maxVisibleLines))
.disabled(ActionSheetPresentation.editingDisabled(isSubmitting: store.isSubmitting))
.accessibilityIdentifier("remote-action-input")
} else {
TextField(input.placeholder ?? "Response", text: $draft)
.lineLimit(1)
.disabled(ActionSheetPresentation.editingDisabled(isSubmitting: store.isSubmitting))
.accessibilityIdentifier("remote-action-input")
}
}

private func pullRequestReviewSection(
_ review: RemoteActionReview,
message: String
Expand Down Expand Up @@ -217,15 +231,32 @@ struct ActionSheetView: View {

@ViewBuilder
private func relatedFilesSection(for presentation: RemoteActionPresentation) -> some View {
if !presentation.relatedFiles.isEmpty {
Section("Related Files") {
ForEach(presentation.relatedFiles.indices, id: \.self) { index in
Label(presentation.relatedFiles[index], systemImage: "doc")
}
Section("Related Files") {
ForEach(presentation.relatedFiles.indices, id: \.self) { index in
Label(presentation.relatedFiles[index], systemImage: "doc")
}
}
}

@ViewBuilder
private func sectionView(
_ section: ActionSheetSection,
presentation: RemoteActionPresentation
) -> some View {
switch section {
case .scope:
scopeSection(for: presentation)
case .consequence:
consequenceSection(for: presentation)
case .content:
contentSections(for: presentation)
case .relatedFiles:
relatedFilesSection(for: presentation)
case .controls:
controlsSection(for: presentation)
}
}

@ViewBuilder
private func controlsSection(for presentation: RemoteActionPresentation) -> some View {
switch presentation.content {
Expand All @@ -250,6 +281,10 @@ struct ActionSheetView: View {
choiceLabel(for: option)
}
.disabled(store.isSubmitting)
.accessibilityElement(children: .ignore)
.accessibilityLabel(option.label)
.accessibilityValue(option.description ?? "")
.accessibilityIdentifier("remote-action-choice-\(option.id)")
}
responseButton("Cancel", response: .cancel)
}
Expand Down Expand Up @@ -316,10 +351,12 @@ struct ActionSheetView: View {
HStack(alignment: .firstTextBaseline, spacing: 12) {
VStack(alignment: .leading, spacing: 3) {
Text(option.label)
.accessibilityIdentifier("remote-action-choice-label-\(option.id)")
if let description = option.description {
Text(description)
.font(.footnote)
.foregroundStyle(.secondary)
.accessibilityIdentifier("remote-action-choice-description-\(option.id)")
}
}
Spacer()
Expand All @@ -331,6 +368,8 @@ struct ActionSheetView: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.accessibilityElement(children: .contain)
.accessibilityIdentifier("remote-action-choice-\(option.id)")
}

private func respond(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,27 @@ enum StopPaneConfirmation {
+ ". The worktree and its branch are kept — this does not delete any work."
}
}

/// The words shown before the cleanup flow begins.
///
/// The confirmation says a close will happen first and that the next screen
/// controls whether the worktree or branch are removed, so "cleanup" cannot be
/// mistaken for the cheaper stop action.
enum CleanupPaneConfirmation {
static func title(paneTitle: String) -> String {
"Close and clean up \(paneTitle)?"
}

static func message(
paneTitle: String,
projectTitle: String,
hostName: String?
) -> String {
var parts = ["Closes \(paneTitle) in \(projectTitle)"]
if let hostName, !hostName.isEmpty {
parts.append("on \(hostName)")
}
return parts.joined(separator: " ")
+ ". The next screen lets you keep the worktree, remove the worktree, or remove the worktree and its branch."
}
}
Loading
Loading