From b4c622005ee100e6baf8446f881198b0b4b77d65 Mon Sep 17 00:00:00 2001 From: Salman Ansari Date: Wed, 9 Sep 2026 12:26:26 -0700 Subject: [PATCH] Fix persistent conversation interruption --- .2119/verdicts/REQ-001.3.1--23b0c6ee18b7.json | 8 ++ .2119/verdicts/REQ-003.3.1--6b150fe4d08a.json | 8 ++ .2119/verdicts/REQ-003.3.2--b4d0a25be6ad.json | 8 ++ .2119/verdicts/REQ-003.3.3--b2a7d666fb0c.json | 8 ++ .2119/verdicts/REQ-003.4.7--5e70640a7941.json | 8 ++ .2119/verdicts/REQ-003.5.1--3a45b40c2919.json | 8 ++ .2119/verdicts/REQ-003.5.2--9aeb2dd320d4.json | 8 ++ .2119/verdicts/REQ-003.5.3--c8d1e2978b8a.json | 8 ++ .2119/verdicts/REQ-003.5.4--2914c78ad137.json | 8 ++ .2119/verdicts/REQ-003.5.5--cc8c912774cd.json | 8 ++ .2119/verdicts/REQ-008.1.2--b8e233ae1065.json | 8 ++ .2119/verdicts/REQ-009.3.3--01f6497894f2.json | 8 ++ .2119/verdicts/REQ-012.1.1--1826f6840eec.json | 8 ++ .2119/verdicts/REQ-012.1.2--012fcfcd65a6.json | 8 ++ .2119/verdicts/REQ-012.2.1--e52d376c9ca9.json | 8 ++ .2119/verdicts/REQ-012.2.3--e522e2809320.json | 8 ++ .2119/verdicts/REQ-012.3.1--68ce168f5ab8.json | 8 ++ .2119/verdicts/REQ-013.1.1--9847b5b4e36c.json | 8 ++ .2119/verdicts/REQ-013.1.2--76dc5ff10310.json | 8 ++ .2119/verdicts/REQ-013.1.3--510e74159816.json | 8 ++ .2119/verdicts/REQ-013.2.1--ee6b97d0a566.json | 8 ++ .2119/verdicts/REQ-013.2.2--b788b02f4d51.json | 8 ++ .2119/verdicts/REQ-013.3.1--ebcd74a8c8f9.json | 8 ++ .2119/verdicts/REQ-013.3.2--88a90fc49ded.json | 8 ++ CHANGELOG.md | 7 ++ PiNative.xcodeproj/project.pbxproj | 6 +- PiNative/AppModel.swift | 12 +- PiNative/AttachmentSupport.swift | 12 ++ PiNative/ChatPaneView.swift | 3 +- PiNative/DiffPaneView.swift | 6 +- PiNative/ExtensionsPageView.swift | 2 +- PiNative/PiConversationModel.swift | 71 +++++++++-- PiNative/PiConversationView.swift | 25 +++- PiNative/ProjectSidebarView.swift | 2 +- PiNative/PromoteToProject.swift | 4 +- PiNativeTests/AppModelConversationTests.swift | 65 ++++++++-- .../ArchivedChatsRenderingTests.swift | 42 +++++++ PiNativeTests/AttachmentSupportTests.swift | 29 +++++ PiNativeTests/ChatTitleTests.swift | 2 +- .../ComposerPromptHistoryMountedTests.swift | 55 +++++++++ PiNativeTests/ParallelRuntimeTests.swift | 13 +- PiNativeTests/StopButtonTests.swift | 116 +++++++++++++++++- PiNativeUITests/ActiveWorkUITests.swift | 73 ++++++++++- ...conversation-navigation-and-active-work.md | 13 +- 44 files changed, 701 insertions(+), 49 deletions(-) create mode 100644 .2119/verdicts/REQ-001.3.1--23b0c6ee18b7.json create mode 100644 .2119/verdicts/REQ-003.3.1--6b150fe4d08a.json create mode 100644 .2119/verdicts/REQ-003.3.2--b4d0a25be6ad.json create mode 100644 .2119/verdicts/REQ-003.3.3--b2a7d666fb0c.json create mode 100644 .2119/verdicts/REQ-003.4.7--5e70640a7941.json create mode 100644 .2119/verdicts/REQ-003.5.1--3a45b40c2919.json create mode 100644 .2119/verdicts/REQ-003.5.2--9aeb2dd320d4.json create mode 100644 .2119/verdicts/REQ-003.5.3--c8d1e2978b8a.json create mode 100644 .2119/verdicts/REQ-003.5.4--2914c78ad137.json create mode 100644 .2119/verdicts/REQ-003.5.5--cc8c912774cd.json create mode 100644 .2119/verdicts/REQ-008.1.2--b8e233ae1065.json create mode 100644 .2119/verdicts/REQ-009.3.3--01f6497894f2.json create mode 100644 .2119/verdicts/REQ-012.1.1--1826f6840eec.json create mode 100644 .2119/verdicts/REQ-012.1.2--012fcfcd65a6.json create mode 100644 .2119/verdicts/REQ-012.2.1--e52d376c9ca9.json create mode 100644 .2119/verdicts/REQ-012.2.3--e522e2809320.json create mode 100644 .2119/verdicts/REQ-012.3.1--68ce168f5ab8.json create mode 100644 .2119/verdicts/REQ-013.1.1--9847b5b4e36c.json create mode 100644 .2119/verdicts/REQ-013.1.2--76dc5ff10310.json create mode 100644 .2119/verdicts/REQ-013.1.3--510e74159816.json create mode 100644 .2119/verdicts/REQ-013.2.1--ee6b97d0a566.json create mode 100644 .2119/verdicts/REQ-013.2.2--b788b02f4d51.json create mode 100644 .2119/verdicts/REQ-013.3.1--ebcd74a8c8f9.json create mode 100644 .2119/verdicts/REQ-013.3.2--88a90fc49ded.json create mode 100644 PiNativeTests/ArchivedChatsRenderingTests.swift diff --git a/.2119/verdicts/REQ-001.3.1--23b0c6ee18b7.json b/.2119/verdicts/REQ-001.3.1--23b0c6ee18b7.json new file mode 100644 index 0000000..8c82867 --- /dev/null +++ b/.2119/verdicts/REQ-001.3.1--23b0c6ee18b7.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-001.3.1--23b0c6ee18b7", + "requirementId": "REQ-001.3.1", + "hash": "23b0c6ee18b7", + "verdict": "pass", + "summary": "Tests exercise the real PromptAttachmentAssembler.prepare and PiRPCClient.promptFields, asserting mimeType and base64EncodedString() data for two distinct image types plus RPC field wiring; a raw-bytes or missing-mimeType violation would fail these assertions.", + "timestamp": "2026-09-09T18:54:31.582Z" +} diff --git a/.2119/verdicts/REQ-003.3.1--6b150fe4d08a.json b/.2119/verdicts/REQ-003.3.1--6b150fe4d08a.json new file mode 100644 index 0000000..bc9b9bf --- /dev/null +++ b/.2119/verdicts/REQ-003.3.1--6b150fe4d08a.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.3.1--6b150fe4d08a", + "requirementId": "REQ-003.3.1", + "hash": "6b150fe4d08a", + "verdict": "pass", + "summary": "Unit tests (archived-newer, pinned-only, loaded-from-disk) and UI test (multi-project, pinned/archived/older exclusions via XCTAssertNotEqual) both verify each conjunct: newest, visible (not archived), unpinned, and scoped to the selected project; no tautologies or over-mocking found.", + "timestamp": "2026-09-09T18:54:32.783Z" +} diff --git a/.2119/verdicts/REQ-003.3.2--b4d0a25be6ad.json b/.2119/verdicts/REQ-003.3.2--b4d0a25be6ad.json new file mode 100644 index 0000000..016c1e9 --- /dev/null +++ b/.2119/verdicts/REQ-003.3.2--b4d0a25be6ad.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.3.2--b4d0a25be6ad", + "requirementId": "REQ-003.3.2", + "hash": "b4d0a25be6ad", + "verdict": "pass", + "summary": "Unit tests verify archived-only/mixed project rows never select archived session (nil or newestVisible), and UI test performs real clicks across row offsets confirming archived chat title never becomes selected; genuine behavioral coverage of the MUST NOT.", + "timestamp": "2026-09-09T18:54:35.665Z" +} diff --git a/.2119/verdicts/REQ-003.3.3--b2a7d666fb0c.json b/.2119/verdicts/REQ-003.3.3--b2a7d666fb0c.json new file mode 100644 index 0000000..556f3b6 --- /dev/null +++ b/.2119/verdicts/REQ-003.3.3--b2a7d666fb0c.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.3.3--b2a7d666fb0c", + "requirementId": "REQ-003.3.3", + "hash": "b2a7d666fb0c", + "verdict": "pass", + "summary": "Unit test rejects selecting pinned/archived-only project (stays nil) and UI test rejects clicking project row selecting the pinned chat when an unpinned visible chat exists, both meaningful negative-space checks.", + "timestamp": "2026-09-09T18:54:26.970Z" +} diff --git a/.2119/verdicts/REQ-003.4.7--5e70640a7941.json b/.2119/verdicts/REQ-003.4.7--5e70640a7941.json new file mode 100644 index 0000000..5f464e1 --- /dev/null +++ b/.2119/verdicts/REQ-003.4.7--5e70640a7941.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.4.7--5e70640a7941", + "requirementId": "REQ-003.4.7", + "hash": "5e70640a7941", + "verdict": "pass", + "summary": "Tests exercise real select() (no mocking/guards) for both pending-startup and queued-prompt cases, asserting selectedSessionID actually changes to the other chat, which would fail if selection were blocked.", + "timestamp": "2026-09-09T18:55:05.405Z" +} diff --git a/.2119/verdicts/REQ-003.5.1--3a45b40c2919.json b/.2119/verdicts/REQ-003.5.1--3a45b40c2919.json new file mode 100644 index 0000000..bac622c --- /dev/null +++ b/.2119/verdicts/REQ-003.5.1--3a45b40c2919.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.5.1--3a45b40c2919", + "requirementId": "REQ-003.5.1", + "hash": "3a45b40c2919", + "verdict": "pass", + "summary": "Stop (StopButtonTests + ActiveWorkUITests) and mounted Escape (ComposerPromptHistoryMountedTests) both assert isRunning=false, a single 'Stopped.' notice, focus-request change, and real elapsed-time <1s from trigger; idle Escape no-op is tested as negative space, so the requirement's same-state and 1s conjuncts are genuinely covered.", + "timestamp": "2026-09-09T19:08:32.528Z" +} diff --git a/.2119/verdicts/REQ-003.5.2--9aeb2dd320d4.json b/.2119/verdicts/REQ-003.5.2--9aeb2dd320d4.json new file mode 100644 index 0000000..baf79c7 --- /dev/null +++ b/.2119/verdicts/REQ-003.5.2--9aeb2dd320d4.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.5.2--9aeb2dd320d4", + "requirementId": "REQ-003.5.2", + "hash": "9aeb2dd320d4", + "verdict": "pass", + "summary": "Tests exercise real late-arriving RPC output (in-process delay and real subprocess) after interrupt/restart, asserting suppression from transcript and correct new-turn output; genuine, non-tautological coverage of the requirement's boundary.", + "timestamp": "2026-09-09T18:54:55.132Z" +} diff --git a/.2119/verdicts/REQ-003.5.3--c8d1e2978b8a.json b/.2119/verdicts/REQ-003.5.3--c8d1e2978b8a.json new file mode 100644 index 0000000..34bc132 --- /dev/null +++ b/.2119/verdicts/REQ-003.5.3--c8d1e2978b8a.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.5.3--c8d1e2978b8a", + "requirementId": "REQ-003.5.3", + "hash": "c8d1e2978b8a", + "verdict": "pass", + "summary": "Tests cover hydration/resync fence dedup, navigation-away-and-back + AppModel relaunch persistence of Stopped. notice, and rendering updates across later turns; genuine behavioral checks, not tautological.", + "timestamp": "2026-09-09T18:55:02.786Z" +} diff --git a/.2119/verdicts/REQ-003.5.4--2914c78ad137.json b/.2119/verdicts/REQ-003.5.4--2914c78ad137.json new file mode 100644 index 0000000..51ca404 --- /dev/null +++ b/.2119/verdicts/REQ-003.5.4--2914c78ad137.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.5.4--2914c78ad137", + "requirementId": "REQ-003.5.4", + "hash": "2914c78ad137", + "verdict": "pass", + "summary": "Test starts two chats running, interrupts the selected one, and asserts the non-selected chat's running state and transcript items remain unchanged (with a follow-up event proving it still functions) — genuinely rejects the plausible cross-chat interruption bug; not mocked, not tautological.", + "timestamp": "2026-09-09T18:56:50.713Z" +} diff --git a/.2119/verdicts/REQ-003.5.5--cc8c912774cd.json b/.2119/verdicts/REQ-003.5.5--cc8c912774cd.json new file mode 100644 index 0000000..d637db1 --- /dev/null +++ b/.2119/verdicts/REQ-003.5.5--cc8c912774cd.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-003.5.5--cc8c912774cd", + "requirementId": "REQ-003.5.5", + "hash": "cc8c912774cd", + "verdict": "pass", + "summary": "Idle-escape assertions (items/composerFocusRequest/isRunning unchanged after second Escape) genuinely reject a spurious no-op interrupt causing extra Stopped. notice or focus churn; test is not tautological.", + "timestamp": "2026-09-09T19:08:37.515Z" +} diff --git a/.2119/verdicts/REQ-008.1.2--b8e233ae1065.json b/.2119/verdicts/REQ-008.1.2--b8e233ae1065.json new file mode 100644 index 0000000..79f9bf4 --- /dev/null +++ b/.2119/verdicts/REQ-008.1.2--b8e233ae1065.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-008.1.2--b8e233ae1065", + "requirementId": "REQ-008.1.2", + "hash": "b8e233ae1065", + "verdict": "pass", + "summary": "Tests genuinely verify at-most-once generation gated on full settlement: agent_end doesn't trigger (count=0), agent_settled does (count=1), second turns/teardown-recreation/restoration/failure/interruption paths all confirm no re-attempt (count stays 1 or 0); call-count assertions are behavioral, not tautological or over-mocked.", + "timestamp": "2026-09-09T18:56:40.668Z" +} diff --git a/.2119/verdicts/REQ-009.3.3--01f6497894f2.json b/.2119/verdicts/REQ-009.3.3--01f6497894f2.json new file mode 100644 index 0000000..2584282 --- /dev/null +++ b/.2119/verdicts/REQ-009.3.3--01f6497894f2.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-009.3.3--01f6497894f2", + "requirementId": "REQ-009.3.3", + "hash": "01f6497894f2", + "verdict": "pass", + "summary": "Tests genuinely verify: after chat switch, first navigateOlder call returns the newly focused chat's most recent user message (not the prior chat's, not an older one), and correctly returns empty when the destination chat has no user messages; uses real PiConversationModel/ComposerPromptHistoryNavigator without mocking the constrained behavior.", + "timestamp": "2026-09-09T18:56:50.713Z" +} diff --git a/.2119/verdicts/REQ-012.1.1--1826f6840eec.json b/.2119/verdicts/REQ-012.1.1--1826f6840eec.json new file mode 100644 index 0000000..d72abc4 --- /dev/null +++ b/.2119/verdicts/REQ-012.1.1--1826f6840eec.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-012.1.1--1826f6840eec", + "requirementId": "REQ-012.1.1", + "hash": "1826f6840eec", + "verdict": "pass", + "summary": "UI test (ProjectUITests) genuinely exercises clicking a project-specific diff pill and asserts the rendered Pending Changes pane shows that project's real git status summary (branch, files, added/deleted), and rejects the cross-project violation by confirming switching projects swaps the displayed status rather than retaining stale data; unit test supplements by verifying correct project routing.", + "timestamp": "2026-09-09T18:58:42.359Z" +} diff --git a/.2119/verdicts/REQ-012.1.2--012fcfcd65a6.json b/.2119/verdicts/REQ-012.1.2--012fcfcd65a6.json new file mode 100644 index 0000000..5afdeda --- /dev/null +++ b/.2119/verdicts/REQ-012.1.2--012fcfcd65a6.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-012.1.2--012fcfcd65a6", + "requirementId": "REQ-012.1.2", + "hash": "012fcfcd65a6", + "verdict": "pass", + "summary": "Test selects a session in one project, then invokes showPendingChanges on a different project and asserts selectedProjectID/selectedSessionID remain unchanged — a genuine, non-mocked check that would fail if opening pending changes altered the selected conversation.", + "timestamp": "2026-09-09T18:58:46.767Z" +} diff --git a/.2119/verdicts/REQ-012.2.1--e52d376c9ca9.json b/.2119/verdicts/REQ-012.2.1--e52d376c9ca9.json new file mode 100644 index 0000000..296db95 --- /dev/null +++ b/.2119/verdicts/REQ-012.2.1--e52d376c9ca9.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-012.2.1--e52d376c9ca9", + "requirementId": "REQ-012.2.1", + "hash": "e52d376c9ca9", + "verdict": "pass", + "summary": "Real git-repo test (staged/unstaged/untracked mix) verifies branch line, per-file status, and aggregate file/addition/deletion counts against actual git output, not mocked; boundary cases (M vs ?? status, mixed change types) are exercised and asserted.", + "timestamp": "2026-09-09T19:08:26.322Z" +} diff --git a/.2119/verdicts/REQ-012.2.3--e522e2809320.json b/.2119/verdicts/REQ-012.2.3--e522e2809320.json new file mode 100644 index 0000000..5a702cd --- /dev/null +++ b/.2119/verdicts/REQ-012.2.3--e522e2809320.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-012.2.3--e522e2809320", + "requirementId": "REQ-012.2.3", + "hash": "e522e2809320", + "verdict": "pass", + "summary": "Test switches DiffModel to a second real-git project and asserts summary is nil (not the first project's file list) synchronously after refresh() is called, matching the sync clear in DiffModel.refresh; real git repos used, no over-mocking, directly rejects the stale-summary-during-load violation.", + "timestamp": "2026-09-09T18:58:48.655Z" +} diff --git a/.2119/verdicts/REQ-012.3.1--68ce168f5ab8.json b/.2119/verdicts/REQ-012.3.1--68ce168f5ab8.json new file mode 100644 index 0000000..972a0ac --- /dev/null +++ b/.2119/verdicts/REQ-012.3.1--68ce168f5ab8.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-012.3.1--68ce168f5ab8", + "requirementId": "REQ-012.3.1", + "hash": "68ce168f5ab8", + "verdict": "pass", + "summary": "Test refreshes same project path twice after mutating repo state and asserts the second summary (staged.txt removed, tracked.txt additions/deletions changed) replaces the first, genuinely covering the replace-with-new-result requirement.", + "timestamp": "2026-09-09T19:07:28.459Z" +} diff --git a/.2119/verdicts/REQ-013.1.1--9847b5b4e36c.json b/.2119/verdicts/REQ-013.1.1--9847b5b4e36c.json new file mode 100644 index 0000000..23ace0f --- /dev/null +++ b/.2119/verdicts/REQ-013.1.1--9847b5b4e36c.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-013.1.1--9847b5b4e36c", + "requirementId": "REQ-013.1.1", + "hash": "9847b5b4e36c", + "verdict": "pass", + "summary": "testArchivedChatsListAndRestorePreserveSelectionAndMetadata now uses distinct real updatedAt values (30 vs 20) across project/quick sources, and asserts exact merged order plus title/source(projectID+projectName)/messageCount for each — genuinely exercises AppModel.archivedChats' cross-source sort-by-updatedAt, not tautological.", + "timestamp": "2026-09-09T19:11:58.437Z" +} diff --git a/.2119/verdicts/REQ-013.1.2--76dc5ff10310.json b/.2119/verdicts/REQ-013.1.2--76dc5ff10310.json new file mode 100644 index 0000000..1de6f78 --- /dev/null +++ b/.2119/verdicts/REQ-013.1.2--76dc5ff10310.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-013.1.2--76dc5ff10310", + "requirementId": "REQ-013.1.2", + "hash": "76dc5ff10310", + "verdict": "pass", + "summary": "ArchivedChatsRenderingTests renders the real RightPaneView with an empty AppModel and OCRs the composited bitmap for 'No Archived Chats', genuinely exercising the archivedChats.isEmpty branch in RightPaneView.swift; AppModelConversationTests corroborates the underlying empty-data precondition. No mocking of the render path, no keyword-only matching, and the assertion would fail on a broken/blank empty state.", + "timestamp": "2026-09-09T19:21:53.780Z" +} diff --git a/.2119/verdicts/REQ-013.1.3--510e74159816.json b/.2119/verdicts/REQ-013.1.3--510e74159816.json new file mode 100644 index 0000000..4474d91 --- /dev/null +++ b/.2119/verdicts/REQ-013.1.3--510e74159816.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-013.1.3--510e74159816", + "requirementId": "REQ-013.1.3", + "hash": "510e74159816", + "verdict": "pass", + "summary": "Test sets selectedSessionID before openRightPane(.archivedChats) and asserts it is unchanged after, genuinely rejecting a violation where opening archived chats would change/reset selection.", + "timestamp": "2026-09-09T19:11:47.055Z" +} diff --git a/.2119/verdicts/REQ-013.2.1--ee6b97d0a566.json b/.2119/verdicts/REQ-013.2.1--ee6b97d0a566.json new file mode 100644 index 0000000..1f70423 --- /dev/null +++ b/.2119/verdicts/REQ-013.2.1--ee6b97d0a566.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-013.2.1--ee6b97d0a566", + "requirementId": "REQ-013.2.1", + "hash": "ee6b97d0a566", + "verdict": "pass", + "summary": "Tests exercise real unarchiveSession/pinnedSessions/visibleSessionShortcuts logic, verifying removal from archivedChats and correct section placement across pinned/unpinned x project/standalone combinations; no over-mocking or tautology found.", + "timestamp": "2026-09-09T19:04:13.870Z" +} diff --git a/.2119/verdicts/REQ-013.2.2--b788b02f4d51.json b/.2119/verdicts/REQ-013.2.2--b788b02f4d51.json new file mode 100644 index 0000000..920ab0a --- /dev/null +++ b/.2119/verdicts/REQ-013.2.2--b788b02f4d51.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-013.2.2--b788b02f4d51", + "requirementId": "REQ-013.2.2", + "hash": "b788b02f4d51", + "verdict": "pass", + "summary": "Test sets selected session distinct from restored chat, asserts selectedSessionID unchanged after unarchiveSession; genuine rejection of the violating case where restore reassigns selection.", + "timestamp": "2026-09-09T19:04:33.941Z" +} diff --git a/.2119/verdicts/REQ-013.3.1--ebcd74a8c8f9.json b/.2119/verdicts/REQ-013.3.1--ebcd74a8c8f9.json new file mode 100644 index 0000000..845d6bc --- /dev/null +++ b/.2119/verdicts/REQ-013.3.1--ebcd74a8c8f9.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-013.3.1--ebcd74a8c8f9", + "requirementId": "REQ-013.3.1", + "hash": "ebcd74a8c8f9", + "verdict": "pass", + "summary": "Tests verify identity (id lookup), project/quick association (session moved between projects.sessions and standaloneSessions), filePath, cachedTranscript, and isPinned are all preserved across both archive and restore directions with real state checks, not tautologies.", + "timestamp": "2026-09-09T19:05:14.123Z" +} diff --git a/.2119/verdicts/REQ-013.3.2--88a90fc49ded.json b/.2119/verdicts/REQ-013.3.2--88a90fc49ded.json new file mode 100644 index 0000000..1a1989a --- /dev/null +++ b/.2119/verdicts/REQ-013.3.2--88a90fc49ded.json @@ -0,0 +1,8 @@ +{ + "reviewId": "REQ-013.3.2--88a90fc49ded", + "requirementId": "REQ-013.3.2", + "hash": "88a90fc49ded", + "verdict": "pass", + "summary": "testArchivedAndRestoredChatsPersistAcrossRelaunch genuinely reloads via fresh AppModel() instances backed by real UserDefaults/disk persistence, verifying both archive and restore survive a simulated relaunch for standalone and project sessions.", + "timestamp": "2026-09-09T19:05:11.557Z" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index b50336b..0554f38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 2026-09-09 + + +### Fixed + +- Unified Stop and Escape interruption so active work stops through one lifecycle, late output stays suppressed, and a persistent bold interruption status survives chat refreshes, navigation, and relaunch. Refined red feedback with a darker light-theme treatment. + ## 2026-09-02 diff --git a/PiNative.xcodeproj/project.pbxproj b/PiNative.xcodeproj/project.pbxproj index c1cc5d5..47c421e 100644 --- a/PiNative.xcodeproj/project.pbxproj +++ b/PiNative.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ B20000000000000000000020 /* StopButtonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000021 /* StopButtonTests.swift */; }; B20000000000000000000040 /* PromoteToProjectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000041 /* PromoteToProjectTests.swift */; }; B20000000000000000000044 /* AppModelConversationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000045 /* AppModelConversationTests.swift */; }; + R1300000000000000000001 /* ArchivedChatsRenderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = R1300000000000000000002 /* ArchivedChatsRenderingTests.swift */; }; B20000000000000000000046 /* ConversationRestorationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000047 /* ConversationRestorationTests.swift */; }; B20000000000000000000042 /* ParallelRuntimeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000043 /* ParallelRuntimeTests.swift */; }; B20000000000000000000030 /* ChatReadinessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000031 /* ChatReadinessTests.swift */; }; @@ -80,6 +81,7 @@ B20000000000000000000021 /* StopButtonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopButtonTests.swift; sourceTree = ""; }; B20000000000000000000041 /* PromoteToProjectTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromoteToProjectTests.swift; sourceTree = ""; }; B20000000000000000000045 /* AppModelConversationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModelConversationTests.swift; sourceTree = ""; }; + R1300000000000000000002 /* ArchivedChatsRenderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchivedChatsRenderingTests.swift; sourceTree = ""; }; B20000000000000000000047 /* ConversationRestorationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationRestorationTests.swift; sourceTree = ""; }; B20000000000000000000043 /* ParallelRuntimeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParallelRuntimeTests.swift; sourceTree = ""; }; B20000000000000000000031 /* ChatReadinessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatReadinessTests.swift; sourceTree = ""; }; @@ -139,7 +141,7 @@ /* Begin PBXGroup section */ E089F906CDE66AC4DE0B4F15 = {isa = PBXGroup; children = (D0E626B11EE2E26B8A53ED03 /* PiNative */, B20000000000000000000005 /* PiNativeTests */, B10000000000000000000005 /* PiNativeUITests */, BD9F9D28A4CE3701DBFE1F16 /* Products */,); sourceTree = ""; }; D0E626B11EE2E26B8A53ED03 /* PiNative */ = {isa = PBXGroup; children = (A4F4E099E55872B7CD055B17 /* Info.plist */, C84606F68DFF82AAD6188CA7, 5CBE4FCD8A524D79E75AB937, T10000000000000000000002 /* ChatTitleService.swift */, AC1000000000000000000002, P20000000000000000000002 /* PromoteToProject.swift */, M00000000000000000000002 /* ModelSettingsModel.swift */, M00000000000000000000004 /* ModelSettingsView.swift */, M00000000000000000000006 /* SettingsSection.swift */, 39174CAEDA0A24BA7426D974, 639F8CF536299A8279E3BDF3, 4CB86D6D5E6DF695BBFC4648, A22222222222222222222222, A44444444444444444444444, 37BDA846F2D5F231576CC31A, H10000000000000000000002 /* PiHealth.swift */, 6D3854512F688EC89284C386, 2E0FAB1DF804CEE1B91D9B4C, 83244A51E66C4FB7B47C044E, EF1D997813F7495191EA5BAA, 2D3E4F5A6B7C8D9E0F112233, F0A000000000000000000020 /* Resources */, 0AAD256F0C3440739EFB395D /* Components */,); path = PiNative; sourceTree = ""; }; - B20000000000000000000005 /* PiNativeTests */ = {isa = PBXGroup; children = (B20000000000000000000002 /* AttachmentSupportTests.swift */, B20000000000000000000052 /* ComposerPromptHistoryUnitTests.swift */, B20000000000000000000054 /* ComposerPromptHistoryMountedTests.swift */, B20000000000000000000021 /* StopButtonTests.swift */, B20000000000000000000031 /* ChatReadinessTests.swift */, H20000000000000000000002 /* PiStartupHealthTests.swift */, B20000000000000000000047 /* ConversationRestorationTests.swift */, B20000000000000000000045 /* AppModelConversationTests.swift */, B20000000000000000000041 /* PromoteToProjectTests.swift */, B20000000000000000000043 /* ParallelRuntimeTests.swift */, M00000000000000000000008 /* ModelSettingsTests.swift */, T20000000000000000000002 /* ChatTitleTests.swift */,); path = PiNativeTests; sourceTree = ""; }; + B20000000000000000000005 /* PiNativeTests */ = {isa = PBXGroup; children = (B20000000000000000000002 /* AttachmentSupportTests.swift */, B20000000000000000000052 /* ComposerPromptHistoryUnitTests.swift */, B20000000000000000000054 /* ComposerPromptHistoryMountedTests.swift */, B20000000000000000000021 /* StopButtonTests.swift */, B20000000000000000000031 /* ChatReadinessTests.swift */, H20000000000000000000002 /* PiStartupHealthTests.swift */, B20000000000000000000047 /* ConversationRestorationTests.swift */, B20000000000000000000045 /* AppModelConversationTests.swift */, R1300000000000000000002 /* ArchivedChatsRenderingTests.swift */, B20000000000000000000041 /* PromoteToProjectTests.swift */, B20000000000000000000043 /* ParallelRuntimeTests.swift */, M00000000000000000000008 /* ModelSettingsTests.swift */, T20000000000000000000002 /* ChatTitleTests.swift */,); path = PiNativeTests; sourceTree = ""; }; B10000000000000000000005 /* PiNativeUITests */ = {isa = PBXGroup; children = (B10000000000000000000002 /* PiNativeUITestCase.swift */, C10000000000000000000002 /* ProjectUITests.swift */, C10000000000000000000004 /* ShellChromeUITests.swift */, C10000000000000000000006 /* NewChatUITests.swift */, C10000000000000000000008 /* PromoteToProjectUITests.swift */, C10000000000000000000010 /* ConversationNavigationUITests.swift */, C10000000000000000000012 /* ChatReadinessUITests.swift */, H30000000000000000000002 /* PiStartupHealthUITests.swift */, C10000000000000000000014 /* ActiveWorkUITests.swift */, C10000000000000000000016 /* ModelSettingsUITests.swift */,); path = PiNativeUITests; sourceTree = ""; }; 0AAD256F0C3440739EFB395D /* Components */ = {isa = PBXGroup; children = (7B6CCA67BBC5465080FDEDA3 /* ResizableDividerView.swift */, 2E66A9D075FC494B888EB403 /* ComingSoonPane.swift */, 9A2222222222222222222222 /* NewChatStartView.swift */, 9A4444444444444444444444 /* WindowChromeConfigurator.swift */, 9A6666666666666666666666 /* MapleFont.swift */,); path = Components; sourceTree = ""; }; F0A000000000000000000020 /* Resources */ = {isa = PBXGroup; children = (AA1000000000000000000002 /* Assets.xcassets */, F0A000000000000000000021 /* Fonts */,); path = Resources; sourceTree = ""; }; @@ -162,7 +164,7 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - B20000000000000000000009 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B20000000000000000000001 /* AttachmentSupportTests.swift in Sources */, PH20000000000000000000001 /* AnalyticsTests.swift in Sources */, B20000000000000000000051 /* ComposerPromptHistoryUnitTests.swift in Sources */, B20000000000000000000053 /* ComposerPromptHistoryMountedTests.swift in Sources */, B20000000000000000000020 /* StopButtonTests.swift in Sources */, B20000000000000000000030 /* ChatReadinessTests.swift in Sources */, H20000000000000000000001 /* PiStartupHealthTests.swift in Sources */, B20000000000000000000046 /* ConversationRestorationTests.swift in Sources */, B20000000000000000000044 /* AppModelConversationTests.swift in Sources */, B20000000000000000000040 /* PromoteToProjectTests.swift in Sources */, B20000000000000000000042 /* ParallelRuntimeTests.swift in Sources */, M00000000000000000000007 /* ModelSettingsTests.swift in Sources */, T20000000000000000000001 /* ChatTitleTests.swift in Sources */,); runOnlyForDeploymentPostprocessing = 0; }; + B20000000000000000000009 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B20000000000000000000001 /* AttachmentSupportTests.swift in Sources */, PH20000000000000000000001 /* AnalyticsTests.swift in Sources */, B20000000000000000000051 /* ComposerPromptHistoryUnitTests.swift in Sources */, B20000000000000000000053 /* ComposerPromptHistoryMountedTests.swift in Sources */, B20000000000000000000020 /* StopButtonTests.swift in Sources */, B20000000000000000000030 /* ChatReadinessTests.swift in Sources */, H20000000000000000000001 /* PiStartupHealthTests.swift in Sources */, B20000000000000000000046 /* ConversationRestorationTests.swift in Sources */, B20000000000000000000044 /* AppModelConversationTests.swift in Sources */, R1300000000000000000001 /* ArchivedChatsRenderingTests.swift in Sources */, B20000000000000000000040 /* PromoteToProjectTests.swift in Sources */, B20000000000000000000042 /* ParallelRuntimeTests.swift in Sources */, M00000000000000000000007 /* ModelSettingsTests.swift in Sources */, T20000000000000000000001 /* ChatTitleTests.swift in Sources */,); runOnlyForDeploymentPostprocessing = 0; }; B10000000000000000000009 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B10000000000000000000001 /* PiNativeUITestCase.swift in Sources */, C10000000000000000000001 /* ProjectUITests.swift in Sources */, C10000000000000000000003 /* ShellChromeUITests.swift in Sources */, C10000000000000000000005 /* NewChatUITests.swift in Sources */, C10000000000000000000007 /* PromoteToProjectUITests.swift in Sources */, C10000000000000000000009 /* ConversationNavigationUITests.swift in Sources */, C10000000000000000000011 /* ChatReadinessUITests.swift in Sources */, H30000000000000000000001 /* PiStartupHealthUITests.swift in Sources */, C10000000000000000000013 /* ActiveWorkUITests.swift in Sources */, C10000000000000000000015 /* ModelSettingsUITests.swift in Sources */,); runOnlyForDeploymentPostprocessing = 0; }; 3B7F5E0625A26093DFD7689A /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (C91A821BBD8FC19135EFCBCA, PH10000000000000000000001 /* Analytics.swift in Sources */, 94A8D39EFF7FB95895DA2429, AC1000000000000000000001, P20000000000000000000001 /* PromoteToProject.swift in Sources */, 235685A27DDE01B5D736BB3C, B6B0755D2911C382985AF503, 0A05B88A079DBE985586E196, A11111111111111111111111, A33333333333333333333333, F9929FC807AAF9F82196B366, H10000000000000000000001 /* PiHealth.swift in Sources */, 48688F523F9A83A0E9442735, 5276C82553DEF1BA0318E7F2, 4A910114936B41909D5C475F, B2CF10EA35FC4681BB02C530, 8DAC23A1E9A942EFB74071DB, C8BCB535833E41F6AD37358B, 9A1111111111111111111111, 9A3333333333333333333333, 9A5555555555555555555555, 1D2E3F4A5B6C7D8E9F001122, M00000000000000000000001 /* ModelSettingsModel.swift in Sources */, M00000000000000000000003 /* ModelSettingsView.swift in Sources */, M00000000000000000000005 /* SettingsSection.swift in Sources */, T10000000000000000000001 /* ChatTitleService.swift in Sources */,); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ diff --git a/PiNative/AppModel.swift b/PiNative/AppModel.swift index 36a154e..bbb5b98 100644 --- a/PiNative/AppModel.swift +++ b/PiNative/AppModel.swift @@ -10,7 +10,11 @@ enum AppTheme { light: NSColor(calibratedRed: 0.20, green: 0.48, blue: 0.78, alpha: 1), dark: NSColor(calibratedRed: 0.47, green: 0.72, blue: 1.00, alpha: 1) ) - static let stopAccent = Color(red: 0.48, green: 0.10, blue: 0.16) + static let dangerText = dynamicColor( + light: NSColor(calibratedRed: 0.47, green: 0.07, blue: 0.11, alpha: 1), + dark: NSColor(calibratedRed: 0.94, green: 0.30, blue: 0.32, alpha: 1) + ) + static let stopAccent = dangerText static let recoveryAccent = Color(nsColor: .systemOrange) static let recoveryBackground = dynamicColor( light: NSColor(calibratedRed: 1.0, green: 0.91, blue: 0.72, alpha: 1), @@ -800,6 +804,12 @@ final class AppModel: ObservableObject { activeConversationModel?.startProcessIfNeeded() } + @discardableResult + func interruptSelectedConversation() -> Bool { + guard selectedSessionID != nil else { return false } + return activeConversationModel?.interruptActiveTurn() ?? false + } + func refreshModelCatalog(force: Bool = false) async { let workingDirectory = URL( fileURLWithPath: ((selectedProject?.path ?? NSHomeDirectory()) as NSString).expandingTildeInPath, diff --git a/PiNative/AttachmentSupport.swift b/PiNative/AttachmentSupport.swift index 4398a13..86eb0b2 100644 --- a/PiNative/AttachmentSupport.swift +++ b/PiNative/AttachmentSupport.swift @@ -344,6 +344,7 @@ struct PasteAwareTextView: NSViewRepresentable { var textUpdateRequest: UUID? = nil @Environment(\.isEnabled) private var isEnabled var onSubmit: () -> Void + var onEscape: () -> Void = {} var onHistoryOlder: () -> Bool = { false } var onHistoryNewer: () -> Bool = { false } var onUserEdit: () -> Void = {} @@ -357,6 +358,7 @@ struct PasteAwareTextView: NSViewRepresentable { scrollView.borderType = .noBorder let textView = PasteInterceptingTextView() textView.onSubmit = onSubmit + textView.onEscape = onEscape textView.onHistoryOlder = onHistoryOlder textView.onHistoryNewer = onHistoryNewer textView.onPasteAttachments = onPasteAttachments @@ -414,6 +416,7 @@ struct PasteAwareTextView: NSViewRepresentable { textView.insertionPointColor = AppTheme.insertionPointNSColor textView.isEditable = isEnabled textView.onSubmit = onSubmit + textView.onEscape = onEscape textView.onHistoryOlder = onHistoryOlder textView.onHistoryNewer = onHistoryNewer textView.onPasteAttachments = onPasteAttachments @@ -444,6 +447,7 @@ struct PasteAwareTextView: NSViewRepresentable { final class PasteInterceptingTextView: NSTextView { var onSubmit: (() -> Void)? + var onEscape: (() -> Void)? var onHistoryOlder: (() -> Bool)? var onHistoryNewer: (() -> Bool)? var onPasteAttachments: (([ComposerAttachment], [AttachmentImportError]) -> Void)? @@ -533,6 +537,14 @@ final class PasteInterceptingTextView: NSTextView { } override func keyDown(with event: NSEvent) { + if event.keyCode == 53 { + if hasMarkedText() { + super.keyDown(with: event) + } else { + onEscape?() + } + return + } if event.keyCode == 36, !event.modifierFlags.contains(.shift) { onSubmit?() return diff --git a/PiNative/ChatPaneView.swift b/PiNative/ChatPaneView.swift index ce18246..92feca8 100644 --- a/PiNative/ChatPaneView.swift +++ b/PiNative/ChatPaneView.swift @@ -20,7 +20,8 @@ struct ChatPaneView: View { PiConversationView( model: model, modelSettings: appModel.modelSettings, - onSelectFavorites: { appModel.presentModelSettings() } + onSelectFavorites: { appModel.presentModelSettings() }, + onInterrupt: { appModel.interruptSelectedConversation() } ) .id(ObjectIdentifier(model)) } else { diff --git a/PiNative/DiffPaneView.swift b/PiNative/DiffPaneView.swift index b7f9ee1..d299854 100644 --- a/PiNative/DiffPaneView.swift +++ b/PiNative/DiffPaneView.swift @@ -255,7 +255,7 @@ private struct DiffTotalsView: View { HStack(spacing: 10) { StatCard(label: "Files", value: "\(summary.changedFileCount)", color: .secondary) StatCard(label: "Added", value: "+\(summary.additions)", color: .green) - StatCard(label: "Deleted", value: "-\(summary.deletions)", color: .red) + StatCard(label: "Deleted", value: "-\(summary.deletions)", color: AppTheme.dangerText) } } } @@ -299,7 +299,7 @@ private struct DiffFileRow: View { Text("+\(file.additions)").foregroundStyle(.green) } if file.deletions > 0 { - Text("-\(file.deletions)").foregroundStyle(.red) + Text("-\(file.deletions)").foregroundStyle(AppTheme.dangerText) } } .font(.caption.monospacedDigit().weight(.semibold)) @@ -311,7 +311,7 @@ private struct DiffFileRow: View { private var statusColor: Color { if file.status.contains("?") || file.status.contains("A") { return .green } - if file.status.contains("D") { return .red } + if file.status.contains("D") { return AppTheme.dangerText } return .orange } } diff --git a/PiNative/ExtensionsPageView.swift b/PiNative/ExtensionsPageView.swift index 249e3c8..ae2a35d 100644 --- a/PiNative/ExtensionsPageView.swift +++ b/PiNative/ExtensionsPageView.swift @@ -72,7 +72,7 @@ struct ExtensionsPageView: View { VStack(alignment: .leading, spacing: 8) { Label("Failed to load", systemImage: "exclamationmark.triangle") .font(.headline) - .foregroundStyle(.red) + .foregroundStyle(AppTheme.dangerText) Text(message) .textSelection(.enabled) .foregroundStyle(.secondary) diff --git a/PiNative/PiConversationModel.swift b/PiNative/PiConversationModel.swift index 7936afb..763fed3 100644 --- a/PiNative/PiConversationModel.swift +++ b/PiNative/PiConversationModel.swift @@ -336,11 +336,12 @@ final class PiConversationModel: ObservableObject { draftAttachments.removeAll { $0.id == id } } - /// Stops the active turn from the user's perspective immediately, then - /// attempts a server-side abort. If Pi does not acknowledge quickly, - /// terminate/restart the RPC process so work is actually interrupted. - func stopActiveTurn() { - guard isRunning || pendingPrompt != nil else { return } + /// Interrupts the active turn from the user's perspective immediately, + /// then performs server-side abort and process recovery. Stop and Escape + /// both enter through this method so their lifecycle cannot diverge. + @discardableResult + func interruptActiveTurn() -> Bool { + guard isRunning || pendingPrompt != nil else { return false } pendingPrompt = nil isRunning = false activeLocalTurnID = nil @@ -348,22 +349,24 @@ final class PiConversationModel: ObservableObject { onAgentRunAbandoned?() assistantBufferID = nil closeCurrentActivityGroup() - items.append(.notice("Stopped.")) + items.append(.notice(TranscriptItem.interruptionNoticeText)) + composerFocusRequest = UUID() let clientToAbort = client client = nil isSessionReady = false processGeneration += 1 lastStartKey = nil - let stoppedGeneration = processGeneration + let interruptedGeneration = processGeneration let workingDirectory = currentWorkingDirectory let sessionPath = currentSessionPath let cachedItems = items let planningMode = isPlanningMode Task { _ = try? await clientToAbort?.abort(timeoutSeconds: 1.25) + await clientToAbort?.stop() await MainActor.run { - guard self.processGeneration == stoppedGeneration, self.client == nil else { return } + guard self.processGeneration == interruptedGeneration, self.client == nil else { return } self.start( workingDirectory: workingDirectory, sessionPath: sessionPath, @@ -372,6 +375,7 @@ final class PiConversationModel: ObservableObject { ) } } + return true } /// Copies the given assistant message text to the clipboard. Wired for @@ -755,12 +759,48 @@ final class PiConversationModel: ObservableObject { private func hydrateTranscript(from envelope: RPCEnvelope) { guard let messages = envelope.data?.objectValue?["messages"]?.arrayValue else { - items = [.notice("No messages in this session yet.")] + if !items.contains(where: Self.isInterruptionItem) { + items = [.notice("No messages in this session yet.")] + } return } let hydrated = Self.buildTranscript(from: messages) - items = hydrated.isEmpty ? [.notice("No messages in this session yet.")] : hydrated + let baseItems = hydrated.isEmpty ? [.notice("No messages in this session yet.")] : hydrated + items = transcriptPreservingLatestInterruption(from: baseItems) + } + + /// Pi's message history cannot contain app-local interruption markers and + /// may include output persisted after an abort raced the interrupted turn. + /// Keep the cached transcript through the latest interruption authoritative, + /// then accept hydrated content only from the next user turn onward. + private func transcriptPreservingLatestInterruption(from hydrated: [TranscriptItem]) -> [TranscriptItem] { + guard let interruptionIndex = items.lastIndex(where: Self.isInterruptionItem) else { + return hydrated + } + let cachedItems = items + let cachedPrefix = Array(cachedItems[...interruptionIndex]) + let interruptedUserTurnCount = cachedPrefix.reduce(into: 0) { count, item in + if case .user = item { count += 1 } + } + + var hydratedUserTurnCount = 0 + let laterTurnIndex = hydrated.firstIndex { item in + guard case .user = item else { return false } + hydratedUserTurnCount += 1 + return hydratedUserTurnCount > interruptedUserTurnCount + } + guard let laterTurnIndex else { + // Hydration has not caught up to any locally cached later turn. + // Retaining the cache avoids both losing that work and admitting + // uncertain output from the interrupted turn. + return cachedItems + } + return cachedPrefix + Array(hydrated[laterTurnIndex...]) + } + + private static func isInterruptionItem(_ item: TranscriptItem) -> Bool { + item.isInterruptionNotice } /// Stateful reducer over the full message history — not a per-message @@ -879,6 +919,10 @@ final class PiConversationModel: ObservableObject { func handleEventForTesting(_ event: RPCEnvelope) { handle(event) } + + func hydrateTranscriptForTesting(_ envelope: RPCEnvelope) { + hydrateTranscript(from: envelope) + } #endif private func handle(_ event: RPCEnvelope, processGeneration: Int? = nil) { @@ -1180,6 +1224,8 @@ struct UserMessagePayload: Hashable, Codable { } enum TranscriptItem: Identifiable, Hashable, Codable { + static let interruptionNoticeText = "Stopped." + case user(id: UUID = UUID(), UserMessagePayload) case assistantText(id: UUID = UUID(), text: String) case activity(ActivityGroup) @@ -1193,6 +1239,11 @@ enum TranscriptItem: Identifiable, Hashable, Codable { case id, text, _0, _1 } + var isInterruptionNotice: Bool { + guard case .notice(_, let text) = self else { return false } + return text == Self.interruptionNoticeText + } + var id: UUID { switch self { case .user(let id, _): id diff --git a/PiNative/PiConversationView.swift b/PiNative/PiConversationView.swift index 33d56f0..c5bc17a 100644 --- a/PiNative/PiConversationView.swift +++ b/PiNative/PiConversationView.swift @@ -96,12 +96,25 @@ struct PiConversationView: View { @ObservedObject var model: PiConversationModel @ObservedObject var modelSettings: ModelSettingsModel let onSelectFavorites: () -> Void + let onInterrupt: () -> Void @State private var expandedGroupIDs: Set = [] @State private var isModelPickerPresented = false @State private var isEffortPickerPresented = false private let transcriptBottomID = "transcript-bottom" private var selectionReady: Bool { model.currentModel != nil && model.currentThinkingLevel != nil } + init( + model: PiConversationModel, + modelSettings: ModelSettingsModel, + onSelectFavorites: @escaping () -> Void, + onInterrupt: @escaping () -> Void = {} + ) { + self.model = model + self.modelSettings = modelSettings + self.onSelectFavorites = onSelectFavorites + self.onInterrupt = onInterrupt + } + var body: some View { ZStack(alignment: .bottom) { ScrollViewReader { proxy in @@ -111,7 +124,7 @@ struct PiConversationView: View { if let notice = model.sessionLoadNotice { Text(notice) .font(ChatTypography.caption()) - .foregroundStyle(model.isCatastrophicRPCFailure ? .red : .secondary) + .foregroundStyle(model.isCatastrophicRPCFailure ? AppTheme.dangerText : Color.secondary) .frame(maxWidth: .infinity, alignment: .center) .accessibilityIdentifier("chat.rpcStatus") } @@ -161,6 +174,7 @@ struct PiConversationView: View { .background(ChatPalette.canvas) } .background(ChatPalette.canvas) + .onExitCommand(perform: onInterrupt) } private var transcriptContentID: String { @@ -189,8 +203,8 @@ struct PiConversationView: View { .frame(maxWidth: .infinity, alignment: .leading) case .notice(_, let text): Text(text) - .font(ChatTypography.caption()) - .foregroundStyle(.secondary) + .font(ChatTypography.caption(weight: item.isInterruptionNotice ? .semibold : .regular)) + .foregroundStyle(item.isInterruptionNotice ? AppTheme.dangerText : Color.secondary) .frame(maxWidth: .infinity, alignment: .center) .accessibilityLabel(text) .accessibilityIdentifier("chat.notice") @@ -246,7 +260,8 @@ struct PiConversationView: View { onAddAttachments: { model.addDraftAttachments($0) }, onRemoveAttachment: { model.removeDraftAttachment($0) }, onSubmit: { model.sendDraft() }, - onStop: { model.stopActiveTurn() } + onStop: onInterrupt, + onEscape: onInterrupt ) { Spacer() @@ -545,6 +560,7 @@ struct AttachmentComposerShell: View { var onRemoveAttachment: (ComposerAttachment.ID) -> Void var onSubmit: () -> Void var onStop: () -> Void + var onEscape: () -> Void = {} var editorHeight: CGFloat = 30 @ViewBuilder var accessoryContent: () -> AccessoryContent @@ -578,6 +594,7 @@ struct AttachmentComposerShell: View { historyNavigator.endBrowsing() onSubmit() }, + onEscape: onEscape, onHistoryOlder: navigateToOlderPrompt, onHistoryNewer: navigateToNewerPrompt, onUserEdit: { historyNavigator.userDidEdit() }, diff --git a/PiNative/ProjectSidebarView.swift b/PiNative/ProjectSidebarView.swift index 2164a10..e917af0 100644 --- a/PiNative/ProjectSidebarView.swift +++ b/PiNative/ProjectSidebarView.swift @@ -410,7 +410,7 @@ private struct DiffStatPill: View { } if stats.deletions > 0 { Text("-\(stats.deletions)") - .foregroundStyle(.red) + .foregroundStyle(AppTheme.dangerText) } } .font(.system(size: SidebarMetrics.supportingFontSize, weight: .semibold, design: .monospaced)) diff --git a/PiNative/PromoteToProject.swift b/PiNative/PromoteToProject.swift index 1ea278e..1351181 100644 --- a/PiNative/PromoteToProject.swift +++ b/PiNative/PromoteToProject.swift @@ -560,7 +560,7 @@ struct PromoteToProjectModal: View { if let message = workflow.validationMessage { Text(message) .font(.caption) - .foregroundStyle(.red) + .foregroundStyle(AppTheme.dangerText) .fixedSize(horizontal: false, vertical: true) .accessibilityIdentifier("promoteToProject.errorMessage") } @@ -731,7 +731,7 @@ private struct PromoteStatusGlyph: View { .accessibilityIdentifier("\(identifierBase).skipped") case .failed: Image(systemName: "exclamationmark.circle.fill") - .foregroundStyle(.red) + .foregroundStyle(AppTheme.dangerText) .accessibilityIdentifier("\(identifierBase).error") } } diff --git a/PiNativeTests/AppModelConversationTests.swift b/PiNativeTests/AppModelConversationTests.swift index bc1b73d..4130743 100644 --- a/PiNativeTests/AppModelConversationTests.swift +++ b/PiNativeTests/AppModelConversationTests.swift @@ -47,6 +47,49 @@ final class AppModelConversationTests: XCTestCase { XCTAssertEqual(model.activeConversationModel?.items, updatedTranscript) } + // 2119: REQ-003.5.3 + func testInterruptedTranscriptPersistsAcrossNavigationAndAppModelRelaunch() throws { + let interruptedSession = Session( + name: "interrupted persistence chat", + status: .idle, + cachedTranscript: [.user(UserMessagePayload(text: "persist interrupted state"))] + ) + let otherSession = Session( + name: "navigation peer chat", + status: .idle, + cachedTranscript: [.user(UserMessagePayload(text: "other chat"))] + ) + let model = AppModel() + model.standaloneSessions = [interruptedSession, otherSession] + model.select(sessionID: interruptedSession.id, in: nil) + let conversation = try XCTUnwrap(model.activeConversationModel) + let agentStartData = try JSONEncoder().encode(JSONValue.object(["type": .string("agent_start")])) + conversation.handleEventForTesting(try JSONDecoder().decode(RPCEnvelope.self, from: agentStartData)) + + XCTAssertTrue(model.interruptSelectedConversation()) + model.select(sessionID: otherSession.id, in: nil) + model.select(sessionID: interruptedSession.id, in: nil) + + XCTAssertTrue(try XCTUnwrap(model.activeConversationModel).items.contains { item in + if case .notice(_, "Stopped.") = item { return true } + return false + }) + model.stopAllRuntimes() + + let restored = AppModel() + let restoredSession = try XCTUnwrap(restored.standaloneSessions.first { $0.id == interruptedSession.id }) + XCTAssertTrue(restoredSession.cachedTranscript.contains { item in + if case .notice(_, "Stopped.") = item { return true } + return false + }) + restored.select(sessionID: interruptedSession.id, in: nil) + XCTAssertTrue(try XCTUnwrap(restored.activeConversationModel).items.contains { item in + if case .notice(_, "Stopped.") = item { return true } + return false + }) + restored.stopAllRuntimes() + } + func testProjectRowWithOnlyPinnedOrArchivedChatsDoesNotSelectThoseChats() throws { let root = try temporaryDirectory(named: "PiNativePinnedOnlyProject") defer { try? FileManager.default.removeItem(at: root) } @@ -154,8 +197,8 @@ final class AppModelConversationTests: XCTestCase { func testArchivedChatsListAndRestorePreserveSelectionAndMetadata() throws { let root = try temporaryDirectory(named: "PiNativeArchivedChats") defer { try? FileManager.default.removeItem(at: root) } - let projectChat = Session(name: "Project archive", status: .idle, filePath: root.appendingPathComponent("project.jsonl").path, updatedAt: Date(timeIntervalSince1970: 20), messageCount: 2, isArchived: true, isPinned: true, cachedTranscript: [.user(UserMessagePayload(text: "project"))]) - let quickChat = Session(name: "Quick archive", status: .idle, filePath: root.appendingPathComponent("quick.jsonl").path, updatedAt: Date(timeIntervalSince1970: 30), messageCount: 3, isArchived: true, cachedTranscript: [.user(UserMessagePayload(text: "quick"))]) + let projectChat = Session(name: "Project archive", status: .idle, filePath: root.appendingPathComponent("project.jsonl").path, updatedAt: Date(timeIntervalSince1970: 30), messageCount: 2, isArchived: true, isPinned: true, cachedTranscript: [.user(UserMessagePayload(text: "project"))]) + let quickChat = Session(name: "Quick archive", status: .idle, filePath: root.appendingPathComponent("quick.jsonl").path, updatedAt: Date(timeIntervalSince1970: 20), messageCount: 3, isArchived: true, cachedTranscript: [.user(UserMessagePayload(text: "quick"))]) let selected = Session(name: "Selected", status: .idle) let project = Project(name: "Archive Project", path: root.path, sessions: [projectChat, selected], diffStats: nil) let model = AppModel() @@ -166,15 +209,15 @@ final class AppModelConversationTests: XCTestCase { // 2119: REQ-013.1.1 // 2119: REQ-013.1.3 model.openRightPane(.archivedChats) - XCTAssertEqual(model.archivedChats.map(\.session.id), [quickChat.id, projectChat.id]) - XCTAssertEqual(model.archivedChats[0].session.name, "Quick archive") - XCTAssertNil(model.archivedChats[0].projectID) - XCTAssertNil(model.archivedChats[0].projectName) - XCTAssertEqual(model.archivedChats[0].session.messageCount, 3) - XCTAssertEqual(model.archivedChats[1].session.name, "Project archive") - XCTAssertEqual(model.archivedChats[1].projectID, project.id) - XCTAssertEqual(model.archivedChats[1].projectName, "Archive Project") - XCTAssertEqual(model.archivedChats[1].session.messageCount, 2) + XCTAssertEqual(model.archivedChats.map(\.session.id), [projectChat.id, quickChat.id]) + XCTAssertEqual(model.archivedChats[0].session.name, "Project archive") + XCTAssertEqual(model.archivedChats[0].projectID, project.id) + XCTAssertEqual(model.archivedChats[0].projectName, "Archive Project") + XCTAssertEqual(model.archivedChats[0].session.messageCount, 2) + XCTAssertEqual(model.archivedChats[1].session.name, "Quick archive") + XCTAssertNil(model.archivedChats[1].projectID) + XCTAssertNil(model.archivedChats[1].projectName) + XCTAssertEqual(model.archivedChats[1].session.messageCount, 3) XCTAssertEqual(model.selectedSessionID, selected.id) // 2119: REQ-013.2.1 diff --git a/PiNativeTests/ArchivedChatsRenderingTests.swift b/PiNativeTests/ArchivedChatsRenderingTests.swift new file mode 100644 index 0000000..bb50006 --- /dev/null +++ b/PiNativeTests/ArchivedChatsRenderingTests.swift @@ -0,0 +1,42 @@ +import AppKit +import SwiftUI +import Vision +import XCTest +@testable import PiNative + +@MainActor +final class ArchivedChatsRenderingTests: XCTestCase { + func testEmptyStateRenders() throws { + let model = AppModel() + model.projects = [] + model.standaloneSessions = [] + model.openRightPane(.archivedChats) + + // 2119: REQ-013.1.2 + let hostingView = NSHostingView(rootView: RightPaneView().environmentObject(model)) + hostingView.frame = NSRect(x: 0, y: 0, width: 420, height: 640) + let window = NSWindow( + contentRect: hostingView.frame, + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.contentView = hostingView + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + RunLoop.main.run(until: Date().addingTimeInterval(0.05)) + + hostingView.layoutSubtreeIfNeeded() + let bitmap = try XCTUnwrap(hostingView.bitmapImageRepForCachingDisplay(in: hostingView.bounds)) + hostingView.cacheDisplay(in: hostingView.bounds, to: bitmap) + let image = try XCTUnwrap(bitmap.cgImage) + let request = VNRecognizeTextRequest() + request.recognitionLevel = .accurate + try VNImageRequestHandler(cgImage: image).perform([request]) + let renderedText = (request.results ?? []) + .compactMap { $0.topCandidates(1).first?.string } + .joined(separator: " ") + + XCTAssertTrue(renderedText.contains("No Archived Chats"), "Rendered text: \(renderedText)") + } +} diff --git a/PiNativeTests/AttachmentSupportTests.swift b/PiNativeTests/AttachmentSupportTests.swift index 32f51ed..cbe4323 100644 --- a/PiNativeTests/AttachmentSupportTests.swift +++ b/PiNativeTests/AttachmentSupportTests.swift @@ -263,6 +263,35 @@ final class AttachmentSupportTests: XCTestCase { XCTAssertEqual(object["data"]?.stringValue, "abc123") } + @MainActor + func testEscapePreservesNativeMarkedTextCancellationBeforeInterrupting() throws { + let textView = PasteInterceptingTextView() + var didInterrupt = false + textView.onEscape = { didInterrupt = true } + textView.setMarkedText( + "候補", + selectedRange: NSRange(location: 0, length: 2), + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + XCTAssertTrue(textView.hasMarkedText()) + let escape = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "\u{1b}", + charactersIgnoringModifiers: "\u{1b}", + isARepeat: false, + keyCode: 53 + )) + + textView.keyDown(with: escape) + + XCTAssertFalse(didInterrupt) + } + private func temporaryURL(extension pathExtension: String) -> URL { FileManager.default.temporaryDirectory .appendingPathComponent("pinative-attachment-test-\(UUID().uuidString)") diff --git a/PiNativeTests/ChatTitleTests.swift b/PiNativeTests/ChatTitleTests.swift index 6300074..74b5db9 100644 --- a/PiNativeTests/ChatTitleTests.swift +++ b/PiNativeTests/ChatTitleTests.swift @@ -580,7 +580,7 @@ final class ChatTitleLifecycleTests: XCTestCase { .assistantText(text: "Partial response") ] model.handleEventForTesting(try Self.event(type: "agent_start")) - model.stopActiveTurn() + model.interruptActiveTurn() model.items.append(.user(UserMessagePayload(text: "Discuss films instead"))) model.items.append(.assistantText(text: "Complete film response.")) diff --git a/PiNativeTests/ComposerPromptHistoryMountedTests.swift b/PiNativeTests/ComposerPromptHistoryMountedTests.swift index 2cc09d7..fbc62d2 100644 --- a/PiNativeTests/ComposerPromptHistoryMountedTests.swift +++ b/PiNativeTests/ComposerPromptHistoryMountedTests.swift @@ -329,6 +329,61 @@ final class ComposerPromptHistoryMountedTests: XCTestCase { XCTAssertEqual(harness.history, chatBModel.promptHistory) } + // 2119: REQ-003.5.1 + // 2119: REQ-003.5.5 + func testMountedEscapeInterruptsActiveTurnAndIsIdleNoOp() throws { + let defaultsName = "ComposerEscapeInterruptionTests-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: defaultsName)) + defer { defaults.removePersistentDomain(forName: defaultsName) } + let selectedModel = PiModelOption(provider: "test", id: "selected", name: "Selected") + let settings = ModelSettingsModel( + storage: UserDefaultsModelFavoritesStorage(defaults: defaults), + favoritesKey: "favorites", + configuredDefaultModel: selectedModel + ) + let conversation = PiConversationModel(modelSettings: settings) + conversation.currentModel = selectedModel + conversation.currentThinkingLevel = .medium + conversation.items = [.user(UserMessagePayload(text: "escape interruption prompt"))] + let agentStartData = try JSONEncoder().encode(JSONValue.object(["type": .string("agent_start")])) + conversation.handleEventForTesting(try JSONDecoder().decode(RPCEnvelope.self, from: agentStartData)) + let focusRequestBeforeEscape = conversation.composerFocusRequest + let hostingView = NSHostingView(rootView: PiConversationView( + model: conversation, + modelSettings: settings, + onSelectFavorites: {}, + onInterrupt: { conversation.interruptActiveTurn() } + )) + hostingView.frame = NSRect(x: 0, y: 0, width: 900, height: 700) + let window = testWindow(containing: hostingView) + defer { tearDown(window: window) } + hostingView.layoutSubtreeIfNeeded() + pumpMainRunLoop() + let textView = try XCTUnwrap(descendant(of: PasteInterceptingTextView.self, in: hostingView)) + XCTAssertTrue(window.makeFirstResponder(textView)) + + let escapeStartedAt = Date() + textView.keyDown(with: try keyEvent(keyCode: 53, characters: "\u{1b}")) + pumpMainRunLoop() + + XCTAssertLessThan(Date().timeIntervalSince(escapeStartedAt), 1) + XCTAssertFalse(conversation.isRunning) + XCTAssertNotEqual(conversation.composerFocusRequest, focusRequestBeforeEscape) + XCTAssertEqual(conversation.items.filter { item in + if case .notice(_, "Stopped.") = item { return true } + return false + }.count, 1) + + let idleItems = conversation.items + let idleFocusRequest = conversation.composerFocusRequest + textView.keyDown(with: try keyEvent(keyCode: 53, characters: "\u{1b}")) + pumpMainRunLoop() + + XCTAssertEqual(conversation.items, idleItems) + XCTAssertEqual(conversation.composerFocusRequest, idleFocusRequest) + XCTAssertFalse(conversation.isRunning) + } + private func makeFileAttachment(_ name: String) -> ComposerAttachment { ComposerAttachment(kind: .fileReference(FileReferenceAttachment( url: URL(fileURLWithPath: "/tmp/\(name)"), diff --git a/PiNativeTests/ParallelRuntimeTests.swift b/PiNativeTests/ParallelRuntimeTests.swift index f190d5d..298b72c 100644 --- a/PiNativeTests/ParallelRuntimeTests.swift +++ b/PiNativeTests/ParallelRuntimeTests.swift @@ -142,10 +142,21 @@ final class ParallelRuntimeTests: XCTestCase { XCTAssertTrue(appModel.isConversationRunning(sessionID: first.id, projectID: projectID)) XCTAssertTrue(appModel.isConversationRunning(sessionID: second.id, projectID: projectID)) + let firstItemsBeforeInterruption = firstModel.items + // 2119: REQ-003.5.4 - secondModel.stopActiveTurn() + XCTAssertTrue(appModel.interruptSelectedConversation()) XCTAssertTrue(appModel.isConversationRunning(sessionID: first.id, projectID: projectID)) XCTAssertFalse(appModel.isConversationRunning(sessionID: second.id, projectID: projectID)) + XCTAssertEqual(firstModel.items, firstItemsBeforeInterruption) + + firstModel.handleEventForTesting(try Self.textDelta("first chat continues after peer interruption")) + XCTAssertTrue(firstModel.items.contains { item in + if case .assistantText(_, let text) = item { + return text.contains("first chat continues after peer interruption") + } + return false + }) } func testQuickChatCreationNavigationDraftsAndOutputAreIsolatedFromProjectChats() throws { diff --git a/PiNativeTests/StopButtonTests.swift b/PiNativeTests/StopButtonTests.swift index 31ef968..996a4d3 100644 --- a/PiNativeTests/StopButtonTests.swift +++ b/PiNativeTests/StopButtonTests.swift @@ -71,7 +71,7 @@ final class StopButtonTests: XCTestCase { model.draft = "first prompt to stop" model.sendDraft() XCTAssertTrue(model.isRunning) - model.stopActiveTurn() + model.interruptActiveTurn() XCTAssertFalse(model.isRunning) setenv("PI_NATIVE_MOCK_RPC_RESPONSE", "new prompt output", 1) @@ -93,6 +93,8 @@ final class StopButtonTests: XCTestCase { XCTAssertFalse(model.isRunning) } + // 2119: REQ-003.5.1 + // 2119: REQ-003.5.2 func testStopImmediatelyLeavesRunningStateAndSuppressesLateTurnOutput() async throws { setenv("PI_NATIVE_MOCK_RPC_RESPONSE", "later prompt response", 1) setenv("PI_NATIVE_MOCK_RPC_RESPONSE_DELAY_MS", "1", 1) @@ -107,10 +109,12 @@ final class StopButtonTests: XCTestCase { model.items.append(.assistantText(text: "existing assistant text")) model.isRunning = true + let focusRequestBeforeStop = model.composerFocusRequest let stopStartedAt = Date() - model.stopActiveTurn() + model.interruptActiveTurn() XCTAssertFalse(model.isRunning) + XCTAssertNotEqual(model.composerFocusRequest, focusRequestBeforeStop) XCTAssertLessThan(Date().timeIntervalSince(stopStartedAt), 1.0) XCTAssertTrue(model.items.contains { item in if case .notice(_, "Stopped.") = item { return true } @@ -146,6 +150,110 @@ final class StopButtonTests: XCTestCase { XCTAssertFalse(model.isRunning) } + // 2119: REQ-003.5.2 + // 2119: REQ-003.5.3 + func testInterruptionFenceSurvivesRepeatedHydrationAndRejectsLateOutput() throws { + let interruptionID = UUID() + let model = PiConversationModel() + model.items = [ + .user(UserMessagePayload(text: "interrupted prompt")), + .assistantText(text: "partial output before interruption"), + .notice(id: interruptionID, "Stopped.") + ] + let hydratedMessages: [JSONValue] = [ + .object([ + "role": .string("user"), + "content": .string("interrupted prompt") + ]), + .object([ + "role": .string("assistant"), + "content": .array([ + .object(["type": .string("text"), "text": .string("late output after interruption")]) + ]) + ]), + .object([ + "role": .string("user"), + "content": .string("later prompt") + ]), + .object([ + "role": .string("assistant"), + "content": .array([ + .object(["type": .string("text"), "text": .string("later response")]) + ]) + ]) + ] + let envelope = try RPCEnvelope.testEnvelope([ + "data": .object(["messages": .array(hydratedMessages)]) + ]) + + model.hydrateTranscriptForTesting(envelope) + model.hydrateTranscriptForTesting(envelope) + + XCTAssertEqual(model.items.filter { item in + if case .notice(let id, "Stopped.") = item { return id == interruptionID } + return false + }.count, 1) + XCTAssertTrue(model.items.contains { item in + if case .assistantText(_, "partial output before interruption") = item { return true } + return false + }) + XCTAssertFalse(model.items.contains { item in + if case .assistantText(_, "late output after interruption") = item { return true } + return false + }) + XCTAssertTrue(model.items.contains { item in + if case .assistantText(_, "later response") = item { return true } + return false + }) + } + + // 2119: REQ-003.5.2 + // 2119: REQ-003.5.3 + func testLatestInterruptionFencePreservesMultipleStopsAndLaterTurns() throws { + let firstInterruptionID = UUID() + let secondInterruptionID = UUID() + let model = PiConversationModel() + model.items = [ + .user(UserMessagePayload(text: "first interrupted prompt")), + .notice(id: firstInterruptionID, "Stopped."), + .user(UserMessagePayload(text: "second interrupted prompt")), + .assistantText(text: "second partial output"), + .notice(id: secondInterruptionID, "Stopped.") + ] + let hydratedMessages: [JSONValue] = [ + .object(["role": .string("user"), "content": .string("first interrupted prompt")]), + .object([ + "role": .string("assistant"), + "content": .array([.object(["type": .string("text"), "text": .string("first late output")])]) + ]), + .object(["role": .string("user"), "content": .string("second interrupted prompt")]), + .object([ + "role": .string("assistant"), + "content": .array([.object(["type": .string("text"), "text": .string("second late output")])]) + ]), + .object(["role": .string("user"), "content": .string("third prompt")]), + .object([ + "role": .string("assistant"), + "content": .array([.object(["type": .string("text"), "text": .string("third response")])]) + ]) + ] + let envelope = try RPCEnvelope.testEnvelope([ + "data": .object(["messages": .array(hydratedMessages)]) + ]) + + model.hydrateTranscriptForTesting(envelope) + + XCTAssertEqual(model.items.filter { item in + if case .notice(_, "Stopped.") = item { return true } + return false + }.count, 2) + XCTAssertTrue(model.items.contains { $0.id == firstInterruptionID }) + XCTAssertTrue(model.items.contains { $0.id == secondInterruptionID }) + XCTAssertFalse(String(describing: model.items).contains("first late output")) + XCTAssertFalse(String(describing: model.items).contains("second late output")) + XCTAssertTrue(String(describing: model.items).contains("third response")) + } + // 2119: REQ-003.5.2 func testRealRPCProcessLateOutputIsSuppressedAfterStop() async throws { unsetenv("PI_NATIVE_MOCK_RPC_RESPONSE") @@ -187,7 +295,7 @@ final class StopButtonTests: XCTestCase { model.draft = "start a turn through the real rpc process" model.sendDraft() try await waitUntil(timeout: 1) { model.isRunning } - model.stopActiveTurn() + model.interruptActiveTurn() try await Task.sleep(nanoseconds: 350_000_000) XCTAssertFalse(model.items.contains { item in @@ -271,7 +379,7 @@ final class StopButtonTests: XCTestCase { (try? String(contentsOf: promptCountFile, encoding: .utf8)) == "1" } XCTAssertTrue(model.isRunning) - model.stopActiveTurn() + model.interruptActiveTurn() model.draft = "second prompt" model.sendDraft() diff --git a/PiNativeUITests/ActiveWorkUITests.swift b/PiNativeUITests/ActiveWorkUITests.swift index e735003..635bba0 100644 --- a/PiNativeUITests/ActiveWorkUITests.swift +++ b/PiNativeUITests/ActiveWorkUITests.swift @@ -56,10 +56,13 @@ final class ActiveWorkUITests: PiNativeUITestCase { XCTAssertFalse(app.descendants(matching: .any)["chat.runningSpinner"].firstMatch.exists) XCTAssertFalse(app.staticTexts.matching(NSPredicate(format: "value CONTAINS %@ OR label CONTAINS %@", "late output after stop", "late output after stop")).firstMatch.exists) + // 2119: REQ-003.5.3 + XCTAssertTrue(app.staticTexts["Stopped."].waitForExistence(timeout: 1)) + // 2119: REQ-003.5.2 XCTAssertFalse(app.staticTexts["transcript.assistantMessage"].waitForExistence(timeout: 3)) - // 2119: REQ-003.5.3 + // 2119: REQ-003.5.1 let composer = app.textViews["composer.textEditor"].firstMatch XCTAssertTrue(composer.waitForExistence(timeout: 5)) composer.click() @@ -69,4 +72,72 @@ final class ActiveWorkUITests: PiNativeUITestCase { app.buttons["Send"].firstMatch.click() XCTAssertGreaterThan(app.staticTexts.matching(identifier: "transcript.userMessage").count, userMessageCountBeforeLaterPrompt) } + + /// Opt-in evidence for the real authenticated Pi lifecycle. Deterministic + /// UI runs leave this skipped; invoke it explicitly with the temporary + /// authorization marker created by the local test command. + @MainActor + func testEscapeInterruptsLivePiAndPersistsAcrossRelaunch() throws { + let authorizationMarker = "/tmp/pinative-run-real-pi-interruption-test" + guard FileManager.default.fileExists(atPath: authorizationMarker) else { + throw XCTSkip("Requires explicit live Pi provider authorization") + } + let app = XCUIApplication() + app.launchArguments += ["-ApplePersistenceIgnoreState", "YES", "--ui-test-autosubmit"] + app.launchEnvironment["PI_NATIVE_RESET_PROJECTS"] = "1" + app.launchEnvironment["PI_NATIVE_TEST_PROJECT_PATH"] = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .path + app.launchEnvironment["PI_NATIVE_AUTOSUBMIT_AFTER_TYPING"] = "1" + app.launch() + + let projectRow = app.buttons["project.row"].firstMatch + XCTAssertTrue(projectRow.waitForExistence(timeout: 10)) + projectRow.hover() + let projectNewChat = app.buttons["project.newChatButton"].firstMatch + XCTAssertTrue(projectNewChat.waitForExistence(timeout: 5)) + XCTAssertTrue(projectNewChat.isHittable) + projectNewChat.click() + let newChatPrompt = app.textViews["composer.textEditor"].firstMatch + XCTAssertTrue(newChatPrompt.waitForExistence(timeout: 5)) + newChatPrompt.click() + app.typeText("Run this exact shell command now and do nothing else until it finishes: sleep 20; printf LIVE_PI_INTERRUPTED_OUTPUT") + + XCTAssertTrue(app.staticTexts["Running a project check"].waitForExistence(timeout: 60)) + app.typeKey(.escape, modifierFlags: []) + + // 2119: REQ-003.5.1 + // 2119: REQ-003.5.3 + XCTAssertTrue(app.staticTexts["Stopped."].waitForExistence(timeout: 1)) + let composer = app.textViews["composer.textEditor"].firstMatch + XCTAssertTrue(composer.waitForExistence(timeout: 5)) + composer.click() + app.typeText("Reply with exactly LIVE_PI_INTERRUPT_RECOVERED and do nothing else.") + let send = app.buttons["Send"].firstMatch + XCTAssertTrue(send.waitForExistence(timeout: 5)) + expectation(for: NSPredicate(format: "enabled == true"), evaluatedWith: send) + waitForExpectations(timeout: 30) + send.click() + + let recoveredReply = app.staticTexts.matching(NSPredicate( + format: "identifier == %@ AND (value CONTAINS %@ OR label CONTAINS %@)", + "transcript.assistantMessage", + "LIVE_PI_INTERRUPT_RECOVERED", + "LIVE_PI_INTERRUPT_RECOVERED" + )).firstMatch + XCTAssertTrue(recoveredReply.waitForExistence(timeout: 60)) + XCTAssertFalse(app.staticTexts.matching(identifier: "transcript.assistantMessage").matching(NSPredicate( + format: "value CONTAINS %@ OR label CONTAINS %@", + "LIVE_PI_INTERRUPTED_OUTPUT", + "LIVE_PI_INTERRUPTED_OUTPUT" + )).firstMatch.exists) + XCTAssertTrue(app.staticTexts["Stopped."].exists) + + app.terminate() + app.launch() + + XCTAssertTrue(app.staticTexts["Stopped."].waitForExistence(timeout: 30)) + XCTAssertTrue(recoveredReply.waitForExistence(timeout: 30)) + } } diff --git a/specs/REQ-003-conversation-navigation-and-active-work.md b/specs/REQ-003-conversation-navigation-and-active-work.md index 5bad74c..5005c7e 100644 --- a/specs/REQ-003-conversation-navigation-and-active-work.md +++ b/specs/REQ-003-conversation-navigation-and-active-work.md @@ -38,12 +38,13 @@ This spec captures the next testing focus for the shell and conversation lifecyc 7. Pending startup or queued prompt work in one chat MUST NOT prevent selecting another chat. 8. In-flight work in one chat MUST NOT prevent opening the new-chat surface or creating a new chat. -### REQ-003.5: Stop behavior +### REQ-003.5: Interruption behavior -1. Pressing Stop for the selected chat's active turn MUST replace its visible Stop control with the Send control within one second. -2. Pressing Stop during an active turn MUST prevent later output from that stopped turn from appending to the transcript. -3. Pressing Stop during an active turn MUST leave the composer usable for a later prompt. -4. Pressing Stop in the selected chat MUST NOT stop in-flight work in another chat. +1. While the selected chat has interruptible work, pressing Stop or Escape MUST produce the same prompt-ready interrupted state within one second. +2. Interrupting an active turn MUST prevent later output from that interrupted turn from appending to the transcript. +3. The selected chat's visible interruption indication MUST remain present through transcript synchronization or hydration, navigation away and back, subsequent rendering updates, and app relaunch. +4. Interrupting the selected chat MUST NOT change another chat's work or transcript. +5. Pressing Escape while the selected chat has no interruptible work MUST NOT change transcript or runtime state. ### REQ-003.6: Quick Chat creation and navigation @@ -67,7 +68,7 @@ This spec captures the next testing focus for the shell and conversation lifecyc - Tests for row hit targets should click representative left, center, and trailing non-control points. - Tests for hidden controls should verify the hidden archive, diff, and new-chat affordances do not intercept row selection. - Tests for active work navigation should start work in one chat, switch to another chat, then verify both the target chat selection and the original chat working indicator. -- Tests for Stop should cover late output suppression after the stop action, not only immediate local state changes. +- Interruption tests should cover both Stop and Escape entry points, late-output suppression, persistent interruption history, and cross-chat isolation. ## Non-goals