From 044419ceadb072afd654f630fa89005924541234 Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 5 Oct 2026 00:09:08 +0100 Subject: [PATCH 1/6] feat(macos): improve long-thread performance and workspace navigation --- fixtures/mock-acp-v2.py | 88 ++- macos/KitDesktop.xcodeproj/project.pbxproj | 116 ++++ macos/KitDesktop/Models/AppModel.swift | 72 ++- macos/KitDesktop/Models/AppState.swift | 74 ++- .../Models/ClipboardMediaImport.swift | 30 + .../Models/ConversationController.swift | 406 +++++++++--- .../Models/FocusedSubagentController.swift | 238 +++++++ macos/KitDesktop/Models/ModelSelection.swift | 11 + macos/KitDesktop/Models/ProjectSummary.swift | 30 + .../Models/TranscriptContentBuffer.swift | 60 ++ .../Models/TranscriptProjection.swift | 92 +++ .../Models/TranscriptToolContentBuffer.swift | 37 ++ macos/KitDesktop/Services/ACPClient.swift | 72 ++- .../Services/ACPKitExtensions.swift | 15 +- .../Services/PersistenceStore.swift | 5 +- .../Services/ProviderUsageService.swift | 104 +++ macos/KitDesktop/Views/ContentView.swift | 606 ++++++++++++------ .../Views/FocusedSubagentView.swift | 89 +++ macos/KitDesktop/Views/MarkdownView.swift | 9 + .../KitDesktop/Views/PendingSteerEditor.swift | 45 ++ .../Views/ProjectDirectoriesView.swift | 47 ++ macos/KitDesktop/Views/ProjectsView.swift | 123 ++++ .../KitDesktop/Views/ProviderUsageView.swift | 42 ++ .../Views/TranscriptMediaView.swift | 236 +++++++ .../ACPMessageIdentityTests.swift | 27 + macos/KitDesktopTests/ACPProcessTests.swift | 6 +- .../DesktopRenderingTests.swift | 50 ++ macos/KitDesktopTests/DesktopUsageTests.swift | 90 +++ .../FocusedSubagentTests.swift | 187 ++++++ .../PendingSteerMutationTests.swift | 127 ++++ .../ProjectDirectoriesTests.swift | 51 ++ .../ProjectDirectoryProtocolTests.swift | 81 +++ .../ProjectNavigationLifecycleTests.swift | 38 ++ .../ProjectNavigationTests.swift | 90 +++ .../KitDesktopTests/ProjectSearchTests.swift | 17 + .../ProviderUsageServiceTests.swift | 56 ++ .../TranscriptMediaTests.swift | 71 ++ .../TranscriptProjectionRegressionTests.swift | 121 ++++ .../TranscriptProjectionTests.swift | 106 +++ .../TranscriptToolContentBufferTests.swift | 31 + macos/README.md | 13 +- 41 files changed, 3498 insertions(+), 311 deletions(-) create mode 100644 macos/KitDesktop/Models/ClipboardMediaImport.swift create mode 100644 macos/KitDesktop/Models/FocusedSubagentController.swift create mode 100644 macos/KitDesktop/Models/ModelSelection.swift create mode 100644 macos/KitDesktop/Models/ProjectSummary.swift create mode 100644 macos/KitDesktop/Models/TranscriptContentBuffer.swift create mode 100644 macos/KitDesktop/Models/TranscriptProjection.swift create mode 100644 macos/KitDesktop/Models/TranscriptToolContentBuffer.swift create mode 100644 macos/KitDesktop/Services/ProviderUsageService.swift create mode 100644 macos/KitDesktop/Views/FocusedSubagentView.swift create mode 100644 macos/KitDesktop/Views/PendingSteerEditor.swift create mode 100644 macos/KitDesktop/Views/ProjectDirectoriesView.swift create mode 100644 macos/KitDesktop/Views/ProjectsView.swift create mode 100644 macos/KitDesktop/Views/ProviderUsageView.swift create mode 100644 macos/KitDesktop/Views/TranscriptMediaView.swift create mode 100644 macos/KitDesktopTests/ACPMessageIdentityTests.swift create mode 100644 macos/KitDesktopTests/DesktopRenderingTests.swift create mode 100644 macos/KitDesktopTests/DesktopUsageTests.swift create mode 100644 macos/KitDesktopTests/FocusedSubagentTests.swift create mode 100644 macos/KitDesktopTests/PendingSteerMutationTests.swift create mode 100644 macos/KitDesktopTests/ProjectDirectoriesTests.swift create mode 100644 macos/KitDesktopTests/ProjectDirectoryProtocolTests.swift create mode 100644 macos/KitDesktopTests/ProjectNavigationLifecycleTests.swift create mode 100644 macos/KitDesktopTests/ProjectNavigationTests.swift create mode 100644 macos/KitDesktopTests/ProjectSearchTests.swift create mode 100644 macos/KitDesktopTests/ProviderUsageServiceTests.swift create mode 100644 macos/KitDesktopTests/TranscriptMediaTests.swift create mode 100644 macos/KitDesktopTests/TranscriptProjectionRegressionTests.swift create mode 100644 macos/KitDesktopTests/TranscriptProjectionTests.swift create mode 100644 macos/KitDesktopTests/TranscriptToolContentBufferTests.swift diff --git a/fixtures/mock-acp-v2.py b/fixtures/mock-acp-v2.py index 483750b2..fd0f4801 100644 --- a/fixtures/mock-acp-v2.py +++ b/fixtures/mock-acp-v2.py @@ -11,6 +11,8 @@ state_lock = threading.Lock() next_session = 1 next_injection = 1 +# state_lock owns IDs and pending contents; delivery removes before emitting. +pending_injections = {} supports_fork = "--no-fork" not in sys.argv supports_steer = "--steer" in sys.argv supports_models = "--models" in sys.argv @@ -38,6 +40,8 @@ def argument_value(name): prompt_release_text = option("--prompt-release-text") inject_release = option("--inject-release") inject_ack_release = option("--inject-ack-release") +mutation_ack_release = option("--mutation-ack-release") +mutation_committed = option("--mutation-committed") close_release = option("--close-release") close_release_session = option("--close-release-session") fail_close_session = option("--fail-close-session") @@ -89,6 +93,8 @@ def log_request(request): entry["sessionId"] = params["sessionId"] if request.get("method") in ("session/new", "session/fork"): entry["cwd"] = params["cwd"] + if "additionalDirectories" in params: + entry["additionalDirectories"] = params["additionalDirectories"] if request.get("method") == "session/prompt": entry["text"] = params["prompt"][0]["text"] if request.get("method") == "session/inject": @@ -275,6 +281,12 @@ def prompt(request): sys.stderr.write("\x01kit-runtime\x01" + json.dumps({"event": "child_finished", "call": "call-1:compose:shell", "tool": "shell", "ok": True, "summary": "done", "millis": 2}) + "\nmock diagnostic\n") sys.stderr.flush() text = "rich done" + if "MOCK_SHARED_MESSAGE_IDS" in text: + for role, value in [("user_message", "User text"), ("agent_thought_chunk", "Thought text"), ("agent_message_chunk", "Assistant text")]: + content = {"type": "text", "text": value} + if role == "user_message": + content = [content] + send({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": session_id, "update": {"sessionUpdate": role, "messageId": "shared-id", "content": content}}}) send({ "jsonrpc": "2.0", "method": "session/update", @@ -309,17 +321,49 @@ def inject(request): with state_lock: message_id = f"injected-{next_injection}" next_injection += 1 + pending_injections[message_id] = (params["sessionId"], params["content"]) while inject_ack_release is not None and not os.path.exists(inject_ack_release): time.sleep(0.01) respond(request["id"], {"messageId": message_id}) + if inject_ack_release is not None: + send({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": params["sessionId"], "update": {"sessionUpdate": "notice", "title": "Injection acknowledged"}}}) while inject_release is not None and not os.path.exists(inject_release): time.sleep(0.01) - send({ - "jsonrpc": "2.0", "method": "session/update", - "params": {"sessionId": params["sessionId"], "update": { - "sessionUpdate": "user_message", "messageId": message_id, "content": params["content"], - }}, - }) + with state_lock: + pending = pending_injections.pop(message_id, None) + if pending is not None: + send({ + "jsonrpc": "2.0", "method": "session/update", + "params": {"sessionId": pending[0], "update": { + "sessionUpdate": "user_message", "messageId": message_id, "content": pending[1], + }}, + }) + + +def mutate_pending_injection(request): + params = request["params"] + replacement = params.get("content") + rejected = replacement is not None and any("MOCK_REJECT_REPLACE" in block.get("text", "") for block in replacement) + with state_lock: + pending = pending_injections.get(params["messageId"]) + accepted = not rejected and pending is not None and pending[0] == params["sessionId"] + if accepted: + if replacement is None: + del pending_injections[params["messageId"]] + else: + pending_injections[params["messageId"]] = (pending[0], replacement) + if mutation_committed is not None: + with open(mutation_committed, "w", encoding="utf-8") as marker: + marker.write("committed" if accepted else "rejected") + # No I/O or callbacks while state_lock is held. Delayed replies exercise + # delivery racing an already committed mutation without resurrecting rows. + while mutation_ack_release is not None and not os.path.exists(mutation_ack_release): + time.sleep(0.01) + if accepted: + respond(request["id"], {}) + else: + send({"jsonrpc": "2.0", "id": request["id"], "error": {"code": -32602, "message": "pending injection unavailable or rejected"}}) + send({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": params["sessionId"], "update": {"sessionUpdate": "notice", "title": "Mutation acknowledged"}}}) def close(request): @@ -361,7 +405,8 @@ def close(request): "authMethods": [{"type": "agent", "methodId": "browser-login", "name": "secret-name", "description": "secret-description"}], "capabilities": {"session": { "prompt": {name: {} for name in prompt_capability_names if name}, - **({"inject": {"modes": ["steer"], "steerInStream": ["finish"]}} if supports_steer else {}), + **({"additionalDirectories": {}} if "--additional-directories" in sys.argv else {}), + **({"inject": {"modes": ["steer"], "steerInStream": ["finish"], "pending": {"replace": "--pending-replace" in sys.argv}}} if supports_steer else {}), }}, }) elif method == "session/new": @@ -437,10 +482,17 @@ def close(request): sys.stderr.write("\x01kit-runtime\x01" + json.dumps({"event": "session_started", "session_id": session_id}) + "\n") sys.stderr.flush() selected_models[session_id] = model_ids[0] - for update in [ + replay_updates = [ {"sessionUpdate": "user_message_chunk", "messageId": "user-1", "content": {"type": "text", "text": "replayed user"}}, {"sessionUpdate": "agent_message_chunk", "messageId": "agent-1", "content": {"type": "text", "text": "replayed assistant"}}, - ]: + ] + if "--replay-shared-ids" in sys.argv: + replay_updates = [ + {"sessionUpdate": kind, "messageId": "shared", "content": {"type": "text", "text": "text"}} + for kind in ["user_message_chunk", "agent_thought_chunk", "agent_message_chunk"] + ] + [{"sessionUpdate": "agent_message", "messageId": "shared", + "content": [{"type": "text", "text": "answer"}]}] + for update in replay_updates: send({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": session_id, "update": update}}) result = {} if supports_models: @@ -455,10 +507,28 @@ def close(request): }}) elif method == "session/fork": threading.Thread(target=fork, args=(request,), daemon=True).start() + elif method == "kit/subagent/transcript/read": + params = request["params"] + if "session_id" not in params or params.get("generation") != 1 or params.get("id") != "fixture-child": + send({"jsonrpc": "2.0", "id": request["id"], "error": {"code": -32602, "message": "invalid child identity"}}) + else: + cursor = params["cursor"] + updates = [] if cursor else [ + {"sessionUpdate": "agent_message_chunk", "messageId": "child-message", "content": {"type": "text", "text": "Child-only fixture output"}}, + ] + respond(request["id"], {"updates": updates, "next_cursor": 128, "generation": 1, "caught_up": True}) + elif method == "kit/subagent/steer": + params = request["params"] + if "session_id" not in params or params.get("generation") != 1 or params.get("id") != "fixture-child" or not isinstance(params.get("prompt"), str): + send({"jsonrpc": "2.0", "id": request["id"], "error": {"code": -32602, "message": "invalid child steer"}}) + else: + respond(request["id"], {"receipt": {"accepted": True}}) elif method == "session/prompt": threading.Thread(target=prompt, args=(request,), daemon=True).start() elif method == "session/inject": threading.Thread(target=inject, args=(request,), daemon=True).start() + elif method in ("session/replace_inject", "session/revoke_inject"): + threading.Thread(target=mutate_pending_injection, args=(request,), daemon=True).start() elif method == "session/set_config_option": params = request["params"] value = params["value"] diff --git a/macos/KitDesktop.xcodeproj/project.pbxproj b/macos/KitDesktop.xcodeproj/project.pbxproj index 7f7f63e2..18aed9bf 100644 --- a/macos/KitDesktop.xcodeproj/project.pbxproj +++ b/macos/KitDesktop.xcodeproj/project.pbxproj @@ -7,25 +7,54 @@ objects = { /* Begin PBXBuildFile section */ + 00D5988FF788044E60696326 /* ProjectDirectoryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3834CD301D85D3ADF5A14C5D /* ProjectDirectoryProtocolTests.swift */; }; 02871F767925761E25F86B3E /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3990BD2C27260A0E70B93A /* AppState.swift */; }; 035DAB141A9BBEED5792F799 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 198107AA95E9C4E57B982AED /* Assets.xcassets */; }; + 0BAAD3A1EE604A1F51594045 /* ClipboardMediaImport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68DA54322BAA752E5971377 /* ClipboardMediaImport.swift */; }; + 0C164E0784F734A939122168 /* TranscriptMediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69962C8E0E1F29C59C77EFF8 /* TranscriptMediaView.swift */; }; + 0C2B7BAD602B7292DE5AD4FA /* TranscriptMediaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FD67F2CC8C0A869D5092324 /* TranscriptMediaTests.swift */; }; 0F21F855B7DA35AD722AC7D3 /* ACPProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A49E707898C1B36BB27BC121 /* ACPProtocolTests.swift */; }; 19B3A6B5BBEE1564C7F4AC0B /* PersistenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06F974BED9068ADA02CD72BC /* PersistenceStore.swift */; }; + 1FD039E35430596E77258CB6 /* ACPMessageIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76BE5F4BB5528635B399D47 /* ACPMessageIdentityTests.swift */; }; + 1FE1E96487F1DC33B4FB0952 /* TranscriptProjectionRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13436ED25775E87B54A271A /* TranscriptProjectionRegressionTests.swift */; }; + 2BB77046C11A5F39987BE7B3 /* TranscriptProjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8BE0AC7BC0655DEFA9DC4DF /* TranscriptProjection.swift */; }; + 2C3BFF1D1473D6E1F7822063 /* TranscriptToolContentBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1A3AC3038089116C5142A6 /* TranscriptToolContentBuffer.swift */; }; + 3308A8C8FA9F2A39E8B13B40 /* PendingSteerMutationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11F006E0AD5F8D6EEF2FC03 /* PendingSteerMutationTests.swift */; }; + 40FCF23FB16ED892E6A0B53C /* FocusedSubagentController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CF3512090AAA66524A87BC /* FocusedSubagentController.swift */; }; + 46C3D9B3F883CDF2F661596B /* ProjectSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5358E0179C1BBBBF93CE24B1 /* ProjectSearchTests.swift */; }; 489E18B321109F8D9F512822 /* ACPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D1610600D0AFF53C50D6E59 /* ACPClient.swift */; }; 532FC0D8C0DA828334BBB26E /* ACPWireModels.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6086BB4FC00B47A7CFEDE75F /* ACPWireModels.generated.swift */; }; + 598034517C8D04CD9FF42D5E /* PendingSteerEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3776184F3E1E03E7A3888101 /* PendingSteerEditor.swift */; }; 5D90E450684ED3EE71B69332 /* ConversationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F52157E3DA76808946939C19 /* ConversationController.swift */; }; + 6582FDDDDA4EE068F0285DBA /* DesktopUsageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622330F9651EF6FE17B0FFD /* DesktopUsageTests.swift */; }; + 668A00A674B09C31C4F9AEF5 /* ProjectNavigationLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77809268413E9E0550C37150 /* ProjectNavigationLifecycleTests.swift */; }; 6A73208124C5F3743BD70F5F /* ACPProcessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7673B303F19B3783B1B84A34 /* ACPProcessTests.swift */; }; 77CE12EFCD9EBB4BE06BBA7D /* ACPKitExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E2EABACCDFBFA37A3DB1077 /* ACPKitExtensions.swift */; }; + 78995CAA5E6507FDE2F19342 /* ProviderUsageServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17B3DDBC123331A4DE01B74 /* ProviderUsageServiceTests.swift */; }; + 825F84E21388B04F8EFF5EDD /* ProjectSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA1525D1387BB6324375D34B /* ProjectSummary.swift */; }; + 88B9E3487F21C94C474B716A /* ProviderUsageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE62A0F49EB0B39295809D2C /* ProviderUsageView.swift */; }; + 892DDBF96218AB5886A20012 /* TranscriptContentBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF81CA9181C9F75289ADA9A9 /* TranscriptContentBuffer.swift */; }; 8E8A10F49B153622CF7DD7FA /* JSONLineParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11C475A19EDCF4F131882AF2 /* JSONLineParser.swift */; }; + 8FB512E68B9F38EE3405C965 /* ModelSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E91430BED2A48D143CB953E /* ModelSelection.swift */; }; + 92253DB358B26BE204476C82 /* ProviderUsageService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F230AF447FD7A01DED5766F5 /* ProviderUsageService.swift */; }; + 99D46DFD2AED28BA9E96C636 /* TranscriptToolContentBufferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D6D636272C479A6AED6F606 /* TranscriptToolContentBufferTests.swift */; }; 9C009A5EE85704A96BECA25C /* MarkdownDocumentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3755EFD0DF6570079CB1E9F /* MarkdownDocumentTests.swift */; }; + A3DBB7E0917F10EF3485AD97 /* ProjectsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FD76F4D03C2D44DA6599243 /* ProjectsView.swift */; }; AA4D62C2F061DA87F223B926 /* KitDesktopApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 612469AB29D1589621F28D29 /* KitDesktopApp.swift */; }; + AC440F6119FF33B7AF2D9C6F /* ProjectNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D4716671696C4413ABDF64A /* ProjectNavigationTests.swift */; }; B03EA9D8EB2F591C684D3D3F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8F619B802566CBF11E36094 /* AppDelegate.swift */; }; + B23F0A4E847B61552A4C8443 /* ProjectDirectoriesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8BD2858C5E19F61C63380FB /* ProjectDirectoriesView.swift */; }; + B92551679F110E1871A50E2D /* FocusedSubagentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A9E6E2B9F08A2B61F5A6532 /* FocusedSubagentTests.swift */; }; B9AC4DF35B2F9D766761C7EB /* JSONLineParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0670A97F6D40D534E10DF6B /* JSONLineParserTests.swift */; }; + C3110D60CAC696DCADB183A6 /* TranscriptProjectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0A68433712740EB10D75548 /* TranscriptProjectionTests.swift */; }; + C5146515CECF2C72EDC40E25 /* ProjectDirectoriesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE581787540D49E5C10E4A92 /* ProjectDirectoriesTests.swift */; }; CA8ABD25EAFDC87274999FA5 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 774A438C39CB539D322381BD /* AppIcon.icon */; }; D84D1F89B4EAA779010CFE3E /* AppModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEBE305CCB3F66B4C6EFE46 /* AppModelTests.swift */; }; E0A3CEF798311F487DD36E56 /* PersistenceStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 319931620251E086D0C605C3 /* PersistenceStoreTests.swift */; }; + E159D772370C615AFC28971C /* FocusedSubagentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77289D47F227111DF7BB9D91 /* FocusedSubagentView.swift */; }; E51D677C599E9529D12CC62C /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C69138F9241277AF871FBF68 /* AppModel.swift */; }; F1CCCF9D9F727E59B384BACC /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B793DB8C6D3587B43CDC552 /* ContentView.swift */; }; + F3D9D1093B2E9F4E07AAA021 /* DesktopRenderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79533B8978F885CB3B560433 /* DesktopRenderingTests.swift */; }; F4581C82DF00798894C85689 /* BrandTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FB0EE32A1CCC0464FF5D14E /* BrandTheme.swift */; }; FCEE0B2CC5DBD6B5F32DD243 /* MarkdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAEAA8A329489C8BD38EA402 /* MarkdownView.swift */; }; /* End PBXBuildFile section */ @@ -46,24 +75,53 @@ 11C475A19EDCF4F131882AF2 /* JSONLineParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONLineParser.swift; sourceTree = ""; }; 198107AA95E9C4E57B982AED /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 2B3990BD2C27260A0E70B93A /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + 2E91430BED2A48D143CB953E /* ModelSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelSelection.swift; sourceTree = ""; }; 319931620251E086D0C605C3 /* PersistenceStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceStoreTests.swift; sourceTree = ""; }; + 3776184F3E1E03E7A3888101 /* PendingSteerEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingSteerEditor.swift; sourceTree = ""; }; + 3834CD301D85D3ADF5A14C5D /* ProjectDirectoryProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectDirectoryProtocolTests.swift; sourceTree = ""; }; + 3A9E6E2B9F08A2B61F5A6532 /* FocusedSubagentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusedSubagentTests.swift; sourceTree = ""; }; + 3D6D636272C479A6AED6F606 /* TranscriptToolContentBufferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptToolContentBufferTests.swift; sourceTree = ""; }; + 5358E0179C1BBBBF93CE24B1 /* ProjectSearchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectSearchTests.swift; sourceTree = ""; }; 5E2EABACCDFBFA37A3DB1077 /* ACPKitExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ACPKitExtensions.swift; sourceTree = ""; }; 6086BB4FC00B47A7CFEDE75F /* ACPWireModels.generated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ACPWireModels.generated.swift; sourceTree = ""; }; 612469AB29D1589621F28D29 /* KitDesktopApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KitDesktopApp.swift; sourceTree = ""; }; + 69962C8E0E1F29C59C77EFF8 /* TranscriptMediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptMediaView.swift; sourceTree = ""; }; + 6FD76F4D03C2D44DA6599243 /* ProjectsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectsView.swift; sourceTree = ""; }; 7673B303F19B3783B1B84A34 /* ACPProcessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ACPProcessTests.swift; sourceTree = ""; }; + 77289D47F227111DF7BB9D91 /* FocusedSubagentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusedSubagentView.swift; sourceTree = ""; }; 774A438C39CB539D322381BD /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = ""; }; + 77809268413E9E0550C37150 /* ProjectNavigationLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectNavigationLifecycleTests.swift; sourceTree = ""; }; + 79533B8978F885CB3B560433 /* DesktopRenderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesktopRenderingTests.swift; sourceTree = ""; }; + 7A1A3AC3038089116C5142A6 /* TranscriptToolContentBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptToolContentBuffer.swift; sourceTree = ""; }; 7D1610600D0AFF53C50D6E59 /* ACPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ACPClient.swift; sourceTree = ""; }; + 7D4716671696C4413ABDF64A /* ProjectNavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectNavigationTests.swift; sourceTree = ""; }; 7FB0EE32A1CCC0464FF5D14E /* BrandTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrandTheme.swift; sourceTree = ""; }; + 7FD67F2CC8C0A869D5092324 /* TranscriptMediaTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptMediaTests.swift; sourceTree = ""; }; 9B793DB8C6D3587B43CDC552 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; A49E707898C1B36BB27BC121 /* ACPProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ACPProtocolTests.swift; sourceTree = ""; }; + A8BE0AC7BC0655DEFA9DC4DF /* TranscriptProjection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptProjection.swift; sourceTree = ""; }; + AE581787540D49E5C10E4A92 /* ProjectDirectoriesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectDirectoriesTests.swift; sourceTree = ""; }; + AF81CA9181C9F75289ADA9A9 /* TranscriptContentBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptContentBuffer.swift; sourceTree = ""; }; + B68DA54322BAA752E5971377 /* ClipboardMediaImport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardMediaImport.swift; sourceTree = ""; }; + C11F006E0AD5F8D6EEF2FC03 /* PendingSteerMutationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingSteerMutationTests.swift; sourceTree = ""; }; C69138F9241277AF871FBF68 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; + CA1525D1387BB6324375D34B /* ProjectSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectSummary.swift; sourceTree = ""; }; D2AE60C80AB214E08161FF6A /* Version.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Version.xcconfig; sourceTree = ""; }; D3755EFD0DF6570079CB1E9F /* MarkdownDocumentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownDocumentTests.swift; sourceTree = ""; }; D5508A00BF37B0CF6A3B361F /* Kit.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Kit.app; sourceTree = BUILT_PRODUCTS_DIR; }; + D622330F9651EF6FE17B0FFD /* DesktopUsageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesktopUsageTests.swift; sourceTree = ""; }; + D76BE5F4BB5528635B399D47 /* ACPMessageIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ACPMessageIdentityTests.swift; sourceTree = ""; }; DAEBE305CCB3F66B4C6EFE46 /* AppModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModelTests.swift; sourceTree = ""; }; + E0A68433712740EB10D75548 /* TranscriptProjectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptProjectionTests.swift; sourceTree = ""; }; + E13436ED25775E87B54A271A /* TranscriptProjectionRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptProjectionRegressionTests.swift; sourceTree = ""; }; + E8BD2858C5E19F61C63380FB /* ProjectDirectoriesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectDirectoriesView.swift; sourceTree = ""; }; E8F619B802566CBF11E36094 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; EAEAA8A329489C8BD38EA402 /* MarkdownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownView.swift; sourceTree = ""; }; + EE62A0F49EB0B39295809D2C /* ProviderUsageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderUsageView.swift; sourceTree = ""; }; F0670A97F6D40D534E10DF6B /* JSONLineParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONLineParserTests.swift; sourceTree = ""; }; + F17B3DDBC123331A4DE01B74 /* ProviderUsageServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderUsageServiceTests.swift; sourceTree = ""; }; + F230AF447FD7A01DED5766F5 /* ProviderUsageService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderUsageService.swift; sourceTree = ""; }; + F3CF3512090AAA66524A87BC /* FocusedSubagentController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusedSubagentController.swift; sourceTree = ""; }; F52157E3DA76808946939C19 /* ConversationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationController.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -73,7 +131,13 @@ children = ( 7FB0EE32A1CCC0464FF5D14E /* BrandTheme.swift */, 9B793DB8C6D3587B43CDC552 /* ContentView.swift */, + 77289D47F227111DF7BB9D91 /* FocusedSubagentView.swift */, EAEAA8A329489C8BD38EA402 /* MarkdownView.swift */, + 3776184F3E1E03E7A3888101 /* PendingSteerEditor.swift */, + E8BD2858C5E19F61C63380FB /* ProjectDirectoriesView.swift */, + 6FD76F4D03C2D44DA6599243 /* ProjectsView.swift */, + EE62A0F49EB0B39295809D2C /* ProviderUsageView.swift */, + 69962C8E0E1F29C59C77EFF8 /* TranscriptMediaView.swift */, ); path = Views; sourceTree = ""; @@ -92,7 +156,14 @@ children = ( C69138F9241277AF871FBF68 /* AppModel.swift */, 2B3990BD2C27260A0E70B93A /* AppState.swift */, + B68DA54322BAA752E5971377 /* ClipboardMediaImport.swift */, F52157E3DA76808946939C19 /* ConversationController.swift */, + F3CF3512090AAA66524A87BC /* FocusedSubagentController.swift */, + 2E91430BED2A48D143CB953E /* ModelSelection.swift */, + CA1525D1387BB6324375D34B /* ProjectSummary.swift */, + AF81CA9181C9F75289ADA9A9 /* TranscriptContentBuffer.swift */, + A8BE0AC7BC0655DEFA9DC4DF /* TranscriptProjection.swift */, + 7A1A3AC3038089116C5142A6 /* TranscriptToolContentBuffer.swift */, ); path = Models; sourceTree = ""; @@ -114,6 +185,7 @@ 5E2EABACCDFBFA37A3DB1077 /* ACPKitExtensions.swift */, 11C475A19EDCF4F131882AF2 /* JSONLineParser.swift */, 06F974BED9068ADA02CD72BC /* PersistenceStore.swift */, + F230AF447FD7A01DED5766F5 /* ProviderUsageService.swift */, ); path = Services; sourceTree = ""; @@ -129,12 +201,27 @@ 9D22957B08B56139F83F43FC /* KitDesktopTests */ = { isa = PBXGroup; children = ( + D76BE5F4BB5528635B399D47 /* ACPMessageIdentityTests.swift */, 7673B303F19B3783B1B84A34 /* ACPProcessTests.swift */, A49E707898C1B36BB27BC121 /* ACPProtocolTests.swift */, DAEBE305CCB3F66B4C6EFE46 /* AppModelTests.swift */, + 79533B8978F885CB3B560433 /* DesktopRenderingTests.swift */, + D622330F9651EF6FE17B0FFD /* DesktopUsageTests.swift */, + 3A9E6E2B9F08A2B61F5A6532 /* FocusedSubagentTests.swift */, F0670A97F6D40D534E10DF6B /* JSONLineParserTests.swift */, D3755EFD0DF6570079CB1E9F /* MarkdownDocumentTests.swift */, + C11F006E0AD5F8D6EEF2FC03 /* PendingSteerMutationTests.swift */, 319931620251E086D0C605C3 /* PersistenceStoreTests.swift */, + AE581787540D49E5C10E4A92 /* ProjectDirectoriesTests.swift */, + 3834CD301D85D3ADF5A14C5D /* ProjectDirectoryProtocolTests.swift */, + 77809268413E9E0550C37150 /* ProjectNavigationLifecycleTests.swift */, + 7D4716671696C4413ABDF64A /* ProjectNavigationTests.swift */, + 5358E0179C1BBBBF93CE24B1 /* ProjectSearchTests.swift */, + F17B3DDBC123331A4DE01B74 /* ProviderUsageServiceTests.swift */, + 7FD67F2CC8C0A869D5092324 /* TranscriptMediaTests.swift */, + E13436ED25775E87B54A271A /* TranscriptProjectionRegressionTests.swift */, + E0A68433712740EB10D75548 /* TranscriptProjectionTests.swift */, + 3D6D636272C479A6AED6F606 /* TranscriptToolContentBufferTests.swift */, ); path = KitDesktopTests; sourceTree = ""; @@ -286,12 +373,27 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 1FD039E35430596E77258CB6 /* ACPMessageIdentityTests.swift in Sources */, 6A73208124C5F3743BD70F5F /* ACPProcessTests.swift in Sources */, 0F21F855B7DA35AD722AC7D3 /* ACPProtocolTests.swift in Sources */, D84D1F89B4EAA779010CFE3E /* AppModelTests.swift in Sources */, + F3D9D1093B2E9F4E07AAA021 /* DesktopRenderingTests.swift in Sources */, + 6582FDDDDA4EE068F0285DBA /* DesktopUsageTests.swift in Sources */, + B92551679F110E1871A50E2D /* FocusedSubagentTests.swift in Sources */, B9AC4DF35B2F9D766761C7EB /* JSONLineParserTests.swift in Sources */, 9C009A5EE85704A96BECA25C /* MarkdownDocumentTests.swift in Sources */, + 3308A8C8FA9F2A39E8B13B40 /* PendingSteerMutationTests.swift in Sources */, E0A3CEF798311F487DD36E56 /* PersistenceStoreTests.swift in Sources */, + C5146515CECF2C72EDC40E25 /* ProjectDirectoriesTests.swift in Sources */, + 00D5988FF788044E60696326 /* ProjectDirectoryProtocolTests.swift in Sources */, + 668A00A674B09C31C4F9AEF5 /* ProjectNavigationLifecycleTests.swift in Sources */, + AC440F6119FF33B7AF2D9C6F /* ProjectNavigationTests.swift in Sources */, + 46C3D9B3F883CDF2F661596B /* ProjectSearchTests.swift in Sources */, + 78995CAA5E6507FDE2F19342 /* ProviderUsageServiceTests.swift in Sources */, + 0C2B7BAD602B7292DE5AD4FA /* TranscriptMediaTests.swift in Sources */, + 1FE1E96487F1DC33B4FB0952 /* TranscriptProjectionRegressionTests.swift in Sources */, + C3110D60CAC696DCADB183A6 /* TranscriptProjectionTests.swift in Sources */, + 99D46DFD2AED28BA9E96C636 /* TranscriptToolContentBufferTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -306,12 +408,26 @@ E51D677C599E9529D12CC62C /* AppModel.swift in Sources */, 02871F767925761E25F86B3E /* AppState.swift in Sources */, F4581C82DF00798894C85689 /* BrandTheme.swift in Sources */, + 0BAAD3A1EE604A1F51594045 /* ClipboardMediaImport.swift in Sources */, F1CCCF9D9F727E59B384BACC /* ContentView.swift in Sources */, 5D90E450684ED3EE71B69332 /* ConversationController.swift in Sources */, + 40FCF23FB16ED892E6A0B53C /* FocusedSubagentController.swift in Sources */, + E159D772370C615AFC28971C /* FocusedSubagentView.swift in Sources */, 8E8A10F49B153622CF7DD7FA /* JSONLineParser.swift in Sources */, AA4D62C2F061DA87F223B926 /* KitDesktopApp.swift in Sources */, FCEE0B2CC5DBD6B5F32DD243 /* MarkdownView.swift in Sources */, + 8FB512E68B9F38EE3405C965 /* ModelSelection.swift in Sources */, + 598034517C8D04CD9FF42D5E /* PendingSteerEditor.swift in Sources */, 19B3A6B5BBEE1564C7F4AC0B /* PersistenceStore.swift in Sources */, + B23F0A4E847B61552A4C8443 /* ProjectDirectoriesView.swift in Sources */, + 825F84E21388B04F8EFF5EDD /* ProjectSummary.swift in Sources */, + A3DBB7E0917F10EF3485AD97 /* ProjectsView.swift in Sources */, + 92253DB358B26BE204476C82 /* ProviderUsageService.swift in Sources */, + 88B9E3487F21C94C474B716A /* ProviderUsageView.swift in Sources */, + 892DDBF96218AB5886A20012 /* TranscriptContentBuffer.swift in Sources */, + 0C164E0784F734A939122168 /* TranscriptMediaView.swift in Sources */, + 2BB77046C11A5F39987BE7B3 /* TranscriptProjection.swift in Sources */, + 2C3BFF1D1473D6E1F7822063 /* TranscriptToolContentBuffer.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/macos/KitDesktop/Models/AppModel.swift b/macos/KitDesktop/Models/AppModel.swift index 90008bb1..e2e1c16b 100644 --- a/macos/KitDesktop/Models/AppModel.swift +++ b/macos/KitDesktop/Models/AppModel.swift @@ -11,6 +11,7 @@ final class AppModel: ObservableObject { @Published private(set) var controllers: [UUID: ConversationController] = [:] @Published private(set) var activity: [UUID: Bool] = [:] @Published private(set) var lockedConversationIDs: Set = [] + @Published private(set) var showingProjects = false @Published var selectedWorkspaceID: UUID? @Published var selectedConversationID: UUID? @Published var persistenceError: String? @@ -18,7 +19,7 @@ final class AppModel: ObservableObject { private let store: PersistenceStore private let catalogLoader: CatalogLoader? - private let controllerFactory: ControllerFactory + private let controllerFactory: ControllerFactory? private var catalogGenerations: [UUID: Int] = [:] private var pendingConversationID: UUID? private var isClosing = false @@ -31,7 +32,7 @@ final class AppModel: ObservableObject { ) { self.store = store self.catalogLoader = catalogLoader - self.controllerFactory = controllerFactory ?? { ConversationController(conversation: $0, workspacePath: $1) } + self.controllerFactory = controllerFactory do { state = try store.load() } catch let error as PersistenceError { state = PersistedAppState() @@ -42,6 +43,7 @@ final class AppModel: ObservableObject { persistenceError = error.localizedDescription } selectedWorkspaceID = state.workspaces.first?.id + showingProjects = state.workspaces.isEmpty if requestNotificationAuthorization { UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in } } @@ -55,6 +57,57 @@ final class AppModel: ObservableObject { state.conversations.filter { $0.workspaceID == selectedWorkspaceID }.sorted { $0.updatedAt > $1.updatedAt } } + /// Groups and sorts once for a complete navigation snapshot, never once per row. + var projects: [ProjectSummary] { + let grouped = Dictionary(grouping: state.conversations, by: \.workspaceID) + return state.workspaces.map { workspace in + ProjectSummary(workspace: workspace, conversations: (grouped[workspace.id] ?? []).sorted { + if $0.updatedAt != $1.updatedAt { return $0.updatedAt > $1.updatedAt } + return $0.id.uuidString < $1.id.uuidString + }) + }.sorted { + if $0.updatedAt != $1.updatedAt { return $0.updatedAt > $1.updatedAt } + return $0.id.uuidString < $1.id.uuidString + } + } + + func showProjects() { + pendingConversationID = nil + showingProjects = true + } + + func createConversation(in workspaceID: UUID) { + guard state.workspaces.contains(where: { $0.id == workspaceID }) else { return } + selectWorkspace(workspaceID) + createConversation() + } + + func addProjectDirectory(_ path: String, to workspaceID: UUID) { + guard allowPersistenceMutation(), + let index = state.workspaces.firstIndex(where: { $0.id == workspaceID }) else { return } + let directory = URL(fileURLWithPath: path).standardizedFileURL.path + guard directory != state.workspaces[index].path, + !state.workspaces[index].additionalDirectories.contains(directory) else { return } + state.workspaces[index].additionalDirectories.append(directory) + updateLaunchDirectories(for: workspaceID) + save() + } + + func removeProjectDirectory(_ path: String, from workspaceID: UUID) { + guard allowPersistenceMutation(), + let index = state.workspaces.firstIndex(where: { $0.id == workspaceID }) else { return } + state.workspaces[index].additionalDirectories.removeAll { $0 == path } + updateLaunchDirectories(for: workspaceID) + save() + } + + private func updateLaunchDirectories(for workspaceID: UUID) { + guard let workspace = state.workspaces.first(where: { $0.id == workspaceID }) else { return } + for conversation in state.conversations where conversation.workspaceID == workspaceID { + controllers[conversation.id]?.setAdditionalDirectoriesForNextStart(workspace.additionalDirectories) + } + } + func addWorkspace(path: String) { guard allowPersistenceMutation() else { return } let standardized = URL(fileURLWithPath: path).standardizedFileURL.path @@ -62,6 +115,7 @@ final class AppModel: ObservableObject { let name = URL(fileURLWithPath: standardized).lastPathComponent let workspace = Workspace(name: name.isEmpty ? standardized : name, path: standardized) state.workspaces.append(workspace) + showingProjects = false selectedWorkspaceID = workspace.id selectedConversationID = nil save() @@ -69,6 +123,7 @@ final class AppModel: ObservableObject { } func selectWorkspace(_ id: UUID?) { + showingProjects = false selectedWorkspaceID = id if let selectedConversationID, !state.conversations.contains(where: { $0.id == selectedConversationID && $0.workspaceID == id }) { self.selectedConversationID = nil } if let pendingConversationID, !state.conversations.contains(where: { $0.id == pendingConversationID && $0.workspaceID == id }) { self.pendingConversationID = nil } @@ -101,6 +156,8 @@ final class AppModel: ObservableObject { } private func commitSelection(_ id: UUID) { + showingProjects = false + selectedWorkspaceID = state.conversations.first(where: { $0.id == id })?.workspaceID selectedConversationID = id updateConversation(id) { item in item.unread = false @@ -110,7 +167,7 @@ final class AppModel: ObservableObject { } func appBecameActive() { - guard let id = selectedConversationID else { return } + guard !showingProjects, let id = selectedConversationID else { return } updateConversation(id) { item in item.unread = false item.awaitingUser = false @@ -266,11 +323,14 @@ final class AppModel: ObservableObject { } guard let conversation = state.conversations.first(where: { $0.id == id }), let workspace = state.workspaces.first(where: { $0.id == conversation.workspaceID }) else { return } - let controller = controllerFactory(conversation, workspace.path) + let controller = controllerFactory?(conversation, workspace.path) ?? ConversationController( + conversation: conversation, workspacePath: workspace.path, + additionalDirectories: workspace.additionalDirectories + ) controller.onSessionReady = { [weak self] sessionID, _ in guard let self else { return } self.sessionBecameReady(conversationID: id, sessionID: sessionID) - if self.pendingConversationID == id || self.selectedConversationID == id { + if self.pendingConversationID == id || (!self.showingProjects && self.selectedConversationID == id) { self.pendingConversationID = nil self.commitSelection(id) } @@ -307,7 +367,7 @@ final class AppModel: ObservableObject { private func turnFinished(id: UUID, reason: String) { let inactive = NSApp == nil || !NSApp.isActive - let hidden = selectedConversationID != id + let hidden = showingProjects || selectedConversationID != id let needsAttention = inactive || hidden let attention = Self.attentionState(reason: reason, isFocused: !needsAttention) updateConversation(id) { item in diff --git a/macos/KitDesktop/Models/AppState.swift b/macos/KitDesktop/Models/AppState.swift index 1216c0e8..43cd1cb0 100644 --- a/macos/KitDesktop/Models/AppState.swift +++ b/macos/KitDesktop/Models/AppState.swift @@ -5,12 +5,27 @@ struct Workspace: Codable, Identifiable, Equatable { var name: String var path: String var createdAt: Date + var additionalDirectories: [String] - init(id: UUID = UUID(), name: String, path: String, createdAt: Date = Date()) { + init(id: UUID = UUID(), name: String, path: String, createdAt: Date = Date(), additionalDirectories: [String] = []) { self.id = id self.name = name self.path = path self.createdAt = createdAt + self.additionalDirectories = additionalDirectories + } + + private enum CodingKeys: String, CodingKey { case id, name, path, createdAt, additionalDirectories } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(UUID.self, forKey: .id) + name = try values.decode(String.self, forKey: .name) + path = try values.decode(String.self, forKey: .path) + createdAt = try values.decode(Date.self, forKey: .createdAt) + // Additive v3 field: absence is legacy; null and malformed values are errors. + additionalDirectories = values.contains(.additionalDirectories) + ? try values.decode([String].self, forKey: .additionalDirectories) : [] } } @@ -306,6 +321,21 @@ struct AgentRoster: Equatable { } private(set) var rowsByID: [String: AgentRosterRow] = [:] + private(set) var usageByID: [String: DesktopUsageUpdate] = [:] + private var steerableGenerations: [String: UInt64] = [:] + + func canSteer(id: String, generation: UInt64) -> Bool { + steerableGenerations[id] == generation + } + // Costs are cumulative snapshots per child, not deltas. Keep closed children + // in the ledger so removal from the live roster does not reduce session spend. + private var costsByID: [String: DesktopCost] = [:] + + var costTotals: [String: Double] { + costsByID.values.reduce(into: [:]) { totals, cost in + totals[cost.currency, default: 0] += cost.amount + } + } private var versions: [String: Version] = [:] private var cleanedIDs: Set = [] @@ -353,6 +383,14 @@ struct AgentRoster: Equatable { mutating func reset() { self = AgentRoster() } + /// A transport gap invalidates live observations, not accumulated provider spend. + mutating func invalidateLiveState() { + rowsByID.removeAll() + usageByID.removeAll() + steerableGenerations.removeAll() + versions.removeAll() + } + @discardableResult mutating func apply(event: [String: Any], nowMS: UInt64) -> Bool { switch event["event"] as? String { @@ -361,13 +399,38 @@ struct AgentRoster: Equatable { let incoming = Version(generation: row.generation, statusRank: row.status.rank) if let current = versions[row.id], incoming <= current { return false } versions[row.id] = incoming + if row.status != .working || steerableGenerations[row.id] != row.generation { + steerableGenerations.removeValue(forKey: row.id) + } if row.status == .removed, row.outcome != .failed || row.generationFinishedAtMS.map({ nowMS - min(nowMS, $0) >= 4_000 }) != false { rowsByID.removeValue(forKey: row.id) + usageByID.removeValue(forKey: row.id) } else { rowsByID[row.id] = row } return true + case "subagent_capabilities": + guard let id = event["id"] as? String, let generation = Self.unsigned(event["generation"]), + let canSteer = event["can_steer"] as? Bool, let row = rowsByID[id], + row.generation == generation, row.status == .working else { return false } + if canSteer { steerableGenerations[id] = generation } + else { steerableGenerations.removeValue(forKey: id) } + return true + case "subagent_usage": + guard let id = event["id"] as? String else { return false } + let used = (event["used"] as? NSNumber)?.intValue + let size = (event["size"] as? NSNumber)?.intValue + var cost: DesktopCost? + if let value = event["cost"] as? [String: Any], + let amount = value["amount"] as? NSNumber, let currency = value["currency"] as? String { + let candidate = DesktopCost(amount: amount.doubleValue, currency: currency) + if candidate.isValid { cost = candidate; costsByID[id] = candidate } + } + if rowsByID[id] != nil { + usageByID[id] = DesktopUsageUpdate(used: used, size: size, cost: cost ?? usageByID[id]?.cost) + } + return true case "subagent_descendants_removed": guard let ancestorID = event["ancestor_id"] as? String else { return false } var removed: Set = [] @@ -379,6 +442,8 @@ struct AgentRoster: Equatable { if removed.count == before { break } } rowsByID = rowsByID.filter { !removed.contains($0.key) } + usageByID = usageByID.filter { !removed.contains($0.key) } + steerableGenerations = steerableGenerations.filter { !removed.contains($0.key) } cleanedIDs.formUnion(removed) return true default: return false @@ -392,6 +457,7 @@ struct AgentRoster: Equatable { row.status != .removed || row.outcome != .failed || row.generationFinishedAtMS.map { nowMS - min(nowMS, $0) < 4_000 } == true } + usageByID = usageByID.filter { rowsByID[$0.key] != nil } return rowsByID.count != before } @@ -405,6 +471,7 @@ struct AgentRoster: Equatable { generationFinishedAtMS: row.generationFinishedAtMS ?? nowMS ) rowsByID[id] = retired + steerableGenerations.removeValue(forKey: id) versions[id] = Version(generation: row.generation, statusRank: SubagentStatus.removed.rank) } } @@ -458,7 +525,10 @@ struct TranscriptEntry: Identifiable { var formatted: AttributedString? var children: [RuntimeChild] var backgrounded: Bool - var contentBlocks: [DesktopContentBlock] + var contentBlocksRevision: UInt64 = 0 + var contentBlocks: [DesktopContentBlock] { + didSet { contentBlocksRevision &+= 1 } + } var presentation: TranscriptPresentation? init( diff --git a/macos/KitDesktop/Models/ClipboardMediaImport.swift b/macos/KitDesktop/Models/ClipboardMediaImport.swift new file mode 100644 index 00000000..459aeb7c --- /dev/null +++ b/macos/KitDesktop/Models/ClipboardMediaImport.swift @@ -0,0 +1,30 @@ +import Foundation +import ImageIO +import UniformTypeIdentifiers + +/// Clipboard bytes are copied on the AppKit thread; validation and conversion are not. +enum ClipboardMediaImport { + static func writeImage(_ data: Data) throws -> URL { + guard data.count <= TranscriptMedia.maximumBytes, + let source = CGImageSourceCreateWithData(data as CFData, nil), + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? Int, + let height = properties[kCGImagePropertyPixelHeight] as? Int, + width > 0, height > 0, width <= 8192, height <= 8192, + width * height <= 16 * 1024 * 1024, + let image = CGImageSourceCreateImageAtIndex(source, 0, nil) + else { throw ACPClientError.attachment("Clipboard image is invalid or exceeds image limits") } + let output = NSMutableData() + guard let destination = CGImageDestinationCreateWithData(output, UTType.png.identifier as CFString, 1, nil) + else { throw ACPClientError.attachment("Could not import clipboard image") } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination), output.length <= TranscriptMedia.maximumBytes + else { throw ACPClientError.attachment("Clipboard image exceeds the 10 MiB limit") } + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("KitDesktop/DroppedAttachments/\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("Clipboard.png") + try (output as Data).write(to: url, options: .atomic) + return url + } +} diff --git a/macos/KitDesktop/Models/ConversationController.swift b/macos/KitDesktop/Models/ConversationController.swift index d019ba32..1da33afc 100644 --- a/macos/KitDesktop/Models/ConversationController.swift +++ b/macos/KitDesktop/Models/ConversationController.swift @@ -8,7 +8,9 @@ final class ConversationController: ObservableObject { static let maximumAttachmentBytes: Int64 = 10 * 1024 * 1024 static let maximumTotalAttachmentBytes: Int64 = 20 * 1024 * 1024 - @Published var entries: [TranscriptEntry] = [] + // Publish once at the revision boundary; per-field array writes must not + // invalidate every transcript view (or copy a publisher snapshot) per token. + private(set) var entries: [TranscriptEntry] = [] @Published var configOptions: [ConfigOption] = [] @Published var advertisedCommands: [AdvertisedCommand] = [] @Published var draft = "" @@ -17,10 +19,30 @@ final class ConversationController: ObservableObject { @Published var status = "Connecting…" @Published var isReady = false @Published var isRunning = false + @Published private(set) var isUpdatingConfig = false @Published var contextUsed: Int? @Published var contextSize: Int? @Published var tokenUsage: DesktopTokenUsage? - @Published var transcriptRevision = 0 + @Published var sessionCost: DesktopCost? + @Published private(set) var runtimeTransportAvailable: Bool? + let transcriptProjection = TranscriptProjection() + private var changedTranscriptIndices: Set = [] + private var entryIndices: [UUID: Int] = [:] + private var toolEntryIDs: [String: UUID] = [:] + private var rebuildTranscriptProjection = false + @Published var transcriptRevision = 0 { + didSet { + if rebuildTranscriptProjection { + entryIndices = Dictionary(uniqueKeysWithValues: entries.enumerated().map { ($0.element.id, $0.offset) }) + toolEntryIDs = entries.reduce(into: [:]) { result, entry in + if let callID = entry.toolCallID { result[callID] = entry.id } + } + } + transcriptProjection.synchronize(entries, changedIndices: changedTranscriptIndices, rebuilding: rebuildTranscriptProjection) + changedTranscriptIndices.removeAll(keepingCapacity: true) + rebuildTranscriptProjection = false + } + } @Published private(set) var pendingAttachmentReceipts = 0 @Published private(set) var isRetryable = false @Published private(set) var isLocked = false @@ -29,6 +51,8 @@ final class ConversationController: ObservableObject { @Published private(set) var pendingSteers: [PendingSteer] = [] @Published private(set) var canSteer = false @Published private(set) var isInjecting = false + @Published private(set) var isMutatingSteerIDs: Set = [] + var supportsPendingSteerEdit: Bool { client.supportsPendingSteerEdit } struct PendingSteer: Identifiable, Equatable { let id: String @@ -70,7 +94,7 @@ final class ConversationController: ObservableObject { } var shouldPresentAgentRoster: Bool { - Self.shouldPresentAgentRoster( + runtimeTransportAvailable != false && Self.shouldPresentAgentRoster( expectedSessionID: expectedRuntimeSessionID, runtimeSessionID: runtimeSessionID, transcriptIsEmpty: entries.isEmpty ) @@ -84,18 +108,21 @@ final class ConversationController: ObservableObject { private var client: ACPClient private let workspacePath: String + private var additionalDirectories: [String] private let launchSessionID: String private var conversation: Conversation private var foregroundRunning = false private var autonomousTurns: Set = [] private var streamingEntryIDs: Set = [] - private var messageEntryIDs: [String: UUID] = [:] + private struct MessageIdentity: Hashable { + let role: String + let id: String + } + private var messageEntryIDs: [MessageIdentity: UUID] = [:] private var planEntryIDs: [String: UUID] = [:] private var toolStates: [String: DesktopToolUpdate] = [:] private var activeThoughtEntryID: UUID? private var activeThoughtStartedAt: ContinuousClock.Instant? - private var thoughtTexts: [String: String] = [:] - private var thoughtBlocks: [String: [DesktopContentBlock]] = [:] private let clock = ContinuousClock() private var foregroundStartedAt: ContinuousClock.Instant? private var autonomousStartedAt: [Int: ContinuousClock.Instant] = [:] @@ -109,6 +136,17 @@ final class ConversationController: ObservableObject { private var startupUpdates: [DesktopUpdate]? private var expectedRuntimeSessionID: String? private var rosterPruneWorkItem: DispatchWorkItem? + private var runtimeLeaseWorkItem: DispatchWorkItem? + private var runtimeLastFrame: ContinuousClock.Instant? + + var subagentClient: FocusedSubagentTransport { client } + + func canSteerSubagent(id: String, generation: UInt64) -> Bool { + guard isReady, !shuttingDown, shouldPresentAgentRoster, + let row = agentRoster.rowsByID[id], row.generation == generation else { return false } + return row.status == .working && row.parentID == nil + && agentRoster.canSteer(id: id, generation: generation) + } var onSessionReady: ((String, [ConfigOption]) -> Void)? var onTurnStarted: ((String) -> Void)? @@ -118,16 +156,21 @@ final class ConversationController: ObservableObject { var onLockChanged: ((Bool) -> Void)? var onConfigChanged: ((String, String, String, Bool) -> Void)? - init(conversation: Conversation, workspacePath: String, client: ACPClient = ACPClient()) { + init(conversation: Conversation, workspacePath: String, additionalDirectories: [String] = [], client: ACPClient = ACPClient()) { conversationID = conversation.id self.conversation = conversation self.workspacePath = workspacePath + self.additionalDirectories = additionalDirectories launchSessionID = conversation.sessionID ?? "s-desktop-\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))" self.client = client } var reservedSessionID: String { conversation.sessionID ?? launchSessionID } + func setAdditionalDirectoriesForNextStart(_ directories: [String]) { + additionalDirectories = directories + } + func start() { start(force: false) } func retryIfNeeded() { @@ -152,6 +195,7 @@ final class ConversationController: ObservableObject { private func start(force: Bool) { guard !shuttingDown else { return } + isUpdatingConfig = false status = force ? "Recovering stale session lock…" : "Connecting…" let persisted = conversation.sessionID let activeClient = client @@ -187,7 +231,7 @@ final class ConversationController: ObservableObject { provider: inheritsConfig ? nil : conversation.provider, model: inheritsConfig ? nil : conversation.model, reasoningEffort: inheritsConfig ? nil : conversation.reasoningEffort, - force: force + additionalDirectories: additionalDirectories, force: force ) activeClient.start(options: options, loading: persisted != nil) { [weak self, weak activeClient] result in guard let self, let activeClient, self.isCurrentClient(activeClient, generation: generation), !self.shuttingDown else { return } @@ -253,7 +297,6 @@ final class ConversationController: ObservableObject { presentation: .user(UserMessagePresentation(text: text, media: media)) ) self.appendEntry(entry) - self.finalizeLastEntry() self.composerTransaction?.transcriptEntryID = entry.id }) { [weak self] result in guard let self else { return } @@ -274,16 +317,26 @@ final class ConversationController: ObservableObject { } func choose(_ option: ConfigOption, value: String) { + guard isReady, !isRunning, !isLocked, !shuttingDown, !isUpdatingConfig, + let current = configOptions.first(where: { $0.id == option.id }), + current.valueType == option.valueType else { return } let wireValue: ACPSessionConfigValue - switch option.valueType { - case "select": wireValue = .select(value) + switch current.valueType { + case "select": + guard current.choices.contains(where: { $0.value == value }) else { return } + wireValue = .select(value) case "boolean": guard value == "true" || value == "false" else { return } wireValue = .boolean(value == "true") default: return } - client.setConfig(id: option.id, value: wireValue) { [weak self] result in - guard let self else { return } + let activeClient = client + let generation = clientGeneration + isUpdatingConfig = true + activeClient.setConfig(id: current.id, value: wireValue) { [weak self, weak activeClient] result in + guard let self, let activeClient, !self.shuttingDown, + self.isCurrentClient(activeClient, generation: generation) else { return } + self.isUpdatingConfig = false switch result { case .failure(let error): self.fail(error) case .success(let payload): @@ -303,8 +356,10 @@ final class ConversationController: ObservableObject { switch result { case .failure(let error): self.fail(error) case .success(let payload): - if payload["detached"] as? Bool == true, let index = self.entries.firstIndex(where: { $0.toolCallID == callID }) { + if payload["detached"] as? Bool == true, let index = self.toolEntryIDs[callID].flatMap({ self.transcriptIndex(for: $0) }) { + self.changedTranscriptIndices.insert(index) self.entries[index].backgrounded = true + self.changedTranscriptIndices.insert(index) self.entries[index].isStreaming = true self.streamingEntryIDs.insert(self.entries[index].id) self.status = "Compose call moved to background" @@ -354,6 +409,10 @@ final class ConversationController: ObservableObject { func removeAttachment(_ id: UUID) { attachments.removeAll { $0.id == id } } func close(completion: (() -> Void)? = nil) { + runtimeLeaseWorkItem?.cancel() + runtimeLeaseWorkItem = nil + runtimeLastFrame = nil + isUpdatingConfig = false let hadActiveTurn = !activeTurns.isEmpty reduceSettlement(.shutdown) client.close(activeTurn: hadActiveTurn) { [weak self] in @@ -382,7 +441,10 @@ final class ConversationController: ObservableObject { finishActiveThought() applyPlan(plan) case .planRemoved(let id): removePlan(id: id) - case .usage(let usage): contextUsed = usage.used; contextSize = usage.size + case .usage(let usage): + contextUsed = usage.used + contextSize = usage.size + if let cost = usage.cost, cost.isValid { sessionCost = cost } case .tokenUsage(let usage): tokenUsage = usage case .configOptions(let options): configOptions = Self.parseConfigOptions(options.anyValue); publishCurrentConfig() case .sessionInfo(let info): if info.titlePresent { onTitleChanged?(info.title ?? "") } @@ -392,12 +454,16 @@ final class ConversationController: ObservableObject { case .notice(let notice): finishActiveThought() status = notice.title - appendEntry(TranscriptEntry(role: .status, title: notice.severity?.capitalized ?? "Notice", text: notice.description ?? notice.title)) + appendEntry(TranscriptEntry(role: notice.severity?.lowercased() == "error" ? .error : .status, title: notice.severity?.capitalized ?? "Notice", text: notice.description ?? notice.title)) case .compaction(let compaction): finishActiveThought() status = compaction.error ?? "Compaction " + compaction.status.replacingOccurrences(of: "_", with: " ") let text = compaction.summary?.map(Self.contentText).joined() ?? status - upsertSingleton(role: .status, title: "Compaction", text: text) + if compaction.error != nil { + appendEntry(TranscriptEntry(role: .error, title: "Compaction", text: text)) + } else { + upsertSingleton(role: .status, title: "Compaction", text: text) + } case .compactionChunk(_, let content): finishActiveThought() appendChunk(role: .status, content: content) @@ -407,83 +473,93 @@ final class ConversationController: ObservableObject { } private func applyThought(_ message: DesktopMessageUpdate) { - let blocks = message.content - let text = blocks.map(Self.contentText).joined() - let currentText = message.replace - ? text - : (thoughtTexts[message.messageId] ?? "") + text - let currentBlocks = message.replace - ? blocks - : (thoughtBlocks[message.messageId] ?? []) + blocks - thoughtTexts[message.messageId] = String(currentText.suffix(256 * 1024)) - thoughtBlocks[message.messageId] = currentBlocks - - if activeThoughtEntryID == nil { + guard message.hasContent else { return } + let identity = MessageIdentity(role: TranscriptRole.thought.rawValue, id: message.messageId) + let text = message.content.map(Self.contentText).joined() + let entryID: UUID + if let existing = messageEntryIDs[identity], transcriptIndex(for: existing) != nil { + entryID = existing + } else { + finishActiveThought() let entry = TranscriptEntry(role: .thought, text: "", isStreaming: true) appendEntry(entry) - activeThoughtEntryID = entry.id + entryID = entry.id + messageEntryIDs[identity] = entryID + } + if activeThoughtEntryID != entryID { + finishActiveThought() + activeThoughtEntryID = entryID activeThoughtStartedAt = clock.now - streamingEntryIDs.insert(entry.id) } - guard let entryID = activeThoughtEntryID, - let index = entries.firstIndex(where: { $0.id == entryID }) else { return } - entries[index].text = thoughtTexts[message.messageId] ?? "" - entries[index].contentBlocks = thoughtBlocks[message.messageId] ?? [] + guard let index = transcriptIndex(for: entryID) else { return } + let previousText = message.replace ? "" : entries[index].text + let previousBlocks = message.replace ? [] : entries[index].contentBlocks + changedTranscriptIndices.insert(index) + entries[index].text = TranscriptContentBuffer.text(previousText + text) + entries[index].contentBlocks = TranscriptContentBuffer.append(message.content, to: previousBlocks) entries[index].formatted = nil entries[index].isStreaming = true - messageEntryIDs[message.messageId] = entryID + streamingEntryIDs.insert(entryID) transcriptRevision += 1 } private func applyMessage(_ message: DesktopMessageUpdate, role: TranscriptRole) { + let identity = MessageIdentity(role: role.rawValue, id: message.messageId) let deliveredSteer = role == .user && pendingSteers.contains(where: { $0.id == message.messageId }) - if deliveredSteer { pendingSteers.removeAll { $0.id == message.messageId } } + if deliveredSteer { pendingSteers.removeAll { $0.id == message.messageId }; isMutatingSteerIDs.remove(message.messageId) } if message.replace, !message.hasContent { if role == .user, !deliveredSteer, let optimistic = composerTransaction?.transcriptEntryID { - messageEntryIDs[message.messageId] = optimistic + messageEntryIDs[identity] = optimistic } return } if role == .user, !deliveredSteer, composerTransaction != nil { if !message.content.isEmpty, consumeComposerEcho(message.content, requireComplete: message.replace) { - if let optimistic = composerTransaction?.transcriptEntryID { messageEntryIDs[message.messageId] = optimistic } + if let optimistic = composerTransaction?.transcriptEntryID { messageEntryIDs[identity] = optimistic } return } if message.replace, let optimistic = composerTransaction?.transcriptEntryID { - messageEntryIDs[message.messageId] = optimistic + messageEntryIDs[identity] = optimistic } } let blocks = message.content let text = blocks.map(Self.contentText).joined() - if role == .assistant { latestAssistantSource = message.replace ? text : latestAssistantSource + text } - if let entryID = messageEntryIDs[message.messageId], let index = entries.firstIndex(where: { $0.id == entryID }) { + if role == .assistant { latestAssistantSource = TranscriptContentBuffer.text(message.replace ? text : latestAssistantSource + text) } + if let entryID = messageEntryIDs[identity], let index = transcriptIndex(for: entryID) { if message.replace { - entries[index].contentBlocks = blocks - entries[index].text = String(text.suffix(256 * 1024)) + changedTranscriptIndices.insert(index) + entries[index].contentBlocks = TranscriptContentBuffer.append(blocks) + changedTranscriptIndices.insert(index) + entries[index].text = TranscriptContentBuffer.text(text) if role == .user { + changedTranscriptIndices.insert(index) entries[index].presentation = .user(UserMessagePresentation( text: entries[index].text, media: blocks.flatMap { Self.userContent($0).media } )) } } else { - entries[index].contentBlocks.append(contentsOf: blocks) - entries[index].text = String((entries[index].text + text).suffix(256 * 1024)) + changedTranscriptIndices.insert(index) + entries[index].contentBlocks = TranscriptContentBuffer.append(blocks, to: entries[index].contentBlocks) + changedTranscriptIndices.insert(index) + entries[index].text = TranscriptContentBuffer.text(entries[index].text + text) } + changedTranscriptIndices.insert(index) entries[index].formatted = nil + changedTranscriptIndices.insert(index) entries[index].isStreaming = true streamingEntryIDs.insert(entryID) transcriptRevision += 1 return } - let limitedText = String(text.suffix(256 * 1024)) + let limitedText = TranscriptContentBuffer.text(text) let presentation: TranscriptPresentation? = role == .user ? .user(UserMessagePresentation(text: limitedText, media: blocks.flatMap { Self.userContent($0).media })) : nil let entry = TranscriptEntry( - role: role, text: limitedText, isStreaming: true, contentBlocks: blocks, presentation: presentation + role: role, text: limitedText, isStreaming: true, contentBlocks: TranscriptContentBuffer.append(blocks), presentation: presentation ) appendEntry(entry) - messageEntryIDs[message.messageId] = entry.id + messageEntryIDs[identity] = entry.id streamingEntryIDs.insert(entry.id) } @@ -497,7 +573,7 @@ final class ConversationController: ObservableObject { status = "Action required" case "idle": canSteer = false - pendingSteers.removeAll() + pendingSteers.removeAll(); isMutatingSteerIDs.removeAll() if let usage = state.usage { tokenUsage = usage } if let turn = activeTurns.first(where: { if case .foreground = $0 { return true }; return false }) { reduceSettlement(.terminal(turn, reason: state.stopReason ?? "end_turn", error: nil)) @@ -515,17 +591,18 @@ final class ConversationController: ObservableObject { case .markdown(_, let content): text = content case .unknown: return } - if let entryID = planEntryIDs[plan.id], let index = entries.firstIndex(where: { $0.id == entryID }) { - entries[index].text = text; entries[index].formatted = Self.markdown(text) + if let entryID = planEntryIDs[plan.id], let index = transcriptIndex(for: entryID) { + changedTranscriptIndices.insert(index) + entries[index].text = text; entries[index].formatted = nil } else { - let entry = TranscriptEntry(role: .plan, title: "Plan", text: text, formatted: Self.markdown(text)) + let entry = TranscriptEntry(role: .plan, title: "Plan", text: text) appendEntry(entry); planEntryIDs[plan.id] = entry.id } transcriptRevision += 1 } private func removePlan(id: String) { - if let entryID = planEntryIDs.removeValue(forKey: id) { entries.removeAll { $0.id == entryID } } + if let entryID = planEntryIDs.removeValue(forKey: id) { rebuildTranscriptProjection = true; entries.removeAll { $0.id == entryID } } transcriptRevision += 1 } @@ -538,12 +615,20 @@ final class ConversationController: ObservableObject { } func applyRuntime(_ event: [String: Any]) { - guard let kind = event["event"] as? String else { return } + guard let kind = event["event"] as? String, expectedRuntimeSessionID != nil else { return } + let now = clock.now + expireRuntimeLease(at: now) + if kind == "runlet_transport", let available = event["available"] as? Bool { + setRuntimeTransportAvailable(available) + if available { refreshRuntimeLease(at: now) } + return + } + if runtimeTransportAvailable != false { refreshRuntimeLease(at: now) } if kind == "session_started" { runtimeSessionID = event["session_id"] as? String return } - guard expectedRuntimeSessionID != nil, runtimeSessionID == expectedRuntimeSessionID else { return } + guard runtimeTransportAvailable != false, runtimeSessionID == expectedRuntimeSessionID else { return } var roster = agentRoster if roster.apply(event: event, nowMS: Self.nowMilliseconds()) { agentRoster = roster @@ -552,13 +637,18 @@ final class ConversationController: ObservableObject { } switch kind { case "child_started": - guard let call = event["call"] as? String, let parent = Self.parentCall(call), let index = entries.firstIndex(where: { $0.toolCallID == parent }) else { return } + guard let call = event["call"] as? String, let parent = Self.parentCall(call), let index = toolEntryIDs[parent].flatMap({ transcriptIndex(for: $0) }) else { return } + changedTranscriptIndices.insert(index) entries[index].children.append(RuntimeChild(id: call, tool: event["tool"] as? String ?? "tool", summary: event["summary"] as? String ?? "", running: true, succeeded: nil, durationMS: nil)) case "child_finished": - guard let call = event["call"] as? String, let parent = Self.parentCall(call), let entry = entries.firstIndex(where: { $0.toolCallID == parent }), let child = entries[entry].children.firstIndex(where: { $0.id == call }) else { return } + guard let call = event["call"] as? String, let parent = Self.parentCall(call), let entry = toolEntryIDs[parent].flatMap({ transcriptIndex(for: $0) }), let child = entries[entry].children.firstIndex(where: { $0.id == call }) else { return } + changedTranscriptIndices.insert(entry) entries[entry].children[child].running = false + changedTranscriptIndices.insert(entry) entries[entry].children[child].succeeded = event["ok"] as? Bool + changedTranscriptIndices.insert(entry) entries[entry].children[child].summary = event["summary"] as? String ?? entries[entry].children[child].summary + changedTranscriptIndices.insert(entry) entries[entry].children[child].durationMS = (event["millis"] as? NSNumber)?.intValue case "compaction_started": status = "Compacting context…" case "compaction_finished": status = (event["ok"] as? Bool == true) ? "Context compaction finished" : "Context compaction failed" @@ -575,11 +665,14 @@ final class ConversationController: ObservableObject { let text = Self.contentText(content) guard !text.isEmpty || content != .text("") else { return } if let index = entries.indices.last, entries[index].role == role, entries[index].isStreaming { - entries[index].text = String((entries[index].text + text).suffix(256 * 1024)) - entries[index].contentBlocks.append(content) + changedTranscriptIndices.insert(index) + entries[index].text = TranscriptContentBuffer.text(entries[index].text + text) + changedTranscriptIndices.insert(index) + entries[index].contentBlocks = TranscriptContentBuffer.append([content], to: entries[index].contentBlocks) + changedTranscriptIndices.insert(index) entries[index].formatted = nil } else { - appendEntry(TranscriptEntry(role: role, text: String(text.suffix(256 * 1024)), isStreaming: true, contentBlocks: [content])) + appendEntry(TranscriptEntry(role: role, text: TranscriptContentBuffer.text(text), isStreaming: true, contentBlocks: [content])) if let id = entries.last?.id { streamingEntryIDs.insert(id) } } transcriptRevision += 1 @@ -591,11 +684,15 @@ final class ConversationController: ObservableObject { if let index = entries.indices.last, entries[index].role == .user, entries[index].isStreaming { var current = entries[index].presentation?.userMessage ?? UserMessagePresentation(text: entries[index].text, media: []) - entries[index].text = String((entries[index].text + content.text).suffix(256 * 1024)) - entries[index].contentBlocks.append(block) + changedTranscriptIndices.insert(index) + entries[index].text = TranscriptContentBuffer.text(entries[index].text + content.text) + changedTranscriptIndices.insert(index) + entries[index].contentBlocks = TranscriptContentBuffer.append([block], to: entries[index].contentBlocks) current.text = entries[index].text current.media.append(contentsOf: content.media) + changedTranscriptIndices.insert(index) entries[index].presentation = .user(current) + changedTranscriptIndices.insert(index) entries[index].formatted = nil } else { let text = String(content.text.suffix(256 * 1024)) @@ -623,12 +720,24 @@ final class ConversationController: ObservableObject { return TurnDurationPresentation(milliseconds: max(0, Int(clamping: milliseconds))) } + private func transcriptIndex(for id: UUID) -> Int? { + if let index = entryIndices[id], entries.indices.contains(index), entries[index].id == id { return index } + return entries.firstIndex { $0.id == id } + } + private func appendEntry(_ entry: TranscriptEntry) { + entryIndices[entry.id] = entries.count + if let callID = entry.toolCallID { toolEntryIDs[callID] = entry.id } entries.append(entry) if entries.count > Self.maximumEntries { let count = entries.count - Self.maximumEntries - let removed = entries.prefix(count).map(\.id) - entries.removeFirst(count) + let removed = Set(entries.prefix(count).map(\.id)) + for entry in entries.prefix(count) { + if let callID = entry.toolCallID { toolStates.removeValue(forKey: callID) } + } + messageEntryIDs = messageEntryIDs.filter { !removed.contains($0.value) } + planEntryIDs = planEntryIDs.filter { !removed.contains($0.value) } + rebuildTranscriptProjection = true; entries.removeFirst(count) streamingEntryIDs.subtract(removed) } transcriptRevision += 1 @@ -639,14 +748,19 @@ final class ConversationController: ObservableObject { let update = (toolStates[id] ?? DesktopToolUpdate(toolCallId: id)).merging(patch) toolStates[id] = update let dictionary = Self.toolDictionary(update) - if let index = entries.lastIndex(where: { $0.role == .tool && $0.toolCallID == id }) { + if let index = toolEntryIDs[id].flatMap({ transcriptIndex(for: $0) }) { let tool = Self.toolPresentation(dictionary) + changedTranscriptIndices.insert(index) entries[index].title = tool.title + changedTranscriptIndices.insert(index) entries[index].text = tool.detail + changedTranscriptIndices.insert(index) entries[index].presentation = .tool(tool) if patch.present.contains("rawInput") { + changedTranscriptIndices.insert(index) entries[index].backgrounded = (update.rawInput?.anyValue as? [String: Any])?["background"] as? Bool == true } + changedTranscriptIndices.insert(index) entries[index].isStreaming = tool.status == .inProgress || tool.status == .pending if entries[index].isStreaming { streamingEntryIDs.insert(entries[index].id) } else { streamingEntryIDs.remove(entries[index].id) } @@ -667,26 +781,30 @@ final class ConversationController: ObservableObject { private func appendToolContent(id: String, content: JSONValue) { var patch = toolStates[id] ?? DesktopToolUpdate(toolCallId: id) - var chunks: [JSONValue] - if case .array(let existing)? = patch.content { chunks = existing } else { chunks = [] } - chunks.append(content) - patch.content = .array(chunks) + patch.content = TranscriptToolContentBuffer.append(content, to: patch.content) patch.present.insert("content") updateTool(patch) } private func upsertSingleton(role: TranscriptRole, title: String, text: String) { - if let index = entries.lastIndex(where: { $0.role == role }) { entries[index].text = text; entries[index].formatted = Self.markdown(text) } - else { appendEntry(TranscriptEntry(role: role, title: title, text: text, formatted: Self.markdown(text))) } + if let index = entries.indices.last, entries[index].role == role, entries[index].title == title { + changedTranscriptIndices.insert(index) + entries[index].text = text + entries[index].formatted = nil + } + else { appendEntry(TranscriptEntry(role: role, title: title, text: text)) } transcriptRevision += 1 } private func finishActiveThought() { guard let entryID = activeThoughtEntryID else { return } - if let index = entries.firstIndex(where: { $0.id == entryID }) { + if let index = transcriptIndex(for: entryID) { let elapsed = activeThoughtStartedAt.map { $0.duration(to: clock.now) } ?? .zero + changedTranscriptIndices.insert(index) entries[index].isStreaming = false - entries[index].formatted = Self.markdown(entries[index].text) + changedTranscriptIndices.insert(index) + entries[index].formatted = nil + changedTranscriptIndices.insert(index) entries[index].presentation = .thought(ThoughtPresentation( milliseconds: Self.turnDuration(elapsed).milliseconds )) @@ -694,8 +812,6 @@ final class ConversationController: ObservableObject { streamingEntryIDs.remove(entryID) activeThoughtEntryID = nil activeThoughtStartedAt = nil - thoughtTexts.removeAll(keepingCapacity: true) - thoughtBlocks.removeAll(keepingCapacity: true) transcriptRevision += 1 } @@ -703,13 +819,15 @@ final class ConversationController: ObservableObject { finishActiveThought() var retained: Set = [] for id in streamingEntryIDs { - guard let index = entries.firstIndex(where: { $0.id == id }) else { continue } + guard let index = transcriptIndex(for: id) else { continue } if preservingBackgroundWork, entries[index].role == .tool, entries[index].backgrounded, entries[index].isStreaming { retained.insert(id) continue } + changedTranscriptIndices.insert(index) entries[index].isStreaming = false - entries[index].formatted = Self.markdown(entries[index].text) + changedTranscriptIndices.insert(index) + entries[index].formatted = nil } streamingEntryIDs = retained transcriptRevision += 1 @@ -722,9 +840,11 @@ final class ConversationController: ObservableObject { return entry.role == .assistant || entry.role == .thought } for id in responseIDs { - guard let index = entries.firstIndex(where: { $0.id == id }) else { continue } + guard let index = transcriptIndex(for: id) else { continue } + changedTranscriptIndices.insert(index) entries[index].isStreaming = false - entries[index].formatted = Self.markdown(entries[index].text) + changedTranscriptIndices.insert(index) + entries[index].formatted = nil } streamingEntryIDs.subtract(responseIDs) transcriptRevision += 1 @@ -786,11 +906,11 @@ final class ConversationController: ObservableObject { foregroundRunning = false canSteer = false isInjecting = false - pendingSteers.removeAll() + pendingSteers.removeAll(); isMutatingSteerIDs.removeAll() if let transaction = composerTransaction, transaction.turn == turn { if let error { restoreComposer(transaction) - if let entryID = transaction.transcriptEntryID { entries.removeAll { $0.id == entryID } } + if let entryID = transaction.transcriptEntryID { rebuildTranscriptProjection = true; entries.removeAll { $0.id == entryID } } fail(error) } composerTransaction = nil @@ -807,6 +927,7 @@ final class ConversationController: ObservableObject { let turns = activeTurns for turn in turns { reduceSettlement(.terminal(turn, reason: "error", error: ACPClientError.process("Kit exited (status \(code))"))) } isReady = false + isUpdatingConfig = false finishStreamingEntries(preservingBackgroundWork: false) if shuttingDown { status = "Closed" } else { status = "Kit exited (status \(code))" } case .shutdown: @@ -814,6 +935,7 @@ final class ConversationController: ObservableObject { shuttingDown = true status = "Closing…" isReady = false + isUpdatingConfig = false let turns = activeTurns for turn in turns { reduceSettlement(.terminal(turn, reason: "cancelled", error: nil)) } finishStreamingEntries(preservingBackgroundWork: false) @@ -829,13 +951,17 @@ final class ConversationController: ObservableObject { } private func sendSteer(text: String, attachments files: [Attachment]) { + guard let turn = activeTurns.first(where: { if case .foreground = $0 { return true }; return false }) else { return } let snapshot = InjectionSnapshot(text: text, attachments: files) draft = "" attachments = [] isInjecting = true status = "Queueing…" - client.inject(text: text, attachments: files) { [weak self] result in - guard let self else { return } + let activeClient = client + let generation = clientGeneration + activeClient.inject(text: text, attachments: files) { [weak self, weak activeClient] result in + guard let self, let activeClient, self.isCurrentClient(activeClient, generation: generation), !self.shuttingDown else { return } + guard self.activeTurns.contains(turn) else { return } self.isInjecting = false switch result { case .failure(let error): @@ -851,6 +977,47 @@ final class ConversationController: ObservableObject { } } + func replacePendingSteer(id: String, text: String) { + guard supportsPendingSteerEdit, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + pendingSteers.first(where: { $0.id == id })?.attachmentCount == 0 else { return } + mutatePendingSteer(id: id, replacement: text) + } + + func revokePendingSteer(id: String) { + mutatePendingSteer(id: id, replacement: nil) + } + + private func mutatePendingSteer(id: String, replacement: String?) { + guard isReady, client.supportsSteering, !shuttingDown, !isMutatingSteerIDs.contains(id), + pendingSteers.contains(where: { $0.id == id }), + let turn = activeTurns.first(where: { if case .foreground = $0 { return true }; return false }) else { return } + let activeClient = client + let generation = clientGeneration + isMutatingSteerIDs.insert(id) + let completion: (Result<[String: Any], Error>) -> Void = { [weak self, weak activeClient] result in + guard let self, let activeClient, !self.shuttingDown, + self.isCurrentClient(activeClient, generation: generation), self.activeTurns.contains(turn) else { return } + self.isMutatingSteerIDs.remove(id) + // Delivery is authoritative: a late replacement/revoke reply never + // recreates a pending row or rewrites an already delivered prompt. + guard let index = self.pendingSteers.firstIndex(where: { $0.id == id }) else { return } + switch result { + case .failure(let error): self.fail(error) + case .success: + if let replacement { + self.pendingSteers[index] = PendingSteer(id: id, text: replacement, attachmentCount: 0) + } else { + self.pendingSteers.remove(at: index) + } + } + } + if let replacement { + activeClient.replacePendingSteer(id: id, text: replacement, completion: completion) + } else { + activeClient.revokePendingSteer(id: id, completion: completion) + } + } + private func restoreInjection(_ snapshot: InjectionSnapshot) { if draft.isEmpty { draft = snapshot.text } else if !snapshot.text.isEmpty, draft != snapshot.text { draft = snapshot.text + "\n" + draft } @@ -866,10 +1033,7 @@ final class ConversationController: ObservableObject { attachments = restored } - private func finalizeLastEntry() { - guard let index = entries.indices.last else { return } - entries[index].formatted = Self.markdown(entries[index].text) - } + private func recordDiagnostic(_ line: String, updateStatus: Bool = true) { diagnostics.append(line) @@ -901,18 +1065,18 @@ final class ConversationController: ObservableObject { } private func replaceTranscript(with updates: [DesktopUpdate]) { - entries.removeAll(keepingCapacity: true) + rebuildTranscriptProjection = true; entries.removeAll(keepingCapacity: true) streamingEntryIDs.removeAll() messageEntryIDs.removeAll() planEntryIDs.removeAll() toolStates.removeAll() activeThoughtEntryID = nil activeThoughtStartedAt = nil - thoughtTexts.removeAll(keepingCapacity: true) - thoughtBlocks.removeAll(keepingCapacity: true) latestAssistantSource = "" contextUsed = nil contextSize = nil + tokenUsage = nil + sessionCost = nil transcriptRevision += 1 for update in updates { apply(update) } } @@ -952,7 +1116,7 @@ final class ConversationController: ObservableObject { private func fail(_ error: Error) { finishActiveThought() status = "Error: \(error.localizedDescription)" - appendEntry(TranscriptEntry(role: .error, text: error.localizedDescription, formatted: Self.markdown(error.localizedDescription))) + appendEntry(TranscriptEntry(role: .error, text: error.localizedDescription)) } static func parseConfigOptions(_ value: Any?) -> [ConfigOption] { @@ -1008,10 +1172,62 @@ final class ConversationController: ObservableObject { rosterPruneWorkItem = nil expectedRuntimeSessionID = sessionID runtimeSessionID = nil + runtimeLeaseWorkItem?.cancel() + runtimeLeaseWorkItem = nil + runtimeLastFrame = nil + runtimeTransportAvailable = nil + isUpdatingConfig = false agentRoster = AgentRoster() canSteer = false isInjecting = false - pendingSteers.removeAll() + pendingSteers.removeAll(); isMutatingSteerIDs.removeAll() + } + + // Matches diagnostic_transport::LEASE. Check before accepting each frame as well + // as on the timer: a delayed main queue must not refresh an already-expired lease. + func expireRuntimeLease(at now: ContinuousClock.Instant) { + if runtimeTransportAvailable != false, let last = runtimeLastFrame, + last.duration(to: now) >= .seconds(5) { + setRuntimeTransportAvailable(false) + } + } + + private func refreshRuntimeLease(at now: ContinuousClock.Instant) { + runtimeLastFrame = now + runtimeLeaseWorkItem?.cancel() + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + self.expireRuntimeLease(at: self.clock.now) + } + runtimeLeaseWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: work) + } + + private func setRuntimeTransportAvailable(_ available: Bool) { + guard runtimeTransportAvailable != available else { return } + let wasUnavailable = runtimeTransportAvailable == false + runtimeTransportAvailable = available + if !available { + runtimeLeaseWorkItem?.cancel() + runtimeLeaseWorkItem = nil + agentRoster.invalidateLiveState() + rosterPruneWorkItem?.cancel() + // Tool status itself is ACP-authoritative. Only nested runtime child + // observations become unknown when the diagnostic stream is lost. + for index in entries.indices where entries[index].children.contains(where: { $0.running }) { + changedTranscriptIndices.insert(index) + for child in entries[index].children.indices where entries[index].children[child].running { + entries[index].children[child].running = false + entries[index].children[child].succeeded = nil + entries[index].children[child].summary += " (live status unavailable)" + } + } + status = "Runtime connection unavailable" + appendEntry(TranscriptEntry(role: .error, title: "Runtime connection", text: status)) + } else if wasUnavailable { + status = "Runtime connection restored; earlier live state remains unknown" + appendEntry(TranscriptEntry(role: .status, title: "Runtime connection", text: status)) + } } private func scheduleRosterPrune() { @@ -1216,7 +1432,5 @@ final class ConversationController: ObservableObject { return String(String(describing: value).prefix(128 * 1024)) } - private static func markdown(_ text: String) -> AttributedString { - (try? AttributedString(markdown: text, options: .init(interpretedSyntax: .full))) ?? AttributedString(text) - } + } diff --git a/macos/KitDesktop/Models/FocusedSubagentController.swift b/macos/KitDesktop/Models/FocusedSubagentController.swift new file mode 100644 index 00000000..e60759c0 --- /dev/null +++ b/macos/KitDesktop/Models/FocusedSubagentController.swift @@ -0,0 +1,238 @@ +import Foundation +import Combine + +struct SubagentTranscriptPage: Decodable { + let updates: [JSONValue] + let nextCursor: UInt64 + let generation: UInt64 + let caughtUp: Bool + enum CodingKeys: String, CodingKey { + case updates, generation + case nextCursor = "next_cursor" + case caughtUp = "caught_up" + } +} + +// The same boundary serves the live ACP connection and protocol fixtures. +protocol FocusedSubagentTransport: AnyObject { + func readSubagentTranscript(id: String, generation: UInt64, cursor: UInt64, + completion: @escaping (Result) -> Void) + func steerSubagent(id: String, generation: UInt64, prompt: String, + completion: @escaping (Result<[String: Any], Error>) -> Void) +} +extension ACPClient: FocusedSubagentTransport {} + +struct FocusedSubagentEntry: Identifiable, Equatable { + let id: String + var title: String + var text: String +} + +@MainActor +final class FocusedSubagentController: ObservableObject { + static let maximumEntries = 500 + static let maximumTextCharacters = 16_384 + @Published private(set) var entries: [FocusedSubagentEntry] = [] + @Published private(set) var notice = "Loading child transcript…" + @Published private(set) var partial = false + @Published private(set) var loading = false + @Published private(set) var steering = false + @Published private(set) var canSteer = false + @Published var draft = "" + private(set) var cursor: UInt64 = 0 + private(set) var generation: UInt64 = 0 + private let transport: FocusedSubagentTransport + private var childID = "" + private var epoch = UUID() + private var active = false + private var capability = false + private var readEnabled = false + private var pendingRead = false + private var poll: Task? + + init(transport: FocusedSubagentTransport) { self.transport = transport } + + func focus(_ row: AgentRosterRow, canSteer: Bool) { + stop() + childID = row.id + generation = row.generation + cursor = 0 + entries = [] + partial = false + active = row.status == .working + capability = canSteer + readEnabled = row.parentID == nil + self.canSteer = active && capability && readEnabled + guard readEnabled else { + notice = "Descendant transcript inspection is not supported." + return + } + loading = true + notice = "Loading child transcript…" + readNext() + } + + func update(_ row: AgentRosterRow?, canSteer: Bool) { + guard let row, row.id == childID else { + stop() + notice = "Child closed; transcript is read-only." + return + } + guard row.generation >= generation else { return } + if row.generation != generation { focus(row, canSteer: canSteer); return } + active = row.status == .working + capability = canSteer + self.canSteer = active && capability && readEnabled + if !loading { refreshNotice() } + } + + func stop() { + epoch = UUID() + poll?.cancel() + poll = nil + readEnabled = false + pendingRead = false + loading = false + steering = false + canSteer = false + } + + func disconnected() { + stop() + notice = "Connection closed; transcript is read-only." + } + + func send() { + let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard canSteer, !steering, !text.isEmpty, text.utf8.count <= 16 * 1024 else { return } + let token = epoch + let submitted = draft + steering = true + notice = "Sending steer to child…" + transport.steerSubagent(id: childID, generation: generation, prompt: text) { [weak self] result in + guard let self, self.epoch == token else { return } + self.steering = false + switch result { + case .success: + if self.draft == submitted { self.draft = "" } + self.notice = "Steer accepted." + case .failure(let error): self.notice = "Steer failed: \(error.localizedDescription)" + } + } + } + + private func readNext() { + guard readEnabled, !pendingRead else { return } + pendingRead = true + let token = epoch + let requestedCursor = cursor + let requestedGeneration = generation + transport.readSubagentTranscript(id: childID, generation: generation, cursor: cursor) { [weak self] result in + guard let self, self.epoch == token, self.cursor == requestedCursor, + self.generation == requestedGeneration, self.readEnabled else { return } + self.pendingRead = false + switch result { + case .failure(let error): + self.stop() + self.notice = "Transcript unavailable: \(error.localizedDescription). Reopen to retry." + case .success(let page): + guard page.generation == requestedGeneration, page.nextCursor >= requestedCursor, + page.updates.isEmpty || page.nextCursor > requestedCursor else { + self.stop() + self.notice = "Transcript generation or cursor changed. Reopen to resync." + return + } + for value in page.updates.prefix(2048) { self.apply(value) } + if page.updates.count > 2048 { self.partial = true } + self.cursor = page.nextCursor + if page.caughtUp { self.loading = false } + self.refreshNotice() + // Drain idle transcripts once, then stop polling. Empty writer pages back off too. + if !self.active && page.caughtUp { return } + let delay: UInt64 = page.caughtUp || page.nextCursor == requestedCursor ? 500_000_000 : 10_000_000 + self.poll = Task { [weak self] in + do { try await Task.sleep(nanoseconds: delay) } catch { return } + guard let self, self.epoch == token else { return } + self.readNext() + } + } + } + } + + private func refreshNotice() { + if steering { notice = "Sending steer to child…" } + else if loading { notice = "Loading child transcript…" } + else if !active { notice = "Child idle or closed; transcript is read-only." } + else if !canSteer { notice = "Read-only: this child has not advertised steering support." } + else { notice = "Text steering available. Attachments are not supported by the child steering protocol." } + } + + private func apply(_ value: JSONValue) { + do { + let update = try DesktopUpdate(wire: value) + switch update { + case .userMessage(let message): appendMessage(message, role: "You") + case .agentMessage(let message): appendMessage(message, role: "Assistant") + case .agentThought(let message): appendMessage(message, role: "Reasoning") + case .toolCall(let tool), .toolCallUpdate(let tool): + let id = "tool:" + (tool.toolCallId ?? UUID().uuidString) + let existing = entries.first { $0.id == id } + let body = tool.content.map(renderJSON) ?? tool.rawOutput.map(renderJSON) ?? existing?.text ?? "" + put(id: id, title: (tool.title ?? existing?.title ?? "Tool") + (tool.status.map { " · \($0)" } ?? ""), text: body) + case .toolCallContent(let id, let content): + let key = "tool:" + id + let existing = entries.first { $0.id == key } + let chunk = renderJSON(content) + let previous = existing?.text ?? "" + put(id: key, title: existing?.title ?? "Tool", + text: previous.isEmpty ? chunk : previous + "\n" + chunk) + case .plan(let plan): + switch plan { + case .items(let id, let items): put(id: "plan:" + id, title: "Plan", text: items.map { "\($0.status ?? "pending"): \($0.content)" }.joined(separator: "\n")) + case .markdown(let id, let content): put(id: "plan:" + id, title: "Plan", text: content) + case .file(let id, let uri): put(id: "plan:" + id, title: "Plan file", text: uri) + case .unknown: partial = true + } + case .planRemoved(let id): entries.removeAll { $0.id == "plan:" + id } + case .usage, .tokenUsage, .configOptions, .sessionInfo, .availableCommands, .currentMode, .state, .turnState: break + default: partial = true + } + } catch { partial = true } + } + + private func appendMessage(_ message: DesktopMessageUpdate, role: String) { + let id = role + ":" + message.messageId + let text = message.content.map(renderBlock).joined() + let previous = message.replace ? "" : entries.first { $0.id == id }?.text ?? "" + guard message.hasContent else { return } + put(id: id, title: role, text: previous + text) + } + + private func put(id: String, title: String, text: String) { + let clipped = String(text.prefix(Self.maximumTextCharacters)) + if clipped != text { partial = true } + let entry = FocusedSubagentEntry(id: id, title: String(title.prefix(300)), text: clipped) + if let index = entries.firstIndex(where: { $0.id == id }) { entries[index] = entry } + else { entries.append(entry) } + if entries.count > Self.maximumEntries { + entries.removeFirst(entries.count - Self.maximumEntries) + partial = true + } + } + + private func renderBlock(_ block: DesktopContentBlock) -> String { + switch block { + case .text(let text): return text + case .resourceLink(let uri, let name, _): return "\n\(name ?? "Resource"): \(uri)\n" + case .resource(let uri, _, let text, _): return text ?? "\nResource: \(uri ?? "embedded")\n" + case .image(_, _, let uri): partial = true; return "\n[Image\(uri.map { ": \($0)" } ?? "")]\n" + case .audio: partial = true; return "\n[Audio]\n" + case .unknown: partial = true; return "\n[Unsupported content]\n" + } + } + + private func renderJSON(_ value: JSONValue) -> String { + guard let data = try? JSONEncoder().encode(value), let text = String(data: data, encoding: .utf8) else { return "" } + return text + } +} diff --git a/macos/KitDesktop/Models/ModelSelection.swift b/macos/KitDesktop/Models/ModelSelection.swift new file mode 100644 index 00000000..a0cba3a6 --- /dev/null +++ b/macos/KitDesktop/Models/ModelSelection.swift @@ -0,0 +1,11 @@ +import Foundation + +// Transient selection validation always uses the latest server advertisement. +enum ModelSelection { + static func option(in options: [ConfigOption], id: String, value: String, disabled: Bool) -> ConfigOption? { + guard !disabled, let current = options.first(where: { $0.id == id }), + current.valueType == "select", current.currentValue != value, + current.choices.contains(where: { $0.value == value }) else { return nil } + return current + } +} diff --git a/macos/KitDesktop/Models/ProjectSummary.swift b/macos/KitDesktop/Models/ProjectSummary.swift new file mode 100644 index 00000000..b68e4589 --- /dev/null +++ b/macos/KitDesktop/Models/ProjectSummary.swift @@ -0,0 +1,30 @@ +import Foundation + +/// A transient navigation snapshot derived from workspace and conversation metadata. +struct ProjectSummary: Identifiable { + let workspace: Workspace + let conversations: [Conversation] + + var id: UUID { workspace.id } + var updatedAt: Date { conversations.first?.updatedAt ?? workspace.createdAt } + var recentConversations: [Conversation] { Array(conversations.prefix(3)) } + + func matchesProject(_ query: String) -> Bool { + let query = query.trimmingCharacters(in: .whitespacesAndNewlines) + return query.isEmpty || workspace.name.localizedCaseInsensitiveContains(query) + || workspace.path.localizedCaseInsensitiveContains(query) + } + + func previewConversations(matching query: String) -> [Conversation] { + guard !matchesProject(query) else { return recentConversations } + let query = query.trimmingCharacters(in: .whitespacesAndNewlines) + return Array(conversations.lazy.filter { $0.title.localizedCaseInsensitiveContains(query) }.prefix(3)) + } + + func matches(_ query: String) -> Bool { + let query = query.trimmingCharacters(in: .whitespacesAndNewlines) + return query.isEmpty || workspace.name.localizedCaseInsensitiveContains(query) + || workspace.path.localizedCaseInsensitiveContains(query) + || conversations.contains { $0.title.localizedCaseInsensitiveContains(query) } + } +} diff --git a/macos/KitDesktop/Models/TranscriptContentBuffer.swift b/macos/KitDesktop/Models/TranscriptContentBuffer.swift new file mode 100644 index 00000000..234b9fab --- /dev/null +++ b/macos/KitDesktop/Models/TranscriptContentBuffer.swift @@ -0,0 +1,60 @@ +import Foundation + +/// Session-local retention, not a wire/persistence schema. Bound both a single +/// long message and mixed media, and coalesce tiny text deltas into small chunks. +enum TranscriptContentBuffer { + static let maximumTextBytes = 256 * 1024 + static let maximumBytes = 8 * 1024 * 1024 + static let maximumBlocks = 256 + private static let textChunkBytes = 16 * 1024 + + static func text(_ source: String) -> String { + guard source.utf8.count > maximumTextBytes else { return source } + var bytes = source.utf8.suffix(maximumTextBytes) + while let first = bytes.first, first & 0xC0 == 0x80 { bytes = bytes.dropFirst() } + return String(decoding: bytes, as: UTF8.self) + } + + static func append(_ incoming: [DesktopContentBlock], to retained: [DesktopContentBlock] = []) -> [DesktopContentBlock] { + var result = retained + for raw in incoming { + let block: DesktopContentBlock + if case .text(let source) = raw { block = .text(text(source)) } + else if byteCount(raw) > maximumBytes { + block = .text("[Content exceeds desktop retention limit]") + } else { block = raw } + if case .text(let next) = block, case .text(let previous)? = result.last, + previous.utf8.count + next.utf8.count <= textChunkBytes { + result[result.count - 1] = .text(previous + next) + } else { result.append(block) } + // At most maximumBlocks + 1 entries are visited, independent of the + // number of deltas received. This is a real retention/iterator bound. + var bytes = result.reduce(0) { $0 + byteCount($1) } + var textBytes = result.reduce(0) { count, block in + if case .text(let value) = block { return count + value.utf8.count } + return count + } + var removed = 0 + while result.count - removed > maximumBlocks || bytes > maximumBytes || textBytes > maximumTextBytes { + let first = result[removed] + bytes -= byteCount(first) + if case .text(let value) = first { textBytes -= value.utf8.count } + removed += 1 + } + if removed > 0 { result.removeFirst(removed) } + } + return result + } + + static func byteCount(_ block: DesktopContentBlock) -> Int { + func bytes(_ strings: String?...) -> Int { strings.reduce(0) { $0 + ($1?.utf8.count ?? 0) } } + switch block { + case .text(let value): return value.utf8.count + case .image(let data, let mime, let uri): return bytes(data, mime, uri) + case .audio(let data, let mime): return bytes(data, mime) + case .resourceLink(let uri, let name, let mime): return bytes(uri, name, mime) + case .resource(let uri, let mime, let text, let blob): return bytes(uri, mime, text, blob) + case .unknown(let type): return type.utf8.count + } + } +} diff --git a/macos/KitDesktop/Models/TranscriptProjection.swift b/macos/KitDesktop/Models/TranscriptProjection.swift new file mode 100644 index 00000000..6e398781 --- /dev/null +++ b/macos/KitDesktop/Models/TranscriptProjection.swift @@ -0,0 +1,92 @@ +import Foundation +import Combine + +/// A stable display identity: messages stand alone; adjacent internal events share one activity. +/// Hidden activity entries are retained here, not instantiated as SwiftUI views. +@MainActor +final class TranscriptItem: ObservableObject, Identifiable { + let id: UUID + let isActivity: Bool + @Published private(set) var entries: [TranscriptEntry] + private(set) var runningCount = 0 + private(set) var errorCount = 0 + private(set) var summary = "Activity" + private var indices: [UUID: Int] = [:] + + init(_ entry: TranscriptEntry) { + id = entry.id + isActivity = entry.role != .user && entry.role != .assistant + entries = [] + append(entry) + } + + func append(_ entry: TranscriptEntry) { + indices[entry.id] = entries.count + adjust(entry, by: 1) + summary = Self.summary(entry) + entries.append(entry) + } + + func update(_ entry: TranscriptEntry) { + guard let index = indices[entry.id] else { return } + adjust(entries[index], by: -1) + adjust(entry, by: 1) + if entry.isStreaming || index == entries.count - 1 { summary = Self.summary(entry) } + entries[index] = entry + } + + private func adjust(_ entry: TranscriptEntry, by delta: Int) { + if entry.isStreaming { runningCount += delta } + if entry.role == .error || entry.presentation?.tool?.status == .failed + || entry.children.contains(where: { $0.succeeded == false }) { + errorCount += delta + } + } + + private static func summary(_ entry: TranscriptEntry) -> String { + switch entry.role { + case .thought: return entry.isStreaming ? "Thinking…" : "Thought" + case .tool: return entry.presentation?.tool?.title ?? entry.title ?? "Tool" + case .plan: return "Updating plan" + case .duration: return "Activity completed" + case .error: return "Activity encountered an error" + default: return entry.title ?? "Activity" + } + } +} + +/// Appends visit only the new suffix; edits visit only explicitly invalidated entries. +/// Deletion/retention/replay rebuilds grouping, preserving surviving row IDs (not row object instances). +/// This iterator boundary avoids scanning a completed thread on each streaming token. +@MainActor +final class TranscriptProjection: ObservableObject { + @Published private(set) var items: [TranscriptItem] = [] + private var itemByEntry: [UUID: TranscriptItem] = [:] + private var projectedCount = 0 + + func synchronize(_ entries: [TranscriptEntry], changedIndices: Set = [], rebuilding: Bool = false) { + if rebuilding || entries.count < projectedCount { + items = [] + itemByEntry = [:] + projectedCount = 0 + } + let previousCount = projectedCount + for entry in entries.dropFirst(previousCount) { + let activity = entry.role != .user && entry.role != .assistant + let item: TranscriptItem + if activity, let last = items.last, last.isActivity { + item = last + item.append(entry) + } else { + item = TranscriptItem(entry) + items.append(item) + } + itemByEntry[entry.id] = item + } + projectedCount = entries.count + for index in changedIndices where index < previousCount && entries.indices.contains(index) { + let entry = entries[index] + itemByEntry[entry.id]?.update(entry) + } + } +} diff --git a/macos/KitDesktop/Models/TranscriptToolContentBuffer.swift b/macos/KitDesktop/Models/TranscriptToolContentBuffer.swift new file mode 100644 index 00000000..aa1db009 --- /dev/null +++ b/macos/KitDesktop/Models/TranscriptToolContentBuffer.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Bounds a single streaming tool's retained JSON, not just its visible text. +enum TranscriptToolContentBuffer { + static let maximumBytes = 256 * 1024 + static let maximumChunks = 64 + private static let marker: JSONValue = .object([ + "type": .string("text"), + "text": .string("[Earlier tool output omitted by desktop retention limit]"), + "_kitDesktopRetention": .bool(true), + ]) + + static func append(_ incoming: JSONValue, to retained: JSONValue?) -> JSONValue { + var chunks: [JSONValue] + if case .array(let existing)? = retained { chunks = existing } + else { chunks = retained.map { [$0] } ?? [] } + var omitted = chunks.first?.objectValue?["_kitDesktopRetention"] == .bool(true) + if omitted { chunks.removeFirst() } + let encoded = (try? JSONEncoder().encode(incoming)) ?? Data() + let chunk: JSONValue = encoded.count > maximumBytes / 2 + ? .object(["truncated": .bool(true), "bytes": .integer(Int64(encoded.count)), + "preview": .string(String(decoding: encoded.prefix(16 * 1024), as: UTF8.self))]) + : incoming + chunks.append(chunk) + while chunks.count + (omitted ? 1 : 0) > maximumChunks { + chunks.removeFirst() + omitted = true + } + while !chunks.isEmpty { + let value = JSONValue.array((omitted ? [marker] : []) + chunks) + if let bytes = try? JSONEncoder().encode(value), bytes.count <= maximumBytes { return value } + chunks.removeFirst() + omitted = true + } + return .array([marker]) + } +} diff --git a/macos/KitDesktop/Services/ACPClient.swift b/macos/KitDesktop/Services/ACPClient.swift index 8d4e9f23..7d09fdda 100644 --- a/macos/KitDesktop/Services/ACPClient.swift +++ b/macos/KitDesktop/Services/ACPClient.swift @@ -8,6 +8,7 @@ struct ACPLaunchOptions { let provider: String? let model: String? let reasoningEffort: String? + var additionalDirectories: [String] = [] var force = false } @@ -66,8 +67,10 @@ final class ACPClient { private var pendingChunk: DesktopUpdate? private var closeCompletions: [() -> Void] = [] private var chunkFlush: DispatchWorkItem? + private var supportsAdditionalDirectories = false private var promptCapabilities: ACPPromptCapabilities? private(set) var supportsSteering = false + private(set) var supportsPendingSteerEdit = false init(launchOverride: LaunchOverride? = nil, requestTimeout: TimeInterval = 30, promptTimeout: TimeInterval = 6 * 60 * 60) { self.launchOverride = launchOverride @@ -129,6 +132,27 @@ final class ACPClient { } } + func replacePendingSteer(id: String, text: String, completion: @escaping (Result) -> Void) { + mutatePendingSteer(method: "session/replace_inject", id: id, text: text, completion: completion) + } + + func revokePendingSteer(id: String, completion: @escaping (Result) -> Void) { + mutatePendingSteer(method: "session/revoke_inject", id: id, text: nil, completion: completion) + } + + private func mutatePendingSteer(method: String, id: String, text: String?, completion: @escaping (Result) -> Void) { + queue.async { + guard let sessionID = self.sessionID, self.supportsSteering, + text == nil || self.supportsPendingSteerEdit else { + self.complete(completion, with: .failure(ACPClientError.protocolError("Pending message mutation is unavailable"))) + return + } + var params: Dictionary = ["sessionId": sessionID, "messageId": id] + if let text { params["content"] = [["type": "text", "text": text]] } + self.requestLocked(method: method, params: params, completion: completion) + } + } + func setConfig(id: String, value: ACPSessionConfigValue, completion: @escaping (Result) -> Void) { queue.async { guard let sessionID = self.sessionID else { return } @@ -139,6 +163,37 @@ final class ACPClient { } } + func readSubagentTranscript(id: String, generation: UInt64, cursor: UInt64, + completion: @escaping (Result) -> Void) { + subagentRequest(method: "kit/subagent/transcript/read", id: id, generation: generation, + fields: ["cursor": cursor]) { result in + completion(result.flatMap { payload in + Result { try Self.decode(SubagentTranscriptPage.self, from: payload) } + }) + } + } + + func steerSubagent(id: String, generation: UInt64, prompt: String, + completion: @escaping (Result) -> Void) { + subagentRequest(method: "kit/subagent/steer", id: id, generation: generation, + fields: ["prompt": prompt], completion: completion) + } + + private func subagentRequest(method: String, id: String, generation: UInt64, fields: Dictionary, + completion: @escaping (Result) -> Void) { + queue.async { + guard let sessionID = self.sessionID, !self.closing, !self.exited else { + self.complete(completion, with: .failure(ACPClientError.protocolError("ACP session is not ready"))) + return + } + var params = fields + params["session_id"] = sessionID + params["id"] = id + params["generation"] = generation + self.requestLocked(method: method, params: params, completion: completion) + } + } + func detachCompose(callID: String, completion: @escaping (Result) -> Void) { privateRequest(method: "kit/compose/detach", callID: callID, completion: completion) } @@ -199,13 +254,16 @@ final class ACPClient { if loading { self.sessionID = options.sessionID } self.queue.async { do { + guard options.additionalDirectories.isEmpty || self.supportsAdditionalDirectories else { + throw ACPClientError.protocolError("This Kit helper does not support additional project directories.") + } let params: Dictionary if loading { params = try Self.dictionary(ACPResumeSessionRequest( - sessionId: options.sessionID, cwd: options.root, replayFrom: ACPReplayFrom() + sessionId: options.sessionID, cwd: options.root, additionalDirectories: options.additionalDirectories, replayFrom: ACPReplayFrom() )) } else { - params = try Self.dictionary(ACPNewSessionRequest(cwd: options.root)) + params = try Self.dictionary(ACPNewSessionRequest(cwd: options.root, additionalDirectories: options.additionalDirectories)) } self.requestLocked(method: method, params: params) { result in self.queue.async { @@ -333,11 +391,17 @@ final class ACPClient { guard response.protocolVersion == Self.protocolVersion else { throw ACPClientError.protocolError("Kit negotiated a non-v2 ACP connection") } + self.supportsAdditionalDirectories = response.capabilities?.session?.additionalDirectories != nil self.promptCapabilities = response.capabilities?.session?.prompt let injection = response.capabilities?.session?.inject // Match the TUI: finish-mode steering queues input for the next safe boundary. self.supportsSteering = injection?.modes.contains("steer") == true && injection?.steerInStream?.contains("finish") == true + let capabilities = payload["capabilities"] as? Dictionary + let session = capabilities?["session"] as? Dictionary + let inject = session?["inject"] as? Dictionary + let pending = inject?["pending"] as? Dictionary + self.supportsPendingSteerEdit = self.supportsSteering && pending?["replace"] as? Bool == true completion(result) } catch { completion(.failure(ACPClientError.protocolError("Malformed ACP v2 initialize response: " + error.localizedDescription))) @@ -746,7 +810,7 @@ final class ACPClient { return pid } - private static func resolveLaunch() throws -> LaunchOverride { + static func resolveLaunch(allowLoginShell: Bool = true) throws -> LaunchOverride { let fileManager = FileManager.default let bundled = Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/kit") if fileManager.isExecutableFile(atPath: bundled.path) { @@ -770,7 +834,7 @@ final class ACPClient { // Finder-launched apps receive a minimal PATH. Ask the user's login shell for // the same command lookup they get in Terminal, then launch the resolved file // directly so every conversation uses an identical executable. - if let executable = resolveFromLoginShell(environment: environment) { + if allowLoginShell, let executable = resolveFromLoginShell(environment: environment) { return LaunchOverride(executable: executable) } throw ACPClientError.missingBinary diff --git a/macos/KitDesktop/Services/ACPKitExtensions.swift b/macos/KitDesktop/Services/ACPKitExtensions.swift index 118c0bee..4199ca72 100644 --- a/macos/KitDesktop/Services/ACPKitExtensions.swift +++ b/macos/KitDesktop/Services/ACPKitExtensions.swift @@ -46,8 +46,8 @@ enum JSONValue: Codable, Equatable { } } - fileprivate var objectValue: [String: JSONValue]? { if case .object(let value) = self { value } else { nil } } - fileprivate var stringValue: String? { if case .string(let value) = self { value } else { nil } } + var objectValue: [String: JSONValue]? { if case .object(let value) = self { value } else { nil } } + var stringValue: String? { if case .string(let value) = self { value } else { nil } } fileprivate var boolValue: Bool? { if case .bool(let value) = self { value } else { nil } } fileprivate var intValue: Int? { switch self { case .integer(let value): Int(value); case .number(let value): Int(value); default: nil } @@ -178,7 +178,16 @@ enum DesktopPlanContent: Equatable { } } } -struct DesktopUsageUpdate: Codable, Equatable { var used: Int?; var size: Int? } +struct DesktopCost: Codable, Equatable { + var amount: Double + var currency: String + + var isValid: Bool { + amount.isFinite && amount >= 0 && currency.utf8.count == 3 + && currency.utf8.allSatisfy { (65...90).contains($0) } + } +} +struct DesktopUsageUpdate: Codable, Equatable { var used: Int?; var size: Int?; var cost: DesktopCost? = nil } struct DesktopTokenUsage: Codable, Equatable { var totalTokens: Int?; var inputTokens: Int?; var outputTokens: Int?; var thoughtTokens: Int?; var cachedReadTokens: Int?; var cachedWriteTokens: Int? } struct DesktopCommand: Codable, Equatable { var name: String; var description: String? = nil } struct DesktopSessionInfo: Equatable { var title: String?; var updatedAt: String?; var titlePresent: Bool; var updatedAtPresent: Bool } diff --git a/macos/KitDesktop/Services/PersistenceStore.swift b/macos/KitDesktop/Services/PersistenceStore.swift index e85c4d24..0ddbe584 100644 --- a/macos/KitDesktop/Services/PersistenceStore.swift +++ b/macos/KitDesktop/Services/PersistenceStore.swift @@ -1,4 +1,5 @@ import Foundation +import CoreFoundation enum PersistenceError: LocalizedError, Equatable { case unsupportedSchema(Int) @@ -95,7 +96,9 @@ final class PersistenceStore { let version: Int if let rawVersion = object["schemaVersion"] { - guard !(rawVersion is Bool), let decoded = rawVersion as? Int, decoded >= 1 else { + guard let number = rawVersion as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID(), + let decoded = rawVersion as? Int, decoded >= 1 else { throw PersistenceError.unreadableState("schemaVersion must be a positive integer.") } version = decoded diff --git a/macos/KitDesktop/Services/ProviderUsageService.swift b/macos/KitDesktop/Services/ProviderUsageService.swift new file mode 100644 index 00000000..82cf37e9 --- /dev/null +++ b/macos/KitDesktop/Services/ProviderUsageService.swift @@ -0,0 +1,104 @@ +import Darwin +import Foundation + +enum ProviderUsageError: LocalizedError { + case timedOut, outputTooLarge, failed(String) + + var errorDescription: String? { + switch self { + case .timedOut: return "Provider usage request timed out. Try refreshing." + case .outputTooLarge: return "Provider usage output exceeded the size limit." + case .failed(let message): return message + } + } +} + +/// Runs the read-only CLI command on a worker, without shell evaluation. The same +/// bundled/override/PATH helper lookup as ACP is used, excluding its login shell. +struct ProviderUsageService { + var executable: URL? = nil + var timeout: TimeInterval = 40 + var maximumOutputBytes = 256 * 1024 + + func fetch() async throws -> String { + let worker = Task.detached(priority: .utility) { try run() } + return try await withTaskCancellationHandler { + try await worker.value + } onCancel: { + worker.cancel() + } + } + + private func run() throws -> String { + try Task.checkCancellation() + let binary = try executable ?? ACPClient.resolveLaunch(allowLoginShell: false).executable + var descriptors: [Int32] = [0, 0] + guard pipe(&descriptors) == 0 else { throw ProviderUsageError.failed("Cannot open usage output pipe.") } + defer { close(descriptors[0]) } + var actions: posix_spawn_file_actions_t? + var attributes: posix_spawnattr_t? + posix_spawn_file_actions_init(&actions) + posix_spawnattr_init(&attributes) + defer { + posix_spawn_file_actions_destroy(&actions) + posix_spawnattr_destroy(&attributes) + } + posix_spawn_file_actions_adddup2(&actions, descriptors[1], STDOUT_FILENO) + posix_spawn_file_actions_adddup2(&actions, descriptors[1], STDERR_FILENO) + posix_spawn_file_actions_addclose(&actions, descriptors[0]) + posix_spawn_file_actions_addclose(&actions, descriptors[1]) + posix_spawn_file_actions_addopen(&actions, STDIN_FILENO, "/dev/null", O_RDONLY, 0) + posix_spawnattr_setflags(&attributes, Int16(POSIX_SPAWN_SETPGROUP)) + posix_spawnattr_setpgroup(&attributes, 0) + let arguments: [UnsafeMutablePointer?] = [binary.path, "usage"].map { + (argument: String) in argument.withCString { strdup($0) } + } + [nil] + let environment = ProcessInfo.processInfo.environment.map { strdup("\($0.key)=\($0.value)") } + [nil] + defer { + arguments.forEach { free($0) } + environment.forEach { free($0) } + } + var pid: pid_t = 0 + let spawned = arguments.withUnsafeBufferPointer { argv in + environment.withUnsafeBufferPointer { env in + posix_spawn(&pid, binary.path, &actions, &attributes, argv.baseAddress!, env.baseAddress!) + } + } + close(descriptors[1]) + guard spawned == 0 else { throw ProviderUsageError.failed("Could not start the Kit usage helper (\(spawned)).") } + var reaped = false + defer { + // Kill the group even after leader exit: descendants can retain output. + kill(-pid, SIGKILL) + if !reaped { + var status: Int32 = 0 + while waitpid(pid, &status, 0) < 0 && errno == EINTR {} + } + } + _ = fcntl(descriptors[0], F_SETFL, O_NONBLOCK) + let deadline = ProcessInfo.processInfo.systemUptime + timeout + var output = Data() + var buffer = [UInt8](repeating: 0, count: 8192) + var eof = false + var status: Int32 = 0 + while !eof || !reaped { + try Task.checkCancellation() + guard ProcessInfo.processInfo.systemUptime < deadline else { throw ProviderUsageError.timedOut } + let count = read(descriptors[0], &buffer, buffer.count) + if count > 0 { + guard output.count + count <= maximumOutputBytes else { throw ProviderUsageError.outputTooLarge } + output.append(contentsOf: buffer.prefix(count)) + } else if count == 0 { eof = true } + else if errno != EAGAIN && errno != EINTR { throw ProviderUsageError.failed("Cannot read provider usage.") } + if !reaped { + let result = waitpid(pid, &status, WNOHANG) + if result == pid { reaped = true } + else if result < 0 && errno != EINTR { throw ProviderUsageError.failed("Cannot wait for provider usage helper.") } + } + if count <= 0 && (!eof || !reaped) { usleep(10_000) } + } + let text = String(decoding: output, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + guard status == 0 else { throw ProviderUsageError.failed(text.isEmpty ? "Kit usage failed." : text) } + return text.isEmpty ? "No provider usage is available. Authenticate with Kit to view supported account quotas." : text + } +} diff --git a/macos/KitDesktop/Views/ContentView.swift b/macos/KitDesktop/Views/ContentView.swift index 538f572a..9c3f61bf 100644 --- a/macos/KitDesktop/Views/ContentView.swift +++ b/macos/KitDesktop/Views/ContentView.swift @@ -3,9 +3,14 @@ import UniformTypeIdentifiers struct ContentView: View { @EnvironmentObject private var model: AppModel + @State private var expandedProjects: Set = [] var body: some View { - NavigationSplitView { sidebar } detail: { detail } + let projects = model.projects + NavigationSplitView { sidebar(projects) } detail: { + if model.showingProjects { ProjectsView(projects: projects, addFolder: chooseWorkspace) } + else { detail } + } .navigationSplitViewStyle(.balanced) .tint(Brand.primary) .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in @@ -21,85 +26,71 @@ struct ContentView: View { } } - private var sidebar: some View { + private func sidebar(_ projects: [ProjectSummary]) -> some View { VStack(spacing: 0) { HStack(spacing: 10) { - Text("Kit").brandDisplay(21) + Image("KitMark").resizable().scaledToFit().frame(width: 24, height: 24) + Text("Kit").brandDisplay(23) Spacer() Button(action: chooseWorkspace) { Image(systemName: "folder.badge.plus") } - .buttonStyle(.plain).pointingHandCursor().help("Add workspace") + .buttonStyle(.plain).pointingHandCursor().help("Add project folder") Button(action: model.createConversation) { Image(systemName: "square.and.pencil") } .buttonStyle(.plain).pointingHandCursor() .disabled(model.selectedWorkspaceID == nil).help("New conversation") - } - .font(.system(size: 15, weight: .medium)) - .padding(.horizontal, 16).frame(height: 50) + }.padding(.horizontal, 16).frame(height: 56) BrandSpectrumRule() - - if model.state.workspaces.isEmpty { - Spacer() - VStack(spacing: 12) { - Image(systemName: "folder.badge.plus").font(.system(size: 28)).foregroundStyle(.secondary) - Text("Add a workspace").brandDisplay(20) - Text("Choose a project folder to start.").font(.callout).foregroundStyle(.secondary) - Button("Choose Folder", action: chooseWorkspace).pointingHandCursor() - }.multilineTextAlignment(.center).padding(24) - Spacer() - } else { - workspacePicker.padding(.horizontal, 12).padding(.top, 10).padding(.bottom, 10) - Divider().opacity(0.55) - ScrollView { - LazyVStack(alignment: .leading, spacing: 3) { - HStack { - Text("Conversations").brandMicroLabel().foregroundStyle(.tertiary) - Spacer() - }.padding(.horizontal, 10).padding(.top, 13).padding(.bottom, 5) - ForEach(model.workspaceConversations) { conversation in + List { + Button(action: model.showProjects) { + Label("Projects", systemImage: "square.grid.2x2") + .font(.system(size: 14, weight: .semibold)) + .frame(maxWidth: .infinity, alignment: .leading).padding(.vertical, 6) + .contentShape(Rectangle()) + }.buttonStyle(.plain).pointingHandCursor() + .listRowBackground(model.showingProjects ? Brand.ember.opacity(0.14) : Color.clear) + ForEach(projects) { project in + Section { + HStack(spacing: 6) { + Button { model.selectWorkspace(project.id) } label: { + Label(project.workspace.name, systemImage: "folder") + .font(.system(size: 13, weight: .semibold)).lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading).contentShape(Rectangle()) + }.buttonStyle(.plain).pointingHandCursor() + Button { model.createConversation(in: project.id) } label: { + Image(systemName: "plus").font(.caption) + }.buttonStyle(.plain).pointingHandCursor().help("New conversation in \(project.workspace.name)") + }.padding(.vertical, 4) + .listRowBackground(!model.showingProjects && model.selectedWorkspaceID == project.id && model.selectedConversationID == nil ? Brand.ember.opacity(0.14) : Color.clear) + ForEach(expandedProjects.contains(project.id) ? project.conversations : project.recentConversations) { conversation in Button { model.selectConversation(conversation.id) } label: { ConversationRow( conversation: conversation, - selected: conversation.id == model.selectedConversationID, + selected: !model.showingProjects && conversation.id == model.selectedConversationID, running: model.activity[conversation.id] == true, locked: model.lockedConversationIDs.contains(conversation.id) ) }.buttonStyle(.plain).pointingHandCursor() } - if model.workspaceConversations.isEmpty { - VStack(spacing: 8) { - Text("No conversations yet").font(.callout).foregroundStyle(.secondary) - Button("Start a conversation", action: model.createConversation) - .buttonStyle(.link).pointingHandCursor() - }.frame(maxWidth: .infinity).padding(.top, 36) + if project.conversations.isEmpty { + Text("No conversations yet").font(.caption).foregroundStyle(.tertiary).padding(.leading, 22) } - }.padding(.horizontal, 8).padding(.bottom, 12) - } - } - } - .background(Brand.paper) - .navigationSplitViewColumnWidth(min: 240, ideal: 280, max: 340) - } - - private var workspacePicker: some View { - Menu { - ForEach(model.state.workspaces) { workspace in - Button { model.selectWorkspace(workspace.id) } label: { - if workspace.id == model.selectedWorkspaceID { Label(workspace.name, systemImage: "checkmark") } - else { Text(workspace.name) } + if project.conversations.count > 3 { + Button { + if expandedProjects.contains(project.id) { expandedProjects.remove(project.id) } + else { expandedProjects.insert(project.id) } + } label: { + Text(expandedProjects.contains(project.id) ? "Show less" : "Show \(project.conversations.count - 3) more") + .font(.caption).foregroundStyle(.secondary).padding(.leading, 22) + }.buttonStyle(.plain).pointingHandCursor() + } + } } - } + }.listStyle(.sidebar) Divider() - Button("Add Workspace…", action: chooseWorkspace) - } label: { - HStack(spacing: 8) { - Image(systemName: "folder").foregroundStyle(.secondary) - Text(model.selectedWorkspace?.name ?? "Workspace").fontWeight(.medium).lineLimit(1) - Spacer() - Image(systemName: "chevron.up.chevron.down").font(.caption2).foregroundStyle(.tertiary) - } - .padding(.horizontal, 10).frame(height: 34) - .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: Brand.Radius.small)) - .overlay { RoundedRectangle(cornerRadius: Brand.Radius.small).stroke(Brand.hairline) } - }.buttonStyle(.plain) + Button(action: chooseWorkspace) { Label("Add project folder", systemImage: "plus") } + .buttonStyle(.plain).pointingHandCursor().font(.callout) + .frame(maxWidth: .infinity, alignment: .leading).padding(16) + }.background(Brand.paper) + .navigationSplitViewColumnWidth(min: 230, ideal: 280, max: 380) } @ViewBuilder @@ -130,7 +121,7 @@ struct ContentView: View { panel.canChooseDirectories = true panel.canChooseFiles = false panel.allowsMultipleSelection = false - panel.prompt = "Add Workspace" + panel.prompt = "Add Project" if panel.runModal() == .OK, let url = panel.url { model.addWorkspace(path: url.path) } } } @@ -312,6 +303,27 @@ private final class DroppableTextView: NSTextView { ) } + override func paste(_ sender: Any?) { + let pasteboard = NSPasteboard.general + let urls = pasteboard.readObjects( + forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true] + ) as? [URL] ?? [] + if !urls.isEmpty, onDrop?(urls) == true { return } + guard let data = pasteboard.data(forType: .png) ?? pasteboard.data(forType: .tiff) else { + super.paste(sender) + return + } + // Publish pending state before scheduling conversion so send/steer cannot race it. + onPromisesStarted?(1) + let receive = onPromiseReceived + Task { @MainActor in + let result = await Task.detached(priority: .userInitiated) { + Result { try ClipboardMediaImport.writeImage(data) } + }.value + receive?(result) + } + } + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { guard canReceiveFiles(from: sender) else { return super.draggingEntered(sender) } onTargeted?(true) @@ -490,9 +502,11 @@ private struct ConversationView: View { @ObservedObject var controller: ConversationController let title: String @State private var choosingFiles = false + @State private var editingSteer: ConversationController.PendingSteer? @State private var followTranscript = true @State private var showDiagnostics = false @State private var showAgentRoster = false + @State private var hasOpenedAgentRoster = false @State private var isTargetingComposer = false var body: some View { @@ -512,14 +526,23 @@ private struct ConversationView: View { .frame(width: geometry.size.width, height: geometry.size.height) } .background(Brand.canvas) + .onChange(of: controller.agentRoster.rowsByID.isEmpty, initial: true) { _, isEmpty in + if !isEmpty && !hasOpenedAgentRoster { + showAgentRoster = true + hasOpenedAgentRoster = true + } + } .fileImporter(isPresented: $choosingFiles, allowedContentTypes: [.image, .audio], allowsMultipleSelection: true) { result in if case .success(let urls) = result { controller.addAttachments(urls) } } + .sheet(item: $editingSteer) { item in + PendingSteerEditor(controller: controller, item: item) + } } @ViewBuilder private var agentRosterPanel: some View { if showAgentRoster && controller.shouldPresentAgentRoster { - AgentRosterView(roster: controller.agentRoster) + AgentRosterView(controller: controller) .frame(minWidth: 240, idealWidth: 290, maxWidth: 340) .padding(.leading, 10).padding(.trailing, 12).padding(.vertical, 12) } @@ -576,15 +599,11 @@ private struct ConversationView: View { Text("What should we work on?").brandDisplay(30) }.frame(maxWidth: .infinity).padding(.top, 150) } else { - LazyVStack(alignment: .leading, spacing: 22) { - ForEach(controller.entries) { entry in - TranscriptRow( - entry: entry, - detach: { if let id = entry.toolCallID { controller.detachCompose(callID: id) } }, - cancelBackground: { if let id = entry.toolCallID { controller.cancelBackground(callID: id) } } - ).id(entry.id) - } - } + TranscriptItemsView( + projection: controller.transcriptProjection, + detach: controller.detachCompose, + cancelBackground: controller.cancelBackground + ) .frame(maxWidth: 820, alignment: .leading) .padding(.horizontal, 30).padding(.top, 28).padding(.bottom, 24) .frame(maxWidth: .infinity) @@ -624,8 +643,7 @@ private struct ConversationView: View { .buttonStyle(.plain).font(.system(size: 15, weight: .medium)).pointingHandCursor() .disabled(controller.attachments.count >= ConversationController.maximumAttachmentCount) .help("Attach image or audio") - modelControl - effortControl + ModelEffortSelector(controller: controller) contextControl Spacer(minLength: 6) Text(controller.pendingAttachmentReceipts > 0 ? "Receiving attachment…" : controller.status) @@ -665,7 +683,19 @@ private struct ConversationView: View { Image(systemName: "clock").foregroundStyle(.secondary) Text(item.summary).lineLimit(1) Spacer() - Text("Pending").brandMicroLabel().foregroundStyle(.secondary) + if controller.isMutatingSteerIDs.contains(item.id) { + ProgressView().controlSize(.mini) + } else { + Text("Pending").brandMicroLabel().foregroundStyle(.secondary) + } + if controller.supportsPendingSteerEdit && item.attachmentCount == 0 { + Button { editingSteer = item } label: { Image(systemName: "pencil") } + .buttonStyle(.plain).pointingHandCursor().help("Edit queued message") + .disabled(!controller.isReady || controller.isMutatingSteerIDs.contains(item.id)) + } + Button { controller.revokePendingSteer(id: item.id) } label: { Image(systemName: "trash") } + .buttonStyle(.plain).pointingHandCursor().help("Remove queued message") + .disabled(!controller.isReady || controller.isMutatingSteerIDs.contains(item.id)) } .font(.caption) .padding(.horizontal, 12).padding(.vertical, 8) @@ -720,99 +750,219 @@ private struct ConversationView: View { } } - @ViewBuilder private var modelControl: some View { - if let option = controller.configOptions.first(where: { $0.id == "model" }) { - Menu { - ForEach(option.groups) { group in - Section(group.name) { - ForEach(group.choices) { choice in - Button { controller.choose(option, value: choice.value) } label: { - if choice.value == option.currentValue { Label(choice.name, systemImage: "checkmark") } - else { Text(choice.name) } - } - } - } - } - } label: { - HStack(spacing: 4) { Image(systemName: "cpu"); Text(selectedModelLabel(option)).lineLimit(1) } - }.menuStyle(.borderlessButton).fixedSize().font(.caption) - } + private var contextControl: some View { + SessionUsageControl(used: controller.contextUsed, size: controller.contextSize, + tokens: controller.tokenUsage, cost: controller.sessionCost, + agentCosts: controller.agentRoster.costTotals) } - @ViewBuilder private var effortControl: some View { - if let option = reasoningOption, !option.choices.isEmpty { - HStack(spacing: 6) { - Image(systemName: "brain.head.profile").foregroundStyle(.secondary) - if usesSegmentedEffortControl(option) { - Picker(option.name, selection: configBinding(option)) { - ForEach(option.choices) { choice in - Text(choice.name).tag(choice.value) - } + private var canSend: Bool { + controller.pendingAttachmentReceipts == 0 && controller.acceptsInput + && (!controller.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !controller.attachments.isEmpty) + } + + +} + +private struct SessionUsageControl: View { + let used: Int? + let size: Int? + let tokens: DesktopTokenUsage? + let cost: DesktopCost? + let agentCosts: [String: Double] + @State private var isPresented = false + + var body: some View { + if used != nil || tokens != nil || cost != nil || !agentCosts.isEmpty { + Button { isPresented.toggle() } label: { + HStack(spacing: 5) { + if let used, let size, size > 0 { + let fraction = min(1, max(0, Double(used) / Double(size))) + ProgressView(value: fraction).controlSize(.mini).frame(width: 34) + Text("\(Int((fraction * 100).rounded()))%").monospacedDigit() + } else { Image(systemName: "chart.bar") } + if let cost { Text(cost.amount, format: .currency(code: cost.currency)).monospacedDigit() } + }.font(.caption2).foregroundStyle(.secondary) + } + .buttonStyle(.plain).pointingHandCursor().help("Context, tokens and reported cost") + .popover(isPresented: $isPresented, arrowEdge: .top) { + VStack(alignment: .leading, spacing: 12) { + Text("Session usage").font(.headline) + if let used, let size, size > 0 { + LabeledContent("Context", value: "\(used.formatted()) / \(size.formatted()) tokens") } - .pickerStyle(.segmented).labelsHidden().fixedSize().controlSize(.mini) - } else { - Menu { - ForEach(option.groups) { group in - Section(group.name) { - ForEach(group.choices) { choice in - Button { controller.choose(option, value: choice.value) } label: { - if choice.value == option.currentValue { Label(choice.name, systemImage: "checkmark") } - else { Text(choice.name) } - } - } - } + if let tokens { + usageRow("Input", tokens.inputTokens) + usageRow("Output", tokens.outputTokens) + usageRow("Reasoning", tokens.thoughtTokens) + usageRow("Cache read", tokens.cachedReadTokens) + usageRow("Cache write", tokens.cachedWriteTokens) + usageRow("Total tokens", tokens.totalTokens) + } + if let cost { + Divider() + LabeledContent("This session") { Text(cost.amount, format: .currency(code: cost.currency)) } + } + if !agentCosts.isEmpty { + Divider() + ForEach(agentCosts.keys.sorted(), id: \.self) { currency in + LabeledContent("Subagents") { Text(agentCosts[currency] ?? 0, format: .currency(code: currency)) } } - } label: { Text(selectedName(option)) } - .menuStyle(.borderlessButton).fixedSize().font(.caption) - } - }.help(option.name) + Text("Includes completed and closed subagents.").font(.caption).foregroundStyle(.secondary) + } + Text("Reported by the provider; unavailable values are omitted.") + .font(.caption).foregroundStyle(.secondary) + }.font(.callout).padding(16).frame(width: 340) + } } } - @ViewBuilder private var contextControl: some View { - if let used = controller.contextUsed, let size = controller.contextSize, size > 0 { - let percentage = min(100, Int((Double(used) / Double(size) * 100).rounded())) - HStack(spacing: 5) { - ProgressView(value: Double(used), total: Double(size)).controlSize(.mini).frame(width: 38) - Text("\(percentage)%").font(.caption2).monospacedDigit().foregroundStyle(.tertiary) - }.help("Context: \(used.formatted()) of \(size.formatted()) tokens") - } + @ViewBuilder private func usageRow(_ label: String, _ count: Int?) -> some View { + if let count { LabeledContent(label, value: count.formatted()) } } +} - private var canSend: Bool { - controller.pendingAttachmentReceipts == 0 && controller.acceptsInput - && (!controller.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !controller.attachments.isEmpty) +private struct ModelEffortSelector: View { + @ObservedObject var controller: ConversationController + @State private var isPresented = false + @State private var query = "" + + private var model: ConfigOption? { + controller.configOptions.first { $0.id == "model" || $0.category == "model" } + } + private var effort: ConfigOption? { + controller.configOptions.first { $0.isReasoningEffort && $0.valueType == "select" && !$0.choices.isEmpty } + } + private var disabled: Bool { + !controller.isReady || controller.isLocked || controller.isRunning || controller.isUpdatingConfig } - private func selectedName(_ option: ConfigOption) -> String { - option.choices.first(where: { $0.value == option.currentValue })?.name ?? option.currentValue.split(separator: ":").last.map(String.init) ?? option.currentValue + var body: some View { + if let model { + Button { query = ""; isPresented.toggle() } label: { + HStack(spacing: 5) { + Image(systemName: "cpu") + Text(selectedName(model)).lineLimit(1) + if let effort { Text(selectedName(effort)).foregroundStyle(.secondary).lineLimit(1) } + if controller.isUpdatingConfig { ProgressView().controlSize(.mini) } + else { Image(systemName: "chevron.down").font(.system(size: 8, weight: .semibold)) } + } + .font(.caption).padding(.horizontal, 8).padding(.vertical, 5) + .background(Brand.paper, in: RoundedRectangle(cornerRadius: Brand.Radius.small)) + } + .buttonStyle(.plain).pointingHandCursor().disabled(!controller.isReady || controller.isLocked) + .help("Model and reasoning effort") + .popover(isPresented: $isPresented, arrowEdge: .top) { panel } + } } - private var reasoningOption: ConfigOption? { - controller.configOptions.first(where: \.isReasoningEffort) + private var panel: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Image(systemName: "cpu").foregroundStyle(Brand.moss) + Text("Model & effort").font(.headline) + Spacer() + if controller.isUpdatingConfig { ProgressView().controlSize(.small) } + }.padding(16) + HStack(spacing: 8) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField("Find a model or provider", text: $query).textFieldStyle(.plain) + if !query.isEmpty { + Button { query = "" } label: { Image(systemName: "xmark.circle.fill") } + .buttonStyle(.plain).accessibilityLabel("Clear model search") + } + } + .padding(10).background(Brand.canvas, in: RoundedRectangle(cornerRadius: Brand.Radius.small)) + .padding(.horizontal, 16).padding(.bottom, 12) + Divider() + ScrollView { + LazyVStack(alignment: .leading, spacing: 5) { + if let model { + let groups = matchingGroups(model) + if groups.isEmpty { Text("No matching models").foregroundStyle(.secondary).padding(16) } + ForEach(groups) { group in + Text(group.name).brandMicroLabel().foregroundStyle(.secondary) + .padding(.horizontal, 10).padding(.top, 10) + ForEach(group.choices) { choice in + Button { choose(model, value: choice.value) } label: { + HStack(spacing: 10) { + Image(systemName: choice.value == model.currentValue ? "checkmark.circle.fill" : "circle") + .foregroundStyle(choice.value == model.currentValue ? Brand.moss : Color.secondary) + Text(choice.name).lineLimit(2).multilineTextAlignment(.leading) + Spacer(minLength: 0) + } + .padding(10).frame(maxWidth: .infinity, alignment: .leading) + .background(choice.value == model.currentValue ? Brand.moss.opacity(0.09) : .clear, + in: RoundedRectangle(cornerRadius: Brand.Radius.small)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain).pointingHandCursor().disabled(disabled) + .accessibilityAddTraits(choice.value == model.currentValue ? .isSelected : []) + .help(choice.value) + } + } + } else { + Text("Model selection is no longer available.").foregroundStyle(.secondary).padding(16) + } + }.padding(8) + }.frame(maxHeight: 290) + Divider() + VStack(alignment: .leading, spacing: 9) { + if let effort { + Text(effort.name).brandMicroLabel().foregroundStyle(.secondary) + if (2...4).contains(effort.choices.count) && effort.choices.reduce(0, { $0 + $1.name.count }) <= 36 { + Picker(effort.name, selection: binding(effort)) { + ForEach(effort.choices) { Text($0.name).tag($0.value) } + }.pickerStyle(.segmented).labelsHidden().disabled(disabled) + } else { + Picker(effort.name, selection: binding(effort)) { + ForEach(effort.choices) { Text($0.name).tag($0.value) } + }.labelsHidden().disabled(disabled) + } + } else { + Text("This model does not advertise an effort control.").font(.caption).foregroundStyle(.secondary) + } + if controller.isUpdatingConfig { + Text("Applying selection…").font(.caption).foregroundStyle(.secondary) + } else if controller.isRunning { + Text("Model settings can be changed when this turn finishes.").font(.caption).foregroundStyle(.secondary) + } else if !controller.isReady || controller.isLocked { + Text("Connect to this thread to change model settings.").font(.caption).foregroundStyle(.secondary) + } + }.padding(16) + }.frame(width: 360).background(Brand.paper) } - private func configBinding(_ option: ConfigOption) -> Binding { - Binding( - get: { option.currentValue }, - set: { value in if value != option.currentValue { controller.choose(option, value: value) } } - ) + private func selectedName(_ option: ConfigOption) -> String { + option.choices.first { $0.value == option.currentValue }?.name ?? option.currentValue } - private func usesSegmentedEffortControl(_ option: ConfigOption) -> Bool { - (2...4).contains(option.choices.count) && option.choices.reduce(0) { $0 + $1.name.count } <= 36 + private func matchingGroups(_ option: ConfigOption) -> [ConfigGroup] { + let search = query.trimmingCharacters(in: .whitespacesAndNewlines) + return option.groups.compactMap { group in + let choices = group.choices.filter { + search.isEmpty || group.name.localizedCaseInsensitiveContains(search) + || $0.name.localizedCaseInsensitiveContains(search) || $0.value.localizedCaseInsensitiveContains(search) + } + return choices.isEmpty ? nil : ConfigGroup(id: group.id, name: group.name, choices: choices) + } } - private func selectedModelLabel(_ option: ConfigOption) -> String { - let pieces = option.currentValue.split(separator: ":", maxSplits: 1).map(String.init) - guard pieces.count == 2 else { return selectedName(option) } - return "\(pieces[0]) / \(selectedName(option))" + private func binding(_ option: ConfigOption) -> Binding { + Binding(get: { option.currentValue }, set: { choose(option, value: $0) }) } + private func choose(_ option: ConfigOption, value: String) { + guard let current = ModelSelection.option(in: controller.configOptions, id: option.id, + value: value, disabled: disabled) else { return } + controller.choose(current, value: value) + } } private struct AgentRosterView: View { - let roster: AgentRoster + @ObservedObject var controller: ConversationController + @State private var focusedAgent: AgentRosterRow? + private var roster: AgentRoster { controller.agentRoster } var body: some View { VStack(spacing: 0) { @@ -839,7 +989,9 @@ private struct AgentRosterView: View { ScrollView { LazyVStack(spacing: 0) { ForEach(roster.treeRows) { treeRow in - AgentRosterRowView(treeRow: treeRow, now: timeline.date) + AgentRosterRowView(treeRow: treeRow, now: timeline.date, usage: roster.usageByID[treeRow.id]) { + focusedAgent = treeRow.row + } Divider().padding(.leading, 14 + CGFloat(treeRow.depth) * 16) } }.padding(.vertical, 6) @@ -854,6 +1006,9 @@ private struct AgentRosterView: View { } .accessibilityElement(children: .contain) .accessibilityLabel("Agent roster") + .sheet(item: $focusedAgent) { agent in + FocusedSubagentView(parent: controller, childID: agent.id) + } } private var countSummary: String { @@ -869,10 +1024,21 @@ private struct AgentRosterView: View { private struct AgentRosterRowView: View { let treeRow: AgentRosterTreeRow let now: Date + let usage: DesktopUsageUpdate? + let inspect: () -> Void + @State private var showDetails = false private var row: AgentRosterRow { treeRow.row } var body: some View { + Button { showDetails = true } label: { + rosterRow + } + .buttonStyle(.plain).pointingHandCursor() + .popover(isPresented: $showDetails) { details } + } + + private var rosterRow: some View { HStack(alignment: .top, spacing: 9) { Image(systemName: statusSymbol).foregroundStyle(statusColor).frame(width: 12) VStack(alignment: .leading, spacing: 4) { @@ -885,12 +1051,51 @@ private struct AgentRosterRowView: View { Text(duration).font(.caption2.monospacedDigit()).foregroundStyle(.tertiary) } Text(row.task).font(.caption).foregroundStyle(.secondary).lineLimit(2) - Text(statusText).brandMicroLabel().foregroundStyle(statusColor) + HStack(spacing: 7) { + Text(statusText).brandMicroLabel().foregroundStyle(statusColor) + Spacer(minLength: 0) + if let usage, let used = usage.used, let size = usage.size, size > 0 { + let fraction = min(1, max(0, Double(used) / Double(size))) + Text("\(Int((fraction * 100).rounded()))% context") + .font(.caption2.monospacedDigit()).foregroundStyle(.secondary) + } + } + Text(row.model.map { "\(row.harness) · \($0)" } ?? row.harness) + .font(.caption2).foregroundStyle(.tertiary).lineLimit(1) + if let cost = usage?.cost { + Text(cost.amount, format: .currency(code: cost.currency)) + .font(.caption2.monospacedDigit()).foregroundStyle(.secondary) + } } } .padding(.leading, 14 + CGFloat(treeRow.depth) * 16).padding(.trailing, 12).padding(.vertical, 9) .help("\(row.harness)\(row.model.map { " · \($0)" } ?? "") · generation \(row.generation)") .accessibilityElement(children: .combine) + .contentShape(Rectangle()) + } + + private var details: some View { + VStack(alignment: .leading, spacing: 12) { + Label(row.name, systemImage: statusSymbol).font(.headline).foregroundStyle(statusColor) + Text(row.task).font(.callout).textSelection(.enabled) + Divider() + LabeledContent("Status", value: statusText) + LabeledContent("Harness", value: row.harness) + if let model = row.model { LabeledContent("Model", value: model) } + LabeledContent("Generation", value: String(row.generation)) + LabeledContent("Duration", value: duration) + Button("Open transcript") { + showDetails = false + inspect() + }.buttonStyle(.borderedProminent) + if let parent = row.parentName { LabeledContent("Parent", value: parent) } + if let usage, let used = usage.used, let size = usage.size, size > 0 { + LabeledContent("Context", value: "\(used.formatted()) / \(size.formatted()) tokens") + } + if let cost = usage?.cost { + LabeledContent("Reported cost") { Text(cost.amount, format: .currency(code: cost.currency)) } + } + }.font(.callout).padding(16).frame(width: 350) } private var ageMilliseconds: UInt64 { @@ -906,9 +1111,7 @@ private struct AgentRosterRowView: View { } private var recentFailure: Bool { - guard row.outcome == .failed, let finished = row.generationFinishedAtMS else { return false } - let nowMS = UInt64(max(0, now.timeIntervalSince1970 * 1_000)) - return nowMS - min(nowMS, finished) < 4_000 + row.outcome == .failed } private var statusText: String { @@ -941,6 +1144,61 @@ private struct AgentRosterRowView: View { } } +private struct TranscriptItemsView: View { + @ObservedObject var projection: TranscriptProjection + let detach: (String) -> Void + let cancelBackground: (String) -> Void + + var body: some View { + LazyVStack(alignment: .leading, spacing: 22) { + ForEach(projection.items) { item in + TranscriptItemView(item: item, detach: detach, cancelBackground: cancelBackground) + } + } + } +} + +private struct TranscriptItemView: View { + @ObservedObject var item: TranscriptItem + let detach: (String) -> Void + let cancelBackground: (String) -> Void + @State private var expanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + if item.isActivity { + Button { expanded.toggle() } label: { + HStack(spacing: 8) { + if item.runningCount > 0 { ProgressView().controlSize(.small) } + Image(systemName: expanded ? "chevron.down" : "chevron.right") + Text(item.summary).lineLimit(1) + Text("\(item.entries.count) events").foregroundStyle(.secondary) + if item.errorCount > 0 { + Label("\(item.errorCount) errors", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(Brand.vermilion) + } + } + .font(.callout).contentShape(Rectangle()) + } + .buttonStyle(.plain).pointingHandCursor() + .accessibilityLabel("Activity: \(item.summary), \(item.entries.count) events, \(item.errorCount) errors") + .accessibilityValue(expanded ? "Expanded" : "Collapsed") + .accessibilityHint("Show or hide thoughts, tool output, plans, and status") + } + // Do not build markdown, media, tool JSON, or nested compose views while hidden. + if !item.isActivity || expanded { + ForEach(item.entries) { entry in + TranscriptRow( + entry: entry, + detach: { if let id = entry.toolCallID { detach(id) } }, + cancelBackground: { if let id = entry.toolCallID { cancelBackground(id) } } + ) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} private struct TranscriptRow: View { let entry: TranscriptEntry let detach: () -> Void @@ -957,7 +1215,7 @@ private struct TranscriptRow: View { case .status: statusMessage case .error: errorMessage case .duration: durationMessage - case .usage: EmptyView() + case .usage: Text(entry.text).font(.caption).textSelection(.enabled) } }.frame(maxWidth: .infinity, alignment: entry.role == .user ? .trailing : .leading) } @@ -970,7 +1228,10 @@ private struct TranscriptRow: View { } private var assistantMessage: some View { - MessageText(entry: entry).frame(maxWidth: 760, alignment: .leading) + VStack(alignment: .leading, spacing: 9) { + MessageText(entry: entry) + AssistantMediaView(entryID: entry.id, revision: entry.contentBlocksRevision, blocks: entry.contentBlocks).equatable() + }.frame(maxWidth: 760, alignment: .leading) } private var planMessage: some View { @@ -1035,33 +1296,11 @@ private struct UserMessageView: View { private struct UserMediaView: View { let media: UserMediaPresentation - private var image: NSImage? { - if let data = media.data { return NSImage(data: data) } - if let url = media.url, url.isFileURL { return NSImage(contentsOf: url) } - return nil - } - var body: some View { - Group { - if media.kind == .image, let image { - Image(nsImage: image).resizable().scaledToFit() - .frame(maxWidth: 320, maxHeight: 240) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } else if media.kind == .image, let url = media.url { - AsyncImage(url: url) { image in - image.resizable().scaledToFit() - } placeholder: { - ProgressView().frame(width: 80, height: 60) - } - .frame(maxWidth: 320, maxHeight: 240) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } else { - Label(media.name ?? media.mimeType, systemImage: "waveform") - .font(.caption).padding(8) - .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) - } - } - .accessibilityLabel(media.name ?? (media.kind == .image ? "Image attachment" : "Audio attachment")) + TranscriptMediaView( + media: TranscriptMedia(mimeType: media.mimeType, name: media.name, data: media.data, url: media.url), + identity: TranscriptMediaIdentity(owner: media.id) + ).equatable() } } @@ -1075,7 +1314,8 @@ private struct MessageText: View { private struct ThoughtCard: View { let entry: TranscriptEntry - @State private var expanded = false + // The parent activity disclosure already controls the default collapsed state. + @State private var expanded = true var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -1187,7 +1427,7 @@ private struct ToolCard: View { let entry: TranscriptEntry let detach: () -> Void let cancelBackground: () -> Void - @State private var expanded = false + @State private var expanded = true private var tool: ToolPresentation { entry.presentation?.tool ?? ToolPresentation( @@ -1226,14 +1466,15 @@ private struct ToolCard: View { Image(systemName: childIcon(child)) .foregroundStyle(childColor(child)) Text(child.tool).font(.caption.weight(.semibold)) - Text(child.summary).font(.caption).foregroundStyle(.secondary).lineLimit(2) + Text(child.summary).font(.caption).foregroundStyle(.secondary).textSelection(.enabled) Spacer() if let duration = child.durationMS { Text(durationText(duration)).font(.caption2).foregroundStyle(.tertiary) } } } if let compose = tool.compose { ComposePresentationView(compose: compose) - } else if !tool.detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + } + if !tool.detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { ScrollView([.horizontal, .vertical]) { Text(tool.detail).font(.system(.caption, design: .monospaced)).textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading).padding(10) @@ -1255,7 +1496,8 @@ private struct ToolCard: View { private func childIcon(_ child: RuntimeChild) -> String { if child.running { return "circle.dotted" } - return child.succeeded == true ? "checkmark.circle.fill" : "xmark.circle.fill" + guard let succeeded = child.succeeded else { return "questionmark.circle" } + return succeeded ? "checkmark.circle.fill" : "xmark.circle.fill" } private func childColor(_ child: RuntimeChild) -> Color { if child.running { return .secondary } diff --git a/macos/KitDesktop/Views/FocusedSubagentView.swift b/macos/KitDesktop/Views/FocusedSubagentView.swift new file mode 100644 index 00000000..e0b72b22 --- /dev/null +++ b/macos/KitDesktop/Views/FocusedSubagentView.swift @@ -0,0 +1,89 @@ +import SwiftUI + +struct FocusedSubagentView: View { + @ObservedObject var parent: ConversationController + let childID: String + @StateObject private var replay: FocusedSubagentController + @Environment(\.dismiss) private var dismiss + @State private var follow = true + + init(parent: ConversationController, childID: String) { + self.parent = parent + self.childID = childID + _replay = StateObject(wrappedValue: FocusedSubagentController(transport: parent.subagentClient)) + } + + private var row: AgentRosterRow? { parent.agentRoster.rowsByID[childID] } + private var canSteer: Bool { + guard let row, parent.isReady else { return false } + return parent.canSteerSubagent(id: row.id, generation: row.generation) + } + + var body: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(row?.name ?? "Subagent").font(.headline) + Text(row?.model.map { "\(row?.harness ?? "") · \($0)" } ?? row?.harness ?? "Closed child") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Toggle("Follow output", isOn: $follow).toggleStyle(.checkbox).font(.caption) + Button("Done") { dismiss() }.keyboardShortcut(.cancelAction) + }.padding(16) + Divider() + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 16) { + ForEach(replay.entries) { entry in + VStack(alignment: .leading, spacing: 6) { + Text(entry.title).brandMicroLabel().foregroundStyle(.secondary) + Text(entry.text).font(.body).textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + }.id(entry.id) + } + Color.clear.frame(height: 1).id("child-bottom") + }.padding(20) + } + .onChange(of: replay.entries) { _, _ in + if follow { proxy.scrollTo("child-bottom", anchor: .bottom) } + } + } + Divider() + VStack(alignment: .leading, spacing: 8) { + if replay.partial { + Label("Partial transcript: unsupported content or older retained output was omitted.", systemImage: "exclamationmark.triangle") + .font(.caption).foregroundStyle(Brand.ember) + } + HStack(spacing: 8) { + if replay.loading || replay.steering { ProgressView().controlSize(.mini) } + Text(replay.notice).font(.caption).foregroundStyle(.secondary).textSelection(.enabled) + } + HStack(alignment: .bottom, spacing: 10) { + TextField("Steer this child with a message", text: $replay.draft, axis: .vertical) + .textFieldStyle(.roundedBorder).lineLimit(1...5) + .disabled(!replay.canSteer || replay.steering) + Button("Send", action: replay.send).buttonStyle(.borderedProminent) + .keyboardShortcut(.return, modifiers: .command) + .disabled(!replay.canSteer || replay.steering || replay.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || replay.draft.utf8.count > 16 * 1024) + } + if replay.draft.utf8.count > 16 * 1024 { + Text("Child steering messages must be no larger than 16 KiB.").font(.caption).foregroundStyle(Brand.vermilion) + } + }.padding(16) + } + .frame(minWidth: 560, idealWidth: 740, minHeight: 420, idealHeight: 640) + .background(Brand.canvas) + .onAppear { if let row { replay.focus(row, canSteer: canSteer) } } + .onChange(of: row) { _, value in replay.update(value, canSteer: canSteer) } + .onChange(of: canSteer) { _, value in replay.update(row, canSteer: value) } + .onChange(of: parent.isReady) { _, ready in + if !ready { replay.disconnected(); dismiss() } + } + .onChange(of: ObjectIdentifier(parent.subagentClient)) { _, _ in + replay.disconnected() + dismiss() + } + .onDisappear { replay.stop() } + } +} diff --git a/macos/KitDesktop/Views/MarkdownView.swift b/macos/KitDesktop/Views/MarkdownView.swift index 30e45e27..17b5b40f 100644 --- a/macos/KitDesktop/Views/MarkdownView.swift +++ b/macos/KitDesktop/Views/MarkdownView.swift @@ -159,6 +159,15 @@ struct MarkdownDocument: Equatable { struct MarkdownView: View { let source: String + + var body: some View { + // Unrelated streaming, roster, and composer changes must not reparse completed text. + MarkdownContentView(source: source).equatable() + } +} + +private struct MarkdownContentView: View, Equatable { + let source: String private var document: MarkdownDocument { MarkdownDocument(source) } var body: some View { diff --git a/macos/KitDesktop/Views/PendingSteerEditor.swift b/macos/KitDesktop/Views/PendingSteerEditor.swift new file mode 100644 index 00000000..c2a4772f --- /dev/null +++ b/macos/KitDesktop/Views/PendingSteerEditor.swift @@ -0,0 +1,45 @@ +import SwiftUI + +struct PendingSteerEditor: View { + @ObservedObject var controller: ConversationController + let id: String + @State private var text: String + @State private var submitted: String? + @Environment(\.dismiss) private var dismiss + + init(controller: ConversationController, item: ConversationController.PendingSteer) { + self.controller = controller + id = item.id + _text = State(initialValue: item.text) + } + + private var current: ConversationController.PendingSteer? { controller.pendingSteers.first { $0.id == id } } + private var busy: Bool { controller.isMutatingSteerIDs.contains(id) } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Edit queued message").font(.headline) + Text("Update the message before Kit receives it. Your current composer draft stays unchanged.") + .font(.callout).foregroundStyle(.secondary) + TextEditor(text: $text).font(.body).frame(minHeight: 140) + .disabled(busy).accessibilityLabel("Queued message") + if submitted != nil && !busy { Text(controller.status).font(.caption).foregroundStyle(.secondary).textSelection(.enabled) } + HStack { + Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) + Spacer() + if busy { ProgressView().controlSize(.small) } + Button("Save") { + submitted = text + controller.replacePendingSteer(id: id, text: text) + } + .buttonStyle(.borderedProminent).keyboardShortcut(.return, modifiers: .command) + .disabled(busy || !controller.isReady || !controller.supportsPendingSteerEdit || current == nil + || text == current?.text || text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + }.padding(24).frame(width: 500) + .onChange(of: controller.pendingSteers, initial: true) { _, _ in + guard let current else { dismiss(); return } + if let submitted, current.text == submitted { dismiss() } + } + } +} diff --git a/macos/KitDesktop/Views/ProjectDirectoriesView.swift b/macos/KitDesktop/Views/ProjectDirectoriesView.swift new file mode 100644 index 00000000..0dad9250 --- /dev/null +++ b/macos/KitDesktop/Views/ProjectDirectoriesView.swift @@ -0,0 +1,47 @@ +import AppKit +import SwiftUI + +struct ProjectDirectoriesView: View { + @EnvironmentObject private var model: AppModel + @Environment(\.dismiss) private var dismiss + let workspaceID: UUID + + private var workspace: Workspace? { model.state.workspaces.first { $0.id == workspaceID } } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Project Directories").font(.title2) + Spacer() + Button("Done") { dismiss() }.keyboardShortcut(.cancelAction) + } + if let workspace { + Text(workspace.name).font(.headline) + Text("Root: \(workspace.path)").textSelection(.enabled) + Text("Additional directories are sent when a conversation next starts or resumes. Already connected sessions are unchanged.") + .font(.callout).foregroundStyle(.secondary) + List { + ForEach(workspace.additionalDirectories, id: \.self) { directory in + HStack { + Text(directory).textSelection(.enabled) + Spacer() + Button { model.removeProjectDirectory(directory, from: workspaceID) } label: { + Image(systemName: "minus.circle") + }.accessibilityLabel("Remove \(directory)") + } + } + } + Button("Add Directory…") { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = true + panel.prompt = "Add" + if panel.runModal() == .OK { + for url in panel.urls { model.addProjectDirectory(url.path, to: workspaceID) } + } + } + } else { Text("This project is no longer available.") } + }.padding(24).frame(width: 620, height: 400) + } +} diff --git a/macos/KitDesktop/Views/ProjectsView.swift b/macos/KitDesktop/Views/ProjectsView.swift new file mode 100644 index 00000000..d7197643 --- /dev/null +++ b/macos/KitDesktop/Views/ProjectsView.swift @@ -0,0 +1,123 @@ +import SwiftUI + +struct ProjectsView: View { + @EnvironmentObject private var model: AppModel + let projects: [ProjectSummary] + let addFolder: () -> Void + @State private var query = "" + @State private var directoryWorkspace: Workspace? + @State private var showingProviderUsage = false + + private var filteredProjects: [ProjectSummary] { projects.filter { $0.matches(query) } } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 7) { + Text("YOUR WORKSPACE").brandMicroLabel().foregroundStyle(Brand.ember) + Text("Projects").brandDisplay(36) + Text("Pick up where you left off.").foregroundStyle(.secondary) + } + Spacer() + Button("Provider Usage") { showingProviderUsage = true } + Button(action: addFolder) { Label("Add Folder", systemImage: "folder.badge.plus") } + .buttonStyle(.borderedProminent).pointingHandCursor() + } + HStack { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField("Search projects and conversations", text: $query).textFieldStyle(.plain) + if !query.isEmpty { + Button { query = "" } label: { Image(systemName: "xmark.circle.fill") } + .buttonStyle(.plain).accessibilityLabel("Clear search") + } + }.padding(12).background(Brand.paper, in: RoundedRectangle(cornerRadius: 8)) + HStack { + Text("\(filteredProjects.count) PROJECTS").brandMicroLabel() + Spacer() + Label("Recently updated", systemImage: "clock").font(.caption) + }.foregroundStyle(.secondary) + if filteredProjects.isEmpty { + ContentUnavailableView( + projects.isEmpty ? "Make room for your next idea" : "No matching projects", + systemImage: projects.isEmpty ? "folder.badge.plus" : "magnifyingglass", + description: Text(projects.isEmpty ? "Add a folder to start a conversation with Kit." : "Try another project name, path, or conversation title.") + ) + } + LazyVStack(spacing: 16) { + ForEach(filteredProjects) { project in + projectCard(project) + } + } + }.padding(32).frame(maxWidth: 1000).frame(maxWidth: .infinity) + }.background(Brand.canvas) + .sheet(item: $directoryWorkspace) { workspace in + ProjectDirectoriesView(workspaceID: workspace.id).environmentObject(model) + } + .sheet(isPresented: $showingProviderUsage) { ProviderUsageView() } + } + + private func projectCard(_ project: ProjectSummary) -> some View { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 12) { + Image(systemName: "folder.fill").font(.title2).foregroundStyle(Brand.ember) + Button { model.selectWorkspace(project.id) } label: { + VStack(alignment: .leading, spacing: 4) { + Text(project.workspace.name).font(.headline) + Text(project.workspace.path).font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle) + }.contentShape(Rectangle()) + }.buttonStyle(.plain).pointingHandCursor() + Spacer() + Button("Directories…") { directoryWorkspace = project.workspace } + Button { model.createConversation(in: project.id) } label: { + Label("New Conversation", systemImage: "square.and.pencil") + }.pointingHandCursor() + } + Divider() + if project.conversations.isEmpty { + Text("No conversations yet. Start something new.").font(.callout).foregroundStyle(.secondary).padding(.vertical, 8) + } else { + if !project.matchesProject(query) { + Text("Matching conversations").font(.caption).foregroundStyle(.secondary) + } + ForEach(project.previewConversations(matching: query)) { conversation in + Button { model.selectConversation(conversation.id) } label: { + HStack(spacing: 10) { + ProjectConversationStatus(conversation: conversation, running: model.activity[conversation.id] == true) + Text(conversation.title).lineLimit(1) + Spacer() + if model.lockedConversationIDs.contains(conversation.id) { + Image(systemName: "lock.fill").foregroundStyle(.secondary) + } + Text(conversation.updatedAt, style: .relative).font(.caption).foregroundStyle(.secondary) + Image(systemName: "chevron.right").font(.caption2).foregroundStyle(.tertiary) + }.padding(.vertical, 4).contentShape(Rectangle()) + }.buttonStyle(.plain).pointingHandCursor() + } + } + HStack { + Text("\(project.conversations.count) conversations").font(.caption).foregroundStyle(.secondary) + Spacer() + Text(project.updatedAt, style: .date).font(.caption).foregroundStyle(.tertiary) + } + }.padding(20).background(Brand.paper, in: RoundedRectangle(cornerRadius: 12)) + .overlay { RoundedRectangle(cornerRadius: 12).stroke(Brand.hairline) } + } +} + +struct ProjectConversationStatus: View { + let conversation: Conversation + let running: Bool + + var body: some View { + Group { + if running { + ProgressView().controlSize(.mini).accessibilityLabel("Running") + } else if conversation.unread || conversation.awaitingUser { + Circle().fill(Brand.ember).frame(width: 7, height: 7).accessibilityLabel("Unread") + } else { + Image(systemName: "bubble.left").font(.caption).foregroundStyle(.tertiary) + } + }.frame(width: 16) + } +} diff --git a/macos/KitDesktop/Views/ProviderUsageView.swift b/macos/KitDesktop/Views/ProviderUsageView.swift new file mode 100644 index 00000000..fce43316 --- /dev/null +++ b/macos/KitDesktop/Views/ProviderUsageView.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct ProviderUsageView: View { + @Environment(\.dismiss) private var dismiss + @State private var report: String? + @State private var error: String? + @State private var loading = true + @State private var refreshID = UUID() + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Provider & Account Usage").font(.title2) + Spacer() + Button("Done") { dismiss() }.keyboardShortcut(.cancelAction) + } + Text("Account quotas reported by authenticated providers, separate from this conversation’s token usage.") + .foregroundStyle(.secondary) + if loading { ProgressView("Fetching usage…") } + if let error { Text(error).foregroundStyle(.red).textSelection(.enabled) } + if let report { + ScrollView { Text(report).font(.system(.body, design: .monospaced)).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading) } + } + Spacer(minLength: 0) + Button("Refresh") { refreshID = UUID() }.disabled(loading) + } + .padding(24).frame(width: 620, height: 440) + .task(id: refreshID) { + loading = true + error = nil + do { + let result = try await ProviderUsageService().fetch() + guard !Task.isCancelled else { return } + report = result + } catch { + guard !Task.isCancelled else { return } + self.error = error.localizedDescription + } + loading = false + } + } +} diff --git a/macos/KitDesktop/Views/TranscriptMediaView.swift b/macos/KitDesktop/Views/TranscriptMediaView.swift new file mode 100644 index 00000000..86246d33 --- /dev/null +++ b/macos/KitDesktop/Views/TranscriptMediaView.swift @@ -0,0 +1,236 @@ +import AppKit +import ImageIO +import SwiftUI +import UniformTypeIdentifiers + +/// Value identity keeps thumbnail work independent of streaming text revisions. +struct TranscriptMedia: Equatable { + static let maximumBytes = 10 * 1024 * 1024 + let mimeType: String + var name: String? = nil + var base64: String? = nil + var data: Data? = nil + var url: URL? = nil + + var isImage: Bool { mimeType.hasPrefix("image/") } + var label: String { name ?? (isImage ? "Image attachment" : "Audio attachment") } + var requiresRemotePreviewConsent: Bool { + data == nil && base64 == nil && Self.safeURL(url).map { !$0.isFileURL } == true + } + + static func safeURL(_ url: URL?) -> URL? { + guard let url else { return nil } + if ["https", "http"].contains(url.scheme?.lowercased() ?? ""), url.host != nil { return url } + guard url.isFileURL, url.host == nil || url.host == "" || url.host == "localhost", + let type = UTType(filenameExtension: url.pathExtension), + type.conforms(to: .image) || type.conforms(to: .audio) else { return nil } + return url + } + + static func derive(_ block: DesktopContentBlock) -> TranscriptMedia? { + switch block { + case .image(let data, let mime, let uri): + return TranscriptMedia(mimeType: mime ?? "image/*", base64: data, url: uri.flatMap(URL.init(string:))) + case .audio(let data, let mime): + return TranscriptMedia(mimeType: mime ?? "audio/*", base64: data) + case .resourceLink(let uri, let name, let mime): + guard let mime = mime ?? inferredMIME(uri), + mime.hasPrefix("image/") || mime.hasPrefix("audio/") else { return nil } + return TranscriptMedia(mimeType: mime, name: name, url: URL(string: uri)) + case .resource(let uri, let mime, _, let blob): + guard let mime = mime ?? inferredMIME(uri), + mime.hasPrefix("image/") || mime.hasPrefix("audio/") else { return nil } + return TranscriptMedia(mimeType: mime, base64: blob, url: uri.flatMap(URL.init(string:))) + default: return nil + } + } + + private static func inferredMIME(_ uri: String?) -> String? { + guard let uri, let url = URL(string: uri) else { return nil } + return UTType(filenameExtension: url.pathExtension)?.preferredMIMEType + } + + func boundedData() throws -> Data? { + if let data { return data.count <= Self.maximumBytes ? data : nil } + if let base64 { + guard base64.utf8.count <= (Self.maximumBytes + 2) / 3 * 4, + let decoded = Data(base64Encoded: base64), decoded.count <= Self.maximumBytes else { return nil } + return decoded + } + guard let url = Self.safeURL(url), url.isFileURL else { return nil } + let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard values.isRegularFile == true, let size = values.fileSize, size <= Self.maximumBytes else { return nil } + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + let bytes = try handle.read(upToCount: Self.maximumBytes + 1) + return bytes.flatMap { $0.count <= Self.maximumBytes ? $0 : nil } + } +} + +/// Actor isolation confines decoding and its bounded cache off the UI actor. +actor TranscriptMediaCache { + static let shared = TranscriptMediaCache() + private var thumbnails: [TranscriptMediaIdentity: (media: TranscriptMedia, image: CGImage)] = [:] + private var order: [TranscriptMediaIdentity] = [] + + func thumbnail( + _ media: TranscriptMedia, identity: TranscriptMediaIdentity, allowRemote: Bool = false + ) async -> CGImage? { + // Even a previously cached remote preview requires consent in this view. + guard media.isImage, allowRemote || !media.requiresRemotePreviewConsent else { return nil } + // Payload equality happens only on this actor, never in SwiftUI's task/equality keys. + if let cached = thumbnails[identity], cached.media == media { return cached.image } + let bytes: Data? + if media.data == nil, media.base64 == nil, + let url = TranscriptMedia.safeURL(media.url), !url.isFileURL { + bytes = await remoteImageData(url) + } else { bytes = try? media.boundedData() } + guard let data = bytes, + let source = CGImageSourceCreateWithData(data as CFData, nil), + let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: 640, + kCGImageSourceShouldCacheImmediately: true, + ] as CFDictionary) else { return nil } + while order.count >= 16 { thumbnails.removeValue(forKey: order.removeFirst()) } + order.removeAll { $0 == identity } + thumbnails[identity] = (media, image) + order.append(identity) + return image + } + + private func remoteImageData(_ url: URL) async -> Data? { + do { + var request = URLRequest(url: url) + request.timeoutInterval = 20 + let (stream, response) = try await URLSession.shared.bytes(for: request) + guard let response = response as? HTTPURLResponse, + (200..<300).contains(response.statusCode), + TranscriptMedia.safeURL(response.url) != nil, + response.mimeType?.hasPrefix("image/") == true, + response.expectedContentLength <= Int64(TranscriptMedia.maximumBytes) else { return nil } + var data = Data() + for try await byte in stream { + guard data.count < TranscriptMedia.maximumBytes, !Task.isCancelled else { return nil } + data.append(byte) + } + return data + } catch { return nil } + } + + func openURL(_ media: TranscriptMedia) -> URL? { + if let url = TranscriptMedia.safeURL(media.url) { + if !url.isFileURL { return url } + // Do not let a media-looking symlink launch an application or script. + guard TranscriptMedia.safeURL(url.resolvingSymlinksInPath()) != nil, + (try? media.boundedData()) != nil else { return nil } + return url + } + guard let data = try? media.boundedData() else { return nil } + let imageType = CGImageSourceCreateWithData(data as CFData, nil) + .flatMap { CGImageSourceGetType($0) }.flatMap { UTType($0 as String) } + guard let type = imageType ?? UTType(mimeType: media.mimeType), + type.conforms(to: .image) || type.conforms(to: .audio), + let ext = type.preferredFilenameExtension else { return nil } + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("Kit-media-\(UUID().uuidString).\(ext)") + do { try data.write(to: url, options: .atomic); return url } catch { return nil } + } +} + +/// Stable, small keys never hash the inline image/audio payload. +struct TranscriptMediaIdentity: Hashable { + let owner: UUID + var block: Int = 0 +} + +private struct TranscriptMediaLoadIdentity: Equatable { + let source: TranscriptMediaIdentity + let revision: UInt64 + let allowRemote: Bool +} + +struct TranscriptMediaView: View, Equatable { + let media: TranscriptMedia + let identity: TranscriptMediaIdentity + var revision: UInt64 = 0 + @State private var thumbnail: CGImage? + @State private var loaded = false + @State private var opening = false + @State private var openFailed = false + @State private var consentedRemoteURL: URL? + private var allowRemote: Bool { + guard let url = TranscriptMedia.safeURL(media.url) else { return false } + return consentedRemoteURL == url + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.identity == rhs.identity && lhs.revision == rhs.revision + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Button { + opening = true + Task { + if let url = await TranscriptMediaCache.shared.openURL(media) { + openFailed = !NSWorkspace.shared.open(url) + } else { openFailed = true } + opening = false + } + } label: { + VStack(alignment: .leading, spacing: 4) { + if let thumbnail { + Image(decorative: thumbnail, scale: 1).resizable().scaledToFit() + .frame(maxWidth: 320, maxHeight: 240) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else if !loaded && media.isImage && (!media.requiresRemotePreviewConsent || allowRemote) { + ProgressView().frame(width: 80, height: 60) + } + Label(media.label, systemImage: media.isImage ? "photo" : "waveform") + .font(.caption).lineLimit(2) + if opening { ProgressView().controlSize(.small) } + if openFailed { Text("Attachment unavailable").font(.caption).foregroundStyle(.secondary) } + } + } + .buttonStyle(.plain) + .disabled(opening) + .accessibilityLabel("Open \(media.label)") + if media.isImage && media.requiresRemotePreviewConsent && !allowRemote { + Button("Load remote preview") { consentedRemoteURL = TranscriptMedia.safeURL(media.url) } + .font(.caption) + .help("Contacts the remote server to load this image") + } + } + .task(id: TranscriptMediaLoadIdentity(source: identity, revision: revision, allowRemote: allowRemote)) { + loaded = false + let image = await TranscriptMediaCache.shared.thumbnail(media, identity: identity, allowRemote: allowRemote) + guard !Task.isCancelled else { return } + thumbnail = image + loaded = true + } + } +} + +/// The producer increments revision whenever blocks are replaced or appended. +/// Unrelated row updates compare only these scalar values, without enumerating blocks. +struct AssistantMediaView: View, Equatable { + let entryID: UUID + let revision: UInt64 + let blocks: [DesktopContentBlock] + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.entryID == rhs.entryID && lhs.revision == rhs.revision + } + + var body: some View { + ForEach(Array(blocks.enumerated()), id: \.offset) { index, block in + if let media = TranscriptMedia.derive(block) { + TranscriptMediaView( + media: media, identity: TranscriptMediaIdentity(owner: entryID, block: index), revision: revision + ).equatable() + } + } + } +} diff --git a/macos/KitDesktopTests/ACPMessageIdentityTests.swift b/macos/KitDesktopTests/ACPMessageIdentityTests.swift new file mode 100644 index 00000000..aea71d2c --- /dev/null +++ b/macos/KitDesktopTests/ACPMessageIdentityTests.swift @@ -0,0 +1,27 @@ +import XCTest +@testable import Kit + +final class ACPMessageIdentityTests: XCTestCase { + @MainActor + func testWireMessageIDsAreScopedByRoleDuringReplay() async throws { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + let client = ACPClient(launchOverride: ACPClient.LaunchOverride( + executable: URL(fileURLWithPath: "/usr/bin/python3"), + prefixArguments: [root.appendingPathComponent("fixtures/mock-acp-v2.py").path, "--models", "--replay-shared-ids"] + ), requestTimeout: 2, promptTimeout: 2) + let controller = ConversationController( + conversation: Conversation(workspaceID: UUID(), sessionID: "shared-session"), + workspacePath: root.path, client: client + ) + let ready = expectation(description: "shared identities replayed") + controller.onSessionReady = { _, _ in ready.fulfill() } + controller.start() + await fulfillment(of: [ready], timeout: 4) + XCTAssertEqual(controller.entries.map(\.role), [.user, .thought, .assistant]) + XCTAssertEqual(controller.transcriptProjection.items.map(\.isActivity), [false, true, false]) + XCTAssertEqual(controller.entries.map(\.text), ["text", "text", "answer"]) + let closed = expectation(description: "shared identities helper closed") + controller.close { closed.fulfill() } + await fulfillment(of: [closed], timeout: 4) + } +} diff --git a/macos/KitDesktopTests/ACPProcessTests.swift b/macos/KitDesktopTests/ACPProcessTests.swift index cee06136..56954346 100644 --- a/macos/KitDesktopTests/ACPProcessTests.swift +++ b/macos/KitDesktopTests/ACPProcessTests.swift @@ -307,10 +307,8 @@ final class ACPProcessTests: XCTestCase { wait(for: [finished], timeout: 3) let thoughts = controller.entries.filter { $0.role == .thought } - XCTAssertEqual(thoughts.count, 1) - XCTAssertEqual(thoughts.first?.text, "latest thought") - XCTAssertNotNil(thoughts.first?.presentation?.thought) - XCTAssertFalse(thoughts.first?.isStreaming ?? true) + XCTAssertEqual(thoughts.map(\.text), ["outdated thought", "latest thought"]) + XCTAssertTrue(thoughts.allSatisfy { $0.presentation?.thought != nil && !$0.isStreaming }) XCTAssertTrue(controller.entries.contains { $0.role == .tool && $0.title == "Inspect files" && !$0.isStreaming && $0.presentation?.tool?.status == .completed }) diff --git a/macos/KitDesktopTests/DesktopRenderingTests.swift b/macos/KitDesktopTests/DesktopRenderingTests.swift new file mode 100644 index 00000000..c8bdd970 --- /dev/null +++ b/macos/KitDesktopTests/DesktopRenderingTests.swift @@ -0,0 +1,50 @@ +import AppKit +import SwiftUI +import XCTest +@testable import Kit + +final class DesktopRenderingTests: XCTestCase { + @MainActor + func testProjectsRenderInBothAppearances() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("kit-desktop-preview-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let store = PersistenceStore(fileURL: directory.appendingPathComponent("state.json")) + let now = Date(timeIntervalSince1970: 1_791_150_000) + let workspaces = [ + Workspace(name: "Kit", path: "/Projects/kit", createdAt: now), + Workspace(name: "Marketing", path: "/Projects/marketing", createdAt: now), + Workspace(name: "Examples", path: "/Projects/examples", createdAt: now), + ] + let conversations = [ + Conversation(workspaceID: workspaces[0].id, title: "Give the desktop its own identity", updatedAt: now), + Conversation(workspaceID: workspaces[0].id, title: "Make long threads feel instant", updatedAt: now.addingTimeInterval(-300), unread: true), + Conversation(workspaceID: workspaces[0].id, title: "Review the release notes", updatedAt: now.addingTimeInterval(-600)), + Conversation(workspaceID: workspaces[1].id, title: "Document the project workflow", updatedAt: now.addingTimeInterval(-900)), + ] + try store.save(PersistedAppState(workspaces: workspaces, conversations: conversations)) + let model = AppModel(store: store, catalogLoader: nil, requestNotificationAuthorization: false) + for (name, scheme) in [("light", ColorScheme.light), ("dark", ColorScheme.dark)] { + let content = ProjectsView(projects: model.projects, addFolder: {}) + .environmentObject(model).environment(\.colorScheme, scheme) + .frame(width: 980, height: 900) + // ImageRenderer does not render AppKit-backed scrolling content. + let hosting = NSHostingView(rootView: content) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 980, height: 900), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = hosting + window.appearance = NSAppearance(named: scheme == .dark ? .darkAqua : .aqua) + hosting.layoutSubtreeIfNeeded() + hosting.displayIfNeeded() + let bitmap = try XCTUnwrap(hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds)) + hosting.cacheDisplay(in: hosting.bounds, to: bitmap) + window.close() + XCTAssertGreaterThan(bitmap.pixelsWide, 0) + let png = try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) + let path = directory.appendingPathComponent("projects-\(name).png") + try png.write(to: path) + print("Desktop preview: \(path.path)") + } + store.flush() + } +} diff --git a/macos/KitDesktopTests/DesktopUsageTests.swift b/macos/KitDesktopTests/DesktopUsageTests.swift new file mode 100644 index 00000000..4f0aa461 --- /dev/null +++ b/macos/KitDesktopTests/DesktopUsageTests.swift @@ -0,0 +1,90 @@ +import XCTest +@testable import Kit + +final class DesktopUsageTests: XCTestCase { + func testUsageDecodesLegacyAndCostReports() throws { + let decoder = JSONDecoder() + let legacy = try decoder.decode(DesktopUsageUpdate.self, from: Data(#"{"used":12,"size":100}"#.utf8)) + XCTAssertEqual(legacy.used, 12) + XCTAssertNil(legacy.cost) + let current = try decoder.decode(DesktopUsageUpdate.self, from: Data(#"{"used":40,"size":100,"cost":{"amount":1.25,"currency":"USD"}}"#.utf8)) + XCTAssertEqual(current.cost, DesktopCost(amount: 1.25, currency: "USD")) + } + + func testChildCostSnapshotsSurviveRemovalWithoutDoubleCounting() { + var roster = AgentRoster() + XCTAssertTrue(roster.apply(event: usage("child", amount: 1), nowMS: 1)) + XCTAssertTrue(roster.apply(event: usage("child", amount: 1.25), nowMS: 2)) + XCTAssertTrue(roster.apply(event: usage("other", amount: 2, currency: "EUR"), nowMS: 3)) + XCTAssertEqual(roster.costTotals, ["USD": 1.25, "EUR": 2]) + XCTAssertTrue(roster.usageByID.isEmpty, "Unknown rows must not appear from telemetry alone") + roster.pruneExpired(at: 5_000) + XCTAssertEqual(roster.costTotals["USD"], 1.25) + roster.reset() + XCTAssertTrue(roster.costTotals.isEmpty) + } + + func testKnownChildContextSurvivesLifecycleUpdatesAndCleansUp() { + var roster = AgentRoster() + XCTAssertTrue(roster.apply(event: lifecycle(status: "working", generation: 1), nowMS: 1)) + XCTAssertTrue(roster.apply(event: usage("child", amount: 0.5), nowMS: 2)) + XCTAssertEqual(roster.usageByID["child"]?.used, 40) + XCTAssertTrue(roster.apply(event: lifecycle(status: "idle", generation: 1), nowMS: 3)) + XCTAssertEqual(roster.usageByID["child"]?.size, 100) + XCTAssertTrue(roster.apply(event: lifecycle(status: "removed", generation: 1), nowMS: 4)) + XCTAssertNil(roster.usageByID["child"]) + XCTAssertEqual(roster.costTotals["USD"], 0.5) + } + + func testSteeringRequiresCurrentGenerationCapability() { + var roster = AgentRoster() + XCTAssertFalse(roster.canSteer(id: "child", generation: 1)) + roster.apply(event: lifecycle(status: "working", generation: 1), nowMS: 1) + XCTAssertFalse(roster.canSteer(id: "child", generation: 1)) + let supported: [String: Any] = ["event": "subagent_capabilities", "id": "child", "generation": 1, "can_steer": true] + XCTAssertTrue(roster.apply(event: supported, nowMS: 2)) + XCTAssertTrue(roster.canSteer(id: "child", generation: 1)) + roster.apply(event: lifecycle(status: "idle", generation: 1), nowMS: 3) + XCTAssertFalse(roster.canSteer(id: "child", generation: 1)) + roster.apply(event: lifecycle(status: "working", generation: 2), nowMS: 4) + XCTAssertFalse(roster.apply(event: supported, nowMS: 5)) + XCTAssertFalse(roster.canSteer(id: "child", generation: 2)) + roster.apply(event: ["event": "subagent_capabilities", "id": "child", "generation": 2, "can_steer": true], nowMS: 6) + XCTAssertTrue(roster.canSteer(id: "child", generation: 2)) + roster.invalidateLiveState() + XCTAssertFalse(roster.canSteer(id: "child", generation: 2)) + } + + func testTransportGapInvalidatesOnlyLiveObservations() { + var roster = AgentRoster() + roster.apply(event: lifecycle(status: "working", generation: 1), nowMS: 1) + roster.apply(event: usage("child", amount: 1), nowMS: 2) + roster.invalidateLiveState() + XCTAssertTrue(roster.rowsByID.isEmpty) + XCTAssertTrue(roster.usageByID.isEmpty) + XCTAssertEqual(roster.costTotals, ["USD": 1]) + XCTAssertTrue(roster.apply(event: lifecycle(status: "idle", generation: 1), nowMS: 3)) + XCTAssertEqual(roster.rowsByID["child"]?.status, .idle) + } + + func testInvalidCostsDoNotReplaceValidSnapshots() { + var roster = AgentRoster() + roster.apply(event: usage("child", amount: 1), nowMS: 1) + for amount in [-1.0, Double.infinity, Double.nan] { + roster.apply(event: usage("child", amount: amount), nowMS: 2) + } + roster.apply(event: usage("child", amount: 3, currency: " "), nowMS: 3) + XCTAssertEqual(roster.costTotals, ["USD": 1]) + } + + private func usage(_ id: String, amount: Double, currency: String = "USD") -> [String: Any] { + ["event": "subagent_usage", "id": id, "used": 40, "size": 100, + "cost": ["amount": amount, "currency": currency]] + } + + private func lifecycle(status: String, generation: Int) -> [String: Any] { + ["event": "subagent_state_changed", "id": "child", "name": "Scout", "status": status, + "generation": generation, "task": "Inspect", "harness": "acp.kit", + "created_at_unix_ms": 1, "generation_started_at_unix_ms": 1] + } +} diff --git a/macos/KitDesktopTests/FocusedSubagentTests.swift b/macos/KitDesktopTests/FocusedSubagentTests.swift new file mode 100644 index 00000000..60a47f97 --- /dev/null +++ b/macos/KitDesktopTests/FocusedSubagentTests.swift @@ -0,0 +1,187 @@ +import XCTest +@testable import Kit + +final class FocusedSubagentTests: XCTestCase { + @MainActor + func testReplayRejectsStaleFocusGenerationAndInvalidCursor() throws { + let transport = ChildTransport() + let controller = FocusedSubagentController(transport: transport) + controller.focus(row(), canSteer: true) + let stale = transport.reads[0].completion + controller.focus(row(generation: 2), canSteer: false) + stale(.success(page(generation: 1, cursor: 10, text: "stale"))) + XCTAssertTrue(controller.entries.isEmpty) + transport.reads[1].completion(.success(page(generation: 1, cursor: 10, text: "wrong generation"))) + XCTAssertTrue(controller.entries.isEmpty) + XCTAssertFalse(controller.canSteer) + XCTAssertTrue(controller.notice.contains("generation or cursor")) + controller.focus(row(), canSteer: true) + transport.reads.last!.completion(.success(page(generation: 1, cursor: 0, text: "invalid cursor"))) + XCTAssertTrue(controller.entries.isEmpty) + XCTAssertFalse(controller.canSteer) + } + + @MainActor + func testSteeringCapabilityLifecycleAndStaleAcknowledgement() { + let transport = ChildTransport() + let controller = FocusedSubagentController(transport: transport) + controller.focus(row(), canSteer: false) + controller.draft = "A child instruction" + controller.send() + XCTAssertTrue(transport.steers.isEmpty) + controller.update(row(), canSteer: true) + controller.send() + XCTAssertEqual(transport.steers.first?.prompt, "A child instruction") + XCTAssertTrue(controller.steering) + controller.send() + XCTAssertEqual(transport.steers.count, 1) + controller.focus(row(generation: 2), canSteer: true) + controller.draft = "New generation draft" + transport.steers[0].completion(.success(["receipt": [:]])) + XCTAssertEqual(controller.draft, "New generation draft") + controller.update(row(generation: 2, status: .idle), canSteer: true) + XCTAssertFalse(controller.canSteer) + controller.stop() + } + + @MainActor + func testReplayIsBoundedAndDescendantsAreReadOnly() { + let transport = ChildTransport() + let controller = FocusedSubagentController(transport: transport) + controller.focus(row(status: .idle), canSteer: true) + let updates = (0..<505).map { index in message(id: String(index), text: String(repeating: "x", count: 17_000)) } + transport.reads[0].completion(.success(SubagentTranscriptPage(updates: updates, nextCursor: 20, generation: 1, caughtUp: true))) + XCTAssertEqual(controller.entries.count, FocusedSubagentController.maximumEntries) + XCTAssertTrue(controller.entries.allSatisfy { $0.text.count <= FocusedSubagentController.maximumTextCharacters }) + XCTAssertTrue(controller.partial) + XCTAssertFalse(controller.canSteer) + XCTAssertEqual(controller.cursor, 20) + controller.focus(row(parent: "ancestor"), canSteer: true) + XCTAssertEqual(transport.reads.count, 1) + XCTAssertFalse(controller.canSteer) + XCTAssertTrue(controller.notice.contains("Descendant")) + } + + @MainActor + func testClosingRejectsReplayAndPreservesFailedSteerDraft() { + let transport = ChildTransport() + let controller = FocusedSubagentController(transport: transport) + controller.focus(row(), canSteer: true) + controller.draft = "Keep this draft" + controller.send() + transport.steers[0].completion(.failure(ACPClientError.protocolError("child stopped"))) + XCTAssertEqual(controller.draft, "Keep this draft") + XCTAssertFalse(controller.steering) + controller.stop() + transport.reads[0].completion(.success(page(generation: 1, cursor: 20, text: "late"))) + XCTAssertTrue(controller.entries.isEmpty) + } + + @MainActor + func testToolContentChunksAppendAndStatusPatchPreservesOutput() { + let transport = ChildTransport() + let controller = FocusedSubagentController(transport: transport) + controller.focus(row(status: .idle), canSteer: false) + let updates: [JSONValue] = [ + .object(["sessionUpdate": .string("tool_call"), "toolCallId": .string("call"), "title": .string("Shell"), "status": .string("in_progress")]), + .object(["sessionUpdate": .string("tool_call_content_chunk"), "toolCallId": .string("call"), "content": .string("first chunk")]), + .object(["sessionUpdate": .string("tool_call_content_chunk"), "toolCallId": .string("call"), "content": .string("second chunk")]), + .object(["sessionUpdate": .string("tool_call_update"), "toolCallId": .string("call"), "status": .string("completed")]), + ] + transport.reads[0].completion(.success(SubagentTranscriptPage(updates: updates, nextCursor: 30, generation: 1, caughtUp: true))) + XCTAssertEqual(controller.entries.count, 1) + XCTAssertEqual(controller.entries[0].text, "\"first chunk\"\n\"second chunk\"") + XCTAssertTrue(controller.entries[0].title.contains("completed")) + XCTAssertFalse(controller.partial) + } + + func testModelSelectionUsesCurrentAdvertisement() { + let option = ConfigOption(id: "model", name: "Model", currentValue: "a", groups: [ConfigGroup(id: "provider", name: "Provider", choices: [ConfigChoice(value: "a", name: "A"), ConfigChoice(value: "b", name: "B")])]) + XCTAssertNotNil(ModelSelection.option(in: [option], id: "model", value: "b", disabled: false)) + XCTAssertNil(ModelSelection.option(in: [option], id: "model", value: "b", disabled: true)) + XCTAssertNil(ModelSelection.option(in: [option], id: "model", value: "a", disabled: false)) + XCTAssertNil(ModelSelection.option(in: [option], id: "model", value: "removed", disabled: false)) + XCTAssertNil(ModelSelection.option(in: [], id: "model", value: "b", disabled: false)) + } + + func testChildProtocolFixtureUsesSeparateReplayAndGenerationCheckedSteering() { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + let client = ACPClient(launchOverride: .init(executable: URL(fileURLWithPath: "/usr/bin/python3"), prefixArguments: [root.appendingPathComponent("fixtures/mock-acp-v2.py").path, "--models"])) + var parentMessages = 0 + client.onUpdate = { update in + if case .agentMessage = update { parentMessages += 1 } + } + let ready = expectation(description: "ready") + client.start(options: ACPLaunchOptions(root: root.path, sessionID: "child-test", resume: false, provider: nil, model: nil, reasoningEffort: nil), loading: false) { result in + if case .failure(let error) = result { XCTFail(error.localizedDescription) } + ready.fulfill() + } + wait(for: [ready], timeout: 3) + let read = expectation(description: "child replay") + client.readSubagentTranscript(id: "fixture-child", generation: 1, cursor: 0) { result in + switch result { + case .success(let page): + XCTAssertEqual(page.generation, 1) + XCTAssertEqual(page.nextCursor, 128) + XCTAssertEqual(page.updates.count, 1) + XCTAssertTrue(page.caughtUp) + case .failure(let error): XCTFail(error.localizedDescription) + } + read.fulfill() + } + let steer = expectation(description: "child steer") + client.steerSubagent(id: "fixture-child", generation: 1, prompt: "Continue") { result in + if case .failure(let error) = result { XCTFail(error.localizedDescription) } + steer.fulfill() + } + let stale = expectation(description: "stale generation") + client.steerSubagent(id: "fixture-child", generation: 2, prompt: "Stale") { result in + if case .success = result { XCTFail("Stale generation must fail") } + stale.fulfill() + } + wait(for: [read, steer, stale], timeout: 3) + XCTAssertEqual(parentMessages, 0, "Child replay must not enter the parent update stream") + let caughtUp = expectation(description: "caught up cursor") + client.readSubagentTranscript(id: "fixture-child", generation: 1, cursor: 128) { result in + switch result { + case .success(let page): + XCTAssertTrue(page.updates.isEmpty) + XCTAssertEqual(page.nextCursor, 128) + case .failure(let error): XCTFail(error.localizedDescription) + } + caughtUp.fulfill() + } + wait(for: [caughtUp], timeout: 3) + let closed = expectation(description: "closed") + client.close(activeTurn: false) { closed.fulfill() } + wait(for: [closed], timeout: 3) + } + + private func row(generation: UInt64 = 1, status: SubagentStatus = .working, parent: String? = nil) -> AgentRosterRow { + AgentRosterRow(id: "child", name: "Child", status: status, outcome: nil, generation: generation, task: "Work", parentID: parent, parentName: nil, harness: "acp.kit", model: nil, createdAtMS: 1, generationStartedAtMS: 1, generationFinishedAtMS: nil) + } + private func message(id: String = "message", text: String) -> JSONValue { + .object(["sessionUpdate": .string("agent_message_chunk"), "messageId": .string(id), "content": .object(["type": .string("text"), "text": .string(text)])]) + } + private func page(generation: UInt64, cursor: UInt64, text: String) -> SubagentTranscriptPage { + SubagentTranscriptPage(updates: [message(text: text)], nextCursor: cursor, generation: generation, caughtUp: true) + } +} + +private final class ChildTransport: FocusedSubagentTransport { + struct Read { + let completion: (Result) -> Void + } + struct Steer { + let prompt: String + let completion: (Result<[String: Any], Error>) -> Void + } + var reads: [Read] = [] + var steers: [Steer] = [] + func readSubagentTranscript(id: String, generation: UInt64, cursor: UInt64, completion: @escaping (Result) -> Void) { + reads.append(Read(completion: completion)) + } + func steerSubagent(id: String, generation: UInt64, prompt: String, completion: @escaping (Result<[String: Any], Error>) -> Void) { + steers.append(Steer(prompt: prompt, completion: completion)) + } +} diff --git a/macos/KitDesktopTests/PendingSteerMutationTests.swift b/macos/KitDesktopTests/PendingSteerMutationTests.swift new file mode 100644 index 00000000..0984a842 --- /dev/null +++ b/macos/KitDesktopTests/PendingSteerMutationTests.swift @@ -0,0 +1,127 @@ +import XCTest +@testable import Kit + +final class PendingSteerMutationTests: XCTestCase { + @MainActor + func testWireMessageIDsAreScopedByRoleAndProjectionMatchesController() async throws { + let controller = makeController(arguments: []) + controller.start() + try await waitUntil { controller.isReady } + controller.draft = "MOCK_SHARED_MESSAGE_IDS" + controller.send() + try await waitUntil { controller.entries.contains { $0.role == .assistant && $0.text == "Assistant text" } && !controller.isRunning } + XCTAssertTrue(controller.entries.contains { $0.role == .user && $0.text == "User text" }) + XCTAssertTrue(controller.entries.contains { $0.role == .thought && $0.text == "Thought text" }) + XCTAssertEqual(controller.transcriptProjection.items.flatMap(\.entries).map(\.id), controller.entries.map(\.id)) + XCTAssertEqual(controller.transcriptProjection.items.flatMap(\.entries).map(\.text), controller.entries.map(\.text)) + XCTAssertTrue(controller.transcriptProjection.items.filter { !$0.isActivity }.flatMap(\.entries).contains { $0.text == "Assistant text" }) + await close(controller) + } + + @MainActor + func testPendingTextCanBeEditedRejectedAndRevoked() async throws { + let delivery = temporaryDirectory().appendingPathComponent("deliver") + let controller = makeController(arguments: ["--steer", "--pending-replace", "--inject-release=\(delivery.path)"]) + controller.start() + try await waitUntil { controller.isReady } + controller.draft = "MOCK_HANG" + controller.send() + try await waitUntil { controller.canSteer } + XCTAssertTrue(controller.supportsPendingSteerEdit) + controller.draft = "original" + controller.send() + try await waitUntil { controller.pendingSteers.count == 1 } + let id = try XCTUnwrap(controller.pendingSteers.first?.id) + controller.replacePendingSteer(id: id, text: "edited") + try await waitUntil { controller.pendingSteers.first?.text == "edited" && controller.isMutatingSteerIDs.isEmpty } + controller.replacePendingSteer(id: id, text: "MOCK_REJECT_REPLACE") + try await waitUntil { controller.isMutatingSteerIDs.isEmpty && controller.entries.contains { $0.role == .error } } + XCTAssertEqual(controller.pendingSteers.first?.text, "edited") + controller.revokePendingSteer(id: id) + try await waitUntil { controller.pendingSteers.isEmpty } + try Data().write(to: delivery) + await close(controller) + XCTAssertFalse(controller.entries.contains { $0.role == .user && $0.text == "edited" }) + } + + @MainActor + func testDeliveryWinsOverDelayedReplacementReply() async throws { + let directory = temporaryDirectory() + let delivery = directory.appendingPathComponent("deliver") + let acknowledgment = directory.appendingPathComponent("ack") + let controller = makeController(arguments: ["--steer", "--pending-replace", "--inject-release=\(delivery.path)", "--mutation-ack-release=\(acknowledgment.path)", "--mutation-committed=\(directory.appendingPathComponent("committed").path)"]) + controller.start() + try await waitUntil { controller.isReady } + controller.draft = "MOCK_HANG"; controller.send() + try await waitUntil { controller.canSteer } + controller.draft = "original"; controller.send() + try await waitUntil { controller.pendingSteers.count == 1 } + let id = try XCTUnwrap(controller.pendingSteers.first?.id) + controller.replacePendingSteer(id: id, text: "edited") + XCTAssertTrue(controller.isMutatingSteerIDs.contains(id)) + try await waitUntil { FileManager.default.fileExists(atPath: directory.appendingPathComponent("committed").path) } + try Data().write(to: delivery) + try await waitUntil { controller.entries.contains { $0.role == .user && $0.text == "edited" } } + XCTAssertTrue(controller.pendingSteers.isEmpty) + try Data().write(to: acknowledgment) + try await waitUntil { controller.entries.contains { $0.text == "Mutation acknowledged" } } + XCTAssertTrue(controller.pendingSteers.isEmpty) + XCTAssertTrue(controller.isMutatingSteerIDs.isEmpty) + await close(controller) + } + + @MainActor + func testDelayedInjectionAcknowledgmentCannotEnterNextTurn() async throws { + let directory = temporaryDirectory() + let prompt = directory.appendingPathComponent("prompt") + let acknowledgment = directory.appendingPathComponent("ack") + let delivery = directory.appendingPathComponent("deliver") + let controller = makeController(arguments: ["--steer", "--prompt-release=\(prompt.path)", "--prompt-release-text=first", "--inject-ack-release=\(acknowledgment.path)", "--inject-release=\(delivery.path)"]) + controller.start() + try await waitUntil { controller.isReady } + controller.draft = "first"; controller.send() + try await waitUntil { controller.canSteer } + controller.draft = "old queued prompt"; controller.send() + XCTAssertTrue(controller.isInjecting) + try Data().write(to: prompt) + try await waitUntil { !controller.isRunning } + controller.draft = "MOCK_HANG"; controller.send() + try await waitUntil { controller.canSteer } + try Data().write(to: acknowledgment) + try await waitUntil { controller.entries.contains { $0.text == "Injection acknowledged" } } + XCTAssertTrue(controller.pendingSteers.isEmpty) + XCTAssertFalse(controller.isInjecting) + await close(controller) + } + + @MainActor + private func makeController(arguments: [String]) -> ConversationController { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + let launch = ACPClient.LaunchOverride(executable: URL(fileURLWithPath: "/usr/bin/python3"), prefixArguments: [root.appendingPathComponent("fixtures/mock-acp-v2.py").path] + arguments) + return ConversationController(conversation: Conversation(workspaceID: UUID()), workspacePath: root.path, client: ACPClient(launchOverride: launch, requestTimeout: 3, promptTimeout: 3)) + } + + @MainActor + private func close(_ controller: ConversationController) async { + let closed = expectation(description: "closed") + controller.close { closed.fulfill() } + await fulfillment(of: [closed], timeout: 4) + } + + @MainActor + private func waitUntil(_ condition: @escaping @MainActor () -> Bool) async throws { + for _ in 0..<150 { + if condition() { return } + try await Task.sleep(nanoseconds: 20_000_000) + } + XCTFail("Condition did not become true") + throw NSError(domain: "PendingSteerMutationTests", code: 1) + } + + private func temporaryDirectory() -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: url) } + return url + } +} diff --git a/macos/KitDesktopTests/ProjectDirectoriesTests.swift b/macos/KitDesktopTests/ProjectDirectoriesTests.swift new file mode 100644 index 00000000..b4267e36 --- /dev/null +++ b/macos/KitDesktopTests/ProjectDirectoriesTests.swift @@ -0,0 +1,51 @@ +import Foundation +import XCTest +@testable import Kit + +final class ProjectDirectoriesTests: XCTestCase { + private func load(version: Int?, directories: Any?, extra: [String: Any] = [:]) throws -> PersistedAppState { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: directory) } + var workspace: [String: Any] = ["id": UUID().uuidString, "name": "Kit", "path": "/tmp/kit", "createdAt": "2025-01-01T00:00:00Z"] + workspace.merge(extra) { _, new in new } + if let directories { workspace["additionalDirectories"] = directories } + var state: [String: Any] = ["workspaces": [workspace], "conversations": []] + if let version { state["schemaVersion"] = version } + let url = directory.appendingPathComponent("state.json") + try JSONSerialization.data(withJSONObject: state).write(to: url) + return try PersistenceStore(fileURL: url).load() + } + + func testLegacyAndCurrentAbsentDirectoriesDefaultEmpty() throws { + for version in [nil, 1, 2, 3] as [Int?] { + XCTAssertEqual(try load(version: version, directories: nil).workspaces[0].additionalDirectories, []) + } + } + + func testExplicitDirectoriesSurviveLegacyAndMixedFields() throws { + for version in [nil, 1, 2, 3] as [Int?] { + let state = try load(version: version, directories: ["/tmp/extra"], extra: ["directories": ["/tmp/old"]]) + XCTAssertEqual(state.workspaces[0].additionalDirectories, ["/tmp/extra"]) + } + } + + func testMalformedDirectoriesAreNotTreatedAsLegacy() { + for malformed: Any in [NSNull(), "bad", 42, ["/tmp", 42] as [Any], ["path": "/tmp"]] { + XCTAssertThrowsError(try load(version: 3, directories: malformed)) + } + } + + func testWriterEmitsDirectoriesAndRoundTrips() throws { + let state = try load(version: 3, directories: ["/tmp/extra"]) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + addTeardownBlock { try? FileManager.default.removeItem(at: directory) } + let store = PersistenceStore(fileURL: directory.appendingPathComponent("state.json")) + try store.save(state) + XCTAssertEqual(try store.load(), state) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: store.fileURL)) as? [String: Any]) + let workspaces = try XCTUnwrap(object["workspaces"] as? [[String: Any]]) + XCTAssertEqual(workspaces[0]["additionalDirectories"] as? [String], ["/tmp/extra"]) + XCTAssertEqual(object["schemaVersion"] as? Int, 3) + } +} diff --git a/macos/KitDesktopTests/ProjectDirectoryProtocolTests.swift b/macos/KitDesktopTests/ProjectDirectoryProtocolTests.swift new file mode 100644 index 00000000..76479335 --- /dev/null +++ b/macos/KitDesktopTests/ProjectDirectoryProtocolTests.swift @@ -0,0 +1,81 @@ +import Foundation +import XCTest +@testable import Kit + +final class ProjectDirectoryProtocolTests: XCTestCase { + @MainActor + func testDirectoriesAreSentForNewAndResumedSessions() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + for resume in [false, true] { + let log = directory.appendingPathComponent("\(resume).jsonl") + let client = makeClient(log: log, supported: true) + let ready = expectation(description: "directory session ready") + client.start(options: ACPLaunchOptions(root: directory.path, sessionID: "directory-session", resume: resume, + provider: nil, model: nil, reasoningEffort: nil, additionalDirectories: ["/tmp/extra"]), loading: resume) { result in + if case .failure(let error) = result { XCTFail(error.localizedDescription) } + ready.fulfill() + } + await fulfillment(of: [ready], timeout: 4) + let closed = expectation(description: "directory session closed") + client.close(activeTurn: false) { closed.fulfill() } + await fulfillment(of: [closed], timeout: 4) + let request = try XCTUnwrap(requests(log).first { $0["method"] as? String == (resume ? "session/resume" : "session/new") }) + XCTAssertEqual(request["additionalDirectories"] as? [String], ["/tmp/extra"]) + } + } + + @MainActor + func testUnsupportedDirectoriesFailBeforeSessionCreation() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let log = directory.appendingPathComponent("requests.jsonl") + let client = makeClient(log: log, supported: false) + let ready = expectation(description: "unsupported directories rejected") + client.start(options: ACPLaunchOptions(root: directory.path, sessionID: "unsupported", resume: false, + provider: nil, model: nil, reasoningEffort: nil, additionalDirectories: ["/tmp/extra"]), loading: false) { result in + if case .success = result { XCTFail("Unsupported directories must not be silently ignored") } + ready.fulfill() + } + await fulfillment(of: [ready], timeout: 4) + let closed = expectation(description: "unsupported helper closed") + client.close(activeTurn: false) { closed.fulfill() } + await fulfillment(of: [closed], timeout: 4) + XCTAssertFalse(try requests(log).contains { $0["method"] as? String == "session/new" }) + } + + @MainActor + func testControllerUsesUpdatedDirectoriesOnNextStart() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let log = directory.appendingPathComponent("requests.jsonl") + let controller = ConversationController(conversation: Conversation(workspaceID: UUID()), workspacePath: directory.path, + additionalDirectories: ["/tmp/old"], client: makeClient(log: log, supported: true)) + controller.setAdditionalDirectoriesForNextStart(["/tmp/new"]) + let ready = expectation(description: "updated controller ready") + controller.onSessionReady = { _, _ in ready.fulfill() } + controller.start() + await fulfillment(of: [ready], timeout: 4) + let closed = expectation(description: "updated controller closed") + controller.close { closed.fulfill() } + await fulfillment(of: [closed], timeout: 4) + let request = try XCTUnwrap(requests(log).first { $0["method"] as? String == "session/new" }) + XCTAssertEqual(request["additionalDirectories"] as? [String], ["/tmp/new"]) + } + + private func makeClient(log: URL, supported: Bool) -> ACPClient { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + let arguments = [root.appendingPathComponent("fixtures/mock-acp-v2.py").path, "--models", "--request-log=" + log.path] + + (supported ? ["--additional-directories"] : []) + return ACPClient(launchOverride: ACPClient.LaunchOverride(executable: URL(fileURLWithPath: "/usr/bin/python3"), prefixArguments: arguments), requestTimeout: 2, promptTimeout: 2) + } + + private func requests(_ url: URL) throws -> [[String: Any]] { + try String(contentsOf: url).split(separator: "\n").map { + try XCTUnwrap(JSONSerialization.jsonObject(with: Data($0.utf8)) as? [String: Any]) + } + } +} diff --git a/macos/KitDesktopTests/ProjectNavigationLifecycleTests.swift b/macos/KitDesktopTests/ProjectNavigationLifecycleTests.swift new file mode 100644 index 00000000..7694de81 --- /dev/null +++ b/macos/KitDesktopTests/ProjectNavigationLifecycleTests.swift @@ -0,0 +1,38 @@ +import Combine +import XCTest +@testable import Kit + +final class ProjectNavigationLifecycleTests: XCTestCase { + @MainActor + func testBackgroundSessionReadinessDoesNotDismissProjects() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + let release = directory.appendingPathComponent("release") + let workspace = Workspace(name: "Project", path: directory.path) + let conversation = Conversation(workspaceID: workspace.id) + let store = PersistenceStore(fileURL: directory.appendingPathComponent("state.json")) + try store.save(PersistedAppState(workspaces: [workspace], conversations: [conversation])) + let model = AppModel(store: store, catalogLoader: nil, controllerFactory: { conversation, path in + let client = ACPClient(launchOverride: ACPClient.LaunchOverride( + executable: URL(fileURLWithPath: "/usr/bin/python3"), + prefixArguments: [root.appendingPathComponent("fixtures/mock-acp-v2.py").path, "--models", "--new-release=" + release.path] + ), requestTimeout: 3, promptTimeout: 3) + return ConversationController(conversation: conversation, workspacePath: path, client: client) + }, requestNotificationAuthorization: false) + model.selectConversation(conversation.id) + let controller = try XCTUnwrap(model.controllers[conversation.id]) + let ready = expectation(description: "background session ready") + let subscription = controller.$isReady.filter { $0 }.prefix(1).sink { _ in ready.fulfill() } + defer { subscription.cancel() } + model.showProjects() + try Data().write(to: release) + await fulfillment(of: [ready], timeout: 5) + XCTAssertTrue(model.showingProjects) + XCTAssertEqual(model.selectedConversationID, conversation.id) + let closed = expectation(description: "background session closed") + model.closeAll { closed.fulfill() } + await fulfillment(of: [closed], timeout: 4) + } +} diff --git a/macos/KitDesktopTests/ProjectNavigationTests.swift b/macos/KitDesktopTests/ProjectNavigationTests.swift new file mode 100644 index 00000000..bb26ad93 --- /dev/null +++ b/macos/KitDesktopTests/ProjectNavigationTests.swift @@ -0,0 +1,90 @@ +import XCTest +@testable import Kit + +final class ProjectNavigationTests: XCTestCase { + @MainActor + func testProjectSnapshotsSortGroupSearchAndLimitPreviews() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = PersistenceStore(fileURL: directory.appendingPathComponent("state.json")) + let older = Workspace(name: "Library", path: "/projects/library", createdAt: Date(timeIntervalSince1970: 1)) + let newer = Workspace(name: "Desktop", path: "/projects/desktop", createdAt: Date(timeIntervalSince1970: 2)) + let conversations = (0..<5).map { + Conversation(workspaceID: older.id, title: "Task \($0)", updatedAt: Date(timeIntervalSince1970: Double($0 + 10))) + } + try store.save(PersistedAppState(workspaces: [newer, older], conversations: conversations)) + let model = AppModel(store: store, catalogLoader: nil, requestNotificationAuthorization: false) + + let projects = model.projects + XCTAssertEqual(projects.map(\.id), [older.id, newer.id]) + XCTAssertEqual(projects[0].recentConversations.map(\.title), ["Task 4", "Task 3", "Task 2"]) + XCTAssertEqual(projects[0].conversations.count, 5) + XCTAssertTrue(projects[1].recentConversations.isEmpty) + XCTAssertTrue(projects[0].matches(" LIBRARY ")) + XCTAssertTrue(projects[0].matches("/projects/lib")) + XCTAssertTrue(projects[0].matches("task 0")) + XCTAssertFalse(projects[1].matches("task 0")) + } + + @MainActor + func testProjectsPageDoesNotMarkHiddenConversationRead() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = PersistenceStore(fileURL: directory.appendingPathComponent("state.json")) + let workspace = Workspace(name: "Project", path: directory.path) + let conversation = Conversation(workspaceID: workspace.id, unread: true, awaitingUser: true) + try store.save(PersistedAppState(workspaces: [workspace], conversations: [conversation])) + let model = AppModel(store: store, catalogLoader: nil, requestNotificationAuthorization: false) + model.selectedConversationID = conversation.id + model.showProjects() + model.appBecameActive() + XCTAssertTrue(model.showingProjects) + XCTAssertTrue(try XCTUnwrap(model.state.conversations.first).unread) + XCTAssertEqual(model.selectedConversationID, conversation.id) + model.selectWorkspace(workspace.id) + XCTAssertFalse(model.showingProjects) + model.appBecameActive() + XCTAssertFalse(try XCTUnwrap(model.state.conversations.first).unread) + store.flush() + } + + @MainActor + func testCrossProjectNavigationKeepsControllerAndDraft() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + let store = PersistenceStore(fileURL: directory.appendingPathComponent("state.json")) + let first = Workspace(name: "First", path: root.path) + let second = Workspace(name: "Second", path: root.path) + let conversation = Conversation(workspaceID: second.id) + try store.save(PersistedAppState(workspaces: [first, second], conversations: [conversation])) + let launch = ACPClient.LaunchOverride( + executable: URL(fileURLWithPath: "/usr/bin/python3"), + prefixArguments: [root.appendingPathComponent("fixtures/mock-acp-v2.py").path, "--models"] + ) + let model = AppModel(store: store, catalogLoader: nil, controllerFactory: { conversation, path in + ConversationController(conversation: conversation, workspacePath: path, + client: ACPClient(launchOverride: launch, requestTimeout: 2, promptTimeout: 2)) + }, requestNotificationAuthorization: false) + model.selectConversation(conversation.id) + XCTAssertEqual(model.selectedWorkspaceID, second.id) + let controller = try XCTUnwrap(model.selectedController) + controller.draft = "Keep this thought" + model.showProjects() + model.selectWorkspace(first.id) + model.selectConversation(conversation.id) + XCTAssertTrue(model.selectedController === controller) + XCTAssertEqual(model.selectedController?.draft, "Keep this thought") + XCTAssertEqual(model.selectedWorkspaceID, second.id) + XCTAssertFalse(model.showingProjects) + model.createConversation(in: first.id) + XCTAssertEqual(model.selectedWorkspaceID, first.id) + XCTAssertEqual(model.state.conversations.count, 2) + XCTAssertEqual(model.state.conversations.last?.workspaceID, first.id) + XCTAssertTrue(model.controllers[conversation.id] === controller) + XCTAssertEqual(controller.draft, "Keep this thought") + let closed = expectation(description: "controllers closed") + model.closeAll { closed.fulfill() } + await fulfillment(of: [closed], timeout: 4) + } +} diff --git a/macos/KitDesktopTests/ProjectSearchTests.swift b/macos/KitDesktopTests/ProjectSearchTests.swift new file mode 100644 index 00000000..1f8b0d84 --- /dev/null +++ b/macos/KitDesktopTests/ProjectSearchTests.swift @@ -0,0 +1,17 @@ +import XCTest +@testable import Kit + +final class ProjectSearchTests: XCTestCase { + func testTitleSearchPreviewsOlderMatchesInsteadOfUnrelatedRecentConversations() { + let workspace = Workspace(name: "Kit", path: "/projects/kit") + let conversations = (0..<7).map { index in + Conversation(workspaceID: workspace.id, title: index < 3 ? "Recent \(index)" : "Needle \(index)") + } + let project = ProjectSummary(workspace: workspace, conversations: conversations) + XCTAssertTrue(project.matches("needle")) + XCTAssertEqual(project.previewConversations(matching: " NEEDLE ").map(\.id), Array(conversations[3...5]).map(\.id)) + XCTAssertEqual(project.previewConversations(matching: "kit").map(\.id), Array(conversations.prefix(3)).map(\.id)) + XCTAssertEqual(project.previewConversations(matching: "").map(\.id), Array(conversations.prefix(3)).map(\.id)) + XCTAssertTrue(project.previewConversations(matching: "missing").isEmpty) + } +} diff --git a/macos/KitDesktopTests/ProviderUsageServiceTests.swift b/macos/KitDesktopTests/ProviderUsageServiceTests.swift new file mode 100644 index 00000000..edaba58e --- /dev/null +++ b/macos/KitDesktopTests/ProviderUsageServiceTests.swift @@ -0,0 +1,56 @@ +import Foundation +import XCTest +@testable import Kit + +final class ProviderUsageServiceTests: XCTestCase { + private func fixture(_ body: String) throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("kit") + try Data(("#!/bin/sh\n[ \"$1\" = usage ] || exit 9\n" + body).utf8).write(to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: url.path) + return url + } + + func testReadsUsageCommandOutput() async throws { + let binary = try fixture("printf 'OpenAI quota: 50%% remaining\\n'\n") + let text = try await ProviderUsageService(executable: binary).fetch() + XCTAssertEqual(text, "OpenAI quota: 50% remaining") + } + + func testReportsNonzeroExit() async throws { + let binary = try fixture("printf 'Please authenticate' >&2\nexit 2\n") + do { + _ = try await ProviderUsageService(executable: binary).fetch() + XCTFail("Expected error") + } catch { XCTAssertEqual(error.localizedDescription, "Please authenticate") } + } + + func testLimitsOutput() async throws { + let binary = try fixture("while :; do printf '0123456789'; done\n") + do { + _ = try await ProviderUsageService(executable: binary, maximumOutputBytes: 64).fetch() + XCTFail("Expected output limit") + } catch ProviderUsageError.outputTooLarge {} + } + + func testTimesOutWhenDescendantRetainsPipe() async throws { + let binary = try fixture("sleep 60 &\nexit 0\n") + do { + _ = try await ProviderUsageService(executable: binary, timeout: 0.1).fetch() + XCTFail("Expected timeout") + } catch ProviderUsageError.timedOut {} + } + + func testCancellationStopsWorker() async throws { + let binary = try fixture("sleep 60\n") + let task = Task { try await ProviderUsageService(executable: binary).fetch() } + try await Task.sleep(nanoseconds: 50_000_000) + task.cancel() + do { + _ = try await task.value + XCTFail("Expected cancellation") + } catch is CancellationError {} + } +} diff --git a/macos/KitDesktopTests/TranscriptMediaTests.swift b/macos/KitDesktopTests/TranscriptMediaTests.swift new file mode 100644 index 00000000..c5043719 --- /dev/null +++ b/macos/KitDesktopTests/TranscriptMediaTests.swift @@ -0,0 +1,71 @@ +import AppKit +import XCTest +@testable import Kit + +final class TranscriptMediaTests: XCTestCase { + func testDerivesInlineAndResourceMedia() throws { + let bytes = Data([1, 2, 3]) + let media = try XCTUnwrap(TranscriptMedia.derive(.image(data: bytes.base64EncodedString(), mimeType: "image/png", uri: nil))) + XCTAssertEqual(try media.boundedData(), bytes) + XCTAssertNotNil(TranscriptMedia.derive(.resource(uri: "file:///tmp/image.png", mimeType: "image/png", text: nil, blob: nil))) + XCTAssertNotNil(TranscriptMedia.derive(.resourceLink(uri: "https://example.com/audio.wav", name: "Audio", mimeType: "audio/wav"))) + XCTAssertNil(TranscriptMedia.derive(.text("hello"))) + } + + func testUnsafeLinksAndOversizedDataAreRejected() throws { + for value in ["javascript:alert(1)", "data:image/png;base64,AA==", "file:///tmp/run.command", "file://remote/tmp/image.png"] { + XCTAssertNil(TranscriptMedia.safeURL(URL(string: value))) + } + XCTAssertNotNil(TranscriptMedia.safeURL(URL(string: "https://example.com/image.png"))) + let large = TranscriptMedia(mimeType: "image/png", data: Data(count: TranscriptMedia.maximumBytes + 1)) + XCTAssertNil(try large.boundedData()) + XCTAssertThrowsError(try ClipboardMediaImport.writeImage(Data([1, 2]))) + } + + func testRemotePreviewRequiresConsentAndOpenDoesNotFetch() async throws { + let media = TranscriptMedia(mimeType: "image/png", url: URL(string: "http://127.0.0.1:1/tracker.png")) + XCTAssertTrue(media.requiresRemotePreviewConsent) + let cache = TranscriptMediaCache() + let thumbnail = await cache.thumbnail(media, identity: TranscriptMediaIdentity(owner: UUID())) + XCTAssertNil(thumbnail) + let openURL = await cache.openURL(media) + XCTAssertEqual(openURL, media.url) + let inline = TranscriptMedia(mimeType: "image/png", data: Data([1]), url: media.url) + XCTAssertFalse(inline.requiresRemotePreviewConsent) + } + + func testViewIdentityDoesNotComparePayloadsAndRevisionInvalidates() { + let id = TranscriptMediaIdentity(owner: UUID()) + let first = TranscriptMediaView(media: TranscriptMedia(mimeType: "image/png", data: Data([1])), identity: id) + let second = TranscriptMediaView(media: TranscriptMedia(mimeType: "image/png", data: Data([2])), identity: id) + XCTAssertEqual(first, second) + let changed = TranscriptMediaView(media: second.media, identity: id, revision: 1) + XCTAssertNotEqual(first, changed) + XCTAssertNotEqual(id, TranscriptMediaIdentity(owner: id.owner, block: 1)) + } + + func testClipboardImportAndThumbnailDownsampling() async throws { + let bitmap = try XCTUnwrap(NSBitmapImageRep( + bitmapDataPlanes: nil, pixelsWide: 1200, pixelsHigh: 800, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0 + )) + let data = try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) + let url = try ClipboardMediaImport.writeImage(data) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let media = TranscriptMedia(mimeType: "image/png", url: url) + XCTAssertNotNil(try media.boundedData()) + let cache = TranscriptMediaCache() + let identity = TranscriptMediaIdentity(owner: UUID()) + let first = await cache.thumbnail(media, identity: identity) + let image = try XCTUnwrap(first) + XCTAssertEqual(image.width, 640) + XCTAssertLessThanOrEqual(image.height, 640) + let second = await cache.thumbnail(media, identity: identity) + XCTAssertTrue(image === second) + // Replacements at the same source identity must never return a stale image. + let invalid = TranscriptMedia(mimeType: "image/png", data: Data([1, 2])) + let replaced = await cache.thumbnail(invalid, identity: identity) + XCTAssertNil(replaced) + } +} diff --git a/macos/KitDesktopTests/TranscriptProjectionRegressionTests.swift b/macos/KitDesktopTests/TranscriptProjectionRegressionTests.swift new file mode 100644 index 00000000..da3210a9 --- /dev/null +++ b/macos/KitDesktopTests/TranscriptProjectionRegressionTests.swift @@ -0,0 +1,121 @@ +import XCTest +@testable import Kit + +/// Regression coverage for the presentation domain, not ACP/controller integration. +final class TranscriptProjectionRegressionTests: XCTestCase { + @MainActor + func testOneSynchronizationCanAppendMessagesAndCompleteAnOlderTool() throws { + var entries = [ + TranscriptEntry(role: .user, text: "Question"), + TranscriptEntry(role: .tool, text: "Pending", toolCallID: "background", isStreaming: true), + TranscriptEntry(role: .assistant, text: "Visible partial answer", isStreaming: true), + ] + let projection = TranscriptProjection() + projection.synchronize(entries) + let activity = projection.items[1] + let assistant = projection.items[2] + + // A delivery batch can contain both new rows and edits behind the tail. + entries[1].text = "Completed background output" + entries[1].isStreaming = false + entries[2].text = "Visible final answer" + entries[2].isStreaming = false + entries.append(TranscriptEntry(role: .status, text: "Verified")) + entries.append(TranscriptEntry(role: .assistant, text: "Final conclusion")) + projection.synchronize(entries, changedIndices: [1, 2, 3, 4]) + + XCTAssertTrue(projection.items[1] === activity) + XCTAssertTrue(projection.items[2] === assistant) + XCTAssertEqual(activity.runningCount, 0) + XCTAssertEqual(activity.entries.first?.text, "Completed background output") + XCTAssertEqual(assistant.entries.first?.text, "Visible final answer") + XCTAssertFalse(assistant.isActivity) + XCTAssertFalse(try XCTUnwrap(projection.items.last).isActivity) + XCTAssertEqual(projection.items.flatMap(\.entries).map(\.id), entries.map(\.id)) + XCTAssertEqual(projection.items.flatMap(\.entries).map(\.text), entries.map(\.text)) + } + + @MainActor + func testRemovalAndRetentionRebuildRemapLaterEditsWithoutLosingFinalAnswer() throws { + var entries = [ + TranscriptEntry(role: .user, text: "Old prompt"), + TranscriptEntry(role: .thought, text: "Reasoning"), + TranscriptEntry(role: .plan, text: "Temporary plan"), + TranscriptEntry(role: .tool, text: "Pending", toolCallID: "tool", isStreaming: true), + TranscriptEntry(role: .assistant, text: "Partial answer", isStreaming: true), + ] + let projection = TranscriptProjection() + projection.synchronize(entries) + let activityID = projection.items[1].id + let answerID = try XCTUnwrap(projection.items.last).id + + entries.remove(at: 2) // Plan removal shifts the tool's source index. + entries.removeFirst() // Retention also shifts the activity's source index. + projection.synchronize(entries, rebuilding: true) + XCTAssertEqual(projection.items.map(\.id), [activityID, answerID]) + + entries[1].text = "Final tool output" + entries[1].isStreaming = false + entries[2].text = "Authoritative final answer" + entries[2].isStreaming = false + projection.synchronize(entries, changedIndices: [1, 2]) + XCTAssertEqual(projection.items.first?.entries.last?.text, "Final tool output") + XCTAssertEqual(projection.items.first?.runningCount, 0) + XCTAssertEqual(projection.items.last?.entries.first?.text, "Authoritative final answer") + XCTAssertEqual(projection.items.map(\.id), [activityID, answerID]) + + // Replay a complete snapshot after edits: display IDs and final text survive. + projection.synchronize(entries, rebuilding: true) + XCTAssertEqual(projection.items.map(\.id), [activityID, answerID]) + XCTAssertEqual(projection.items.flatMap(\.entries).map(\.text), entries.map(\.text)) + } + + @MainActor + func testSameLengthReplayReplacesOldEntryMappingsAndResetCanStartAnotherThread() { + let projection = TranscriptProjection() + let old = [TranscriptEntry(role: .assistant, text: "Previous thread")] + projection.synchronize(old) + var replacement = [TranscriptEntry(role: .assistant, text: "Replayed partial", isStreaming: true)] + projection.synchronize(replacement, rebuilding: true) + replacement[0].text = "Replayed final" + replacement[0].isStreaming = false + projection.synchronize(replacement, changedIndices: [0]) + XCTAssertEqual(projection.items.map(\.id), replacement.map(\.id)) + XCTAssertEqual(projection.items.first?.entries.first?.text, "Replayed final") + XCTAssertFalse(projection.items.flatMap(\.entries).contains { $0.id == old[0].id }) + + projection.synchronize([], rebuilding: true) + XCTAssertTrue(projection.items.isEmpty) + projection.synchronize(old) + XCTAssertEqual(projection.items.map(\.id), old.map(\.id)) + XCTAssertEqual(projection.items.first?.entries.first?.text, "Previous thread") + } + + @MainActor + func testMeasureProjectionStreamingTailOfThousandRowThread() { + var entries = (0..<1000).map { index in + TranscriptEntry(role: .assistant, text: "Completed row \(index)") + } + entries[999].isStreaming = true + let projection = TranscriptProjection() + projection.synchronize(entries) + let tail = projection.items.last + let tailID = entries[999].id + + // Measures only the real projection update domain, not the controller, + // SwiftUI layout, or ACP transport. No timing threshold or work counter. + // Each iteration reuses the completed prefix and changes one tail entry. + measure { + MainActor.assumeIsolated { + for _ in 0..<200 { + entries[999].text += " tail" + projection.synchronize(entries, changedIndices: [999]) + } + } + } + XCTAssertTrue(projection.items.last === tail) + XCTAssertEqual(projection.items.last?.id, tailID) + XCTAssertEqual(projection.items.last?.entries.first?.text, entries[999].text) + XCTAssertEqual(projection.items.first?.entries.first?.text, "Completed row 0") + } +} diff --git a/macos/KitDesktopTests/TranscriptProjectionTests.swift b/macos/KitDesktopTests/TranscriptProjectionTests.swift new file mode 100644 index 00000000..57cc1af4 --- /dev/null +++ b/macos/KitDesktopTests/TranscriptProjectionTests.swift @@ -0,0 +1,106 @@ +import XCTest +@testable import Kit + +final class TranscriptProjectionTests: XCTestCase { + @MainActor + func testMessagesRemainStandaloneAndAllInternalEventsAreRetained() { + let roles: [TranscriptRole] = [.user, .thought, .tool, .plan, .status, .duration, .usage, .error, .assistant, .tool, .assistant] + let entries = roles.map { TranscriptEntry(role: $0, text: $0.rawValue) } + let projection = TranscriptProjection() + projection.synchronize(entries) + XCTAssertEqual(projection.items.map(\.isActivity), [false, true, false, true, false]) + XCTAssertEqual(projection.items.flatMap(\.entries).map(\.id), entries.map(\.id)) + XCTAssertEqual(projection.items[1].errorCount, 1) + XCTAssertEqual(projection.items[1].entries.map(\.role), Array(roles[1...7])) + } + + @MainActor + func testStreamingAndNonTailToolUpdatesKeepDisplayIdentity() { + var entries = [TranscriptEntry(role: .assistant, text: "completed"), TranscriptEntry(role: .tool, text: "pending", isStreaming: true)] + let projection = TranscriptProjection() + projection.synchronize(entries) + let message = projection.items[0] + let activity = projection.items[1] + entries.append(TranscriptEntry(role: .assistant, text: "answer", isStreaming: true)) + projection.synchronize(entries) + entries[1].text = "all output" + entries[1].isStreaming = false + entries[1].children = [RuntimeChild(id: "nested", tool: "shell", summary: "full nested output", running: false, succeeded: false, durationMS: 3)] + projection.synchronize(entries, changedIndices: [1]) + XCTAssertTrue(projection.items[0] === message) + XCTAssertTrue(projection.items[1] === activity) + XCTAssertEqual(activity.runningCount, 0) + XCTAssertEqual(activity.errorCount, 1) + XCTAssertEqual(activity.entries[0].children[0].summary, "full nested output") + XCTAssertEqual(activity.entries[0].text, "all output") + XCTAssertEqual(projection.items[2].entries[0].text, "answer") + } + + @MainActor + func testAppendMergesActivityAndRetentionRebuildPreservesSurvivingIDs() { + var entries = [TranscriptEntry(role: .user, text: "prompt"), TranscriptEntry(role: .thought, text: "thought")] + let projection = TranscriptProjection() + projection.synchronize(entries) + let activityID = projection.items[1].id + entries.append(TranscriptEntry(role: .tool, text: "output")) + projection.synchronize(entries) + XCTAssertEqual(projection.items.count, 2) + XCTAssertEqual(projection.items[1].id, activityID) + entries.removeFirst() + projection.synchronize(entries, rebuilding: true) + XCTAssertEqual(projection.items.map(\.id), [activityID]) + XCTAssertEqual(projection.items[0].entries.count, 2) + } + + func testSingleLongMessageCoalescesDeltasAndBoundsUnicodeAndMedia() { + var blocks: [DesktopContentBlock] = [] + let delta = String(repeating: "🙂", count: 128) + for _ in 0..<1024 { blocks = TranscriptContentBuffer.append([.text(delta)], to: blocks) } + let text = blocks.compactMap { block -> String? in if case .text(let value) = block { return value }; return nil }.joined() + XCTAssertLessThanOrEqual(text.utf8.count, TranscriptContentBuffer.maximumTextBytes) + XCTAssertLessThan(blocks.count, 1024) + XCTAssertFalse(text.contains("�")) + XCTAssertTrue(text.hasSuffix(delta)) + let enormous = String(repeating: "a", count: TranscriptContentBuffer.maximumBytes + 1) + blocks = TranscriptContentBuffer.append([.image(data: enormous, mimeType: "image/png", uri: nil)], to: blocks) + XCTAssertLessThanOrEqual(blocks.reduce(0) { $0 + TranscriptContentBuffer.byteCount($1) }, TranscriptContentBuffer.maximumBytes) + let links = (0..<1024).map { DesktopContentBlock.resourceLink(uri: "file:///\($0)", name: nil, mimeType: nil) } + XCTAssertLessThanOrEqual(TranscriptContentBuffer.append(links).count, TranscriptContentBuffer.maximumBlocks) + } + + func testLongSingleMessageAccumulationBenchmark() { + // A benchmark, not a wall-clock assertion or test-only work counter. + measure { + var blocks: [DesktopContentBlock] = [] + for _ in 0..<2048 { + blocks = TranscriptContentBuffer.append([.text(String(repeating: "x", count: 256))], to: blocks) + } + XCTAssertLessThanOrEqual(blocks.reduce(0) { $0 + TranscriptContentBuffer.byteCount($1) }, TranscriptContentBuffer.maximumTextBytes) + } + } + + @MainActor + func testRuntimeLossRejectsBufferedEventsAndHealthyHeartbeatDoesNotResurrectRoster() { + let controller = ConversationController(conversation: Conversation(workspaceID: UUID()), workspacePath: "/tmp") + controller.prepareRuntimeSession("current") + // Heartbeat is process-scoped and may precede attachment identity. + controller.applyRuntime(["event": "runlet_transport", "available": true]) + controller.applyRuntime(["event": "session_started", "session_id": "current"]) + let event: [String: Any] = ["event": "subagent_state_changed", "id": "child", "name": "Child", "status": "working", "generation": 1, "task": "task", "harness": "acp.kit", "created_at_unix_ms": 1, "generation_started_at_unix_ms": 2] + controller.applyRuntime(event) + XCTAssertEqual(controller.agentRoster.rowsByID.count, 1) + controller.expireRuntimeLease(at: ContinuousClock().now.advanced(by: .seconds(6))) + XCTAssertEqual(controller.runtimeTransportAvailable, false) + XCTAssertTrue(controller.agentRoster.rowsByID.isEmpty) + controller.applyRuntime(event) + XCTAssertTrue(controller.agentRoster.rowsByID.isEmpty) + XCTAssertGreaterThan(controller.transcriptProjection.items.last?.errorCount ?? 0, 0) + controller.applyRuntime(["event": "runlet_transport", "available": true]) + XCTAssertTrue(controller.agentRoster.rowsByID.isEmpty) + controller.applyRuntime(event) + XCTAssertEqual(controller.agentRoster.rowsByID.count, 1) + controller.prepareRuntimeSession("next") + XCTAssertNil(controller.runtimeTransportAvailable) + XCTAssertTrue(controller.agentRoster.rowsByID.isEmpty) + } +} diff --git a/macos/KitDesktopTests/TranscriptToolContentBufferTests.swift b/macos/KitDesktopTests/TranscriptToolContentBufferTests.swift new file mode 100644 index 00000000..7b8144a5 --- /dev/null +++ b/macos/KitDesktopTests/TranscriptToolContentBufferTests.swift @@ -0,0 +1,31 @@ +import XCTest +@testable import Kit + +final class TranscriptToolContentBufferTests: XCTestCase { + func testSmallChunksAndExistingSnapshotArePreserved() { + let value = TranscriptToolContentBuffer.append(.string("next"), to: .array([.string("first")])) + XCTAssertEqual(value, .array([.string("first"), .string("next")])) + } + + func testLongToolStreamIsBoundedAndMarksOmittedOutput() throws { + var value: JSONValue? + for index in 0..<1000 { + value = TranscriptToolContentBuffer.append(.string("\(index):" + String(repeating: "x", count: 4096)), to: value) + } + let retained = try XCTUnwrap(value) + XCTAssertLessThanOrEqual(try JSONEncoder().encode(retained).count, TranscriptToolContentBuffer.maximumBytes) + guard case .array(let chunks) = retained else { return XCTFail("Expected tool chunks") } + XCTAssertLessThanOrEqual(chunks.count, TranscriptToolContentBuffer.maximumChunks) + XCTAssertEqual(chunks.first?.objectValue?["_kitDesktopRetention"], .bool(true)) + XCTAssertTrue(chunks.last?.stringValue?.hasPrefix("999:") == true) + } + + func testOversizedNestedChunkRetainsAnExplicitPreview() throws { + let original = JSONValue.object(["output": .string(String(repeating: "x", count: 512 * 1024))]) + let value = TranscriptToolContentBuffer.append(original, to: nil) + XCTAssertLessThanOrEqual(try JSONEncoder().encode(value).count, TranscriptToolContentBuffer.maximumBytes) + guard case .array(let chunks) = value else { return XCTFail("Expected tool chunks") } + XCTAssertEqual(chunks.last?.objectValue?["truncated"], .bool(true)) + XCTAssertNotNil(chunks.last?.objectValue?["preview"]?.stringValue) + } +} diff --git a/macos/README.md b/macos/README.md index 9240c069..f6f304da 100644 --- a/macos/README.md +++ b/macos/README.md @@ -1,4 +1,4 @@ -# Kit Desktop for macOS (v0) +# Kit Desktop for macOS A dependency-free SwiftUI client for Kit's native ACP server. It targets macOS 14, disables App Sandbox so Kit and its tools can access workspaces, and launches one retained Kit helper process per opened conversation. Switching conversations never cancels their work. @@ -39,15 +39,20 @@ The app and helper are intentionally thin ARM64 binaries. Kit's current release - `AppModel` owns a controller dictionary keyed by conversation ID. Each controller and helper remain alive across sidebar/workspace navigation, so multiple conversations can run independently and update unread/awaiting-user state. - `ACPClient` launches `kit serve --stdio-protocol-version 2` with root and optional model defaults plus `KIT_RUNTIME_EVENTS=1`. It strictly negotiates ACP v2 and uses typed `session/new`, `session/resume` (with replay from start), cursor-based `session/list`, prompt acceptance, cancel, and close. - A serial transport queue owns newline framing, JSON decoding, per-session routing, pending requests, timeouts, ordered writes, stderr event parsing, and process shutdown. Replay updates are delivered before the response that completes resume. UI callbacks are delivered on the main actor. -- Streaming text is coalesced to about 30 updates per second. Transcript count, stream text, parser lines, diagnostics, and raw tool output are bounded. Markdown is parsed only after a stream completes. +- Streaming text is coalesced to about 30 updates per second. Transcript count, stream text, parser lines, diagnostics, and raw tool output are bounded. Incremental transcript projection updates only changed entries and new suffixes; completed markdown views skip parsing when their source has not changed. +- User prompts and assistant blocks remain visible. Adjacent thoughts, tools, plans, notices, and timing share a collapsed activity item between message blocks. Its live summary follows the current work; expand it to inspect retained events and nested tool details. Long tool streams mark omitted output explicitly at the desktop retention limit. Failures are marked even when the activity is collapsed. +- The sidebar groups conversations by project. The dedicated Projects page searches names, paths, and conversation titles, sorts by recent activity, and previews the latest three conversations (or matching titles during conversation search). Navigation retains running controllers and drafts. Projects can include additional directories, applied on the next session start/resume when the server advertises support. +- A combined model/effort popover searches provider groups and uses server-advertised effort choices. Configuration changes are disabled during active work or another configuration request. The usage popover separates root-session costs from accumulated child costs; the Projects page also exposes read-only provider account quota reports through `kit usage`. +- Context reports accept optional provider costs. Child cost snapshots accumulate by currency and remain included after children leave the live roster; unknown costs are not treated as zero. A diagnostic transport gap invalidates live roster observations without discarding reported spend. +- Direct subagents have a separate bounded transcript inspector and text steering, gated by generation-specific advertised capabilities. Closed children and unsupported descendants are read-only; child steering does not support attachments. Pending root-session messages can be revoked before delivery, or edited when replacement is advertised; edits preserve the current composer draft. - The UI supports typed select/boolean config values, context and completed-turn token usage, available-command discovery, copy-last-response, notices, compaction, diagnostics, nested runtime graph rows, foreground cancel, compose detach, and detached-call cancellation. Slash-prefixed text is submitted unchanged; the desktop does not implement TUI command parsing. -- Attachments match the TUI: PNG/JPEG/GIF/WebP and WAV/MP3 are base64 ACP image/audio blocks, limited to 8 files, 10 MiB each, and 20 MiB total. +- Attachments support PNG/JPEG/GIF/WebP and WAV/MP3 base64 ACP image/audio blocks, limited to 8 files, 10 MiB each, and 20 MiB total. Clipboard images show import feedback immediately. User and assistant media have bounded asynchronous thumbnails and explicit open actions; media payloads are not decoded in SwiftUI body evaluation. - `PersistenceStore` uses schema version 3, migrates the versionless v1 state, atomically saves on a utility queue, keeps a validated backup, quarantines corrupt primary files, and refuses unsupported newer schemas. State lives at `~/Library/Application Support/KitDesktop/state.json`; Kit remains the transcript source of truth. - The pinned schema is in `macos/ACP/Schema`; `scripts/generate-acp-swift.py` deterministically produces the dependency-free Codable wire subset without network access. `ACPClient.swift` is the small handwritten Kit transport/extension layer. - Process tests launch the shared ACP v2 `fixtures/mock-acp-v2.py`, cover strict negotiation, typed updates, rich streaming/media, prompt acceptance, slash-text preservation, cancel/close, and verify resume replay arrives before the resume response. CI also launches the real `target/debug/kit` lifecycle smoke coverage. ## Current limitations -- Conversation rename/delete/search and transcript export are not part of v0. +- Conversation rename/delete and transcript export are not yet available. Project search matches conversation titles, not transcript contents. - Unknown agent-to-client request methods receive JSON-RPC `Method not found`. Permission requests are safely cancelled because the desktop does not yet expose an interactive permission surface; filesystem, terminal, authentication, and elicitation client capabilities are not advertised. - Notifications require macOS permission and are posted for completed turns while the app is inactive. From 8e2d1d0d9ad4412cd0389ea5b80fa1f3df657a72 Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 5 Oct 2026 00:14:26 +0100 Subject: [PATCH 2/6] test(macos): align smoke deadlines and diagnose fixture loading --- macos/KitDesktopTests/ACPProcessTests.swift | 13 ++++++++----- macos/KitDesktopTests/ProjectNavigationTests.swift | 4 ++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/macos/KitDesktopTests/ACPProcessTests.swift b/macos/KitDesktopTests/ACPProcessTests.swift index 56954346..3becb3bd 100644 --- a/macos/KitDesktopTests/ACPProcessTests.swift +++ b/macos/KitDesktopTests/ACPProcessTests.swift @@ -450,7 +450,9 @@ final class ACPProcessTests: XCTestCase { provider: "openrouter", model: "openai/gpt-4o-mini", reasoningEffort: "default" ) - let created = ACPClient(launchOverride: launch, requestTimeout: 5, promptTimeout: 5) + // Exercise the real helper with the production initialization deadline. + // Debug helper startup is not a five-second performance assertion. + let created = ACPClient(launchOverride: launch, promptTimeout: 5) var createdSessionID: String? let ready = expectation(description: "real kit session/new") created.start(options: options, loading: false) { result in @@ -458,7 +460,8 @@ final class ACPProcessTests: XCTestCase { catch { XCTFail(error.localizedDescription) } ready.fulfill() } - wait(for: [ready], timeout: 10) + wait(for: [ready], timeout: 40) + let sessionID = try XCTUnwrap(createdSessionID) let prompted = expectation(description: "real kit session/prompt") created.prompt(text: "wait until cancelled", attachments: []) { _ in prompted.fulfill() } @@ -466,17 +469,17 @@ final class ACPProcessTests: XCTestCase { created.close(activeTurn: true) { closed.fulfill() } wait(for: [prompted, closed], timeout: 12) - let loaded = ACPClient(launchOverride: launch, requestTimeout: 5, promptTimeout: 5) + let loaded = ACPClient(launchOverride: launch, promptTimeout: 5) let resumed = expectation(description: "real kit session/resume") let resumedOptions = ACPLaunchOptions( - root: root.path, sessionID: try XCTUnwrap(createdSessionID), resume: true, + root: root.path, sessionID: sessionID, resume: true, provider: "openrouter", model: "openai/gpt-4o-mini", reasoningEffort: "default" ) loaded.start(options: resumedOptions, loading: true) { result in if case .failure(let error) = result { XCTFail(error.localizedDescription) } resumed.fulfill() } - wait(for: [resumed], timeout: 10) + wait(for: [resumed], timeout: 40) let loadClosed = expectation(description: "loaded session close") loaded.close(activeTurn: false) { loadClosed.fulfill() } wait(for: [loadClosed], timeout: 5) diff --git a/macos/KitDesktopTests/ProjectNavigationTests.swift b/macos/KitDesktopTests/ProjectNavigationTests.swift index bb26ad93..1f205a36 100644 --- a/macos/KitDesktopTests/ProjectNavigationTests.swift +++ b/macos/KitDesktopTests/ProjectNavigationTests.swift @@ -15,8 +15,10 @@ final class ProjectNavigationTests: XCTestCase { try store.save(PersistedAppState(workspaces: [newer, older], conversations: conversations)) let model = AppModel(store: store, catalogLoader: nil, requestNotificationAuthorization: false) + XCTAssertNil(model.persistenceError) let projects = model.projects XCTAssertEqual(projects.map(\.id), [older.id, newer.id]) + _ = try XCTUnwrap(projects.first) XCTAssertEqual(projects[0].recentConversations.map(\.title), ["Task 4", "Task 3", "Task 2"]) XCTAssertEqual(projects[0].conversations.count, 5) XCTAssertTrue(projects[1].recentConversations.isEmpty) @@ -35,6 +37,7 @@ final class ProjectNavigationTests: XCTestCase { let conversation = Conversation(workspaceID: workspace.id, unread: true, awaitingUser: true) try store.save(PersistedAppState(workspaces: [workspace], conversations: [conversation])) let model = AppModel(store: store, catalogLoader: nil, requestNotificationAuthorization: false) + XCTAssertNil(model.persistenceError) model.selectedConversationID = conversation.id model.showProjects() model.appBecameActive() @@ -66,6 +69,7 @@ final class ProjectNavigationTests: XCTestCase { ConversationController(conversation: conversation, workspacePath: path, client: ACPClient(launchOverride: launch, requestTimeout: 2, promptTimeout: 2)) }, requestNotificationAuthorization: false) + XCTAssertNil(model.persistenceError) model.selectConversation(conversation.id) XCTAssertEqual(model.selectedWorkspaceID, second.id) let controller = try XCTUnwrap(model.selectedController) From b706b57c1cba3200640ec0cf382397c45563994b Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 5 Oct 2026 00:23:53 +0100 Subject: [PATCH 3/6] fix(macos): preserve media loads and resume child transcript polling --- .../Models/FocusedSubagentController.swift | 7 +++ .../Views/TranscriptMediaView.swift | 20 ++++++--- .../FocusedSubagentTests.swift | 19 ++++++++ .../TranscriptMediaTests.swift | 44 +++++++++++++++---- 4 files changed, 75 insertions(+), 15 deletions(-) diff --git a/macos/KitDesktop/Models/FocusedSubagentController.swift b/macos/KitDesktop/Models/FocusedSubagentController.swift index e60759c0..ca5868f3 100644 --- a/macos/KitDesktop/Models/FocusedSubagentController.swift +++ b/macos/KitDesktop/Models/FocusedSubagentController.swift @@ -80,10 +80,17 @@ final class FocusedSubagentController: ObservableObject { } guard row.generation >= generation else { return } if row.generation != generation { focus(row, canSteer: canSteer); return } + let wasActive = active active = row.status == .working capability = canSteer self.canSteer = active && capability && readEnabled if !loading { refreshNotice() } + if active && !wasActive && readEnabled { + // A caught-up startup page is not the end of this generation. + poll?.cancel() + poll = nil + readNext() + } } func stop() { diff --git a/macos/KitDesktop/Views/TranscriptMediaView.swift b/macos/KitDesktop/Views/TranscriptMediaView.swift index 86246d33..2ae668e6 100644 --- a/macos/KitDesktop/Views/TranscriptMediaView.swift +++ b/macos/KitDesktop/Views/TranscriptMediaView.swift @@ -78,7 +78,7 @@ actor TranscriptMediaCache { ) async -> CGImage? { // Even a previously cached remote preview requires consent in this view. guard media.isImage, allowRemote || !media.requiresRemotePreviewConsent else { return nil } - // Payload equality happens only on this actor, never in SwiftUI's task/equality keys. + // Validate the source as retention can reuse the same owner/block key. if let cached = thumbnails[identity], cached.media == media { return cached.image } let bytes: Data? if media.data == nil, media.base64 == nil, @@ -145,16 +145,18 @@ struct TranscriptMediaIdentity: Hashable { var block: Int = 0 } -private struct TranscriptMediaLoadIdentity: Equatable { +/// Compare source values rather than the message-wide streaming revision. +/// Equatable avoids hashing/copying the inline payload on every body evaluation; +/// unchanged String/Data values retain their shared storage. +struct TranscriptMediaLoadIdentity: Equatable { let source: TranscriptMediaIdentity - let revision: UInt64 + let media: TranscriptMedia let allowRemote: Bool } struct TranscriptMediaView: View, Equatable { let media: TranscriptMedia let identity: TranscriptMediaIdentity - var revision: UInt64 = 0 @State private var thumbnail: CGImage? @State private var loaded = false @State private var opening = false @@ -166,7 +168,11 @@ struct TranscriptMediaView: View, Equatable { } static func == (lhs: Self, rhs: Self) -> Bool { - lhs.identity == rhs.identity && lhs.revision == rhs.revision + lhs.identity == rhs.identity && lhs.media == rhs.media + } + + var loadIdentity: TranscriptMediaLoadIdentity { + TranscriptMediaLoadIdentity(source: identity, media: media, allowRemote: allowRemote) } var body: some View { @@ -203,7 +209,7 @@ struct TranscriptMediaView: View, Equatable { .help("Contacts the remote server to load this image") } } - .task(id: TranscriptMediaLoadIdentity(source: identity, revision: revision, allowRemote: allowRemote)) { + .task(id: loadIdentity) { loaded = false let image = await TranscriptMediaCache.shared.thumbnail(media, identity: identity, allowRemote: allowRemote) guard !Task.isCancelled else { return } @@ -228,7 +234,7 @@ struct AssistantMediaView: View, Equatable { ForEach(Array(blocks.enumerated()), id: \.offset) { index, block in if let media = TranscriptMedia.derive(block) { TranscriptMediaView( - media: media, identity: TranscriptMediaIdentity(owner: entryID, block: index), revision: revision + media: media, identity: TranscriptMediaIdentity(owner: entryID, block: index) ).equatable() } } diff --git a/macos/KitDesktopTests/FocusedSubagentTests.swift b/macos/KitDesktopTests/FocusedSubagentTests.swift index 60a47f97..00e935e2 100644 --- a/macos/KitDesktopTests/FocusedSubagentTests.swift +++ b/macos/KitDesktopTests/FocusedSubagentTests.swift @@ -21,6 +21,25 @@ final class FocusedSubagentTests: XCTestCase { XCTAssertFalse(controller.canSteer) } + @MainActor + func testCaughtUpStartingChildResumesPollingWhenWorking() { + let transport = ChildTransport() + let controller = FocusedSubagentController(transport: transport) + controller.focus(row(status: .starting), canSteer: true) + transport.reads[0].completion(.success(SubagentTranscriptPage(updates: [], nextCursor: 0, generation: 1, caughtUp: true))) + XCTAssertFalse(controller.loading) + XCTAssertFalse(controller.canSteer) + controller.update(row(status: .working), canSteer: true) + XCTAssertEqual(transport.reads.count, 2) + controller.update(row(status: .working), canSteer: true) + XCTAssertEqual(transport.reads.count, 2) + transport.reads[1].completion(.success(page(generation: 1, cursor: 10, text: "Child output after startup"))) + XCTAssertEqual(controller.entries.first?.text, "Child output after startup") + XCTAssertEqual(controller.cursor, 10) + XCTAssertTrue(controller.canSteer) + controller.stop() + } + @MainActor func testSteeringCapabilityLifecycleAndStaleAcknowledgement() { let transport = ChildTransport() diff --git a/macos/KitDesktopTests/TranscriptMediaTests.swift b/macos/KitDesktopTests/TranscriptMediaTests.swift index c5043719..319e387e 100644 --- a/macos/KitDesktopTests/TranscriptMediaTests.swift +++ b/macos/KitDesktopTests/TranscriptMediaTests.swift @@ -34,14 +34,42 @@ final class TranscriptMediaTests: XCTestCase { XCTAssertFalse(inline.requiresRemotePreviewConsent) } - func testViewIdentityDoesNotComparePayloadsAndRevisionInvalidates() { - let id = TranscriptMediaIdentity(owner: UUID()) - let first = TranscriptMediaView(media: TranscriptMedia(mimeType: "image/png", data: Data([1])), identity: id) - let second = TranscriptMediaView(media: TranscriptMedia(mimeType: "image/png", data: Data([2])), identity: id) - XCTAssertEqual(first, second) - let changed = TranscriptMediaView(media: second.media, identity: id, revision: 1) - XCTAssertNotEqual(first, changed) - XCTAssertNotEqual(id, TranscriptMediaIdentity(owner: id.owner, block: 1)) + func testLoadIdentitySurvivesStreamingTextButInvalidatesReplacedMedia() throws { + let image = DesktopContentBlock.image(data: nil, mimeType: "image/png", uri: "https://example.com/first.png") + var entry = TranscriptEntry(role: .assistant, text: "Hello", isStreaming: true, + contentBlocks: [image, .text("Hello")]) + let identity = TranscriptMediaIdentity(owner: entry.id, block: 0) + let first = TranscriptMediaView(media: try XCTUnwrap(TranscriptMedia.derive(entry.contentBlocks[0])), identity: identity) + let revision = entry.contentBlocksRevision + entry.contentBlocks[1] = .text("Hello world") + XCTAssertNotEqual(entry.contentBlocksRevision, revision) + let streamed = TranscriptMediaView(media: try XCTUnwrap(TranscriptMedia.derive(entry.contentBlocks[0])), identity: identity) + XCTAssertEqual(first, streamed) + XCTAssertEqual(first.loadIdentity, streamed.loadIdentity) + + // Replacing a block (including retention reusing its index) must reload. + entry.contentBlocks = [.image(data: nil, mimeType: "image/png", uri: "https://example.com/second.png")] + let replaced = TranscriptMediaView(media: try XCTUnwrap(TranscriptMedia.derive(entry.contentBlocks[0])), identity: identity) + XCTAssertNotEqual(first, replaced) + XCTAssertNotEqual(first.loadIdentity, replaced.loadIdentity) + } + + func testLoadIdentityTracksInlinePayloadAndRemoteConsent() { + let identity = TranscriptMediaIdentity(owner: UUID()) + let first = TranscriptMediaView(media: TranscriptMedia(mimeType: "image/png", data: Data([1])), identity: identity) + let replaced = TranscriptMediaView(media: TranscriptMedia(mimeType: "image/png", data: Data([2])), identity: identity) + XCTAssertNotEqual(first, replaced) + XCTAssertNotEqual(first.loadIdentity, replaced.loadIdentity) + let inline = TranscriptMediaView(media: TranscriptMedia(mimeType: "image/png", base64: "AQ=="), identity: identity) + let replacedInline = TranscriptMediaView(media: TranscriptMedia(mimeType: "image/png", base64: "Ag=="), identity: identity) + XCTAssertNotEqual(inline.loadIdentity, replacedInline.loadIdentity) + XCTAssertNotEqual(identity, TranscriptMediaIdentity(owner: identity.owner, block: 1)) + + let remote = TranscriptMedia(mimeType: "image/png", url: URL(string: "https://example.com/image.png")) + XCTAssertNotEqual( + TranscriptMediaLoadIdentity(source: identity, media: remote, allowRemote: false), + TranscriptMediaLoadIdentity(source: identity, media: remote, allowRemote: true) + ) } func testClipboardImportAndThumbnailDownsampling() async throws { From a9ac42b06ca99e7fbb4bc81f6b83f840fa0e669a Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 5 Oct 2026 00:55:22 +0100 Subject: [PATCH 4/6] fix(macos): show standalone turn duration without disclosure --- macos/KitDesktop/Models/TranscriptProjection.swift | 4 ++++ macos/KitDesktop/Views/ContentView.swift | 4 ++-- .../KitDesktopTests/TranscriptProjectionTests.swift | 13 +++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/macos/KitDesktop/Models/TranscriptProjection.swift b/macos/KitDesktop/Models/TranscriptProjection.swift index 6e398781..3fc0a5aa 100644 --- a/macos/KitDesktop/Models/TranscriptProjection.swift +++ b/macos/KitDesktop/Models/TranscriptProjection.swift @@ -13,6 +13,10 @@ final class TranscriptItem: ObservableObject, Identifiable { private(set) var summary = "Activity" private var indices: [UUID: Int] = [:] + var isExpandableActivity: Bool { + isActivity && !(entries.count == 1 && entries[0].role == .duration) + } + init(_ entry: TranscriptEntry) { id = entry.id isActivity = entry.role != .user && entry.role != .assistant diff --git a/macos/KitDesktop/Views/ContentView.swift b/macos/KitDesktop/Views/ContentView.swift index 9c3f61bf..3c938d48 100644 --- a/macos/KitDesktop/Views/ContentView.swift +++ b/macos/KitDesktop/Views/ContentView.swift @@ -1166,7 +1166,7 @@ private struct TranscriptItemView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { - if item.isActivity { + if item.isExpandableActivity { Button { expanded.toggle() } label: { HStack(spacing: 8) { if item.runningCount > 0 { ProgressView().controlSize(.small) } @@ -1186,7 +1186,7 @@ private struct TranscriptItemView: View { .accessibilityHint("Show or hide thoughts, tool output, plans, and status") } // Do not build markdown, media, tool JSON, or nested compose views while hidden. - if !item.isActivity || expanded { + if !item.isExpandableActivity || expanded { ForEach(item.entries) { entry in TranscriptRow( entry: entry, diff --git a/macos/KitDesktopTests/TranscriptProjectionTests.swift b/macos/KitDesktopTests/TranscriptProjectionTests.swift index 57cc1af4..cac5cd11 100644 --- a/macos/KitDesktopTests/TranscriptProjectionTests.swift +++ b/macos/KitDesktopTests/TranscriptProjectionTests.swift @@ -14,6 +14,19 @@ final class TranscriptProjectionTests: XCTestCase { XCTAssertEqual(projection.items[1].entries.map(\.role), Array(roles[1...7])) } + @MainActor + func testDurationOnlyItemIsNotExpandable() { + let projection = TranscriptProjection() + let answer = TranscriptEntry(role: .assistant, text: "Done") + let duration = TranscriptEntry(role: .duration, text: "took 3 s") + projection.synchronize([answer, duration]) + XCTAssertFalse(projection.items[0].isExpandableActivity) + XCTAssertFalse(projection.items[1].isExpandableActivity) + XCTAssertEqual(projection.items[1].entries.map(\.id), [duration.id]) + projection.synchronize([answer, duration, TranscriptEntry(role: .status, text: "Notice")]) + XCTAssertTrue(projection.items[1].isExpandableActivity) + } + @MainActor func testStreamingAndNonTailToolUpdatesKeepDisplayIdentity() { var entries = [TranscriptEntry(role: .assistant, text: "completed"), TranscriptEntry(role: .tool, text: "pending", isStreaming: true)] From 47c7829cfd5c0b2a33ff526525cbc54b99e80bdc Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 5 Oct 2026 01:05:59 +0100 Subject: [PATCH 5/6] fix(macos): unblock child-exit signals in desktop helpers --- fixtures/mock-openrouter-shell.py | 51 +++++++ macos/KitDesktop.xcodeproj/project.pbxproj | 4 + macos/KitDesktop/Services/ACPClient.swift | 9 +- .../RealShellLifecycleTests.swift | 127 ++++++++++++++++++ macos/README.md | 2 +- 5 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 fixtures/mock-openrouter-shell.py create mode 100644 macos/KitDesktopTests/RealShellLifecycleTests.swift diff --git a/fixtures/mock-openrouter-shell.py b/fixtures/mock-openrouter-shell.py new file mode 100644 index 00000000..0e8033bb --- /dev/null +++ b/fixtures/mock-openrouter-shell.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Local provider boundary for the desktop's real ACP/compose/shell regression. + +Prints one ephemeral loopback port, then serves OpenRouter-compatible SSE. +No model service, credentials, or shell execution occurs in this fixture. +""" +import json +from http.server import BaseHTTPRequestHandler, HTTPServer + + +class Provider(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): + self.respond(json.dumps({"data": []}).encode(), "application/json") + + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + results = [m for m in body["messages"] if m.get("role") == "tool"] + if results: + # Do not claim success merely because a tool returned an error. + result = json.loads(results[-1]["content"]) + success = result.get("success") is True and result.get("exit_code") == 0 + delta = {"role": "assistant", "content": "REAL_SHELL_COMPLETE" if success else "REAL_SHELL_FAILED"} + reason = "stop" + else: + name = next(t["function"]["name"] for t in body["tools"] + if t["function"]["name"].split(".")[-1] == "compose") + delta = {"role": "assistant", "tool_calls": [{ + "index": 0, "id": "real-shell-pwd", "type": "function", + "function": {"name": name, "arguments": json.dumps({ + "script": 'return shell({command: "pwd", timeout_seconds: 3})' + })} + }]} + reason = "tool_calls" + chunk = {"id": "local-shell", "choices": [{"index": 0, "delta": delta, "finish_reason": reason}]} + self.respond(("data: " + json.dumps(chunk) + "\n\ndata: [DONE]\n\n").encode(), "text/event-stream") + + def respond(self, body, content_type): + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +if __name__ == "__main__": + with HTTPServer(("127.0.0.1", 0), Provider) as server: + print(server.server_port, flush=True) + server.serve_forever() diff --git a/macos/KitDesktop.xcodeproj/project.pbxproj b/macos/KitDesktop.xcodeproj/project.pbxproj index 18aed9bf..a6220a22 100644 --- a/macos/KitDesktop.xcodeproj/project.pbxproj +++ b/macos/KitDesktop.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 0BAAD3A1EE604A1F51594045 /* ClipboardMediaImport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68DA54322BAA752E5971377 /* ClipboardMediaImport.swift */; }; 0C164E0784F734A939122168 /* TranscriptMediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69962C8E0E1F29C59C77EFF8 /* TranscriptMediaView.swift */; }; 0C2B7BAD602B7292DE5AD4FA /* TranscriptMediaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FD67F2CC8C0A869D5092324 /* TranscriptMediaTests.swift */; }; + 0CC2B9555CF2287B30B38BB6 /* RealShellLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EC4964D4244F191B0B2EE29 /* RealShellLifecycleTests.swift */; }; 0F21F855B7DA35AD722AC7D3 /* ACPProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A49E707898C1B36BB27BC121 /* ACPProtocolTests.swift */; }; 19B3A6B5BBEE1564C7F4AC0B /* PersistenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06F974BED9068ADA02CD72BC /* PersistenceStore.swift */; }; 1FD039E35430596E77258CB6 /* ACPMessageIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D76BE5F4BB5528635B399D47 /* ACPMessageIdentityTests.swift */; }; @@ -83,6 +84,7 @@ 3D6D636272C479A6AED6F606 /* TranscriptToolContentBufferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptToolContentBufferTests.swift; sourceTree = ""; }; 5358E0179C1BBBBF93CE24B1 /* ProjectSearchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectSearchTests.swift; sourceTree = ""; }; 5E2EABACCDFBFA37A3DB1077 /* ACPKitExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ACPKitExtensions.swift; sourceTree = ""; }; + 5EC4964D4244F191B0B2EE29 /* RealShellLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealShellLifecycleTests.swift; sourceTree = ""; }; 6086BB4FC00B47A7CFEDE75F /* ACPWireModels.generated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ACPWireModels.generated.swift; sourceTree = ""; }; 612469AB29D1589621F28D29 /* KitDesktopApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KitDesktopApp.swift; sourceTree = ""; }; 69962C8E0E1F29C59C77EFF8 /* TranscriptMediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptMediaView.swift; sourceTree = ""; }; @@ -218,6 +220,7 @@ 7D4716671696C4413ABDF64A /* ProjectNavigationTests.swift */, 5358E0179C1BBBBF93CE24B1 /* ProjectSearchTests.swift */, F17B3DDBC123331A4DE01B74 /* ProviderUsageServiceTests.swift */, + 5EC4964D4244F191B0B2EE29 /* RealShellLifecycleTests.swift */, 7FD67F2CC8C0A869D5092324 /* TranscriptMediaTests.swift */, E13436ED25775E87B54A271A /* TranscriptProjectionRegressionTests.swift */, E0A68433712740EB10D75548 /* TranscriptProjectionTests.swift */, @@ -390,6 +393,7 @@ AC440F6119FF33B7AF2D9C6F /* ProjectNavigationTests.swift in Sources */, 46C3D9B3F883CDF2F661596B /* ProjectSearchTests.swift in Sources */, 78995CAA5E6507FDE2F19342 /* ProviderUsageServiceTests.swift in Sources */, + 0CC2B9555CF2287B30B38BB6 /* RealShellLifecycleTests.swift in Sources */, 0C2B7BAD602B7292DE5AD4FA /* TranscriptMediaTests.swift in Sources */, 1FE1E96487F1DC33B4FB0952 /* TranscriptProjectionRegressionTests.swift in Sources */, C3110D60CAC696DCADB183A6 /* TranscriptProjectionTests.swift in Sources */, diff --git a/macos/KitDesktop/Services/ACPClient.swift b/macos/KitDesktop/Services/ACPClient.swift index 7d09fdda..836c58e0 100644 --- a/macos/KitDesktop/Services/ACPClient.swift +++ b/macos/KitDesktop/Services/ACPClient.swift @@ -792,8 +792,13 @@ final class ACPClient { let chdirResult = workingDirectory.withCString { directory in posix_spawn_file_actions_addchdir_np(&actions, directory) } - guard chdirResult == 0, - posix_spawnattr_setflags(&attributes, Int16(POSIX_SPAWN_SETPGROUP)) == 0, + // Dispatch worker threads block SIGCHLD. Do not pass that mask to Kit: + // Tokio needs child-exit signals to complete shell subprocess waits. + var signalMask = sigset_t() + guard sigemptyset(&signalMask) == 0, + chdirResult == 0, + posix_spawnattr_setsigmask(&attributes, &signalMask) == 0, + posix_spawnattr_setflags(&attributes, Int16(POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_SETSIGMASK)) == 0, posix_spawnattr_setpgroup(&attributes, 0) == 0 else { throw ACPClientError.process("Unable to configure process group") } diff --git a/macos/KitDesktopTests/RealShellLifecycleTests.swift b/macos/KitDesktopTests/RealShellLifecycleTests.swift new file mode 100644 index 00000000..d80322cf --- /dev/null +++ b/macos/KitDesktopTests/RealShellLifecycleTests.swift @@ -0,0 +1,127 @@ +import Foundation +import XCTest +@testable import Kit + +/// Only the provider is fake: desktop spawn, ACP v2, compose and ShellTool are real. +final class RealShellLifecycleTests: XCTestCase { + func testDesktopLaunchExecutesRealComposeShell() throws { + let repository = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + let binary = repository.appendingPathComponent("target/debug/kit") + guard FileManager.default.isExecutableFile(atPath: binary.path) else { + throw XCTSkip("Build target/debug/kit before running the real-shell lifecycle regression") + } + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let config = directory.appendingPathComponent(".kit") + try FileManager.default.createDirectory(at: config, withIntermediateDirectories: true) + try Data("credential_store = \"memory\"\n".utf8).write(to: config.appendingPathComponent("config.toml")) + + let server = Process() + server.executableURL = URL(fileURLWithPath: "/usr/bin/python3") + server.arguments = [repository.appendingPathComponent("fixtures/mock-openrouter-shell.py").path] + server.environment = ["HOME": directory.path, "PATH": "/usr/bin:/bin"] + server.standardInput = FileHandle.nullDevice + server.standardError = FileHandle.nullDevice + let portPipe = Pipe() + server.standardOutput = portPipe + let listening = expectation(description: "local provider listening") + var portData = Data() + var port: Int? + portPipe.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + DispatchQueue.main.async { + guard port == nil else { return } + portData.append(data) + if portData.contains(10) { + port = Int(String(decoding: portData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)) + listening.fulfill() + } + } + } + defer { + portPipe.fileHandleForReading.readabilityHandler = nil + if server.isRunning { server.terminate(); server.waitUntilExit() } + try? portPipe.fileHandleForReading.close() + } + try server.run() + wait(for: [listening], timeout: 5) + let providerPort = try XCTUnwrap(port) + + // env execs the real helper in place; ACPClient still owns the ordinary + // desktop spawn/process group/pipes. Clear inherited provider/MCP settings. + let launch = ACPClient.LaunchOverride( + executable: URL(fileURLWithPath: "/usr/bin/env"), + prefixArguments: ["-i", "HOME=\(directory.path)", "PATH=/usr/bin:/bin", + "OPENROUTER_API_KEY=local-test-key", + "OPENROUTER_BASE_URL=http://127.0.0.1:\(providerPort)/stream", binary.path] + ) + let client = ACPClient(launchOverride: launch, requestTimeout: 15, promptTimeout: 20) + defer { + let closed = expectation(description: "real helper closed") + client.close(activeTurn: true) { closed.fulfill() } + wait(for: [closed], timeout: 8) + } + var updates: [DesktopUpdate] = [] + var finalText = "" + var receivedFinal = false + let finished = expectation(description: "real shell final answer") + client.onUpdate = { update in + updates.append(update) + if case .agentMessage(let message) = update { + let text = message.content.compactMap { block -> String? in + if case .text(let value) = block { return value } + return nil + }.joined() + finalText = message.replace ? text : finalText + text + if !receivedFinal && (finalText == "REAL_SHELL_COMPLETE" || finalText == "REAL_SHELL_FAILED") { + receivedFinal = true + finished.fulfill() + } + } + } + let ready = expectation(description: "real ACP v2 session ready") + var started = false + client.start(options: ACPLaunchOptions(root: directory.path, sessionID: UUID().uuidString, + resume: false, provider: "openrouter", model: "test/model", + reasoningEffort: "default"), loading: false) { result in + switch result { + case .success: started = true + case .failure(let error): XCTFail(error.localizedDescription) + } + ready.fulfill() + } + wait(for: [ready], timeout: 20) + XCTAssertTrue(started) + guard started else { return } + let completed = expectation(description: "real shell prompt completed") + client.prompt(text: "Run pwd using compose shell", attachments: []) { result in + if case .failure(let error) = result { XCTFail(error.localizedDescription) } + completed.fulfill() + } + wait(for: [completed, finished], timeout: 25) + + let outputs = updates.compactMap { update -> JSONValue? in + switch update { + case .toolCall(let tool), .toolCallUpdate(let tool): return tool.rawOutput + default: return nil + } + } + let shell = try XCTUnwrap(outputs.first { $0.objectValue?["success"] == .bool(true) }?.objectValue, + "Tool output: \(outputs)") + XCTAssertEqual(shell["exit_code"], .integer(0)) + XCTAssertEqual(shell["stderr"], .string("")) + let outputPath = try XCTUnwrap(shell["stdout"]?.stringValue).trimmingCharacters(in: .whitespacesAndNewlines) + XCTAssertEqual(URL(fileURLWithPath: outputPath).resolvingSymlinksInPath(), directory.resolvingSymlinksInPath()) + let answer = updates.flatMap { update -> [DesktopContentBlock] in + guard case .agentMessage(let message) = update else { return [] } + return message.content + }.compactMap { block -> String? in + guard case .text(let text) = block else { return nil } + return text + }.joined() + XCTAssertEqual(answer, "REAL_SHELL_COMPLETE") + } +} diff --git a/macos/README.md b/macos/README.md index f6f304da..997834c8 100644 --- a/macos/README.md +++ b/macos/README.md @@ -38,7 +38,7 @@ The app and helper are intentionally thin ARM64 binaries. Kit's current release - `AppModel` owns a controller dictionary keyed by conversation ID. Each controller and helper remain alive across sidebar/workspace navigation, so multiple conversations can run independently and update unread/awaiting-user state. - `ACPClient` launches `kit serve --stdio-protocol-version 2` with root and optional model defaults plus `KIT_RUNTIME_EVENTS=1`. It strictly negotiates ACP v2 and uses typed `session/new`, `session/resume` (with replay from start), cursor-based `session/list`, prompt acceptance, cancel, and close. -- A serial transport queue owns newline framing, JSON decoding, per-session routing, pending requests, timeouts, ordered writes, stderr event parsing, and process shutdown. Replay updates are delivered before the response that completes resume. UI callbacks are delivered on the main actor. +- A serial transport queue owns newline framing, JSON decoding, per-session routing, pending requests, timeouts, ordered writes, stderr event parsing, and process shutdown. Helper spawning clears the dispatch thread's inherited signal mask so subprocess-exit notifications remain available to Kit. Replay updates are delivered before the response that completes resume. UI callbacks are delivered on the main actor. - Streaming text is coalesced to about 30 updates per second. Transcript count, stream text, parser lines, diagnostics, and raw tool output are bounded. Incremental transcript projection updates only changed entries and new suffixes; completed markdown views skip parsing when their source has not changed. - User prompts and assistant blocks remain visible. Adjacent thoughts, tools, plans, notices, and timing share a collapsed activity item between message blocks. Its live summary follows the current work; expand it to inspect retained events and nested tool details. Long tool streams mark omitted output explicitly at the desktop retention limit. Failures are marked even when the activity is collapsed. - The sidebar groups conversations by project. The dedicated Projects page searches names, paths, and conversation titles, sorts by recent activity, and previews the latest three conversations (or matching titles during conversation search). Navigation retains running controllers and drafts. Projects can include additional directories, applied on the next session start/resume when the server advertises support. From 476c854962aefebda2130aef01a2060e54add384 Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 5 Oct 2026 10:44:23 +0100 Subject: [PATCH 6/6] fix(macos): keep model selection layout stable during updates --- macos/KitDesktop/Views/ContentView.swift | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/macos/KitDesktop/Views/ContentView.swift b/macos/KitDesktop/Views/ContentView.swift index 3c938d48..95f0d033 100644 --- a/macos/KitDesktop/Views/ContentView.swift +++ b/macos/KitDesktop/Views/ContentView.swift @@ -833,9 +833,10 @@ private struct ModelEffortSelector: View { private var effort: ConfigOption? { controller.configOptions.first { $0.isReasoningEffort && $0.valueType == "select" && !$0.choices.isEmpty } } - private var disabled: Bool { - !controller.isReady || controller.isLocked || controller.isRunning || controller.isUpdatingConfig + private var settingsUnavailable: Bool { + !controller.isReady || controller.isLocked || controller.isRunning } + private var disabled: Bool { settingsUnavailable || controller.isUpdatingConfig } var body: some View { if let model { @@ -844,8 +845,7 @@ private struct ModelEffortSelector: View { Image(systemName: "cpu") Text(selectedName(model)).lineLimit(1) if let effort { Text(selectedName(effort)).foregroundStyle(.secondary).lineLimit(1) } - if controller.isUpdatingConfig { ProgressView().controlSize(.mini) } - else { Image(systemName: "chevron.down").font(.system(size: 8, weight: .semibold)) } + Image(systemName: "chevron.down").font(.system(size: 8, weight: .semibold)) } .font(.caption).padding(.horizontal, 8).padding(.vertical, 5) .background(Brand.paper, in: RoundedRectangle(cornerRadius: Brand.Radius.small)) @@ -862,7 +862,6 @@ private struct ModelEffortSelector: View { Image(systemName: "cpu").foregroundStyle(Brand.moss) Text("Model & effort").font(.headline) Spacer() - if controller.isUpdatingConfig { ProgressView().controlSize(.small) } }.padding(16) HStack(spacing: 8) { Image(systemName: "magnifyingglass").foregroundStyle(.secondary) @@ -896,7 +895,8 @@ private struct ModelEffortSelector: View { in: RoundedRectangle(cornerRadius: Brand.Radius.small)) .contentShape(Rectangle()) } - .buttonStyle(.plain).pointingHandCursor().disabled(disabled) + .buttonStyle(.plain).pointingHandCursor().disabled(settingsUnavailable) + .allowsHitTesting(!controller.isUpdatingConfig) .accessibilityAddTraits(choice.value == model.currentValue ? .isSelected : []) .help(choice.value) } @@ -913,18 +913,18 @@ private struct ModelEffortSelector: View { if (2...4).contains(effort.choices.count) && effort.choices.reduce(0, { $0 + $1.name.count }) <= 36 { Picker(effort.name, selection: binding(effort)) { ForEach(effort.choices) { Text($0.name).tag($0.value) } - }.pickerStyle(.segmented).labelsHidden().disabled(disabled) + }.pickerStyle(.segmented).labelsHidden().disabled(settingsUnavailable) + .allowsHitTesting(!controller.isUpdatingConfig) } else { Picker(effort.name, selection: binding(effort)) { ForEach(effort.choices) { Text($0.name).tag($0.value) } - }.labelsHidden().disabled(disabled) + }.labelsHidden().disabled(settingsUnavailable) + .allowsHitTesting(!controller.isUpdatingConfig) } } else { Text("This model does not advertise an effort control.").font(.caption).foregroundStyle(.secondary) } - if controller.isUpdatingConfig { - Text("Applying selection…").font(.caption).foregroundStyle(.secondary) - } else if controller.isRunning { + if controller.isRunning { Text("Model settings can be changed when this turn finishes.").font(.caption).foregroundStyle(.secondary) } else if !controller.isReady || controller.isLocked { Text("Connect to this thread to change model settings.").font(.caption).foregroundStyle(.secondary)