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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ Thumbs.db
/macos/*/build/
*.xcodeproj/xcuserdata/
*.xcuserstate
.worktrees
1 change: 1 addition & 0 deletions MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Expected behaviors and their verification status. This is the project's oracle
| Control Center | Monitoring reconnect forces an authoritative detail/event reload before resuming cursor-based event fetches | `cd macos/ControlCenter && swift test --filter MonitoringStoreTests/testReconnectForcesAuthoritativeEventReloadBeforeCursorResumes` | covered | 2026-03-15 |
| Control Center | Selected session detail stays visible with retained output after the workload leaves live status | `cd macos/ControlCenter && swift test --filter MonitoringStoreTests/testSelectedDetailIsRetainedWhenWorkloadLeavesLiveStatus` | covered | 2026-03-15 |
| Control Center | Menu bar session shortcuts deep-link the app to the correct session detail and surface handoff-unavailable copy | `cd macos/ControlCenter && swift test --filter MonitoringStoreTests` | covered | 2026-03-15 |
| Control Center | Session detail launches Opencode through the supported attach flow using recorded session metadata | `cd macos/ControlCenter && swift test --filter SessionHandoffLauncherTests` | covered | 2026-03-15 |
| Agent Control | af kill <agent> sends SIGTERM and validates agent state | `go test ./internal/daemon/... -run TestHandleAgentKill` | covered | 2026-02-10 |
| Agent Control | Kill rejects invalid PIDs (0 or negative) | `go test ./internal/daemon/... -run TestHandleAgentKillInvalidPID` | covered | 2026-02-10 |
| Agent Control | Kill rejects non-running agents | `go test ./internal/daemon/... -run TestHandleAgentKillNonRunningAgent` | covered | 2026-02-10 |
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ struct AetherflowControlCenterApp: App {
@StateObject private var lifecycleStore: DaemonLifecycleStore
@StateObject private var monitoringStore: MonitoringStore
@StateObject private var navigationStore: NavigationStore
@StateObject private var handoffStore: SessionHandoffStore

init() {
let bootstrap = ShellBootstrapContext.detect()
Expand All @@ -18,6 +19,7 @@ struct AetherflowControlCenterApp: App {
_lifecycleStore = StateObject(wrappedValue: DaemonLifecycleStore(context: bootstrap, transportStore: transportStore))
_monitoringStore = StateObject(wrappedValue: MonitoringStore(context: bootstrap))
_navigationStore = StateObject(wrappedValue: NavigationStore())
_handoffStore = StateObject(wrappedValue: SessionHandoffStore())
}

var body: some Scene {
Expand All @@ -27,6 +29,7 @@ struct AetherflowControlCenterApp: App {
.environmentObject(lifecycleStore)
.environmentObject(monitoringStore)
.environmentObject(navigationStore)
.environmentObject(handoffStore)
}
.defaultSize(width: 1480, height: 920)

Expand All @@ -36,6 +39,7 @@ struct AetherflowControlCenterApp: App {
.environmentObject(lifecycleStore)
.environmentObject(monitoringStore)
.environmentObject(navigationStore)
.environmentObject(handoffStore)
}
.menuBarExtraStyle(.window)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,7 @@ private struct SectionPreview: View {
}
}
case .sessions:
SessionsDetailPanel(snapshot: monitoring)
SessionsDetailPanel(snapshot: monitoring, transport: transport)
case .queue:
QueueDetailPanel(snapshot: monitoring)
case .diagnostics:
Expand Down Expand Up @@ -1151,6 +1151,7 @@ private struct MonitoringEmptyRow: View {

private struct SessionsDetailPanel: View {
let snapshot: MonitoringSnapshot
let transport: TransportSnapshot

var body: some View {
if let detail = snapshot.selectedDetail {
Expand Down Expand Up @@ -1198,6 +1199,9 @@ private struct SessionsDetailPanel: View {
SessionFactCard(label: "Updated", value: monitoringTimestampLabel(detail.session.updatedAt))
}

SessionHandoffSection(detail: detail, transport: transport)
.id("\(detail.workloadID)::\(detail.session.sessionID)")

if let handoffUnavailableCopy = detail.handoffUnavailableCopy {
HandoffUnavailableCard(copy: handoffUnavailableCopy)
}
Expand Down Expand Up @@ -1263,6 +1267,88 @@ private struct SessionsDetailPanel: View {
}
}

private struct SessionHandoffSection: View {
@EnvironmentObject private var handoffStore: SessionHandoffStore

let detail: MonitoringSelectionDetail
let transport: TransportSnapshot

var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Opencode handoff")
.font(.system(size: 12, weight: .bold, design: .monospaced))
.foregroundStyle(ShellPalette.mutedInk)

HStack(alignment: .center, spacing: 12) {
Button {
handoffStore.requestLaunch(detail: detail, transport: transport)
} label: {
if launchPhase == .launching {
Text("Launching...")
} else {
Text("Open in Opencode")
}
}
.buttonStyle(.borderedProminent)
.disabled(!detail.session.attachable || launchPhase == .launching)

if launchPhase == .launching {
ProgressView()
.controlSize(.small)
}

MiniChip(
text: detail.session.attachable ? "Ready" : "Unavailable",
tone: detail.session.attachable ? ShellPalette.moss : ShellPalette.ember
)
}

Text(statusMessage)
.font(.system(size: 12, weight: .medium, design: .rounded))
.foregroundStyle(statusTone)
.fixedSize(horizontal: false, vertical: true)
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 18)
.fill(Color.white.opacity(0.22))
.overlay(
RoundedRectangle(cornerRadius: 18)
.stroke(ShellPalette.panelBorder, lineWidth: 1)
)
)
}

private var statusMessage: String {
switch launchPhase {
case .idle:
if detail.session.attachable {
return "Launches Terminal and runs the supported af session attach flow for this session."
}
return "This session cannot be opened in Opencode until the daemon exposes an attachable route."
case .launching:
return "Opening a Terminal handoff for \(detail.session.sessionID.nonEmptyValue ?? "the selected session")."
case let .success(message), let .failure(message):
return message
}
}

private var statusTone: Color {
switch launchPhase {
case .success:
return ShellPalette.moss
case .failure:
return ShellPalette.ember
case .idle, .launching:
return ShellPalette.mutedInk
}
}

private var launchPhase: SessionHandoffPhase {
handoffStore.phase(for: detail)
}
}

private struct HandoffUnavailableCard: View {
let copy: String

Expand Down
Loading
Loading