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
40 changes: 37 additions & 3 deletions Sources/CodeIsland/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ final class AppState {
}
private var modelReadRetryAt: [String: Date] = [:]

private var dismissedPermissionSessionIds: Set<String> = []
private func nextVisiblePermissionIndex() -> Int? {
permissionQueue.firstIndex { request in
let sid = request.event.sessionId ?? "default"
return !dismissedPermissionSessionIds.contains(sid)
}
}

var rotatingSessionId: String?
var rotatingSession: SessionSnapshot? {
guard let rid = rotatingSessionId else { return nil }
Expand Down Expand Up @@ -844,6 +852,9 @@ final class AppState {
extractMetadata(into: &sessions, sessionId: sessionId, event: event)
tryMonitorSession(sessionId)

// New incoming permission request means session needs user decision again.
dismissedPermissionSessionIds.remove(sessionId)

// Clear any pending questions for THIS session (mutually exclusive within a session)
drainQuestions(forSession: sessionId)

Expand All @@ -867,6 +878,8 @@ final class AppState {
func approvePermission(always: Bool = false) {
guard !permissionQueue.isEmpty else { return }
let pending = permissionQueue.removeFirst()
let sessionId = pending.event.sessionId ?? "default"
dismissedPermissionSessionIds.remove(sessionId)
let responseData: Data
if always {
let toolName = pending.event.toolName ?? ""
Expand All @@ -890,7 +903,6 @@ final class AppState {
responseData = Data(response.utf8)
}
pending.continuation.resume(returning: responseData)
let sessionId = pending.event.sessionId ?? "default"
sessions[sessionId]?.status = .running

showNextPending()
Expand All @@ -900,9 +912,10 @@ final class AppState {
func denyPermission() {
guard !permissionQueue.isEmpty else { return }
let pending = permissionQueue.removeFirst()
let sessionId = pending.event.sessionId ?? "default"
dismissedPermissionSessionIds.remove(sessionId)
let response = #"{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny"}}}"#
pending.continuation.resume(returning: Data(response.utf8))
let sessionId = pending.event.sessionId ?? "default"
sessions[sessionId]?.status = .idle
sessions[sessionId]?.currentTool = nil
sessions[sessionId]?.toolDescription = nil
Expand All @@ -915,6 +928,24 @@ final class AppState {
refreshDerivedState()
}

func dismissPermissionPrompt() {
guard let pending = permissionQueue.first else { return }

let sessionId = pending.event.sessionId ?? "default"
dismissedPermissionSessionIds.insert(sessionId)

if nextVisiblePermissionIndex() != nil {
showNextPending()
} else {
if case .approvalCard = surface {
withAnimation(NotchAnimation.close) {
surface = .collapsed
}
}
}
refreshDerivedState()
}

func handleQuestion(_ event: HookEvent, continuation: CheckedContinuation<Data, Never>) {
let sessionId = event.sessionId ?? "default"
if sessions[sessionId] == nil {
Expand Down Expand Up @@ -1150,6 +1181,7 @@ final class AppState {

/// Drain all queued permissions for a specific session, resuming their continuations with deny
private func drainPermissions(forSession sessionId: String) {
dismissedPermissionSessionIds.remove(sessionId)
let denyResponse = Data(#"{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny"}}}"#.utf8)
permissionQueue.removeAll { item in
guard item.event.sessionId == sessionId else { return false }
Expand Down Expand Up @@ -1195,7 +1227,9 @@ final class AppState {
/// After dequeuing, show next pending item or collapse
@discardableResult
private func showNextPending() -> Bool {
if let next = permissionQueue.first {
if let idx = nextVisiblePermissionIndex() {
let next = permissionQueue.remove(at: idx)
permissionQueue.insert(next, at: 0)
let sid = next.event.sessionId ?? "default"
activeSessionId = sid
surface = .approvalCard(sessionId: sid)
Expand Down
3 changes: 3 additions & 0 deletions Sources/CodeIsland/L10n.swift
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ final class L10n: ObservableObject {
"enable_sound_tooltip": "Enable Sound",
"settings": "Settings",
"deny": "DENY",
"dismiss": "DISMISS",
"allow_once": "ALLOW ONCE",
"always": "ALWAYS",
"type_answer": "Type your answer...",
Expand Down Expand Up @@ -460,6 +461,7 @@ final class L10n: ObservableObject {
"enable_sound_tooltip": "开启音效",
"settings": "设置",
"deny": "拒绝",
"dismiss": "忽略",
"allow_once": "允许一次",
"always": "始终允许",
"type_answer": "输入回答…",
Expand Down Expand Up @@ -684,6 +686,7 @@ final class L10n: ObservableObject {
"enable_sound_tooltip": "Sesi Etkinleştir",
"settings": "Ayarlar",
"deny": "REDDET",
"dismiss": "ERTELE",
"allow_once": "BİR KEZ İZİN VER",
"always": "HER ZAMAN",
"type_answer": "Cevabınızı yazın...",
Expand Down
5 changes: 4 additions & 1 deletion Sources/CodeIsland/NotchPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,8 @@ struct NotchPanelView: View {
queueTotal: appState.permissionQueue.count,
onAllow: { appState.approvePermission(always: false) },
onAlwaysAllow: { appState.approvePermission(always: true) },
onDeny: { appState.denyPermission() }
onDeny: { appState.denyPermission() },
onDismiss: { appState.dismissPermissionPrompt() }
)
.transition(.blurFade.combined(with: .scale(scale: 0.96, anchor: .top)))
}
Expand Down Expand Up @@ -667,6 +668,7 @@ private struct ApprovalBar: View {
let onAllow: () -> Void
let onAlwaysAllow: () -> Void
let onDeny: () -> Void
let onDismiss: () -> Void

private var fileName: String? {
guard let fp = toolInput?["file_path"] as? String else { return nil }
Expand Down Expand Up @@ -726,6 +728,7 @@ private struct ApprovalBar: View {
// Pixel-style buttons
HStack(spacing: 6) {
PixelButton(label: L10n.shared["deny"], fg: .white.opacity(0.95), bg: Color(red: 0.45, green: 0.12, blue: 0.12), border: Color(red: 0.7, green: 0.25, blue: 0.25), action: onDeny)
PixelButton(label: L10n.shared["dismiss"], fg: .white.opacity(0.95), bg: Color(red: 0.25, green: 0.25, blue: 0.25), border: Color.white.opacity(0.28), action: onDismiss)
PixelButton(label: L10n.shared["allow_once"], fg: .white.opacity(0.95), bg: Color(red: 0.16, green: 0.38, blue: 0.18), border: Color(red: 0.28, green: 0.62, blue: 0.32), action: onAllow)
PixelButton(label: L10n.shared["always"], fg: .white.opacity(0.95), bg: Color(red: 0.14, green: 0.28, blue: 0.52), border: Color(red: 0.28, green: 0.48, blue: 0.82), action: onAlwaysAllow)
}
Expand Down
157 changes: 157 additions & 0 deletions Tests/CodeIslandTests/AppStatePermissionFlowTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import XCTest
@testable import CodeIsland
import CodeIslandCore

@MainActor
final class AppStatePermissionFlowTests: XCTestCase {

func testDismissPermissionSkipsAlreadyDismissedSessions() async throws {
let appState = AppState()

let eventA = try makePermissionRequestEvent(sessionId: "s1", toolName: "Bash")
let eventB = try makePermissionRequestEvent(sessionId: "s2", toolName: "Read")

let responseTaskA = Task<Data, Never> {
await withCheckedContinuation { continuation in
appState.handlePermissionRequest(eventA, continuation: continuation)
}
}
let responseTaskB = Task<Data, Never> {
await withCheckedContinuation { continuation in
appState.handlePermissionRequest(eventB, continuation: continuation)
}
}

await Task.yield()

XCTAssertEqual(appState.permissionQueue.count, 2)
XCTAssertEqual(appState.surface, .approvalCard(sessionId: "s1"))

appState.dismissPermissionPrompt()
XCTAssertEqual(appState.surface, .approvalCard(sessionId: "s2"))
XCTAssertEqual(appState.permissionQueue.count, 2)

appState.dismissPermissionPrompt()
XCTAssertEqual(appState.surface, .collapsed)
XCTAssertEqual(appState.permissionQueue.count, 2)

await assertTaskNotResolved(responseTaskA)
await assertTaskNotResolved(responseTaskB)

appState.handlePeerDisconnect(sessionId: "s1")
appState.handlePeerDisconnect(sessionId: "s2")

let responseA = await responseTaskA.value
let responseB = await responseTaskB.value

XCTAssertEqual(try extractPermissionBehavior(from: responseA), "deny")
XCTAssertEqual(try extractPermissionBehavior(from: responseB), "deny")
XCTAssertEqual(appState.permissionQueue.count, 0)
}

func testDismissSinglePermissionCollapsesAndKeepsPending() async throws {
let appState = AppState()
let sessionId = "s-single"
let event = try makePermissionRequestEvent(sessionId: sessionId, toolName: "Bash")

let responseTask = Task<Data, Never> {
await withCheckedContinuation { continuation in
appState.handlePermissionRequest(event, continuation: continuation)
}
}

await Task.yield()

XCTAssertEqual(appState.surface, .approvalCard(sessionId: sessionId))
XCTAssertEqual(appState.permissionQueue.count, 1)
XCTAssertEqual(appState.sessions[sessionId]?.status, .waitingApproval)

appState.dismissPermissionPrompt()

XCTAssertEqual(appState.surface, .collapsed)
XCTAssertEqual(appState.permissionQueue.count, 1)
XCTAssertEqual(appState.sessions[sessionId]?.status, .waitingApproval)

await assertTaskNotResolved(responseTask)

appState.handlePeerDisconnect(sessionId: sessionId)
let response = await responseTask.value
XCTAssertEqual(try extractPermissionBehavior(from: response), "deny")
}

func testDismissedSessionGetsShownAgainWhenNewPermissionArrivesAfterDrain() async throws {
let appState = AppState()
let sessionId = "s-reappear"

let firstEvent = try makePermissionRequestEvent(sessionId: sessionId, toolName: "Edit")
let firstResponseTask = Task<Data, Never> {
await withCheckedContinuation { continuation in
appState.handlePermissionRequest(firstEvent, continuation: continuation)
}
}

await Task.yield()
appState.dismissPermissionPrompt()
XCTAssertEqual(appState.surface, .collapsed)
XCTAssertEqual(appState.permissionQueue.count, 1)

appState.handlePeerDisconnect(sessionId: sessionId)
let firstResponse = await firstResponseTask.value
XCTAssertEqual(try extractPermissionBehavior(from: firstResponse), "deny")
XCTAssertEqual(appState.permissionQueue.count, 0)

let secondEvent = try makePermissionRequestEvent(sessionId: sessionId, toolName: "Write")
let secondResponseTask = Task<Data, Never> {
await withCheckedContinuation { continuation in
appState.handlePermissionRequest(secondEvent, continuation: continuation)
}
}

await Task.yield()

XCTAssertEqual(appState.surface, .approvalCard(sessionId: sessionId))
XCTAssertEqual(appState.permissionQueue.count, 1)

appState.approvePermission()

let secondResponse = await secondResponseTask.value
XCTAssertEqual(try extractPermissionBehavior(from: secondResponse), "allow")
XCTAssertEqual(appState.permissionQueue.count, 0)
}

// MARK: - Helpers

private func makePermissionRequestEvent(sessionId: String, toolName: String) throws -> HookEvent {
let payload: [String: Any] = [
"hook_event_name": "PermissionRequest",
"session_id": sessionId,
"tool_name": toolName,
"tool_input": ["command": "echo test"]
]
let data = try JSONSerialization.data(withJSONObject: payload)
guard let event = HookEvent(from: data) else {
XCTFail("Failed to parse HookEvent")
throw NSError(domain: "AppStatePermissionFlowTests", code: 1)
}
return event
}

private func extractPermissionBehavior(from responseData: Data) throws -> String {
let json = try XCTUnwrap(try JSONSerialization.jsonObject(with: responseData) as? [String: Any])
let hookSpecificOutput = try XCTUnwrap(json["hookSpecificOutput"] as? [String: Any])
let decision = try XCTUnwrap(hookSpecificOutput["decision"] as? [String: Any])
return try XCTUnwrap(decision["behavior"] as? String)
}

private func assertTaskNotResolved(_ task: Task<Data, Never>, timeout: TimeInterval = 0.05) async {
let exp = expectation(description: "task should stay pending")
exp.isInverted = true

Task {
_ = await task.value
exp.fulfill()
}

await fulfillment(of: [exp], timeout: timeout)
}
}