diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e196c49..cff30e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,10 +13,7 @@ concurrency: cancel-in-progress: true env: - AISUITE_REVISION: ff130d741ec50ed7dba1890f7981bc62f26f15db - SNODEC_REVISION: bc43179dbee2b5a0286420a61d8f1ceaef01530d - CMAKE_BUILD_PARALLEL_LEVEL: 2 - CTEST_PARALLEL_LEVEL: 2 + CMAKE_BUILD_PARALLEL_LEVEL: 8 LD_LIBRARY_PATH: ${{ github.workspace }}/_stage/aisuite/lib:${{ github.workspace }}/_stage/snodec/lib LDFLAGS: -Wl,-rpath-link,${{ github.workspace }}/_stage/aisuite/lib -Wl,-rpath-link,${{ github.workspace }}/_stage/snodec/lib @@ -35,18 +32,18 @@ jobs: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - - name: Check out accepted SNode.C 2.0.0 + - name: Check out SNode.C master uses: actions/checkout@v5 with: repository: SNodeC/snode.c - ref: ${{ env.SNODEC_REVISION }} + ref: master path: _deps/snodec - - name: Check out required AISuite 0.6.0 + - name: Check out canonical AISuite Codex uses: actions/checkout@v5 with: repository: SNodeC/AISuite - ref: ${{ env.AISUITE_REVISION }} + ref: master path: _deps/aisuite - name: Install build dependencies @@ -65,7 +62,7 @@ jobs: git config --global --add safe.directory "$GITHUB_WORKSPACE/_deps/snodec" git config --global --add safe.directory "$GITHUB_WORKSPACE/_deps/aisuite" - - name: Verify exact source revisions + - name: Verify CodexUI source revision shell: bash env: CODEXUI_EXPECTED_HEAD: >- @@ -74,8 +71,6 @@ jobs: run: | set -euo pipefail test "$(git rev-parse HEAD)" = "$CODEXUI_EXPECTED_HEAD" - test "$(git -C _deps/snodec rev-parse HEAD)" = "$SNODEC_REVISION" - test "$(git -C _deps/aisuite rev-parse HEAD)" = "$AISUITE_REVISION" - name: Build and install SNode.C run: | @@ -87,38 +82,26 @@ jobs: cmake --build _build/snodec --target all cmake --install _build/snodec - - name: Build and install AISuite 0.6.0 + - name: Build and install canonical AISuite Codex run: | cmake -S _deps/aisuite -B _build/aisuite -G Ninja \ -DCMAKE_BUILD_TYPE=Debug \ -DCMAKE_INSTALL_PREFIX="$GITHUB_WORKSPACE/_stage/aisuite" \ -DCMAKE_PREFIX_PATH="$GITHUB_WORKSPACE/_stage/snodec" \ - -DAISUITE_BUILD_TESTS=OFF \ - -DAISUITE_BUILD_APPS=OFF \ - -DAISUITE_BUILD_CODEX_FRONTEND_CLIENT=ON \ - -DAISUITE_ENABLE_CODEX_FRONTEND_TLS=OFF \ - -DAISUITE_ENABLE_CODEX_FRONTEND_WEBSOCKET=OFF \ - -DAISUITE_ENABLE_CODEX_FRONTEND_RFCOMM=OFF - cmake --build _build/aisuite --target all + -DAISUITE_BUILD_CODEX_TESTS=OFF \ + -DAISUITE_BUILD_APPS=ON + cmake --build _build/aisuite --target all --parallel 2 cmake --install _build/aisuite - grep -F 'set(PACKAGE_VERSION "0.6.0")' \ - _stage/aisuite/lib/cmake/AISuite/AISuiteConfigVersion.cmake - name: Configure CodexUI run: | cmake -S . -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Debug \ - -DCMAKE_PREFIX_PATH="$GITHUB_WORKSPACE/_stage/aisuite;$GITHUB_WORKSPACE/_stage/snodec" \ - -DBUILD_TESTING=ON + -DCMAKE_PREFIX_PATH="$GITHUB_WORKSPACE/_stage/aisuite;$GITHUB_WORKSPACE/_stage/snodec" - name: Build CodexUI run: cmake --build build --target all - - name: Run CodexUI tests headlessly - env: - QT_QPA_PLATFORM: offscreen - run: ctest --test-dir build --output-on-failure - - name: Check changed lines shell: bash env: diff --git a/CMakeLists.txt b/CMakeLists.txt index 655970e..a9a08fb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,206 +4,353 @@ cmake_minimum_required(VERSION 3.20) project(CodexUI LANGUAGES CXX) -include(CTest) include(GNUInstallDirs) +include(CTest) set(CMAKE_AUTOMOC ON) -find_package(AISuite 0.6.0 CONFIG REQUIRED) -find_package(Qt6 REQUIRED COMPONENTS Concurrent Network Widgets) +find_package(AISuite CONFIG REQUIRED) +find_package( + snodec 2.0 CONFIG REQUIRED + COMPONENTS net-un-stream-legacy net-in-stream-legacy net-in6-stream-legacy +) +find_package( + snodec 2.0 CONFIG QUIET + COMPONENTS net-in-stream-tls net-in6-stream-tls +) +find_package( + snodec 2.0 CONFIG QUIET + COMPONENTS net-rc-stream-legacy net-rc-stream-tls +) +find_package(Qt6 REQUIRED COMPONENTS Widgets) +find_package(Threads REQUIRED) + +set( + CODEXUI_CODEX_COMMON_SOURCES + src/codex/ClientRuntime.cpp + src/codex/ClientRuntime.h + src/codex/Configuration.cpp + src/codex/Configuration.h + src/codex/ConnectionDialog.cpp + src/codex/ConnectionDialog.h + src/codex/DiffViewer.cpp + src/codex/DiffViewer.h + src/codex/FrontendSession.cpp + src/codex/FrontendSession.h + src/codex/FileSelectionDialog.cpp + src/codex/FileSelectionDialog.h + src/codex/MainWindow.cpp + src/codex/MainWindow.h + src/codex/NewThreadDialog.cpp + src/codex/NewThreadDialog.h + src/codex/PresentationModel.cpp + src/codex/PresentationModel.h + src/codex/PresentationProtocol.cpp + src/codex/PresentationProtocol.h + src/codex/ProtocolNormalizer.cpp + src/codex/ProtocolNormalizer.h + src/codex/ipc/QtSocketPairEndpoint.cpp + src/codex/ipc/QtSocketPairEndpoint.h + src/codex/ipc/SNodeSocketPairEndpoint.cpp + src/codex/ipc/SNodeSocketPairEndpoint.h + src/codex/ipc/SocketPair.cpp + src/codex/ipc/SocketPair.h + src/codex/main.cpp + src/codex/ui/ExpandingPromptEditor.cpp + src/codex/ui/ExpandingPromptEditor.h + src/codex/ui/BrandMark.cpp + src/codex/ui/BrandMark.h + src/codex/ui/UiStyle.cpp + src/codex/ui/UiStyle.h +) + +set( + CODEXUI_GREENFIELD_MIDDLE_SOURCES + src/greenfield/codex/ShellWidget.cpp + src/greenfield/codex/ShellWidget.h + src/greenfield/codex/middle/ComposerPane.cpp + src/greenfield/codex/middle/ComposerPane.h + src/greenfield/codex/middle/ConversationCards.cpp + src/greenfield/codex/middle/ConversationCards.h + src/greenfield/codex/middle/ConversationProjection.cpp + src/greenfield/codex/middle/ConversationProjection.h + src/greenfield/codex/middle/ConversationView.cpp + src/greenfield/codex/middle/ConversationView.h + src/greenfield/codex/middle/InspectorPane.cpp + src/greenfield/codex/middle/InspectorPane.h + src/greenfield/codex/middle/MiddleRegionWidget.cpp + src/greenfield/codex/middle/MiddleRegionWidget.h + src/greenfield/codex/middle/MiddleTypes.cpp + src/greenfield/codex/middle/MiddleTypes.h + src/greenfield/codex/middle/PromptCoordinator.cpp + src/greenfield/codex/middle/PromptCoordinator.h + src/greenfield/codex/middle/ThreadPane.cpp + src/greenfield/codex/middle/ThreadPane.h +) qt_add_executable( codex-ui - src/app/Application.cpp - src/app/Application.h - src/app/AttachmentManager.cpp - src/app/AttachmentManager.h - src/app/FrontendSession.cpp - src/app/FrontendSession.h - src/app/FrontendSessionWorker.cpp - src/app/FrontendSessionWorker.h - src/main.cpp - src/ui/AnchoredTurnSurface.cpp - src/ui/AnchoredTurnSurface.h - src/ui/ConversationWidget.cpp - src/ui/ConversationWidget.h - src/ui/ExpandingPromptEditor.cpp - src/ui/ExpandingPromptEditor.h - src/ui/InspectorWidget.cpp - src/ui/InspectorWidget.h - src/ui/InteractiveRequestDialog.cpp - src/ui/InteractiveRequestDialog.h - src/ui/MainWindow.cpp - src/ui/MainWindow.h - src/ui/PresentationRefreshAccumulator.cpp - src/ui/PresentationRefreshAccumulator.h - src/ui/SidebarWidget.cpp - src/ui/SidebarWidget.h - src/ui/ThreadSetupDialog.cpp - src/ui/ThreadSetupDialog.h - src/ui/UpcomingTurnDock.cpp - src/ui/UpcomingTurnDock.h - src/ui/UiStyle.cpp - src/ui/UiStyle.h - src/ui/WorkbenchWidget.cpp - src/ui/WorkbenchWidget.h + ${CODEXUI_CODEX_COMMON_SOURCES} + ${CODEXUI_GREENFIELD_MIDDLE_SOURCES} + src/codex/PendingRequestDialog.cpp + src/codex/PendingRequestDialog.h + src/codex/TurnSettingsWidget.cpp + src/codex/TurnSettingsWidget.h ) -target_compile_features(codex-ui PRIVATE cxx_std_20) -target_include_directories(codex-ui PRIVATE src) -target_link_libraries( - codex-ui - PRIVATE - AISuite::OpenAICodexFrontendClient - Qt6::Concurrent - Qt6::Network - Qt6::Widgets +qt_add_executable( + codex-ui-harness + ${CODEXUI_CODEX_COMMON_SOURCES} + src/codex/WorkbenchWidget.cpp + src/codex/WorkbenchWidget.h ) -install( - TARGETS codex-ui - RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} - BUNDLE DESTINATION . -) +function(configure_codexui_target target) + target_compile_features(${target} PRIVATE cxx_std_20) + target_include_directories(${target} PRIVATE src) + target_link_libraries( + ${target} + PRIVATE + AISuite::OpenAICodex + Qt6::Widgets + Threads::Threads + snodec::net-un-stream-legacy + snodec::net-in-stream-legacy + snodec::net-in6-stream-legacy + ) +endfunction() + +configure_codexui_target(codex-ui) +target_include_directories(codex-ui BEFORE PRIVATE src/greenfield) +configure_codexui_target(codex-ui-harness) +target_compile_definitions(codex-ui-harness PRIVATE CODEXUI_DEVELOPMENT_HARNESS=1) + +if(TARGET snodec::net-in-stream-tls AND TARGET snodec::net-in6-stream-tls) + foreach(target codex-ui codex-ui-harness) + target_compile_definitions(${target} PRIVATE CODEXUI_CODEX_FRONTEND_TLS=1) + target_link_libraries( + ${target} PRIVATE snodec::net-in-stream-tls snodec::net-in6-stream-tls + ) + endforeach() +endif() + +if(TARGET snodec::net-rc-stream-legacy AND TARGET snodec::net-rc-stream-tls) + foreach(target codex-ui codex-ui-harness) + target_compile_definitions(${target} PRIVATE CODEXUI_CODEX_FRONTEND_RFCOMM=1) + target_link_libraries( + ${target} PRIVATE snodec::net-rc-stream-legacy snodec::net-rc-stream-tls + ) + endforeach() +endif() + +if(TARGET snodec::http-client AND TARGET snodec::websocket-client) + foreach(target codex-ui codex-ui-harness) + target_compile_definitions(${target} PRIVATE CODEXUI_CODEX_FRONTEND_WEBSOCKET=1) + target_link_libraries(${target} PRIVATE snodec::http-client snodec::websocket-client) + endforeach() +endif() if(BUILD_TESTING) add_executable( - CodexUIAttachmentManagerTest - tests/AttachmentManagerTest.cpp - src/app/AttachmentManager.cpp - src/app/AttachmentManager.h + codexui-socketpair-contract-test + tests/codex/SocketPairContractTest.cpp + src/codex/ipc/QtSocketPairEndpoint.cpp + src/codex/ipc/QtSocketPairEndpoint.h + src/codex/ipc/SNodeSocketPairEndpoint.cpp + src/codex/ipc/SNodeSocketPairEndpoint.h + src/codex/ipc/SocketPair.cpp + src/codex/ipc/SocketPair.h + ) + target_compile_features( + codexui-socketpair-contract-test PRIVATE cxx_std_20 + ) + target_include_directories( + codexui-socketpair-contract-test PRIVATE src ) - target_compile_features(CodexUIAttachmentManagerTest PRIVATE cxx_std_20) - target_include_directories(CodexUIAttachmentManagerTest PRIVATE src) - target_link_libraries(CodexUIAttachmentManagerTest PRIVATE Qt6::Network) - add_test(NAME CodexUIAttachmentManagerTest COMMAND CodexUIAttachmentManagerTest) - - add_executable( - CodexUIPromptLimitTest - tests/PromptLimitTest.cpp - src/app/FrontendSession.cpp - src/app/FrontendSession.h - src/app/FrontendSessionWorker.cpp - src/app/FrontendSessionWorker.h - ) - target_compile_features(CodexUIPromptLimitTest PRIVATE cxx_std_20) - target_include_directories(CodexUIPromptLimitTest PRIVATE src) target_link_libraries( - CodexUIPromptLimitTest - PRIVATE - AISuite::OpenAICodexFrontendClient - Qt6::Network + codexui-socketpair-contract-test + PRIVATE Qt6::Core Threads::Threads snodec::net-un-stream-legacy + ) + add_test( + NAME codexui-socketpair-contract + COMMAND codexui-socketpair-contract-test + ) + set_tests_properties( + codexui-socketpair-contract PROPERTIES TIMEOUT 10 ) - add_test(NAME CodexUIPromptLimitTest COMMAND CodexUIPromptLimitTest) add_executable( - CodexUIFrontendSessionTest - tests/FrontendSessionTest.cpp - src/app/FrontendSession.cpp - src/app/FrontendSession.h - src/app/FrontendSessionWorker.cpp - src/app/FrontendSessionWorker.h - ) - target_compile_features(CodexUIFrontendSessionTest PRIVATE cxx_std_20) - target_include_directories(CodexUIFrontendSessionTest PRIVATE src) + codexui-presentation-pipeline-test + tests/codex/PresentationPipelineTest.cpp + src/codex/PresentationModel.cpp + src/codex/PresentationModel.h + src/codex/PresentationProtocol.cpp + src/codex/PresentationProtocol.h + src/codex/ProtocolNormalizer.cpp + src/codex/ProtocolNormalizer.h + ) + target_compile_features( + codexui-presentation-pipeline-test PRIVATE cxx_std_20 + ) + target_include_directories( + codexui-presentation-pipeline-test PRIVATE src + ) target_link_libraries( - CodexUIFrontendSessionTest - PRIVATE - AISuite::OpenAICodexFrontendClient - Qt6::Network + codexui-presentation-pipeline-test + PRIVATE AISuite::OpenAICodex + ) + add_test( + NAME codexui-presentation-pipeline + COMMAND codexui-presentation-pipeline-test + ) + set_tests_properties( + codexui-presentation-pipeline PROPERTIES TIMEOUT 10 ) - add_test(NAME CodexUIFrontendSessionTest COMMAND CodexUIFrontendSessionTest) add_executable( - CodexUIPresentationRefreshAccumulatorTest - tests/PresentationRefreshAccumulatorTest.cpp - src/ui/PresentationRefreshAccumulator.cpp - src/ui/PresentationRefreshAccumulator.h + codexui-greenfield-projection-test + tests/codex/GreenfieldProjectionTest.cpp + src/greenfield/codex/middle/ConversationProjection.cpp + src/greenfield/codex/middle/ConversationProjection.h + src/greenfield/codex/middle/MiddleTypes.cpp + src/greenfield/codex/middle/MiddleTypes.h + src/greenfield/codex/middle/PromptCoordinator.cpp + src/greenfield/codex/middle/PromptCoordinator.h + ) + target_compile_features( + codexui-greenfield-projection-test PRIVATE cxx_std_20 + ) + target_include_directories( + codexui-greenfield-projection-test BEFORE PRIVATE src/greenfield src ) - target_compile_features(CodexUIPresentationRefreshAccumulatorTest PRIVATE cxx_std_20) - target_include_directories(CodexUIPresentationRefreshAccumulatorTest PRIVATE src) target_link_libraries( - CodexUIPresentationRefreshAccumulatorTest - PRIVATE - AISuite::OpenAICodexFrontendClient - Qt6::Widgets + codexui-greenfield-projection-test PRIVATE Qt6::Widgets ) add_test( - NAME CodexUIPresentationRefreshAccumulatorTest - COMMAND CodexUIPresentationRefreshAccumulatorTest + NAME codexui-greenfield-projection + COMMAND codexui-greenfield-projection-test + ) + set_tests_properties( + codexui-greenfield-projection PROPERTIES TIMEOUT 10 ) - add_executable( - CodexUIConversationLayoutTest - tests/ConversationLayoutTest.cpp - src/app/AttachmentManager.cpp - src/app/AttachmentManager.h - src/ui/AnchoredTurnSurface.cpp - src/ui/AnchoredTurnSurface.h - src/ui/ConversationWidget.cpp - src/ui/ConversationWidget.h - src/ui/ExpandingPromptEditor.cpp - src/ui/ExpandingPromptEditor.h - src/ui/InspectorWidget.cpp - src/ui/InspectorWidget.h - src/ui/PresentationRefreshAccumulator.cpp - src/ui/PresentationRefreshAccumulator.h - src/ui/UpcomingTurnDock.cpp - src/ui/UpcomingTurnDock.h - ) - target_compile_features(CodexUIConversationLayoutTest PRIVATE cxx_std_20) - target_include_directories(CodexUIConversationLayoutTest PRIVATE src) + qt_add_executable( + codexui-greenfield-middle-test + tests/codex/GreenfieldMiddleTest.cpp + src/greenfield/codex/middle/ConversationCards.cpp + src/greenfield/codex/middle/ConversationCards.h + src/greenfield/codex/middle/ConversationView.cpp + src/greenfield/codex/middle/ConversationView.h + src/greenfield/codex/middle/MiddleTypes.cpp + src/greenfield/codex/middle/MiddleTypes.h + ) + target_compile_features( + codexui-greenfield-middle-test PRIVATE cxx_std_20 + ) + target_include_directories( + codexui-greenfield-middle-test BEFORE PRIVATE src/greenfield src + ) target_link_libraries( - CodexUIConversationLayoutTest - PRIVATE - AISuite::OpenAICodexFrontendClient - Qt6::Widgets + codexui-greenfield-middle-test PRIVATE Qt6::Widgets + ) + add_test( + NAME codexui-greenfield-middle + COMMAND codexui-greenfield-middle-test + ) + set_tests_properties( + codexui-greenfield-middle + PROPERTIES TIMEOUT 15 ENVIRONMENT "QT_QPA_PLATFORM=offscreen" ) - add_test(NAME CodexUIConversationLayoutTest COMMAND CodexUIConversationLayoutTest) - set_tests_properties(CodexUIConversationLayoutTest PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") - add_executable( - CodexUIPhase1ThreadTurnUxTest - tests/Phase1ThreadTurnUxTest.cpp - src/app/AttachmentManager.cpp - src/app/AttachmentManager.h - src/ui/AnchoredTurnSurface.cpp - src/ui/AnchoredTurnSurface.h - src/ui/ExpandingPromptEditor.cpp - src/ui/ExpandingPromptEditor.h - src/ui/SidebarWidget.cpp - src/ui/SidebarWidget.h - src/ui/ThreadSetupDialog.cpp - src/ui/ThreadSetupDialog.h - src/ui/UpcomingTurnDock.cpp - src/ui/UpcomingTurnDock.h - src/ui/UiStyle.cpp - src/ui/UiStyle.h - ) - target_compile_features(CodexUIPhase1ThreadTurnUxTest PRIVATE cxx_std_20) - target_include_directories(CodexUIPhase1ThreadTurnUxTest PRIVATE src) + qt_add_executable( + codexui-greenfield-layout-test + tests/codex/GreenfieldLayoutTest.cpp + src/codex/DiffViewer.cpp + src/codex/DiffViewer.h + src/codex/FileSelectionDialog.cpp + src/codex/FileSelectionDialog.h + src/codex/PresentationModel.cpp + src/codex/PresentationModel.h + src/codex/PresentationProtocol.cpp + src/codex/PresentationProtocol.h + src/codex/TurnSettingsWidget.cpp + src/codex/TurnSettingsWidget.h + src/codex/ui/ExpandingPromptEditor.cpp + src/codex/ui/ExpandingPromptEditor.h + src/greenfield/codex/middle/ComposerPane.cpp + src/greenfield/codex/middle/ComposerPane.h + src/greenfield/codex/middle/ConversationCards.cpp + src/greenfield/codex/middle/ConversationCards.h + src/greenfield/codex/middle/ConversationView.cpp + src/greenfield/codex/middle/ConversationView.h + src/greenfield/codex/middle/InspectorPane.cpp + src/greenfield/codex/middle/InspectorPane.h + src/greenfield/codex/middle/MiddleRegionWidget.cpp + src/greenfield/codex/middle/MiddleRegionWidget.h + src/greenfield/codex/middle/MiddleTypes.cpp + src/greenfield/codex/middle/MiddleTypes.h + src/greenfield/codex/middle/ThreadPane.cpp + src/greenfield/codex/middle/ThreadPane.h + ) + target_compile_features( + codexui-greenfield-layout-test PRIVATE cxx_std_20 + ) + target_include_directories( + codexui-greenfield-layout-test BEFORE PRIVATE src/greenfield src + ) target_link_libraries( - CodexUIPhase1ThreadTurnUxTest - PRIVATE - AISuite::OpenAICodexFrontendClient - Qt6::Widgets + codexui-greenfield-layout-test PRIVATE AISuite::OpenAICodex Qt6::Widgets + ) + add_test( + NAME codexui-greenfield-layout + COMMAND codexui-greenfield-layout-test + ) + set_tests_properties( + codexui-greenfield-layout + PROPERTIES TIMEOUT 15 ENVIRONMENT "QT_QPA_PLATFORM=offscreen" ) - add_test(NAME CodexUIPhase1ThreadTurnUxTest COMMAND CodexUIPhase1ThreadTurnUxTest) - set_tests_properties(CodexUIPhase1ThreadTurnUxTest PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") - add_executable( - CodexUIInteractiveRequestDialogTest - tests/InteractiveRequestDialogTest.cpp - src/ui/InteractiveRequestDialog.cpp - src/ui/InteractiveRequestDialog.h + set(CODEXUI_GREENFIELD_SHELL_TEST_SOURCES ${CODEXUI_CODEX_COMMON_SOURCES}) + list( + REMOVE_ITEM CODEXUI_GREENFIELD_SHELL_TEST_SOURCES + src/codex/main.cpp + src/codex/MainWindow.cpp + src/codex/MainWindow.h ) - target_compile_features(CodexUIInteractiveRequestDialogTest PRIVATE cxx_std_20) - target_include_directories(CodexUIInteractiveRequestDialogTest PRIVATE src) - target_link_libraries( - CodexUIInteractiveRequestDialogTest - PRIVATE - AISuite::OpenAICodexFrontendClient - Qt6::Widgets + qt_add_executable( + codexui-greenfield-shell-test + tests/codex/GreenfieldShellIntegrationTest.cpp + ${CODEXUI_GREENFIELD_SHELL_TEST_SOURCES} + ${CODEXUI_GREENFIELD_MIDDLE_SOURCES} + src/codex/PendingRequestDialog.cpp + src/codex/PendingRequestDialog.h + src/codex/TurnSettingsWidget.cpp + src/codex/TurnSettingsWidget.h + ) + configure_codexui_target(codexui-greenfield-shell-test) + target_include_directories( + codexui-greenfield-shell-test BEFORE PRIVATE src/greenfield + ) + add_test( + NAME codexui-greenfield-shell + COMMAND codexui-greenfield-shell-test + ) + set_tests_properties( + codexui-greenfield-shell + PROPERTIES TIMEOUT 20 ENVIRONMENT "QT_QPA_PLATFORM=offscreen" ) - add_test(NAME CodexUIInteractiveRequestDialogTest COMMAND CodexUIInteractiveRequestDialogTest) - set_tests_properties(CodexUIInteractiveRequestDialogTest PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + endif() + +install( + TARGETS codex-ui codex-ui-harness + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + BUNDLE DESTINATION . +) +install( + FILES resources/icons/codex-ui.svg + DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/scalable/apps +) +install( + FILES resources/applications/codex-ui.desktop + DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/applications +) diff --git a/README.md b/README.md index 70ca7ec..480f8f2 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,64 @@ # CodexUI -CodexUI is a native Qt 6 Widgets graphical Codex client built on AISuite's -public Codex frontend C++ SDK. The UI shell implements the Figma v2 workbench -([node 4:2](https://www.figma.com/design/IScmS9lHPduDueN2sVEsiO/Codex-UI-Prototype-v0.1?node-id=4-2)). +CodexUI is a native Qt 6 Widgets frontend for the AISuite `codex-bridge`. +It presents the Codex app-server protocol without introducing another backend, +semantic cache, snapshot store, or persistence authority. + +The canonical process has two threads: ```text -CodexUI - | - | AISuite public frontend C++ SDK - v -codex-backend - | - v -codex app-server +Qt GUI thread + <-> bounded nonblocking Unix socketpair +SNode.C client thread + <-> codex-bridge + <-> Codex app-server ``` -CodexUI connects automatically to the local `codex-backend` Unix frontend -socket through Qt and delegates authentication, protocol handling, and state -synchronization to AISuite's public frontend SDK. The sidebar displays real -synchronized threads, and the center work area renders the selected thread's -real turns, messages, semantic items, activity, token usage, and failures from -the current immutable frontend State. The composer can lazily acquire -controller ownership, create a thread, start or continue a real turn, and -interrupt the selected active turn. Live output continues to arrive entirely -through AISuite's immutable State projection. - -Sending on a persisted thread automatically resumes it in the running Codex -App Server before starting the turn; selecting a thread still performs only the -read-side synchronization needed to render it. - -CodexUI also presents real command and file-change approvals and typed -user-input requests from AISuite's canonical pending-request collection. A real -attention count opens the compact request dialog, responses acquire controller -ownership only when submitted, and requests remain visible until a subsequent -immutable State update removes or changes them. - -The Inspector follows the selected thread and renders its latest plan, -subagent/collaboration activity, reported file changes, and compact factual -thread/turn/synchronization information from public typed AISuite projections. -Unavailable or truncated fields remain visibly absent rather than being -reconstructed from protocol JSON or the local filesystem. - -Attachments, advanced thread management, and settings/persistence remain -outside the current interactive core. +The Qt thread owns widgets and `PresentationModel`. The SNode.C thread owns the +event loop, selected transport, `AISuite::OpenAICodex` frontend proxy SDK, +native protocol normalization, and connection/controller telemetry. They +exchange only bounded `codexui.presentation` JSONL commands and events. + +## Applications + +- `codex-ui`: the normal visual application. +- `codex-ui-harness`: the permanent protocol and reducer development harness. + +Both applications use the same transport, socketpair, normalization, +presentation protocol, and model implementation. Only their top-level Qt +consumer differs. ## Build -Qt 6 Widgets, Qt 6 Network, and an installed AISuite package that exports its -public Codex frontend client are required. +Qt 6 Widgets, Threads, SNode.C `master`/HEAD, and an installed canonical +AISuite package exporting `AISuite::OpenAICodex` are required. ```sh -cmake -S . -B build -G Ninja -cmake --build build --parallel 28 +cmake -S . -B "${BUILD_DIR}" -G Ninja \ + -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_PREFIX_PATH="/path/to/aisuite;/path/to/snodec" +cmake --build "${BUILD_DIR}" --parallel 8 +ctest --test-dir "${BUILD_DIR}" --output-on-failure --parallel 8 +cmake --install "${BUILD_DIR}" ``` +Installation includes the `codex-ui` executable, desktop entry, and SVG icon. +The executable name, application ID, `StartupWMClass`, desktop entry, and icon +name intentionally match so Linux launchers and taskbars associate the window +with the installed CodexUI application. + +## Architecture + +The complete thread model, presentation protocol, authority rules, normalized +event vocabulary, public APIs, shell behavior, implementation report, and test +boundaries are documented in +[`docs/codex-architecture.md`](docs/codex-architecture.md). + +Current message routing, pending-prompt acknowledgment, scrolling, composer +geometry, shell-output, Inspector, and desktop-integration decisions are +documented in +[`docs/ui-behavior.md`](docs/ui-behavior.md). + ## License CodexUI is available under the LGPL-3.0-or-later OR MIT dual license. diff --git a/design/t1-item-upsert-investigation.md b/design/t1-item-upsert-investigation.md deleted file mode 100644 index fed1db2..0000000 --- a/design/t1-item-upsert-investigation.md +++ /dev/null @@ -1,256 +0,0 @@ -# T1 item-upsert reconciliation investigation - -Status: implementation gate triggered; no `src/` change is justified by the -current T1 brief. - -Reviewed targets: - -- CodexUI `3679e38` (merged PR #38) -- AISuite `61ed370` -- CodexUI history `7e19d8c`, failed attempt `c680e37`, and revert `f7b931c` - -## Standing safeguards - -- No `git merge`, `git pull`, `git rebase`, `git cherry-pick`. Linear history only. -- One concern per PR. If the diff touches a second concern, stop and split. -- Per-commit deletion census: count of removed lines under `src/`, and a reason for every removal longer than five lines. -- Call-site audit before implementation: how many call sites exist, how many will change, how many are deliberately left, and why. -- Tests are runtime proof. A test asserting on source text, policy, or the presence of a symbol does not count. -- Do not refresh a golden hash or protocol fingerprint without naming, in the commit message, the semantic change that moved it. -- If a task says "investigate and report before implementing", the first commit contains no `src/` changes. - -## Finding in one sentence - -`c680e37` failed only when a valid exact content append and an item upsert were -coalesced: the exact append succeeded and returned before topology -reconciliation, while the existing reconciliation is too entangled and not -exhaustively keyed enough to satisfy T1's performance and widget-identity -invariants by adding only a third scope set. - -## 1. What `requiresFullRefresh` concretely changes - -`requiresFullRefresh` is not an input to `ConversationWidget::render`. It exists -only in `WorkbenchWidget::scheduleStateRefresh` at -`src/ui/WorkbenchWidget.cpp:357-364`. - -When it is true, Workbench: - -1. sets `selectedPresentationFullRefreshPending`; -2. clears every accumulated exact-content update and its byte count; -3. reaches the 16 ms flush with `exactContentOnly == false`; and -4. calls `ConversationWidget::render` without exact-content hints. - -When it is false: - -- an affected conversation with no exact identity is promoted back to a full - refresh at `src/ui/WorkbenchWidget.cpp:389-396`; -- exact identities are retained and, only when no Inspector, Sidebar, or - pending turn/resume work also needs the frame, Workbench calls - `updateExactMessageContent` directly at - `src/ui/WorkbenchWidget.cpp:453-457`; otherwise it forwards the hints to - `render`; and -- if Workbench reaches `render` with valid exact appends, `render` tries the - same exact updater and returns on success at - `src/ui/ConversationWidget.cpp:3004-3010`. - -The segment topology/key reconciliation at -`src/ui/ConversationWidget.cpp:3221-3588` therefore does not run after a -successful exact append. It runs for the no-hint/full route and as a fallback -after an exact update cannot be applied. There is no separate full-refresh -branch inside `render`. - -The reconciliation does not blindly recreate every widget. It preserves -compatible turns and segments, skips equal presentation keys, updates supported -widgets in place, and creates or replaces only entries it considers -incompatible (`src/ui/ConversationWidget.cpp:3290-3583`). The comment in -`c680e37` implying that `fullyAffectedThreadIds` itself invalidated all retained -widgets was therefore factually wrong. - -There is a second limitation: the compatibility test is not an exhaustive -keyed diff. It accepts a removable old prefix followed by an aligned surviving -sequence. An insertion or regrouping in the middle clears that turn's item -layout at `src/ui/ConversationWidget.cpp:3411-3427`, deleting unchanged later -widgets. Reusing the existing block cannot prove the broad invariant that every -unchanged presentation key retains the same `QWidget*`. - -## 2. Why the reverted attempt failed - -A lone `ItemUpsertedChange` under `c680e37` still worked. Because it carried no -exact content identity, Workbench promoted it to the general reconciliation. - -The failing sequence was: - -1. an `ItemContentAppendedChange` and an `ItemUpsertedChange` reached the same - scope/mailbox/16 ms presentation window; -2. `c680e37` kept the thread out of `fullyAffectedThreadIds`; -3. Workbench retained the append and classified the refresh as exact-content - only; -4. `updateExactMessageContent` applied the append and returned `true`; and -5. `render` returned before deriving the new segment list, so the new item never - received a widget. - -The existing mixed-scope test could not reproduce this. It combines an item -upsert with `ItemContentReplacedChange`, whose append payload is absent. -`updateExactMessageContent` rejects that input at -`src/ui/ConversationWidget.cpp:3623-3624`, so the test always falls through to -the safe reconciliation path. The valid append fixture is tested separately -and never combined with an upsert. - -## 3. Operations bundled with general reconciliation - -Before and alongside the segment reconciliation, the current `render` path -performs: - -- canonical upcoming-turn configuration synchronization; -- the viewport freeze check; -- the incomplete-history containment proof; -- generation, follow-tail, anchor, and thread-switch pin bookkeeping; -- thread title and detail rewriting; -- a forward scan over all ordered turns to find the last retained turn; -- latest-turn summary and failure recomputation; -- `latestTimelineWindow` recomputation; -- segment regeneration for the selected window; -- turn and segment compatibility checks and presentation-key computation; and -- conditional timeline layout and scroll settling. - -Inspector rendering is separate, but `ItemUpsertedChange` marks the relevant -Inspector thread, so Workbench refreshes it. Workbench's selected-presentation -refresh also rebuilds breadcrumb/context text and scans the latest turn's items -for its activity count at `src/ui/WorkbenchWidget.cpp:629-666`. - -## 4. Required work for a pure item upsert - -A pure item upsert requires: - -- the existing freeze ordering; -- selection of the current bounded window, because a tail addition can evict - the oldest visible item or turn; -- segment regeneration for that window, because an activity bucket can retain - its segment ID while gaining a row; -- presentation-key comparison, because an item upsert may replace an existing - same-identity item as well as add one; -- exhaustive keyed topology reconciliation that preserves every unchanged - widget; -- updated rendered item-range and item-identity bookkeeping; -- geometry/follow-tail settling when the visible structure changes; and -- retention and application of every coalesced exact-content append; -- the separately gated Inspector refresh, because item changes can affect its - plan, activity, and file-change projections; and -- Workbench's latest-turn agent-activity status update, because a new - collaboration/subagent item can change that count. - -It does not require thread title/detail rewriting, the all-turn current-turn -scan, turn summary/failure reconstruction, workspace breadcrumb reconstruction, -thread-switch pinning, or unrelated attachment/settings/controller work. - -The bounded window calculation itself is necessary. What T1 must avoid is a -destructive or monolithic full presentation rebuild, not the bounded calculation -needed to know which entries currently belong in a capped window. - -## 5. Mandatory gate result - -A structural boolean could suppress the exact-content early return and enter -the existing late reconciliation. That would make new widgets appear, but it -would not satisfy the stated invariant: - -- it would execute the monolithic metadata, all-turn, summary, and general - presentation work identified above; -- an exact message append would go through canonical message reconstruction - rather than retaining the O(delta) direct append path; and -- a middle insertion or regrouping could still destroy later widgets whose - presentation keys did not change. - -There is no callable boundary that performs only a fully keyed timeline -reconciliation. Reconciliation and the unnecessary full-render work are -inseparable in the current structure. The brief explicitly says to stop in -this case because the task becomes a `render()` split. This investigation -therefore makes no `src/` change. - -The proposed thread-ID-only structural set also cannot honor a literal ban on -window re-derivation. By the time it reaches `ConversationWidget`, it has lost -the changed turn/item identity and whether the upsert added or replaced an -item. A richer structural delta or a separately maintained window delta would -be required to patch the capped window without recalculating it. - -## 6. Correction to the proposed scope taxonomy - -The expected shape in the brief cannot be applied literally at AISuite -`61ed370`: - -- the public `client::Change` variant has no `TurnRemovedChange` or - `ItemRemovedChange`; it exposes only `ThreadRemovedChange` for explicit - removals; -- `TurnUpsertedOccurrence` carries an internal `replaceItems` bit; -- legacy `turn.updated` sets `replaceItems = true` at AISuite - `Occurrence.cpp:2318-2332`; -- the reducer then replaces the turn's ordered items and deletes omitted - descendants at `Occurrence.cpp:2705-2724`; and -- the public client collapses this to `TurnUpsertedChange` without exposing the - replacement bit at `Client.cpp:449-454`. - -CodexUI leaves the SDK's supported legacy-v1 fallback enabled. Consequently, -`TurnUpsertedChange` must remain fully affected under the current public -contract. Moving it into an add-only structural set could reintroduce stale -widgets after a legitimate descendant deletion. - -`ItemUpsertedChange` is narrower: its reducer upserts one composite item -identity and does not interpret other descendants' omission as deletion. A -future structural scope may therefore classify resolved item upserts, but not -all turn upserts. Thread-read publications map to `ThreadUpsertedChange` and -must remain fully affected. - -## 7. Call-site audit for the follow-up implementation - -- `ConversationWidget::render` has one production caller and 68 direct test - calls. A new structural entry point or mode would change the one production - caller and new targeted tests; existing tests should deliberately remain on - the default general-render contract. -- `StateUpdateScope` has one canonical per-update producer, - `stateUpdateScope`, plus broad manually constructed worker scopes for - lifecycle/discovery boundaries. Only the two resolved parent branches of - `ItemUpsertedChange` should become structural. Broad scopes deliberately - remain broad. -- `mergeScope` is the single GUI-mailbox merge site. A future structural set - must use the same bounded unique-union, overflow-to-`allThreadsAffected`, and - clear-on-all discipline as the existing thread sets. -- `WorkbenchWidget::scheduleStateRefresh` is the single production consumer of - the distinction. Full/deletion-capable input must dominate structural input; - structural input must not clear exact-content updates. - -## 8. Runtime proof required in the follow-up PR - -1. Render an existing live turn, retain every original segment address, apply - 50 item upserts, and assert that every new item has a widget while every - unchanged presentation key retains the exact original `QWidget*`. -2. Apply deletion-capable authority to the same thread and assert the removed - widget is both absent from lookup and destroyed (`QPointer` becomes null). -3. Coalesce a valid exact append with an item upsert in both arrival orders. - Assert the appended text, all new widgets, original widget identities, and - unchanged `sourceMaterializationCount`/incremental append instrumentation. -4. Add an insertion/regrouping case, not only tail appends, so the keyed reuse - invariant is actually proved. -5. Exercise mailbox merging so structural scope and exact append metadata - survive in both orders while a later full scope still dominates. - -## Baseline runtime result - -Before any edit, both relevant runtime suites passed: - -```text -CodexUIFrontendSessionTest ....... Passed -CodexUIConversationLayoutTest .... Passed -100% tests passed, 0 tests failed out of 2 -``` - -Command: - -```sh -cmake --build build \ - --target CodexUIFrontendSessionTest CodexUIConversationLayoutTest -ctest --test-dir build \ - --output-on-failure \ - -R 'CodexUI(FrontendSession|ConversationLayout)Test' -``` - -That green baseline confirms the current suite does not expose the mixed -append/upsert defect. diff --git a/design/ux-decisions/thread-turn-model.md b/design/ux-decisions/thread-turn-model.md index 24e8737..393832c 100644 --- a/design/ux-decisions/thread-turn-model.md +++ b/design/ux-decisions/thread-turn-model.md @@ -1,505 +1,144 @@ -# CodexUI UX Decision: Thread and Turn Model +# CodexUI UX Decision: Threads, Turns, and Prompt Admission -Status: **Agreed design baseline** +Status: **Implemented design baseline** -This document records the current UX decisions for thread creation, persistent execution settings, turns, resume/fork behavior, and thread actions. It is intended as source material for the upcoming Figma reconciliation and redesign work. +## Domain model -## 1. Core mental model - -CodexUI distinguishes four concepts: - -```text -THREAD -| -+-- Creation properties -+-- Foundational instructions -+-- Current execution settings -+-- Turns - +-- turn-specific input/settings - +-- historical effective settings -``` - -The central UX rule is: - -> **Every mutable execution setting has exactly one editable representation: at the next-turn composer.** - -There are no separate editable "thread reasoning" and "turn reasoning" controls. - -The authoritative values are persistent thread settings, but they are presented beside the next-turn composer because that is the point at which the user needs to understand or change what the next Codex turn will use. - -## 2. New Thread - -`+ New Thread` opens a fresh conversation immediately. There is no mandatory configuration dialog. - -Conceptually: - -```text -NEW THREAD - -+----------------------------------------------------+ -| What do you want Codex to work on? | -| | -+----------------------------------------------------+ - -Model | Reasoning | Workspace | Access | Approval - -> Advanced thread options - - Start -``` - -The initial values come from the **Codex/app-server defaults**. CodexUI does not introduce artificial concepts such as "Starting configuration" or "Initial reasoning". - -The controls beside the composer show what the **first turn will use**. They may be changed before pressing **Start**. - -## 3. Advanced Thread Options - -Only genuine thread-creation/context properties belong here: - -```text -Advanced thread options - -[ ] Temporary thread - Do not persist this thread to history - -Base instructions -Default Edit... - -Developer instructions -Default Edit... -``` - -Protocol fields are not exposed merely because they exist. - -## 4. Foundational instructions - -CodexUI distinguishes: - -```text -Base instructions - -> Fundamental behavior of the Codex agent - -Developer instructions - -> Project/workflow/architecture/testing rules - -User prompt - -> Concrete task -``` - -For normal use, Base and Developer Instructions inherit the Codex/app-server defaults. - -They may be explicitly changed during: - -- New Thread -- Fork... -- Resume with options... - -They are not ordinary next-turn execution settings. - -For an already active thread they are informational/read-only unless the user explicitly chooses an operation that permits changing them. - -## 5. Next-turn configuration - -Every composer, including the first one, shows the effective execution configuration that the next turn will use. - -Conceptually: - -```text -+----------------------------------------------------+ -| Ask Codex... | -| | -+----------------------------------------------------+ - -Model | Reasoning | Workspace | Access | Approval - - Send -``` - -Persistent mutable execution settings include, where supported and useful in the UI: - -- Model -- Reasoning effort -- Workspace / cwd -- Sandbox / workspace access -- Approval policy / reviewer -- Service tier -- Reasoning summary -- Personality -- Collaboration mode - -The final Figma design will decide which settings deserve first-level visibility and which belong under a compact secondary control such as `More...`. - -## 6. Meaning of changing a next-turn setting - -Suppose the current inherited reasoning value is `High` and the user selects `XHigh` before the next turn. - -The meaning is: - -```text -Current inherited value - High - | - v -user selects XHigh - | - v -Next turn uses XHigh - + -XHigh becomes the inherited value -for subsequent turns -``` - -The UI should make this persistence rule discoverable, for example through a tooltip: - -> Changes apply to this and subsequent turns. - -The control is positioned at the next-turn boundary because that is when the value matters to the user, even though the resulting value is stored persistently with the thread. - -## 7. True turn-specific data - -These values belong only to one turn and do not become inherited thread configuration: - -- Prompt / UserInput -- Attachments, images, and other turn input -- Output schema -- Client message identity - -Conceptually: - -```text -NEXT TURN -| -+-- inherited execution settings -| +-- Model -| +-- Reasoning -| +-- Workspace -| +-- Access -| +-- Approval -| +-- ... -| -+-- turn-local data - +-- Prompt - +-- Attachments - +-- Output requirements -``` - -## 8. Historical turns - -Historical turns should expose the **effective settings they actually used**, where the Codex app-server and AISuite can provide them. - -These values are read-only: - -```text -TURN DETAILS - -Model GPT-5.x Codex -Reasoning XHigh -Workspace ~/AISuite -Access Workspace Write -Approval On Request -``` - -This permits truthful history even after the current thread configuration has changed: - -```text -Turn 12 High -Turn 13 High -Turn 14 XHigh -Turn 15 XHigh - -Current next-turn setting: - Medium -``` - -A reconnected CodexUI should ideally receive these historical effective values from authoritative app-server/AISuite state rather than infer them from the current thread settings. - -Historical effective settings are never editable. - -## 9. Thread header - -The thread header should identify the conversation and workspace clearly: - -```text -AISuite Performance -~/Projects/SNodeC/AISuite -``` - -It may also contain a quiet read-only summary such as: +CodexUI presents the current Codex app-server model directly: ```text -GPT-5.x Codex | XHigh | Workspace Write +Thread +├── stable identity and lifetime +├── foundational instructions +├── current upcoming-turn configuration +└── Turns + ├── user input and attachments + ├── activity and output + └── completion or failure ``` -Such a summary must not look like a second set of editable controls. The single editable execution controls remain beside the next-turn composer. +AISuite and the app-server are authoritative for thread, turn, item, and +configuration semantics. CodexUI retains only bounded presentation state and +client-local interaction state. -## 10. Normal open / resume +## Thread selection and routing -Selecting an existing thread should simply work: +The visible thread selection is user-owned. Background events, reconnects, +thread refreshes, and activity in another thread do not change it. -```text -click thread - | - v -load/resume if necessary - | - v -conversation appears -``` +Send and Steer resolve the destination from the visibly selected row and its +stable thread ID. Missing or inconsistent selection is an error; it never +causes implicit thread creation. New thread creation requires an explicit New +Thread intent. -There is no normal resume dialog. Whether CodexUI internally performs `thread/resume` is an implementation detail that should normally remain invisible. +## New thread -Existing foundational instructions and thread context are retained. +New Thread opens a dialog for creation-only properties: -## 11. Thread context menu +- workspace; +- optional name; +- optional base instructions; +- optional developer instructions; +- ephemeral lifetime. -The context menu is state-dependent. +The accepted dialog creates a local draft. The app-server thread is created +when the first prompt is admitted. Prompts entered while creation is in flight +remain attached to the draft and move to the returned thread ID when creation +succeeds. -### Idle thread - -```text -Open -Rename... -Fork... --------------------- -Resume with options... --------------------- -Archive -Delete... -``` +## Upcoming-turn configuration -### Running thread +The single editable settings surface is adjacent to the composer. It contains +the supported model, reasoning, workspace, sandbox/access, network, approval, +personality, service-tier, reasoning-summary, permission-profile, reviewer, and +collaboration choices. -```text -Open -Rename... -Fork... --------------------- -Interrupt --------------------- -Resume with options... if applicable --------------------- -Archive possibly disabled while running -Delete... possibly disabled while running -``` +Thread-creation properties are not duplicated in this surface. Untouched +settings remain omitted from native operations so CodexUI does not replace +provider state with inferred defaults. -### Archived thread +## Prompt admission and acknowledgment -```text -Open -Fork... --------------------- -Unarchive -Delete... -``` - -Developer-oriented utilities may live under a secondary menu rather than cluttering the primary menu: - -```text -More -+-- Copy Thread ID -``` - -## 12. Open - -`Open` is the normal operation. - -If necessary, CodexUI automatically resumes the thread using its existing context and settings. No configuration interaction is required. - -## 13. Rename - -`Rename...` changes the human-readable thread title. It does not affect execution state, instructions, or Codex context. - -## 14. Fork - -`Fork...` creates a **new thread derived from the selected thread**. - -Because it creates a new thread, foundational context becomes editable again: - -```text -FORK THREAD - -Base instructions -[ editable ] - -Developer instructions -[ editable ] - -[ ] Temporary thread - - Cancel Fork -``` +Clicking Send or Steer performs local admission immediately: -The new fork then uses the normal next-turn composer and its execution-setting controls. +1. create a pending user card in the destination thread; +2. clear the submitted prompt and attachments from the composer; +3. leave the composer enabled for more input; +4. dispatch the operation when it reaches the front of that thread's queue. -Forking is the preferred way to continue historical context under changed foundational instructions because it creates a clean semantic boundary. +A pending card is muted blue and has a brighter highlight sweeping left and +right. Its visual identity is a process-wide local submission ID, so assigning +an app-server thread ID to a creation draft and switching threads do not replace +or relocate it. -## 15. Resume with options +Only one prompt operation per thread is unacknowledged at a time. Further +prompts are admitted and displayed immediately but dispatched in order. This +ensures that a later prompt observes the active-turn state published by the +preceding acknowledgment. Queues belonging to different threads are +independent. -`Resume with options...` is an explicit advanced operation: +Only the correlated `turn.start` or `turn.steer` completion callback can +acknowledge a prompt. A successful callback begins a 500-millisecond accepted +transition. Every submission carries a unique `clientUserMessageId`, allowing +the authoritative user item to inherit the local card's stable visual key even +when multiple prompts have identical text. Failure stops the animation and +leaves an explicit error card. -```text -RESUME THREAD WITH OPTIONS - -Base instructions -[ editable ] - -Developer instructions -[ editable ] - - Cancel Resume -``` - -It permits the same historical thread to continue under modified foundational instructions. - -This is considered an **expert operation**. Normal `Open` resumes using the existing context without interruption. - -## 16. Interrupt - -`Interrupt` is visible when the thread has a running turn. - -```text -Thread A Working... - right-click - | - v - Interrupt -``` - -This is important because multiple Codex threads can be running concurrently. The user should not have to switch to a thread merely to stop its active work. - -## 17. Archive / Unarchive - -`Archive` is the normal non-destructive way to remove completed threads from the active workspace. - -```text -Active Threads - | - +-- Archive --> Archived Threads -``` - -Archived threads remain discoverable and expose `Unarchive` in their context menu. - -Archive should generally be preferred over Delete. - -## 18. Delete - -`Delete...` is destructive and visually separated from ordinary actions. It requires explicit confirmation, for example: - -> Permanently delete "AISuite Performance"? - -If deletion is unsafe while a turn is running, it should be disabled rather than implicitly interrupting and deleting. - -## 19. Temporary threads - -Temporary/ephemeral behavior is a creation-time property: - -```text -[ ] Temporary thread - Do not persist this thread to history -``` - -It belongs under Advanced Thread Options because it changes the lifetime and persistence semantics of the thread. It is not a next-turn setting. - -## 20. Overall lifecycle - -```text - + NEW THREAD - | - v - app-server defaults - | - optional Advanced - +-- Temporary - +-- Base instructions - +-- Developer instructions - | - v - FIRST TURN - +-----------------+ - | Prompt | - | Model | - | Reasoning | - | Workspace | - | Access | - | Approval | - +-----------------+ - | - Start - | - v - THREAD - | - v - NEXT TURN - +-----------------+ - | Prompt | - | current Model | - | current Reason. | - | current Access | - | ... | - +-----------------+ - | - optionally change - | - Send - | - v - changed values become - current inherited values - | - v - NEXT TURN... -``` - -Thread actions are orthogonal to that turn lifecycle: - -```text -Existing Thread - | - +-- Open - | +-- automatic resume - | - +-- Rename - | - +-- Fork - | +-- new thread - | foundational instructions editable - | - +-- Resume with options - | +-- same thread - | foundational instructions editable - | - +-- Interrupt - | +-- active turn only - | - +-- Archive / Unarchive - | - +-- Delete -``` +Prompt dispatch waits for once-per-connection-generation thread hydration. A +provider-marked `notLoaded` thread is resumed first. A transient +thread-not-found submission result triggers one resume-and-retry; a repeated +failure becomes the card's terminal error. Failed hydration leaves the composer +draft intact and requires an explicit reload before admission. Dispatch +rechecks connection and recovery ownership at its queued execution boundary, so +a disconnect cannot send and an in-flight resume cannot overlap a hydration +read or another turn operation. -## 21. Design principles carried into Figma +## Start, steer, and interrupt -The Figma redesign should preserve these principles: +- An idle loaded thread uses `turn.start`. +- An active thread uses `turn.steer` with the stable active turn ID. +- A not-loaded thread is resumed before starting its turn. +- Stop uses `turn.interrupt` for the stable active turn ID. -1. **One editable control per mutable execution setting.** Do not duplicate Model, Reasoning, Access, etc. at thread and turn level. -2. **Place mutable execution settings at the next-turn boundary.** This is where users need to understand and modify them. -3. **Use Codex/app-server defaults for new threads.** Avoid an unnecessary creation-configuration step. -4. **Keep creation-only and foundational options progressive.** Temporary mode and foundational instructions belong under Advanced Thread Options. -5. **Historical truth is read-only.** A historical turn may display its effective execution settings but never edits them. -6. **Normal resume is invisible.** Opening an existing thread should not present protocol mechanics to the user. -7. **Fork is the preferred semantic boundary for changed foundational instructions.** Resume with Options remains available for expert use. -8. **Thread actions are state-dependent.** Interrupt, Archive/Unarchive, and destructive operations appear only when meaningful and safe. -9. **Archive before delete.** Non-destructive lifecycle management should be easier than permanent deletion. -10. **Protocol capability does not automatically imply UI exposure.** Every control must justify itself as a useful user-facing concept. +CodexUI does not fabricate turns or infer active identity from row position. -## 22. Open design questions +## Conversation hierarchy -The following are intentionally left for later UX/Figma exploration: +The app-server's authoritative hierarchy supplies the single semantic grouping +level in the conversation: one section per turn, with items retained in their +exact server order. A turn boundary is taken from the stable turn ID and never +inferred from the presence of a visible user-message card; reviews, automation, +or another controlling client may initiate work without a prompt typed in this +window, while Steer adds input to an existing turn. + +Operational items remain individual cards inside their turn; there is no +second Activity batch or arbitrary visible grouping. Pending prompts remain +thread-local presentation cards until acknowledgment supplies their +authoritative turn and item position. -- Exact visual layout of the next-turn execution controls. -- Which execution settings remain permanently visible versus move under `More...`. -- Exact thread-header presentation and how much current configuration it summarizes. -- Historical turn-detail presentation and which effective settings are useful enough to show. -- Archived-thread discovery/filtering UI. -- Exact confirmation and disabled-state behavior for destructive operations while work is running. -- Keyboard shortcuts for Open, New Thread, Interrupt, Archive, and related actions. +`PresentationModel` is the retained normalized source. A pure projection adds +local prompt admissions and emits stable keyed turn sections and cards. Initial +display and all updates use the same reconcile path; retained card widgets are +mutated in place, and a visually identical projection performs no layout work. + +## Thread lifecycle actions + +The thread context menu operates on the stable ID under the pointer and offers +Reload, Rename, Fork, Archive/Unarchive, and Delete. Opening the menu does not +change the selected thread. Mutating actions require controller authority. + +## Conversation and composer layout + +The conversation is independently scrollable above a bottom-anchored settings +and composer surface. The message viewport reserves the surface's canonical +height. Further composer growth overlays the viewport without resizing it. A +matching trailing spacer in the conversation supplies enough additional range +to scroll the final message above the overlay with the normal visual gap. + +Composer growth preserves the current reading position even when it was at the +old bottom. Reaching the extended range's new bottom enables automatic +following again. Composer contraction removes the spacer; a Qt range clamp at +the former bottom is accepted. Incoming content follows with a short, +interruptible animation only while the conversation is at its current bottom. +While following is paused, the first visible stable card and its pixel offset +anchor the reading position across appends, card reflow, and reconstruction. diff --git a/docs/codex-architecture.md b/docs/codex-architecture.md new file mode 100644 index 0000000..c8eea69 --- /dev/null +++ b/docs/codex-architecture.md @@ -0,0 +1,1432 @@ +# CodexUI Architecture + +## 1. Purpose + +CodexUI is a remote frontend for `codex-bridge`. It uses the AISuite +`ai::openai::codex` frontend proxy SDK and presents Codex app-server behavior +without introducing another backend, protocol authority, or retained semantic +store. + +The architecture has three explicit boundaries: + +```text +Qt presentation + <-> normalized UI command/event protocol +SNode.C client runtime + codex frontend proxy SDK + <-> slim codex-bridge envelope over a selected SNode.C transport +codex-bridge + <-> native Codex app-server JSON-RPC +Codex app-server +``` + +The Codex app-server remains authoritative for Codex account, configuration, +model, thread, turn, item, plan, tool, approval, and persistence semantics. +`codex-bridge` adds multi-client routing and telemetry. CodexUI adds only +client-local interaction and presentation state. + +## 2. Runtime Object Graph + +CodexUI has two main operating-system threads. A Codex conversation thread is +a protocol object and is unrelated to these execution threads. + +```text + CodexUI process + + Qt GUI thread SNode.C client thread + +------------------------+ +---------------------------+ + | Qt application loop | | SNode.C event loop | + | widgets | | selected client transport | + | presentation model | | ClientConnection | + | normalized UI events | | frontend proxy SDK | + | user interaction | | protocol normalizer | + +-----------+------------+ +-------------+-------------+ + | | + | bounded full-duplex Unix socketpair | + +--------------------------------------------+ + | + v + codex-bridge + | + v + Codex app-server +``` + +The SNode.C side has the same principal application shape as +`codex-bridge-client`. The socketpair gateway replaces that application's +interactive stdin parser and terminal presenter: + +```text +Qt command gateway + -> ClientSession-equivalent dispatcher + -> ai::openai::codex::frontend::CodexBridge + -> frontend::client::ClientConnection + -> exactly one enabled SNode.C client transport +``` + +All objects have explicit application ownership. Socket contexts, +subprotocols, and factories borrow the SDK/mediator they need. No singleton is +required. + +## 3. Thread Ownership + +### 3.1 Qt GUI Thread + +The Qt thread exclusively owns: + +- `QApplication`, the Qt event loop, and all GUI objects; +- selected thread, selected tab, scroll, expansion, draft, and focus state; +- the `PresentationModel`, which is the sole retained authoritative store for + normalized presentation state; +- rendering and user-action translation; +- correlation of normalized UI operation results with UI intents. + +Only the Qt thread may mutate Qt objects or presentation state. It performs no +bridge transport, app-server framing, JSON-RPC correlation, or typed app-server +decoding. + +### 3.2 SNode.C Client Thread + +The SNode.C thread exclusively owns: + +- the SNode.C event loop; +- the selected frontend transport and its connection lifecycle; +- `ai::openai::codex::frontend::CodexBridge`; +- `frontend::client::ClientConnection` and transport adapters; +- frontend SDK method execution and callbacks; +- bridge-envelope and native app-server message classification; +- app-server JSON-RPC request/response/server-request correlation; +- typed protocol decoding and normalization into bounded UI events; +- bridge connection, role, and diagnostic telemetry. + +The SNode.C thread never accesses widgets or Qt presentation objects. + +Conversation discovery remains latency-sensitive. On connection CodexUI asks +only for the thread list; selecting a thread can therefore issue its +`thread/read` without waiting behind unrelated catalog traffic. The complete +account, configuration, model, permission, skill, hook, plugin, app, and MCP +catalog set is queried lazily when its presentation surface is opened. These +are fresh app-server requests, not a CodexUI or bridge cache. + +The shared provider handshake is owned by `codex-bridge`, not by any frontend. +Its `initialize` request advertises `experimentalApi: true`, making the complete +generated experimental feature types and typed list/enablement operations +available through the frontend proxy SDK. CodexUI does not perform a second +provider initialization. + +## 4. Inter-Thread Socketpair + +One unnamed full-duplex Unix socketpair is the only cross-thread transport: + +```text +Qt endpoint: commands ->, events <- + AF_UNIX SOCK_STREAM socketpair +SNode.C endpoint: commands <-, events -> +``` + +The implementation uses: + +- `AF_UNIX`, `SOCK_STREAM`, `SOCK_NONBLOCK`, and `SOCK_CLOEXEC`; +- one endpoint registered with Qt through `QSocketNotifier`; +- one endpoint registered with the SNode.C descriptor event system; +- bounded JSONL frames in both directions; +- bounded socket and application write queues; +- exclusive endpoint ownership and deterministic close behavior. + +The socket buffers are both the bounded queues and the readiness mechanism. No +parallel in-memory queue, condition variable, eventfd, or other wakeup +descriptor is added. + +The local `SocketPair` follows the ownership shape of SNode.C's +`core::pipe::Pipe`: movable, noncopyable, error-reporting, and responsible for +closing descriptors it still owns. Its endpoint adapters contain no CodexUI +presentation policy so the primitive can move into SNode.C later. + +Named pipes/FIFOs are not used. They add names, filesystem cleanup, directional +composition, and discovery semantics that two threads in one process do not +need. Socketpair overhead is immaterial for the expected control/event volume. + +## 5. CodexUI Presentation Protocol v1 + +### 5.1 Boundary and Reuse + +The Codex app-server protocol terminates in the SNode.C thread. Every normal +socketpair message uses the presentation protocol identified by: + +```json +{"protocol":"codexui.presentation","version":1} +``` + +No bridge envelope, JSON-RPC envelope, native app-server method name, Qt object +name, widget pointer, or widget identifier is part of the normal contract. Qt +does not parse app-server methods or correlate app-server JSON-RPC IDs. + +The protocol is transport-neutral JSON. A stream transport carries one bounded +JSON object per JSONL line. A browser WebSocket carries the same object in one +text message. Browser and Qt consumers therefore share the same reducer and +event semantics without sharing Qt classes or the internal socketpair. + +### 5.2 Frame Grammar + +Exactly three frame kinds cross the socketpair: + +| `kind` | Direction | Purpose | +| --- | --- | --- | +| `command` | UI to SNode.C | Asynchronous user or lifecycle intent | +| `result` | SNode.C to UI | One terminal result for a correlated command | +| `event` | SNode.C to UI | Unsolicited presentation-state or diagnostic update | + +All frames contain `protocol`, `version`, and `kind`. A command contains +`action` and `data`; commands expecting a result also contain +`correlationId`. A result contains `action`, `correlationId`, `ok`, and either +`data` or `error`. An event contains `type` and `data`. + +Every SNode.C-to-UI frame contains: + +- `sequence`: process-local, monotonically increasing output sequence; +- `generation`: bridge connection generation; +- `authority`: `none`, `merge`, `replace`, or `remove`; +- optional `scope`: stable `threadId`, `turnId`, `itemId`, `requestId`, or + `processId` identities represented by the frame. + +`correlationId` identifies one asynchronous command/result exchange. It never +identifies a widget or a presentation entity. Widgets are reached indirectly +through the reducer using stable IDs in `scope` and domain data. + +Sequence zero is reserved for a Qt-local diagnostic that did not cross the +socketpair. Such a diagnostic has no state authority. + +### 5.3 Authority + +Authority has one meaning across all domains: + +- `none`: telemetry, notice, or diagnostics; no retained-domain authority; +- `merge`: update only represented fields and preserve omitted fields; +- `replace`: replace exactly the represented scope and collection completeness; +- `remove`: remove exactly the stable scope identified by the frame. + +An omitted field is unchanged. It is never an implicit deletion. Empty data is +authoritative only when accompanied by `replace` or `remove` for an explicit +scope. Unknown event types and diagnostics never mutate retained conversation +state. + +### 5.4 UI-to-SNode.C Commands + +The v1 command catalog used by the application is: + +| Action | Result | Meaning | +| --- | --- | --- | +| `runtime.shutdown` | no | Orderly SNode.C runtime shutdown | +| `connection.connect` | no | Connect the selected configured frontend transport | +| `connection.disconnect` | no | Explicitly disconnect the selected frontend transport | +| `connection.reconnect` | no | Explicit bridge transport reconnect | +| `connection.configure` | yes | Apply a transient endpoint selection and connect it | +| `controller.claim` | no | Request controller ownership | +| `controller.release` | no | Release controller ownership | +| `threads.list` | yes | Discover threads without deletion authority | +| `thread.read` | yes | Read one thread with full turns where available | +| `thread.create` | yes | Start a thread | +| `thread.resume` | yes | Resume a thread through app-server semantics | +| `thread.fork` | yes | Fork a thread through app-server semantics | +| `thread.rename` | yes | Set a thread name | +| `thread.archive` | yes | Archive a thread | +| `thread.unarchive` | yes | Unarchive a thread | +| `thread.delete` | yes | Delete a thread | +| `models.list` | yes | Read the available model catalog | +| `turn.start` | yes | Start a turn in an idle thread | +| `turn.steer` | yes | Steer the identified active turn | +| `turn.interrupt` | yes | Interrupt the identified active turn | +| `pending-request.resolve` | no | Send typed result/error for a server request | +| `diagnostic.raw.send` | no | Explicit development-only native JSON path | + +The implemented typed action catalog additionally covers: + +- thread goals, metadata, sections, compaction, rollback, shell commands, + guardian decisions, item injection, loaded-thread discovery, and unsubscribe; +- reviews and experimental-feature listing and enablement; +- account read, login, login cancellation, logout, rate limits, token usage, + reset-credit consumption, credit nudges, and workspace messages; +- configuration read, requirements read, single-value write, and batch write; +- model-provider capabilities and permission-profile discovery; +- skills, hooks, marketplaces, plugins, plugin sharing, and apps; +- MCP status, refresh, OAuth login, resource reads, and tool calls; +- filesystem reads, writes, metadata, directory operations, copy/remove, and + watch management; +- one-off command execution, stdin writes, resize, and termination; +- external-agent configuration discovery/import/history, fuzzy file search, + feedback upload, and Windows sandbox setup/readiness. + +Every action is dispatched through its generated AISuite codex operation type. +Qt sends semantic presentation action names and typed `data`; native app-server +method names do not cross the regular socketpair contract. `initialize` and +`initialized` are deliberately absent because the bridge owns the one shared +provider handshake. + +Commands are asynchronous. No Qt call blocks waiting for SNode.C. Unsupported +correlated actions receive one `result` with `ok:false` and a structured error. + +### 5.5 SNode.C-to-UI Results + +Results preserve their originating `action` and `correlationId`. The currently +reduced result payloads are: + +- `threads.list`: `threads`, `nextCursor`, and `backwardsCursor`, with `merge`; +- `thread.read`: returned `thread`, with `merge` because the current app-server + read projection can omit live-only Plan, Agent, command, and Changes detail; +- `thread.create`, `thread.resume`, and `thread.fork`: returned `thread`, with + `merge`; +- `thread.rename`, `thread.archive`, `thread.unarchive`, and `thread.delete`: + terminal operation status; their app-server notifications carry state + authority; +- `models.list`: `models` and `nextCursor`, with `replace`; +- `turn.start`: returned `turn`, with `merge` scoped to its thread; +- all other successful actions: typed result data with `none` until a reducer + explicitly declares a presentation scope. + +A failed result contains a structured `error` and has no state authority. + +### 5.6 Event Vocabulary + +The core retained-state events are: + +- `thread.upsert`, `thread.name.changed`, `thread.status.changed`, + `thread.lifecycle`, and `thread.removed`; +- `turn.upsert`, `turn.diff.changed`, `turn.moderation.changed`, and + `plan.replaced`; +- `conversation.item.upsert`, `conversation.item.append`, + `conversation.command.interaction`, `conversation.file-change.output-appended`, + `conversation.file-change.patch-replaced`, `conversation.mcp.progress`, and + `conversation.reasoning.part-added`; +- `agents.activity.upsert`; +- `pending-request.upsert` and `pending-request.removed`. + +Connection and operational events are: + +- `connection.lifecycle`, `connection.bridge`, `connection.controller`, and + `connection.remote-control.changed`; +- `terminal.command.output-appended`, `terminal.process.output-appended`, and + `terminal.process.completed`; +- `activity.hook.started` and `activity.hook.completed`; +- `approval.review.started`, `approval.review.completed`, and + `approval.strict-review.required`. + +Catalog, account, settings, and workspace events are: + +- `account.changed`, `account.rate-limits.changed`, and + `account.login.completed`; +- `catalog.skills.invalidated` and `catalog.apps.changed`; +- `integration.mcp.login-completed`, `integration.mcp.status-changed`, and + `integration.mcp.event`; +- `workspace.project.changed`, `workspace.files.changed`, + `workspace.search.changed`, and `workspace.search.completed`; +- `settings.external-agent-import.progress` and + `settings.external-agent-import.completed`; +- thread goal, queue, project, environment, settings, token-usage, compacted, + and reverted events under the `thread.*` namespace; +- model reroute, verification, and safety-buffering events under `model.*`. + +Realtime and platform events are normalized under `realtime.*` and `system.*`. +Warnings and errors use `notice.added`. Unknown or malformed input uses +`system.diagnostic`. Every generated app-server notification is either mapped +to one of these semantic event types or produces a diagnostic-only event; it is +never forwarded as generic presentation state. + +### 5.7 Pending Requests + +All app-server server-request families normalize to +`pending-request.upsert`. Its data contains the native stable request ID, a +presentation category, and typed request data. Categories are: + +- `command-approval`, `file-change-approval`, `user-input`, + `mcp-elicitation`, and `permissions-approval`; +- `dynamic-tool-call`, `authentication-refresh`, and `attestation`. + +Resolution uses `pending-request.resolve` in the other direction and +`pending-request.removed` when authoritative resolution is observed. Secret +request content is not copied into diagnostics. + +### 5.8 Raw JSON and Compatibility + +The codex SDK preserves complete native app-server JSON and unknown fields in +its generated C++ values on the SNode.C side. The regular socketpair boundary +carries bounded normalized presentation data, including only the native fields +needed to render and answer a pending request. The request object is retained +transiently until that request is resolved and is never rendered as a raw dump. +Arbitrary raw JSON crosses the boundary only through the explicit bounded +`diagnostic.raw.send` development action. Raw data is not normal UI state, +deletion authority, or an escape from typed normalization. + +Consumers reject an unsupported protocol name or major version. They ignore +unknown semantic event types without deleting state. New optional fields, +actions, and event types are backward-compatible within version 1 when old +consumers can safely ignore them. Any change to frame meaning, authority, or +identity requires a new major version. + +## 6. Presentation Authority and Reduction + +Qt owns `PresentationModel`, the sole retained authoritative store for +normalized presentation state. Widgets and projections read from it; they do +not retain competing copies of thread, turn, item, plan, agent, request, or +global-domain state. The app-server remains the semantic and persistence +authority, so the model is not a persistence layer or substitute for +app-server history. + +Presentation reduction follows these rules: + +1. Stable `threadId`, `turnId`, `itemId`, agent-thread ID, and request ID define + identity; row position never defines identity. +2. Incremental events merge only fields they represent. +3. Deltas append to the identified field of the identified item. +4. A richer completed item is not degraded by a later partial item view. +5. Authoritative replacement is honored only when the normalized event marks + the represented scope and completeness explicitly. +6. Explicit removals remove exactly their identified scope. +7. Unknown, malformed, stale-generation, or diagnostic-only events do not + mutate retained presentation content. +8. Thread/turn completion does not itself remove completed activity. + +This prevents an incomplete publication from acquiring accidental deletion +authority while preserving the app-server's explicit authority. + +## 7. Thread Selection and Interaction + +The selected Codex thread is user-owned UI state. + +- A thread created or updated by another frontend does not change selection. +- Incoming activity in a parallel thread does not change selection. +- Controller changes, reconnects, list refreshes, and read completions do not + change selection merely because another thread is newer. +- User selection changes the selected thread. +- A user-initiated local new-thread action may select its returned thread as + part of that same explicit intent. +- An explicitly removed selected thread may clear selection. + +There is no automatic switch to the newest, active, or newly created thread. + +For an idle selected thread, submitting a prompt starts a new turn. For an +active selected turn, a steering action uses the app-server steering operation +rather than fabricating another local turn. Interrupt targets the stable active +turn ID. + +Switching threads or inspector tabs while turns, plans, commands, agents, or +requests are changing must not stop, reset, or reorder those lifecycles. + +Selecting a thread hydrates it once per bridge connection, including when the +thread-list projection already reports materialized or active turns. The +`thread.read` result is merge-authoritative: it fills reconstruction data but +does not erase retained live-only Plan, Agent, or Changes details that the +provider omits. This explicit hydration state prevents a partial discovery +projection from being mistaken for an operation-ready thread. Reload is the +explicit forced fresh-read operation. + +### 7.1 Upcoming-Turn Settings + +The real shell has a codex-native upcoming-turn settings surface backed by the +normalized `PresentationModel`. Its primary controls are: + +- model and model-constrained reasoning effort; +- sandbox access and the sandbox-native network choice; +- workspace; +- approval policy; +- personality/style. + +The compact More menu contains the named permission profile, approval +reviewer, service tier, reasoning summary, and collaboration mode. Model, +effort, service-tier, and permission-profile choices are populated from fresh +app-server catalogs. A named permission profile and a sandbox policy are +mutually exclusive, matching the native app-server contract. + +The settings object is a transient draft bound to the stable selected thread +identity. User changes are serialized into native `thread/start` and +`turn/start` fields; untouched fields remain omitted so UI defaults cannot +replace provider state. Collaboration mode is the deliberate exception: +app-server may retain Plan mode without returning it from a later +`thread/read`, so every `turn/start` explicitly sends the Code or Plan mode +currently displayed by CodexUI. The new-thread workspace always has an +explicit local fallback. Settings are disabled while steering because +`turn/steer` does not accept upcoming-turn configuration. No setting is +persisted by CodexUI or treated as canonical before the app-server publishes +it. + +### 7.2 Thread Creation and Per-Thread Actions + +New thread creation starts with a canonical custom dialog. It captures the +workspace, optional thread name, optional base and developer instructions, and +the native ephemeral flag. The dialog creates only a transient draft. CodexUI +does not create an empty provider thread until the user submits the first +prompt, so canceling or switching away cannot leave a phantom app-server +thread. Model, reasoning, access, permission, style, service-tier, reviewer, +and collaboration choices remain in the shared upcoming-turn controls rather +than being duplicated in the dialog. + +Workspace selection uses the shared custom file browser in directory-only +mode. It validates that the selected directory exists and returns an absolute +local path. The accepted workspace is encoded as the native `thread/start` +`cwd`; CodexUI does not persist it as an application preference. + +The visual shell's thread sidebar has no global More menu. A right-click +context menu is created for the stable thread ID under the pointer and exposes +Reload, Rename, Fork, Archive/Unarchive, and Delete. Read-only Reload remains +available to an observer; mutations require the connected controller role. +Opening or invoking the menu does not select the row or disturb the thread +currently being reviewed. The permanent development harness may retain compact +diagnostic controls that are not part of the product UI/UX. + +### 7.3 Message Attachments + +The composer opens the same custom file browser in multi-file mode. It supports +up to sixteen unique files and reports detected MIME type and size. Local +admission moves the prompt and attachments into a per-thread pending card and +immediately clears the composer so another prompt can be entered. Images become +native `localImage` input, audio becomes `localAudio`, and other files become +native `mention` input. + +These are app-server local-path references, not bytes uploaded through +`codex-bridge`. The provider must be able to access the selected path. This is +correct for a local CodexUI/app-server workspace and remains explicit for a +remote bridge topology; adding remote file transfer would require a separate +bounded protocol and security design. + +### 7.4 Changes and Diff Presentation + +The Changes inspector uses a dedicated diff viewer. It prefers the latest +authoritative `turn.diff.changed` domain for the selected thread. When that +live domain is absent after reconstruction, it may display diffs explicitly +retained in app-server `fileChange` items and labels that source as a fallback. +It never invents a patch by comparing local files. + +Unified diffs are separated by file, counted for additions and deletions, and +rendered read-only with fixed-width text and addition, deletion, header, and +hunk highlighting. A user can select a file, copy its patch, or open an +expanded viewer. The file list and patch view retain canonical CodexUI sizing, +colors, controls, and scrollbars. + +### 7.5 Conversation Projection and Prompt Admission + +The selected conversation is a pure projection of `PresentationModel` plus +client-local prompt admissions. Its one structural grouping level is the +app-server turn: each retained turn contributes one transparent section, and +its items remain in exact server order. A turn is identified only by its stable +turn ID; CodexUI does not infer a turn boundary from a user-message card. + +Authoritative cards use the stable `(threadId, turnId, itemId)` identity. +Locally admitted cards use a process-wide submission identity that remains +stable when a new-thread draft receives its app-server thread ID. Initial +render and later updates use the same keyed reconcile path. Existing widgets +are updated in place, absent keys are removed, new keys are inserted at their +projected positions, and an identical typed projection is a true visual no-op. + +Prompt admission and app-server acknowledgment are separate states. On Send or +Steer, CodexUI immediately appends a client-local pending user card to the +destination thread. The card uses a muted blue user-prompt treatment and a +Qt-painted highlight sweeping left and right until the correlated app-server +result callback arrives. Only the matching `turn.start` or `turn.steer` +completion callback acknowledges the prompt; conversation events cannot infer +acknowledgment. Each request carries a unique `clientUserMessageId`, allowing +the resulting user item to bind exactly even when prompts have identical text. +A fast successful result retains a 500-millisecond accepted transition so the +state change remains visible. Pending cards survive thread switching and +become normal authoritative user messages when the corresponding app-server +item materializes. The pending and authoritative forms share one visual key +and anchor during replacement. Once materialization and the accepted transition +are complete, the prompt coordinator releases dispatch-only payload while +retaining that lightweight identity alias. Failure produces a retained error +card. + +The composer remains enabled while acknowledgments are outstanding. Multiple +prompts may be admitted, but CodexUI dispatches them sequentially per thread so +each operation observes the turn state established by the preceding result. +Queues for different threads are independent. New-thread prompts remain bound +to the explicit creation draft until `thread.create` returns its stable ID. +Dispatch waits for explicit connection-generation thread hydration. A +provider-marked `notLoaded` thread is resumed first, and a transient +thread-not-found submission result permits exactly one resume-and-retry before +becoming a terminal error. Failed hydration rejects local admission without +clearing the composer draft; an explicit reload is required before sending. +Transport eligibility is rechecked at the queued dispatch boundary: a +disconnect leaves the prompt queued until bridge-open re-drives dispatch, and +an in-flight resume gates both hydration reads and turn operations. + +The conversation smoothly follows new content only while its vertical scrollbar +is at the bottom. Geometry bursts retarget a short monotonic animation to the +latest maximum. Manual upward scrolling interrupts that animation immediately +and pauses following until the user returns to the bottom. Programmatic Qt +range clamps from card reflow do not change this user-owned state. While paused, +the first visible stable card and its viewport offset anchor the reading position +across appends, card reflow, and reconstruction. Wheel and touchpad events over +non-scrollable center-pane chrome and splitter handles are forwarded to the +conversation. Nested scrollable controls consume an event only while they can +move in that direction and return edge events to the conversation. +Follow/pause mode and the stable anchor are stored per thread and restored when +the user returns to that thread. + +The update pipeline compares each card's typed visible projection. +Protocol-only changes cannot mutate widgets or scroll state. All visible item +changes in one reconcile are measured and applied inside one paint-suppressed +layout transaction, followed by one scroll settlement. This is especially +important for Command execution cards, whose streaming output and bounded +nested viewer alter geometry. New authoritative items are inserted at their +server-ordered layout position without rebuilding retained cards. While +following is paused, the effective history window expands with appends so its +stable visual anchor cannot be evicted; the requested bound is restored when +following resumes. + +The bottom composer overlay has a canonical in-layout reservation. As multiline +input, attachments, settings, or attention controls grow beyond that height, +the conversation viewport keeps its geometry and the composer overlays its +lower portion. A content-owned trailing spacer grows by the same extra height, +extending the natural `QScrollArea` range so the final card can be scrolled +above the overlay with the normal gap. The scrollbar maximum is never assigned +manually. + +Spacer growth temporarily suppresses range-driven bottom following and restores +the previous scrollbar value, so existing messages do not move. Reaching the +new maximum re-enables following. Composer contraction removes the spacer; Qt +may clamp the value to the reduced range, and being at that maximum re-enables +following for later content. + +### 7.6 Command Execution Output and Info Viewers + +Command execution output controls exist only for printable, non-whitespace +output after terminal control sequences are ignored. Empty, whitespace-only, and +ANSI/control-only results create no black output surface. A shown control grows +from zero content height to a 220-pixel maximum. Its width-dependent content +height is measured synchronously inside the conversation update transaction. +Streaming output and command completion mutate the retained outer card in +place; a protocol update with an unchanged visible fingerprint touches neither +the widget nor scroll state. Beyond the maximum the output control uses the +shared styled vertical scrollbar. It follows appended output only while already +at its bottom; manual upward scrolling pauses following, and the state is +retained across in-place output updates. + +The Info tab's State and Protocol viewers use the same scrollbar styling and +show vertical scrollbars only when required. The Protocol log owns the tab's +expanding region and its statistics summary is placed below the log. Inspector +content is read from retained per-thread presentation snapshots; selecting an +already materialized thread does not temporarily clear Plan, Agents, Changes, +or Requests while unrelated frames are processed. + +## 8. Plans and Agents + +### 8.1 Plans + +`turn/plan/updated` is the canonical structured plan update. A normalized plan +replacement carries the thread ID, turn ID, optional explanation, and ordered +steps with `pending`, `inProgress`, or `completed` status. + +Plan presentation is retained across tab and thread switching. It changes only +for the identified turn and is cleared only by an explicit authoritative empty +or replacement event for that turn. A completed textual plan item may be shown +as conversation activity. When no structured plan survives a fresh +`thread/read`, the Plan inspector renders the newest retained textual plan item +as a read-only compatibility view; it never overrides a newer authoritative +structured turn plan. + +### 8.2 Agents + +Agent presentation is derived from typed collaboration data, especially +`collabAgentToolCall` and `subAgentActivity` items. It retains, when supplied: + +- tool operation and stable item ID; +- sender thread ID; +- receiver/agent thread IDs; +- prompt; +- requested model and reasoning effort; +- current tool-call status; +- last known per-agent state and path/activity details. + +Completion must not collapse this information into only a generic +"Subagent activity completed" row. Completed and failed agent activity remains +inspectable as part of its owning turn. Later partial events may update status +without erasing richer agent identity or prompt data. + +Only spawn operations create agent rows. Provisional spawn starts without a +child identity are not independently presented, and `wait`, `sendInput`, and +other collaboration operations update an already identified child rather than +being counted as additional agents. Once supplied, the child thread ID is the +stable presentation identity across spawn completion, child activity, wait, +and result events. + +App-server may publish a parent `subAgentActivity(kind=started)` and later +complete the child thread without replacing the parent item with a completed +variant. CodexUI correlates those authoritative records by `agentThreadId` and +projects child turn status and retained child result into the original parent +activity. This is transient presentation correlation, not backend state. +Identified subagent implementation threads remain addressable for correlation +but are omitted from the ordinary top-level thread list. When one is already +user-selected, the sidebar retains that visible row across subsequent +navigation for the session. An authoritative thread removal still drops it. + +The Agents view follows the currently selected thread; it never selects an +agent thread or parent thread automatically. + +## 9. Pending Requests and Attention State + +App-server-initiated requests are normalized into explicit pending-request +events using the native stable JSON-RPC request ID and associated thread ID. +Supported request families include approvals, user input, MCP elicitation, +permission approval, dynamic tool calls, and other generated server-request +types. + +The Requests view presents each pending request independently. Command and +file-change approvals use native decision enums, user-input answers preserve +question IDs and support options/free text/secret input, MCP form responses +return structured JSON, and permission approvals preserve the requested +permission object and selected turn/session scope. Dynamic tools unavailable +in CodexUI return a typed failed-tool response. Authentication, attestation, +and unknown capabilities receive an explicit JSON-RPC error rather than +remaining pending indefinitely. Canceling the dialog itself does not resolve +the request. + +The UI attention/brown state is derived only from currently unresolved pending +requests associated with that thread. It is not inferred from historical item +status or retained across process restart without fresh provider evidence. + +A pending request is retired exactly once when: + +- its typed response/error is accepted and the corresponding resolution is + observed; or +- `serverRequest/resolved` identifies that same request; or +- the owning connection/generation terminates and the request can no longer be + answered by this frontend. + +Resolution matching uses stable request identity plus available thread and +connection generation context. The presentation request record retains that +generation. A mismatch is diagnostic and must not retire an +unrelated request. Resolved request content is removed from actionable UI while +non-secret lifecycle diagnostics may remain observable. + +## 10. Controller and Observer Roles + +The bridge permits one controller and multiple observers. + +- The controller may mutate Codex state, steer turns, and answer server + requests. +- Observers receive fanout events and may use bridge-approved read operations. +- Mutating observer operations fail visibly rather than appearing accepted. +- Controller claim and release are explicit. +- No frontend silently steals control. +- A disconnected controller is not replaced by automatic promotion. +- Thread selection is independent of controller ownership. + +CodexUI displays connection identity and role. Controls requiring authority are +disabled or produce a precise role error while CodexUI is an observer. A local +policy may request initial control explicitly, but role assignment remains a +bridge decision reported through telemetry. + +Connection controls sit immediately to the left of Claim/Release control +because transport lifecycle and controller ownership are distinct operations. +The menu exposes Configure, Connect, Disconnect, and Reconnect. It never claims +control as a side effect. + +## 11. Recovery, History, and No-Cache Policy + +CodexUI does not request or reconstruct an AISuite-owned snapshot because +codex has no snapshot authority, replay store, frontend `State`, or backend +semantic cache. + +Connection and process recovery uses fresh app-server queries through the +bridge: + +1. establish the frontend transport and observe bridge readiness/role; +2. issue `thread/list` for discovery; +3. issue `thread/read(includeTurns=true)` for the selected materialized thread; +4. continue applying normalized live events. + +A refresh result applies its declared authority. In particular, `thread.read` +merges represented content and has no deletion authority because the current +provider projection is incomplete. Explicit scoped remove events remain +authoritative. Temporary disconnect, incomplete discovery, request failure, or +an unknown message does not authorize clearing the existing presentation. + +Current app-server behavior may return `itemsView: "notLoaded"`, reject +`includeTurns` for an unmaterialized thread, or reconstruct less live detail +than was previously emitted under its active history mode. CodexUI reports +that provider limitation; it does not invent missing items or add an implicit +long-term history cache. Adding caching later requires a separate explicit +architecture decision covering authority, bounds, persistence, and eviction. + +## 12. External Transport and Configuration + +The socketpair is internal only. The SNode.C thread connects to `codex-bridge` +through exactly one configured frontend transport supported by codex and +SNode.C: + +- Unix stream; +- IPv4 or IPv6 stream; +- IPv4 or IPv6 TLS stream; +- IPv4 or IPv6 WebSocket; +- IPv4 or IPv6 WSS; +- RFCOMM or RFCOMM TLS where available. + +Transport and encryption do not change normalized UI semantics. WebSocket +changes framing; TLS changes transport protection. Neither creates state, +authority, or authentication semantics. + +There is no bearer-token or other codex-bridge authentication layer. Native +Codex account/login operations remain app-server protocol features and are +handled through typed SDK operations when exposed by the UI. + +Command-line configuration uses the SNode.C configuration subsystem. Any +CodexUI-specific configuration class is a `utils::SubCommand`. Existing SNode.C +instance options remain authoritative for addresses, Unix paths, IPv4/IPv6, +TLS certificates, WebSocket setup, reconnect behavior, timeouts, and queue +limits; CodexUI must not duplicate those semantics. + +The connection dialog reads the effective SNode.C client configurations to +enumerate compiled transports and provide current endpoint defaults. A user may +override the selected Unix path, IP host/port, WebSocket path, or RFCOMM +address/channel for the running CodexUI session. TLS certificate and +verification configuration remains in the corresponding SNode.C config +object. Runtime overrides are intentionally transient and are not written to a +CodexUI data file. + +Changing transport uses one asynchronous lifecycle: disconnect the attached +frontend SDK, terminate the selected SNode.C flow, wait for both to detach, +apply the new selection, then connect once. Repeated logical connect requests +cannot create parallel flows or reuse an attached SDK. Local disconnect, +reconnect, and transport-switch reasons remain distinguishable from remote +closure in normalized diagnostics. + +Quiet Codex sessions are normal, so transport inactivity read/write timeouts +default to zero (unlimited). Frame bounds, write-queue bounds, connect errors, +and explicit lifecycle controls remain enforced. + +## 13. Startup and Shutdown + +The process lifecycle is: + +```text +QApplication construction and Qt argument handling + -> core::SNodeC::init(argc, argv) + -> construct socketpair and both ownership graphs + -> start the SNode.C client thread + -> core::SNodeC::start() inside that thread + -> run the Qt event loop on the main thread + -> request inner transport shutdown + -> core::SNodeC::stop() + -> close socketpair endpoints and join the client thread +``` + +There is no `core::SNodeC::free()` call. Shutdown is asynchronous and +idempotent. Qt does not destroy objects still used by the client thread, and +the process does not exit while the SNode.C thread is still running. + +EOF or terminal failure on either socketpair endpoint initiates orderly +shutdown. External bridge disconnect does not terminate CodexUI; it produces a +normalized disconnected state and follows configured reconnect policy. + +## 14. Boundedness and Failure Semantics + +Every boundary is bounded: + +- bridge transport frame size; +- socketpair frame size; +- socketpair and transport write queues; +- bytes processed per readiness callback; +- retained diagnostics; +- UI presentation work scheduled per event-loop pass. + +No operation may block the Qt event loop or wait synchronously across threads. +Backpressure, oversized frames, malformed JSON, queue rejection, transport +closure, and callback failure produce classified diagnostics. They are not +silently converted into generic disconnects or state deletion. + +Outstanding normalized UI operations complete once with success or a concrete +failure. A disconnect clears ephemeral request correlation and role telemetry, +not Codex presentation content. Reconnect starts a new connection generation so +late results from an old generation cannot resolve new operations or pending +requests. + +## 15. Protocol Compatibility + +AISuite codex generates concrete C++ datatypes for the complete exported Codex +app-server protocol, including client requests, client notifications, server +requests, server notifications, responses, errors, nested objects, enums, and +unions. Every generated value preserves its native JSON through `getRaw()` and +preserves unknown fields. + +CodexUI uses those generated types and typed callbacks in the SNode.C thread. +Every generated server notification and request is classified into a v1 +presentation event or a diagnostic-only event. No known message silently falls +through as generic state. Unknown future messages remain observable through +bounded diagnostics and cannot mutate presentation state. The app-server +source/schema checkout is read-only and is never modified by CodexUI. + +The app-server wire is JSON-RPC-shaped but may omit the optional +`"jsonrpc": "2.0"` member. The frontend SDK owns that compatibility; Qt never +depends on the member's presence. + +## 16. Permanent Development Harness + +The codex workbench is retained as a deliberately plain development harness +alongside the final visual UI/UX shell. Both consumers use the same +`codexui.presentation` contract and `PresentationModel`; the harness does not +receive native app-server JSON through a privileged path. + +The harness provides: + +- explicit thread selection with no automatic selection changes; +- conversation, plan, correlated agent-activity, and generation-aware + pending-request inspection; +- read-only State inspection for model, account, configuration, permission, + feature, skills, hooks, plugin, app, MCP, and other retained domains; +- controller/observer role and connection-generation visibility; +- a bounded protocol log containing timestamp, sequence, generation, frame + kind, action/event type, authority, stable scope IDs, correlation ID, and + result status; +- explicit sequence-gap and non-monotonic-sequence diagnostics; +- bounded model counters for discovered threads and selected-thread turns and + items. + +The protocol log retains at most 2,000 display records. It is telemetry only: +it cannot replay frames, hydrate the presentation model, supply deletion +authority, or conceal a missing app-server result. `thread/list` discovery +preserves the order supplied by app-server for listed IDs, while IDs omitted +from a non-authoritative discovery page remain retained after that page. + +The presentation model separately retains at most 256 authority-free telemetry +records for status and warning presentation. These records cannot mutate or +hydrate conversation state. Authoritative normalized domains not requiring a +specialized reducer remain accessible at their stable global, thread, turn, or +item scope; their `merge`, `replace`, and `remove` semantics are applied before +the visual shell consumes them. + +The harness is used first when extending normalization or reduction. Once a +path is proven there, the visual shell consumes the same model and command +surface through narrow Qt adapters. The shell has no semantic snapshot or +parallel state authority. + +## 17. Implemented Components and APIs + +The canonical CodexUI implementation contains both the complete visual shell +and the permanent functional presentation harness. `ExpandingPromptEditor` and +the visual style helpers live under +`src/codex/ui` because they contain no protocol authority. + +The implementation is divided into the following concrete components: + +| Component | Responsibility | +| --- | --- | +| `Configuration` | CodexUI `utils::SubCommand`; adds only CodexUI-specific frame-size and WebSocket-path options | +| `SocketPair` | Movable RAII owner for the unnamed nonblocking `AF_UNIX` socketpair | +| `QtSocketPairEndpoint` | Qt-thread descriptor adapter using `QSocketNotifier`, bounded reads, and bounded writes | +| `SNodeSocketPairEndpoint` | SNode.C-thread descriptor adapter using `ReadEventReceiver` and `WriteEventReceiver`, bounded reads, and bounded writes | +| `FrontendSession` | Qt-side asynchronous command facade, correlation registry, lifecycle owner, and socketpair JSONL endpoint | +| `ClientRuntime` | SNode.C-thread application graph, selected transport, frontend proxy SDK dispatch, reconnect, and shutdown | +| `ProtocolNormalizer` | Native app-server/bridge input to `codexui.presentation` result/event conversion | +| `PresentationProtocol` | Frame construction, validation, authority, sequence, generation, and scope utilities | +| `PresentationModel` | Qt-owned stable-ID reducer for threads, turns, items, plans, agents, requests, global domains, and telemetry | +| `WorkbenchWidget` | Permanent development harness and user-intent adapter | +| `ShellWidget` | Product shell and protocol/application coordinator; owns stable selection, hydration, recovery, and command dispatch | +| `MiddleRegionWidget` | Three-pane visual composition and center-region wheel routing | +| `ThreadPane` | Stable-ID thread-list projection and thread actions | +| `ConversationProjection` | Pure thread-to-turn-to-card projection over `PresentationModel` and local prompts | +| `ConversationView` | Stable-key reconciliation, card geometry, per-thread follow/pause state, and anchor-preserving scrolling | +| `ConversationCard` implementations | In-place typed card presentation, including pending prompts and bounded Command execution output | +| `PromptCoordinator` | Per-thread prompt admission queues, callback-only acknowledgment, and authoritative-item correlation | +| `ComposerPane` | Bottom-anchored upcoming-turn controls, attachments, prompt editor, and overlay-height reporting | +| `InspectorPane` | Retained Plan, Agents, Changes, Requests, State, and Protocol presentation | +| `TurnSettingsWidget` | Codex-native transient settings draft and native thread/turn option encoder | +| `NewThreadDialog` | Transient native thread-start draft with workspace selection and instructions | +| `FileSelectionDialog` | Canonical directory or bounded multi-file browser shared by workspace and attachments | +| `ConnectionDialog` | Session-only selector over effective compiled SNode.C client configurations | +| `DiffViewer` | Authoritative live or retained-provider unified-diff presentation | +| `PendingRequestDialog` | Typed, generation-preserving UI for app-server server-request families | +| `MainWindow` | Top-level Qt window ownership only | +| `BrandMark` and desktop resources | Shared visual mark and the consistent `codex-ui` executable/application/window/icon identity | + +### 17.1 FrontendSession API + +`FrontendSession` is the normal Qt-side entry point. It provides asynchronous +methods for thread discovery/read/create/resume/fork/rename/archive/delete, +model and environment discovery, turn start/steer/interrupt, controller +claim/release, transport connect/disconnect/reconnect/configure, raw diagnostic +send, and typed server-request +resolution. Every correlated method returns a presentation correlation ID and +optionally invokes a Qt-thread response callback. It never blocks the GUI +thread or exposes a transport socket. + +The generic operation method: + +```cpp +request(std::string operation, + nlohmann::json parameters, + ResponseHandler handler = {}) +``` + +supports the complete generated AISuite operation catalog without adding one +Qt facade method per rarely used operation. Frequently used UI actions have +narrow named methods such as `listThreads()`, `readThread()`, `startTurn()`, +`steerTurn()`, `configureConnection()`, and `respondToServerRequest()`. + +Lifecycle is explicit: `start()` creates the endpoint/runtime graph, +`shutdown()` requests orderly asynchronous termination, and `wait()` joins the +SNode.C thread. `setEventHandler()` receives normalized frames and +`setRuntimeStoppedHandler()` reports terminal worker shutdown. + +### 17.2 Normalizer and reducer APIs + +`ProtocolNormalizer` accepts transport lifecycle, bridge telemetry, typed +server notifications, server requests, raw inbound observation, operation +success, and operation rejection. Its only output is a validated bounded +presentation frame through its sink. `knownServerMethod()` makes coverage gaps +observable rather than silently treating an unknown method as state. + +`PresentationModel::applyEvent()` is the single public reduction entry point. +The model exposes stable thread ordering and lookup, active-turn lookup, +generation-aware pending-request queries, retained global domains, bounded +telemetry, and pending-request presentation records. Internal upsert helpers +preserve complete fields across partial events, correlate child-agent threads, +and apply explicit merge/replace/remove authority. + +### 17.3 Transport availability + +The executable always builds Unix, IPv4, and IPv6 JSONL clients. TLS, RFCOMM, +WebSocket, and WSS clients are compiled when their SNode.C targets are +available. Exactly one configured client instance may be enabled. Address, +certificate, timeout, queue, reconnect, and instance-enable options come from +the corresponding SNode.C client configuration; CodexUI adds no duplicate +transport configuration. + +The build produces two independently launchable applications: + +- `codex-ui` is the normal visual UI/UX shell; +- `codex-ui-harness` is the permanent plain protocol/reducer harness. + +They compile the same `FrontendSession`, `ClientRuntime`, socketpair, +normalizer, protocol, and presentation-model sources. Only the top-level Qt +consumer differs. Neither executable has a privileged transport or state path. + +The current build links the codex AISuite frontend library as +`AISuite::OpenAICodex`, Qt Widgets, Threads, and the selected SNode.C client +modules. CodexUI CI consumes AISuite from `master`/HEAD and does not pin a +particular AISuite revision. The canonical AISuite change must therefore be +merged before the dependent CodexUI change. The AISuite dependency build is +limited to two compiler jobs because its generated protocol translation units +can otherwise exceed the hosted runner's aggregate memory. + +### 17.4 Shell settings and pending-request APIs + +`TurnSettingsWidget` owns only an upcoming-turn draft. The shell supplies fresh +provider context and catalogs through: + +```cpp +setContext(std::string identity, + const nlohmann::json &canonical, + const nlohmann::json &models, + const nlohmann::json &permissionProfiles); +setControlsEnabled(bool enabled); +``` + +`workspace()` resolves the visible workspace against the caller's local +fallback. `threadStartOptions()` emits only native `thread/start` fields, while +`turnStartOptions()` emits only native `turn/start` fields. Untouched fields are +omitted except that collaboration mode is always explicit because app-server +does not reliably reconstruct its retained value. Explicitly selecting a +provider default emits `null`; named permissions and sandbox policy remain +mutually exclusive. The three app-server +`thread/start` sandbox strings are encoded directly. The richer +`externalSandbox` object is emitted only as a `turn/start.sandboxPolicy`, where +the native protocol defines it. Reasoning efforts, service tiers, default tier, +and personality availability follow the selected model catalog. + +The native collaboration object is not a partial mask: its nested `model` is +mandatory, while `reasoning_effort` and `developer_instructions` use the +app-server schema's snake-case names. When the UI shows `Codex default`, the +encoder resolves the catalog entry marked `isDefault` and sends its concrete +model ID. Until that fresh catalog is available, CodexUI omits the otherwise +explicit collaboration object rather than constructing an invalid one. + +`PendingRequestDialog::present()` accepts one generation-preserving +`PendingRequestPresentation` and returns either no value when the user closes +the dialog or a `PendingRequestResponse` containing exactly one native result +or JSON-RPC error. `negativeResponse()` constructs the family-specific explicit +decline used by the Requests surface. The caller resolves through +`FrontendSession::respondToServerRequest()` with the stable connection +generation and request ID; the dialog never mutates presentation state itself. + +`ShellWidget` is the sole visual command adapter. It translates selection, +composer, settings, controller, thread-management, and request-review actions +into `FrontendSession` calls. Agent messages, plan text, reasoning summaries, +and agent results pass through `QTextDocument::setMarkdown()` with +`MarkdownNoHTML`; user prompts, commands, and command output remain literal. +Its custom dialogs return transient value objects and never mutate the +presentation model directly. The composer owns attachment drafts; the +connection dialog edits only the SNode.C runtime selection; and `DiffViewer` +is a read-only consumer of normalized model domains and retained provider +items. + +### 17.5 Essential Automated Architecture Tests + +The permanent automated-test policy protects architectural boundaries rather +than individual fixes, widget details, or lines of implementation. A defect +correction does not automatically justify another test. A test belongs in the +codex suite only when it validates a boundary whose failure would undermine +the application architecture independently of the particular symptom that +revealed it. + +Six focused CTest executables form the essential suite. They use production +classes directly and are built when standard CMake `BUILD_TESTING` is enabled. +CTest enables that option by default; disabling it remains the conventional +packaging choice and does not select a different runtime implementation. + +#### Socketpair Contract + +`codexui-socketpair-contract-test` exercises the actual two-thread IPC +mechanism: + +```text +QCoreApplication / Qt event loop + -> QtSocketPairEndpoint + -> nonblocking AF_UNIX SOCK_STREAM socketpair + -> SNodeSocketPairEndpoint + -> SNode.C event loop on its worker thread +``` + +The test constructs the production `SocketPair`, gives one descriptor to the +production Qt endpoint and the other to the production SNode.C endpoint, and +runs both framework event loops. Multiple newline-delimited records travel in +both directions as separately queued writes. The test establishes that byte +ordering is preserved across partial/coalesced stream delivery, both endpoint +queue bounds reject an oversized write without replacing the bound with an +unbounded buffer, and closing the Qt endpoint produces orderly closure on the +SNode.C side without a transport error. It also requires the SNode.C event loop +to terminate cleanly. The test does not introduce another IPC implementation, +polling loop, mock event loop, or synchronous cross-thread method call. + +This test deliberately treats the socketpair as an ordered byte stream. JSONL +framing and semantic interpretation remain above this boundary; duplicating +the AISuite `JsonLineFramer` tests here would test another project rather than +CodexUI's thread boundary. + +#### Presentation Pipeline + +`codexui-presentation-pipeline-test` exercises the production semantic +path without a bridge substitute: + +```text +representative native app-server and bridge records + -> ProtocolNormalizer + -> codexui.presentation v1 frames + -> PresentationModel::applyEvent() + -> coherent Qt-owned presentation state +``` + +The representative lifecycle includes connection and controller publication, +effective transport-settings publication, thread discovery, an authoritative +full thread read, a later live turn, command start, command output, command +completion, authoritative turn-diff publication, and turn completion. The +test verifies the contract at architectural granularity: every emitted frame +has the expected protocol version, monotonic sequence, and connection +generation; connection settings reduce coherently; list/read/live updates +converge on stable thread, turn, and item identities; and the completed model +contains one coherent command result and scoped diff with no active turn left +behind. It does not enumerate every generated app-server method, every +presentation field, or every historical correction. + +The normalizer sink is connected directly to the reducer because the +socketpair itself is independently covered by the first test. This keeps a +failure attributable to either inter-thread transport or semantic reduction +instead of repeating both mechanisms in every case. + +#### Conversation Projection + +`codexui-greenfield-projection-test` verifies the pure typed projection and +prompt coordinator: one section per app-server turn, stable card identity and +server ordering, per-thread prompt queues, dispatch-time Start/Steer choice, +callback-only acknowledgment, exact `clientUserMessageId` correlation, +duplicate-prompt ordering, history bounds, resolved-payload compaction, and +Command execution output visibility. + +#### Middle-Region Behavior + +`codexui-greenfield-middle-test` exercises the actual conversation widgets +programmatically. It verifies smooth follow, user-owned pause, stable +card-and-pixel anchoring across every card type and width-dependent reflow, +per-thread restoration, composer trailing space, pending-prompt animation, +and independent Command execution output sizing and scroll ownership. + +`codexui-greenfield-layout-test` verifies the three-pane constraints, composer +overlay geometry, complete center-region wheel routing, thread-list selection +projection, nested-scroll handoff, and retained Inspector/Info behavior. These +are state and geometry assertions over Qt widgets, not golden-screenshot or +pixel-perfect visual baselines. The pending-animation check compares two +transient card rasters only to prove that motion exists. + +#### Shell Integration + +`codexui-greenfield-shell-test` drives the production `ShellWidget` and +`FrontendSession` across their real socketpair presentation boundary. It +verifies exact visible-thread routing, independent prompt queues, real result +acknowledgment, background completion, retained Plan/Agents state, monotonic +hydration across reconnect, terminal callbacks, failed-hydration draft +retention, bounded child-thread reads, and one-shot thread-not-found recovery. + +#### Explicit Exclusions + +The permanent automated suite does not include: + +- a fake or scripted codex-bridge; +- a fake app-server or synthetic network server; +- external GUI-driving automation, golden screenshots, or pixel-perfect + styling baselines; +- one test per fixed issue, setting, request family, widget, or source branch; +- the Unix/IPv4/IPv6/TLS/WebSocket/RFCOMM transport matrix already owned by + AISuite and SNode.C; +- authenticated model execution, approval interaction, or assumptions about + nondeterministic model output. + +A real app-server-to-bridge-to-CodexUI turn remains a manual live acceptance +procedure. It depends on external authentication, service availability, +credits, approval policy, and model behavior, so presenting it as a +deterministic CI test would be misleading. The persistent live topology and +independent bridge observer provide that evidence without introducing a fake +bridge into the CodexUI repository. + +The six focused tests can be built and run directly: + +```sh +cmake --build "${BUILD_DIR}" --parallel 8 \ + --target codexui-socketpair-contract-test \ + codexui-presentation-pipeline-test \ + codexui-greenfield-projection-test \ + codexui-greenfield-middle-test \ + codexui-greenfield-layout-test \ + codexui-greenfield-shell-test +ctest --test-dir "${BUILD_DIR}" --output-on-failure \ + -R '^codexui-(socketpair-contract|presentation-pipeline|greenfield-(projection|middle|layout|shell))$' +``` + +Each test has a 10-to-20-second CTest ceiling. Normal successful execution is +substantially shorter and requires no network listener, credentials, isolated +Codex home, or user interaction. + +## 18. Live Harness Validation + +The harness was exercised against one persistent real topology: + +```text +Codex app-server over IPv4 WebSocket + <-> codex-bridge over IPv4 WebSocket + <-> CodexUI harness over IPv4 WebSocket +``` + +An independent `codex-bridge-client` observer remained connected to the same +bridge while CodexUI held controller ownership. The run used an authenticated +isolated Codex home and an existing persistent bridge process rather than a +simulated provider. + +Validated behavior includes: + +- initial connection, explicit controller claim/release, and observer fanout; +- fresh thread discovery followed by selected `thread/read(includeTurns=true)`; +- no automatic thread selection when another client or subagent creates a + thread; +- multiple turns, steering, structured plan updates, command execution, + command output/completion, and final answers; +- pending-request presentation and resolution without stale brown attention; +- parent/child agent correlation, child history hydration, and retained child + result presentation in the parent Agents view; +- switching among Conversation, Plan, Agents, Requests, State, and Protocol + surfaces while turns and agents were active; +- retention of an early completed marker command while later commands, plan + transitions, subagent activity, and final output arrived; +- stable presentation after turn completion, with no observed disconnect, + sequence gap, stale pending request, or retained-item disappearance. + +The final validation turn lasted about 36 seconds and included a completed +marker command, a three-step completed plan, one subagent thread, later Command +execution activity, and a final answer. At the final checkpoint the normalized +model held one top-level selected thread, three turns, seventeen items, zero pending +requests, and the retained marker and later activity simultaneously. + +A new CodexUI process was then validated against the same persistent bridge. +Selecting the completed parent thread retained all top-level rows and +hydrated the Conversation. The Plan inspector reconstructed the retained +textual plan with Markdown formatting; Changes displayed the explicit +read-only empty state; Requests remained at zero; and opening Info lazily +populated the environment State without clearing or blocking Conversation. +Controller and connection status remained stable throughout these tab +transitions. The fresh Agents view correctly remained empty because the +authoritative `thread/read` omitted all prior collaboration items, as documented +below. + +A final focused live turn requested exactly one subagent. Raw observer events +contained one completed `spawnAgent` item with child thread ID, one `wait` +operation, the child command/result, and the parent final answer. During the +turn the shell reported `1 agent | 1 active`; after completion it reported +`1 agent | 0 active` and retained one completed agent card with model, effort, +prompt, child thread ID, and result. No provisional spawn or wait row appeared. +The settings controls also displayed explicit chevrons. This run exposed one +additional compatibility defect: Code was displayed after a fresh read while +`turn/start` omitted collaboration mode and app-server silently continued its +retained Plan mode. The encoder now sends the displayed collaboration mode on +every new turn once the mandatory model has been resolved from the fresh +catalog. + +The post-fix live acceptance used frontend connection `frontend-27` and a fresh +thread. Its raw `turn/start` contained `mode: "default"`, catalog-resolved model +`gpt-5.6-sol`, and native `reasoning_effort: null` and +`developer_instructions: null` fields. App-server accepted the request, +published matching Default collaboration settings, completed the turn without +tools, and returned the requested `CODE_MODE_OK` response. + +Startup latency was traced to eager account/configuration/plugin/app catalogs +queued before the selected thread read. Startup now requests thread discovery +plus the small model and permission-profile catalogs required by the composer. +The larger environment catalog is fetched lazily when Info is first opened, +allowing the selected conversation to hydrate promptly without introducing a +cache. + +This live run proves the implemented paths exercised by the scenario; it is +not a claim that every generated operation or every optional transport has +received equivalent live coverage. The canonical build and `git diff --check` +completed successfully. Automated coverage is intentionally limited to the +socketpair contract and presentation pipeline described in Section 17.5; the +real authenticated topology remains the manual live acceptance boundary. + +## 19. Provider Limitations Observed Live + +### 19.1 Reconstruction Shortcomings + +Three app-server reconstruction shortcomings were observed live: + +1. Live parent events include `collabAgentToolCall` and child-agent activity, + but a later `thread/read(includeTurns=true)` returned both parent turns while + omitting every collaboration and subagent item. A fresh no-cache CodexUI + therefore cannot reconstruct historical Agents content. During a continuous + connection CodexUI correlates the authoritative live records by child thread + ID and keeps implementation threads out of the ordinary top-level list. +2. `turn/plan/updated` notifications produced and updated the Plan view + correctly during the live session, but a later + `thread/read(includeTurns=true)` did not return those completed plan updates + or an equivalent current-plan field. During the current session, the merge + authority of `thread.read` preserves the live structured plan. On a fresh + process, a completed textual plan item is used as the Plan inspector's + read-only fallback when available. +3. Under the configured app-server history representation, a later + `thread/read` can reconstruct generic + item IDs and omit a live command-execution item even though the live event + stream contained the richer item. + +CodexUI preserves already observed live presentation when an incomplete read +omits it, but it does not synthesize content that the process has never +observed. A fresh process therefore remains limited to the provider's +reconstruction. This merge policy is bounded in-memory presentation retention, +not a semantic cache or persistence authority. + +### 19.2 Capability Limitations + +The current app-server does not support `historyMode: "paginated"` and returns +`paginated_threads is not supported yet`. A newly started thread is also not +materialized for `thread/read(includeTurns=true)` until it receives its first +user message. + +These are provider-boundary discrepancies. CodexUI reports and renders the +authoritative result it receives; it does not hide them with a bridge snapshot, +AISuite cache, or CodexUI persistence layer. A future caching design requires a +separate explicit authority and retention decision. + +## 20. Visual Shell Integration Boundary + +The CodexUI shell is implemented in codex-owned Qt widgets. Those widgets +consume only `PresentationModel` and call only `FrontendSession`. The permanent +harness remains available as the protocol/reducer diagnostic surface and is not +itself the product shell. + +The implemented shell contains the 64-pixel top bar, hideable work sidebar, +thread list, conversation timeline and composer, hideable inspector, Plan, +Agents, Changes, Requests, and Info surfaces, explicit controller control, +connection lifecycle/configuration, canonical new-thread/workspace/attachment +dialogs, per-thread context actions, complete upcoming-turn settings, a +first-class diff viewer, pending-prompt cards, request status, and the 40-pixel +status bar. Agent +messages, plans, reasoning summaries, and agent results are +rendered with Qt Markdown parsing while embedded HTML is disabled. User text, +commands, and command output remain literal. State and Protocol diagnostics +remain nested under Info rather than dominating normal use. + +Pending-request presentation exposes category, stable request ID, connection +generation, owning thread, and a bounded set of safe typed details. The native +request object remains transiently available to the typed response dialog but +is never dumped to the shell, harness State view, notice banner, or protocol +log. Secret answers are held only by password editors until the dialog is +destroyed. + +Operation errors, provider notices, protocol diagnostics, and connection +failures produce a dismissible latest-notice banner. Its text is extracted +only from bounded message/detail fields. The complete bounded frame chronology +remains in Info/Protocol. Neither surface has state authority. + +Further shell work remains presentation-only. It must not change the socketpair +protocol, app-server normalization, model authority, bridge role semantics, +recovery policy, or AISuite codex implementation unless a proven missing +contract requires a separately reviewed change. + +## 21. Architectural Invariants + +1. The app-server is Codex semantic and persistence authority. +2. `codex-bridge` is a thin multi-client router with telemetry, not a cache. +3. The codex frontend SDK is a typed proxy, not a frontend state store. +4. SNode.C owns transport, SDK execution, protocol decoding, and normalization. +5. Qt owns widgets, interaction, selection, and transient presentation state. +6. Only normalized commands/events form the regular inter-thread contract. +7. Cross-thread work is asynchronous and bounded. +8. Partial omission is not deletion authority. +9. Stable protocol IDs, never row order, define identity. +10. Controller transfer and thread selection are explicit; neither auto-switches. +11. Plans, completed commands, and completed agent activity remain visible until + an authoritative scoped update says otherwise. +12. Pending attention exists only while a matching server request is unresolved. +13. Recovery queries app-server; no snapshot, replay store, or semantic cache is + introduced. +14. Generic Qt and SNode.C socket classes remain free of Codex-specific methods. +15. Native app-server and bridge transport failures remain distinguishable. +16. Prompt routing always uses the stable visibly selected thread; thread + creation requires an explicit new-thread intent. +17. Pending prompts are presentation state until acknowledged and are + dispatched sequentially per thread without disabling the composer. +18. Conversation and nested-output following is enabled exactly while the + corresponding scrollbar is at its bottom. Conversation following is smooth + and user-interruptible; its paused state preserves a stable visual anchor. +19. Composer growth overlays the unchanged message viewport and adds equal + trailing content space without automatically moving existing messages. +20. Thread selection hydrates once per bridge connection; prompt dispatch waits + for readiness and permits at most one resume-and-retry after a transient + thread-not-found result. +21. Nonvisual item updates do not reconstruct cards; one coalesced refresh uses + one hidden layout transaction and one scroll settlement. +22. Conversation hierarchy has exactly one semantic grouping level: stable + app-server turns containing stable server-ordered items. +23. `PresentationModel` is the only retained normalized presentation store; + conversation and inspector views are keyed projections, not parallel state + authorities. + +## 22. Resolved Presentation Decisions + +The remaining presentation-level choices are implemented as follows: + +- every incoming frame is reduced immediately; the selected conversation then + takes one typed projection snapshot and one stable-key reconcile, with + identical visible projections producing no widget or geometry work; +- the Info/Protocol view retains at most 2,000 text blocks and the presentation + model retains at most 256 authority-free telemetry records; the protocol + statistics summary is below the expanding log; +- pending prompt acknowledgment uses a per-thread animated card rather than an + application-wide busy state or composer lock; +- reaching the conversation bottom re-enables automatic following, including + after scrolling through composer-added trailing space or a contraction clamp; +- paused conversation updates preserve the first visible stable card and its + pixel offset through appends, reflow, and reconstruction; +- typed operation errors and provider notices use a dismissible latest-notice + banner, while unknown/malformed protocol input remains visible in bounded + diagnostics and never mutates retained presentation state. + +No architectural decision remains open in the canonical CodexUI implementation. +Interactive visual validation covered settings, Markdown, plans, pending +requests, and live agent lifecycle. Provider-omitted history remains visible as +an explicit reconstruction boundary rather than being hidden by client state. +The current CodexUI acceptance boundary requires focused build/tests and live +visual acceptance of thread routing, prompt acknowledgment, scrolling, +composer geometry, attachments, connection, and diff surfaces. No semantic +cache is part of this boundary. diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md new file mode 100644 index 0000000..4651a8a --- /dev/null +++ b/docs/ui-behavior.md @@ -0,0 +1,191 @@ +# CodexUI Interaction and Presentation Decisions + +This document defines the current CodexUI interaction contract. AISuite and the +Codex app-server own protocol and domain semantics; CodexUI owns only local +presentation, input, selection, and scroll state. + +## Conversation source and structure + +`PresentationModel` is the sole retained authoritative store for normalized UI +state. The message view is a projection of its selected thread plus +client-local prompt admissions; cards and inspectors do not maintain a second +domain store. + +The conversation has one semantic grouping level: an app-server turn contains +its items in server order. Authoritative cards are keyed by stable thread, +turn, and item IDs; local prompt cards are keyed by their submission IDs. The +same keyed reconcile path handles initial display and updates, mutating a card +in place when its visible data changes. An identical visible projection does +not rebuild widgets or change geometry. + +Local prompt admission resumes bottom following when the only pause was caused +by composer overlay growth, so the complete pending prompt becomes visible. +It never overrides a pause created by user scrolling. + +Mouse-wheel and touchpad gestures use Qt's native platform/device scroll +handling. CodexUI only records whether the resulting position follows the +bottom or is owned by the user. + +## Thread identity and prompt routing + +- The selected thread is identified by its stable app-server thread ID. +- Once selected, a hydrated thread remains visible in the sidebar for the + session even when it is outside the ordinary top-level thread ordering; an + authoritative removal still removes it, and other rows retain app-server + ordering. +- Sending always targets the visibly selected thread. CodexUI validates the + visible selection before dispatch and never creates a thread as an implicit + fallback for missing or inconsistent selection state. +- A new thread is created only from an explicit New Thread intent. Its dialog + captures the workspace, optional name, instructions, and ephemeral state. +- Background thread activity, list refreshes, reconnects, and creation by + another frontend never change the user's selected thread. +- Selecting a thread hydrates it once per bridge connection even when the + discovery result already contains an active turn. The full read is merged + into the retained per-thread presentation, so live Plan, Agents, and Changes + state cannot be erased by an incomplete reconstruction. Reload remains the + explicit forced fresh-read action. + +## Prompt submission and acknowledgment + +Submitting a prompt creates a client-local pending prompt card at the bottom of +the destination thread immediately. The card uses a muted version of the normal +blue user-card treatment, with a brighter blue highlight sweeping left and +right across it until the app-server acknowledges the operation. + +Each pending prompt has a process-wide client-local submission ID and remains +associated with its destination thread. It therefore remains visible when the +user switches threads and returns. On +successful acknowledgment, the card shows a short accepted sweep before it +becomes a normal user message. If the authoritative app-server item arrives +during that transition, it inherits the pending card's stable visual anchor and +replaces it after the 500-millisecond transition completes. Only the correlated +`turn.start` or `turn.steer` completion callback acknowledges a prompt; +conversation events never infer acknowledgment. Each operation carries a +unique `clientUserMessageId`, which binds the authoritative user item without +confusing identical prompt text. A failed submission remains visible with an +explicit error state. + +The composer is cleared immediately after local admission and remains enabled. +Users may enter additional prompts while earlier prompts await acknowledgment. +CodexUI queues submissions per thread and dispatches them in order: only one +unacknowledged prompt operation is in flight for a thread. After each result, +the next queued prompt is sent using the app-server state produced by the +preceding acknowledgment. Different threads remain independent. + +Submission waits until the destination thread has completed its connection- +generation hydration. A provider-marked `notLoaded` thread is resumed before +the turn operation. If a submission still receives a transient thread-not-found +result, CodexUI performs one bounded resume-and-retry; a repeated failure is +shown on the pending card rather than retried indefinitely. If hydration has +failed, submission is rejected without clearing the composer draft; Reload +must succeed before that prompt can be admitted. A disconnect between admission +and dispatch leaves the pending card in place and unsent until bridge-open +re-drives it. An active resume prevents a concurrent hydration read or turn +operation for the same thread. + +For an explicit new-thread draft, prompts entered while `thread.create` is in +flight remain attached to that draft. When creation succeeds, all pending +prompts move to the returned stable thread ID and are dispatched in order. + +## Conversation scrolling + +The message view smoothly follows incoming content only while it is already at +the bottom. Consecutive geometry changes retarget one short, monotonic animation +to the newest bottom. If the user scrolls upward, the animation stops +immediately and automatic following pauses so the current text can be read. +Returning to the bottom re-enables following. + +Follow/pause mode and the visible-card/pixel-offset anchor are retained per +thread and restored when the user switches back. + +This policy applies to new messages, streaming updates, pending prompt cards, +and card reconstruction. It is based on the scroll bar's actual bottom state, +not on turn activity. + +While following is paused, CodexUI anchors the first visible card and its pixel +offset. Appends below the viewport keep the scrollbar value unchanged; card +reflow or reconstruction restores that visual anchor after Qt completes layout. +Incoming data therefore cannot move the user's reading position merely because +content above or below it changed size. Protocol updates that do not change a +card's visible projection do not rebuild that card. Multiple visible card +changes from one refresh are applied as one paint-suppressed layout transaction +with one anchor restoration, including streaming Command execution updates. +New authoritative cards are inserted at their server-ordered position without +reconstructing retained cards. While following is paused, the effective history +window expands with incoming cards so its visible anchor is not evicted; the +requested bound is restored after following resumes. + +User scrolling to the current bottom re-enables following. A generic Qt range +clamp caused by card reflow does not count as user intent and cannot silently +re-enable following. Composer contraction is the explicit exception: after its +trailing space is removed, CodexUI recomputes whether the resulting clamped +position is the new bottom. + +The complete center region is wheel- and touchpad-scroll sensitive. Wheel +events over non-scrollable center chrome and the horizontal splitter handles +are forwarded to the message view. A nested scrollable control, such as Command +execution output, consumes an event while it can scroll in that direction and +hands an edge event back to the conversation. + +## Composer geometry + +The upcoming-turn controls are anchored to the bottom of the center pane. The +prompt editor starts at one line, grows upward for multiline input, and stops at +its configured maximum height, after which it scrolls internally. + +The message-view layout reserves only the composer's canonical height. When +prompt text, attachments, settings, or attention controls increase that height, +the composer grows upward as an overlay: the viewport keeps its normal geometry +and may be partly covered. An equal-height trailing spacer is added to the +scrollable conversation content so the final card can still be moved into the +visible region with the normal gap above the composer. + +Growing this spacer preserves the current scrollbar value and does not move the +messages automatically. Reaching its new maximum re-enables bottom-follow for +subsequent content. When the composer returns to canonical height, the spacer +is removed; Qt may clamp a former bottom position to the reduced range, after +which the normal viewport state and bottom-follow policy apply again. + +## Command execution cards + +The card's visible label is **Command execution**. + +Command execution output boxes are created only when output contains printable, +non-whitespace text after terminal control sequences are ignored; empty, +whitespace-only, and ANSI/control-only output has no output surface. A shown box +has no non-content minimum height, grows from zero to a maximum of 220 pixels, +and exposes a styled vertical scrollbar only when content exceeds that limit. +Its content height is measured synchronously during the outer layout +transaction. Streaming output, completion status, and metadata update the +retained outer Command execution card in place; they do not replace it. Output +follows its bottom while already at the bottom. A manual upward scroll pauses +following until the user returns to the bottom. Each output card retains its +own follow/pause position across in-place output updates. + +## Inspector and Info presentation + +The State and Protocol viewers use the common CodexUI scrollbar styling and +show vertical scrollbars only when needed. The Protocol log occupies the +expanding area of its tab; protocol statistics are displayed below the log. +Protocol and State data are diagnostic presentation only and do not create +domain authority. Plan, Agents, Changes, and Requests use retained per-thread +presentation snapshots, so revisiting a materialized thread does not clear or +flash those surfaces while unrelated frames arrive. + +## Desktop identity + +The application identity is `codex-ui`. The executable, desktop entry, +`StartupWMClass`, application icon name, and installed SVG icon use that same +identity so Linux desktop environments associate the running window with the +correct launcher and taskbar icon. + +## Progress indication + +Long-running operations need scoped progress presentation rather than a global +busy state. Candidate scopes include prompt acknowledgment, thread creation, +and loading a long thread. Pending prompt acknowledgment already has its own +animated highlight sweep. Any additional progress indicator must preserve input +and navigation that can safely remain interactive, identify the operation it +represents, and avoid suggesting that unrelated threads are blocked. No general +spinner contract is defined yet. diff --git a/resources/applications/codex-ui.desktop b/resources/applications/codex-ui.desktop new file mode 100644 index 0000000..797f331 --- /dev/null +++ b/resources/applications/codex-ui.desktop @@ -0,0 +1,11 @@ +# SPDX-License-Identifier: LGPL-3.0-or-later OR MIT +[Desktop Entry] +Type=Application +Name=CodexUI +Comment=Codex agent workspace +Exec=codex-ui +Icon=codex-ui +Terminal=false +Categories=Development;IDE; +StartupNotify=true +StartupWMClass=codex-ui diff --git a/resources/icons/codex-ui.svg b/resources/icons/codex-ui.svg new file mode 100644 index 0000000..e9442ac --- /dev/null +++ b/resources/icons/codex-ui.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/src/app/Application.cpp b/src/app/Application.cpp deleted file mode 100644 index 170f700..0000000 --- a/src/app/Application.cpp +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "app/Application.h" - -namespace codexui { - -Application::~Application() -{ - // Complete/cancel frontend operations while MainWindow and its callback - // targets are still alive. Member destruction happens after this body. - frontendSession.shutdown(); -} - -void Application::show() -{ - mainWindow.show(); - frontendSession.connectToBackend(); -} - -} // namespace codexui diff --git a/src/app/Application.h b/src/app/Application.h deleted file mode 100644 index 4cd3572..0000000 --- a/src/app/Application.h +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#ifndef CODEXUI_APP_APPLICATION_H -#define CODEXUI_APP_APPLICATION_H - -#include "app/FrontendSession.h" -#include "ui/MainWindow.h" - -namespace codexui { - -class Application -{ -public: - ~Application(); - void show(); - -private: - FrontendSession frontendSession; - MainWindow mainWindow{frontendSession}; -}; - -} // namespace codexui - -#endif // CODEXUI_APP_APPLICATION_H diff --git a/src/app/AttachmentManager.cpp b/src/app/AttachmentManager.cpp deleted file mode 100644 index cc61394..0000000 --- a/src/app/AttachmentManager.cpp +++ /dev/null @@ -1,765 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "app/AttachmentManager.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -namespace codexui { -namespace { - -constexpr QFileDevice::Permissions PrivateDirectoryPermissions = - QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner; -constexpr QFileDevice::Permissions PrivateFilePermissions = - QFileDevice::ReadOwner | QFileDevice::WriteOwner; -constexpr auto StagingRegistryGroup = "attachmentStaging/v1"; - -QString errorWithPath(const QString& message, const QString& path) -{ - return QStringLiteral("%1\n\n%2").arg(message, QDir::toNativeSeparators(path)); -} - -QString safeFileName(const QString& name) -{ - QString result = QFileInfo(name).fileName(); - if (result.isEmpty() || result == QStringLiteral(".") || result == QStringLiteral("..")) - result = QStringLiteral("attachment"); - for (QChar& character : result) { - if (character.unicode() < 0x20 || character == QLatin1Char('/') - || character == QLatin1Char('\\')) - character = QLatin1Char('_'); - } - return result; -} - -QString uniqueDestinationName(const QString& requested, QSet& occupiedNames) -{ - const QFileInfo info(requested); - const QString suffix = info.completeSuffix(); - const QString base = info.completeBaseName().isEmpty() - ? QStringLiteral("attachment") : info.completeBaseName(); - QString candidate = requested; - int ordinal = 2; - while (occupiedNames.contains(candidate.toCaseFolded())) { - candidate = suffix.isEmpty() - ? QStringLiteral("%1-%2").arg(base).arg(ordinal) - : QStringLiteral("%1-%2.%3").arg(base).arg(ordinal).arg(suffix); - ++ordinal; - } - occupiedNames.insert(candidate.toCaseFolded()); - return candidate; -} - -bool copyFileAtomically(const QString& sourcePath, - const QString& destinationPath, - QString* errorMessage, - const AttachmentManager::CancellationCheck& cancelled) -{ - const auto reportCancellation = [errorMessage]() { - if (errorMessage) - *errorMessage = QStringLiteral("Attachment preparation was cancelled."); - }; - if (cancelled && cancelled()) { - reportCancellation(); - return false; - } - - QFile source(sourcePath); - if (!source.open(QIODevice::ReadOnly)) { - if (errorMessage) - *errorMessage = errorWithPath( - QStringLiteral("Unable to open the attachment: %1").arg(source.errorString()), - sourcePath); - return false; - } - - QSaveFile destination(destinationPath); - destination.setDirectWriteFallback(true); - if (!destination.open(QIODevice::WriteOnly)) { - if (errorMessage) - *errorMessage = errorWithPath( - QStringLiteral("Unable to create the staged attachment: %1") - .arg(destination.errorString()), - destinationPath); - return false; - } - - const auto cancelDestination = [&]() { - destination.cancelWriting(); - // QSaveFile's direct-write fallback cannot roll back by itself. This - // path is always a fresh file inside a fresh staging directory. - (void)QFile::remove(destinationPath); - reportCancellation(); - return false; - }; - - constexpr qint64 chunkSize = 1024 * 1024; - QByteArray buffer(static_cast(chunkSize), Qt::Uninitialized); - while (!source.atEnd()) { - if (cancelled && cancelled()) - return cancelDestination(); - const qint64 count = source.read(buffer.data(), chunkSize); - if (count < 0 || (count > 0 && destination.write(buffer.constData(), count) != count)) { - destination.cancelWriting(); - if (errorMessage) - *errorMessage = errorWithPath( - count < 0 - ? QStringLiteral("Unable to read the attachment: %1").arg(source.errorString()) - : QStringLiteral("Unable to write the staged attachment: %1") - .arg(destination.errorString()), - count < 0 ? sourcePath : destinationPath); - return false; - } - if (count == 0) - break; - if (cancelled && cancelled()) - return cancelDestination(); - } - if (cancelled && cancelled()) - return cancelDestination(); - // Apply the final private mode to QSaveFile's temporary inode before its - // atomic rename publishes that inode at destinationPath. - if (!destination.setPermissions(PrivateFilePermissions)) { - destination.cancelWriting(); - if (errorMessage) - *errorMessage = errorWithPath( - QStringLiteral("Unable to make the staged attachment private."), - destinationPath); - return false; - } - if (!destination.commit()) { - if (errorMessage) - *errorMessage = errorWithPath( - QStringLiteral("Unable to finish the staged attachment: %1") - .arg(destination.errorString()), - destinationPath); - return false; - } - return true; -} - -QString stagingThreadToken(const QString& threadId) -{ - const QByteArray source = threadId.isEmpty() ? QByteArrayLiteral("pending-thread") - : threadId.toUtf8(); - return QString::fromLatin1( - QCryptographicHash::hash(source, QCryptographicHash::Sha256).toHex().left(16)); -} - -bool ensurePrivateDirectory(const QString& path, - const QString& failureMessage, - QString* errorMessage) -{ - QFileInfo info(path); - if (info.exists() && (!info.isDir() || info.isSymLink())) { - if (errorMessage) - *errorMessage = errorWithPath(failureMessage, path); - return false; - } - if (!info.exists() && !QDir().mkpath(path)) { - if (errorMessage) - *errorMessage = errorWithPath(failureMessage, path); - return false; - } - info.refresh(); - if (!info.isDir() || info.isSymLink() - || !QFile::setPermissions(path, PrivateDirectoryPermissions)) { - if (errorMessage) - *errorMessage = errorWithPath(failureMessage, path); - return false; - } - return true; -} - -bool ensurePrivateStagingRoot(const QString& workspace, - QString* rootPath, - QString* errorMessage) -{ - QDir workspaceDirectory(workspace); - const QString metadataPath = workspaceDirectory.filePath(QStringLiteral(".codex-ui")); - const QString attachmentsPath = QDir(metadataPath).filePath(QStringLiteral("attachments")); - const QString directoryError = QStringLiteral( - "Unable to create a private CodexUI attachment directory."); - if (!ensurePrivateDirectory(metadataPath, directoryError, errorMessage) - || !ensurePrivateDirectory(attachmentsPath, directoryError, errorMessage)) { - return false; - } - - const QString ignorePath = QDir(attachmentsPath).filePath(QStringLiteral(".gitignore")); - QFileInfo ignoreInfo(ignorePath); - if (ignoreInfo.exists() && (!ignoreInfo.isFile() || ignoreInfo.isSymLink())) { - if (errorMessage) - *errorMessage = errorWithPath( - QStringLiteral("Unable to secure the CodexUI attachment ignore file."), - ignorePath); - return false; - } - QByteArray ignoreContents; - bool appendIgnoreRule = true; - if (ignoreInfo.exists()) { - QFile existingIgnoreFile(ignorePath); - if (!existingIgnoreFile.open(QIODevice::ReadOnly | QIODevice::Text)) { - if (errorMessage) - *errorMessage = errorWithPath( - QStringLiteral("Unable to read the CodexUI attachment ignore file."), - ignorePath); - return false; - } - ignoreContents = existingIgnoreFile.readAll(); - const QList lines = ignoreContents.split('\n'); - for (auto iterator = lines.crbegin(); iterator != lines.crend(); ++iterator) { - const QByteArray line = iterator->trimmed(); - if (line.isEmpty() || line.startsWith('#')) - continue; - appendIgnoreRule = line != QByteArrayLiteral("*"); - break; - } - } - if (appendIgnoreRule) { - if (!ignoreContents.isEmpty() && !ignoreContents.endsWith('\n')) - ignoreContents.append('\n'); - ignoreContents.append("# Transient files staged by CodexUI\n*\n"); - QSaveFile ignoreFile(ignorePath); - ignoreFile.setDirectWriteFallback(true); - if (!ignoreFile.open(QIODevice::WriteOnly | QIODevice::Text) - || ignoreFile.write(ignoreContents) != ignoreContents.size() || !ignoreFile.commit()) { - if (errorMessage) - *errorMessage = errorWithPath( - QStringLiteral("Unable to secure the CodexUI attachment ignore file."), - ignorePath); - return false; - } - } - if (!QFile::setPermissions(ignorePath, PrivateFilePermissions)) { - if (errorMessage) - *errorMessage = errorWithPath( - QStringLiteral("Unable to secure the CodexUI attachment ignore file."), - ignorePath); - return false; - } - if (rootPath) - *rootPath = attachmentsPath; - return true; -} - -bool validRegistryId(const QString& registryId) -{ - const QUuid id(QStringLiteral("{%1}").arg(registryId)); - return !id.isNull() && id.toString(QUuid::WithoutBraces) == registryId; -} - -QString cleanAbsolutePath(const QString& path) -{ - if (!QDir::isAbsolutePath(path)) - return {}; - return QDir::cleanPath(QFileInfo(path).absoluteFilePath()); -} - -bool isSafeStagingPath(const QString& workspace, - const QString& stagingDirectory, - const QStringList& stagedFiles) -{ - const QString cleanWorkspace = cleanAbsolutePath(workspace); - const QString cleanStagingDirectory = cleanAbsolutePath(stagingDirectory); - if (cleanWorkspace.isEmpty() || cleanStagingDirectory.isEmpty()) - return false; - - const QFileInfo workspaceInfo(cleanWorkspace); - if (!workspaceInfo.exists() || !workspaceInfo.isDir() || workspaceInfo.isSymLink() - || workspaceInfo.canonicalFilePath() != cleanWorkspace) { - return false; - } - - const QString metadataDirectory = QDir(cleanWorkspace).filePath(QStringLiteral(".codex-ui")); - const QString stagingRoot = QDir(metadataDirectory).filePath(QStringLiteral("attachments")); - const QFileInfo stagingInfo(cleanStagingDirectory); - if (QDir::cleanPath(stagingInfo.absolutePath()) != QDir::cleanPath(stagingRoot) - || stagingInfo.fileName().isEmpty() || stagingInfo.fileName() == QStringLiteral(".") - || stagingInfo.fileName() == QStringLiteral("..")) { - return false; - } - - for (const QString& component : {metadataDirectory, stagingRoot, cleanStagingDirectory}) { - const QFileInfo info(component); - if (info.exists() && (info.isSymLink() || !info.isDir())) - return false; - } - - QSet uniqueFiles; - for (const QString& path : stagedFiles) { - const QString cleanPath = cleanAbsolutePath(path); - const QFileInfo fileInfo(cleanPath); - if (cleanPath.isEmpty() - || QDir::cleanPath(fileInfo.absolutePath()) != cleanStagingDirectory - || fileInfo.fileName().isEmpty() || fileInfo.fileName() == QStringLiteral(".") - || fileInfo.fileName() == QStringLiteral("..") - || uniqueFiles.contains(cleanPath)) { - return false; - } - if (fileInfo.exists() && (fileInfo.isSymLink() || fileInfo.isDir())) - return false; - uniqueFiles.insert(cleanPath); - } - return !stagedFiles.isEmpty(); -} - -bool stagingArtifactsAreAbsent(const QString& stagingDirectory, - const QStringList& stagedFiles) -{ - const QString cleanStagingDirectory = cleanAbsolutePath(stagingDirectory); - if (cleanStagingDirectory.isEmpty()) - return false; - - const QFileInfo directoryInfo(cleanStagingDirectory); - if (directoryInfo.exists() || directoryInfo.isSymLink()) - return false; - return std::ranges::all_of(stagedFiles, [](const QString& path) { - const QString cleanPath = cleanAbsolutePath(path); - if (cleanPath.isEmpty()) - return false; - const QFileInfo info(cleanPath); - return !info.exists() && !info.isSymLink(); - }); -} - -bool synchronizePrivateSettings(QSettings& settings, QString* errorMessage) -{ - const QString fileName = settings.fileName(); - QFileInfo settingsInfo(fileName); - if (settingsInfo.isSymLink() - || (settingsInfo.exists() - && (!settingsInfo.isFile() - || !QFile::setPermissions(fileName, PrivateFilePermissions)))) { - if (errorMessage) - *errorMessage = errorWithPath( - QStringLiteral("Unable to make the attachment staging registry private."), - fileName); - return false; - } - - settings.sync(); - if (settings.status() != QSettings::NoError) { - if (errorMessage) - *errorMessage = QStringLiteral("Unable to update the attachment staging registry."); - return false; - } - - settingsInfo.refresh(); - if (settingsInfo.exists() - && (!settingsInfo.isFile() || settingsInfo.isSymLink() - || !QFile::setPermissions(fileName, PrivateFilePermissions))) { - if (errorMessage) - *errorMessage = errorWithPath( - QStringLiteral("Unable to make the attachment staging registry private."), - fileName); - return false; - } - return true; -} - -} // namespace - -AttachmentStagingLease::AttachmentStagingLease(QString workspace, QString directory) - : workspaceDirectory(std::move(workspace)) - , stagingDirectory(std::move(directory)) -{ -} - -AttachmentStagingLease::~AttachmentStagingLease() -{ - if (!dispatched) - (void)cleanup(); -} - -void AttachmentStagingLease::trackFile(QString path) -{ - stagedFiles.append(std::move(path)); -} - -const QString& AttachmentStagingLease::directory() const noexcept -{ - return stagingDirectory; -} - -bool AttachmentStagingLease::cleanup() noexcept -{ - if (stagingDirectory.isEmpty()) - return true; - - QFileInfo directoryInfo(stagingDirectory); - if (directoryInfo.exists() && (!directoryInfo.isDir() || directoryInfo.isSymLink())) - return false; - - bool removed = true; - QStringList remainingFiles; - const QString expectedDirectory = QDir(stagingDirectory).absolutePath(); - for (const QString& path : std::as_const(stagedFiles)) { - const QFileInfo fileInfo(path); - if (!fileInfo.exists() && !fileInfo.isSymLink()) - continue; - if (fileInfo.absolutePath() != expectedDirectory - || fileInfo.isDir() || !QFile::remove(path)) { - removed = false; - remainingFiles.append(path); - } - } - stagedFiles = std::move(remainingFiles); - - directoryInfo.refresh(); - if (directoryInfo.exists()) { - if (!directoryInfo.isDir() || directoryInfo.isSymLink() - || !QDir().rmdir(stagingDirectory)) { - removed = false; - } - } - return removed; -} - -void AttachmentStagingLease::markDispatched() noexcept -{ - dispatched = true; -} - -void AttachmentStagingLease::cancelDispatch() noexcept -{ - dispatched = false; -} - -bool AttachmentManager::inspectFile(const QString& path, - AttachmentInfo* result, - QString* errorMessage) -{ - if (!result) { - if (errorMessage) - *errorMessage = QStringLiteral("No attachment result object was provided."); - return false; - } - QFileInfo info(path); - const QString canonicalPath = info.canonicalFilePath(); - if (canonicalPath.isEmpty()) { - if (errorMessage) - *errorMessage = errorWithPath( - QStringLiteral("The selected attachment does not exist or cannot be resolved."), - path); - return false; - } - info.setFile(canonicalPath); - if (!info.isFile() || !info.isReadable()) { - if (errorMessage) - *errorMessage = errorWithPath( - info.isFile() ? QStringLiteral("The selected attachment is not readable.") - : QStringLiteral("The selected attachment is not a regular file."), - canonicalPath); - return false; - } - if (info.size() > MaximumSingleFileBytes) { - if (errorMessage) - *errorMessage = errorWithPath( - QStringLiteral("The selected attachment exceeds the %1 per-file limit.") - .arg(formatSize(MaximumSingleFileBytes)), - canonicalPath); - return false; - } - - QMimeDatabase mimeDatabase; - QString mimeType = mimeDatabase.mimeTypeForFile( - canonicalPath, QMimeDatabase::MatchExtension).name(); - if (mimeType.isEmpty()) - mimeType = QStringLiteral("application/octet-stream"); - *result = AttachmentInfo{ - canonicalPath, - info.fileName(), - mimeType, - info.size(), - isSupportedLocalImage(canonicalPath, mimeType) - ? AttachmentInfo::Kind::Image : AttachmentInfo::Kind::File}; - return true; -} - -bool AttachmentManager::validateForWorkspace(const QList& attachments, - const QString& workspace, - QString* errorMessage) -{ - if (totalSize(attachments) > MaximumTotalBytes) { - if (errorMessage) - *errorMessage = QStringLiteral( - "The selected attachments exceed the %1 total attachment limit.") - .arg(formatSize(MaximumTotalBytes)); - return false; - } - const bool hasGenericFile = std::ranges::any_of(attachments, [](const auto& attachment) { - return attachment.kind == AttachmentInfo::Kind::File; - }); - if (!hasGenericFile) - return true; - - const QFileInfo workspaceInfo(workspace.trimmed()); - if (!workspaceInfo.exists() || !workspaceInfo.isDir() - || !workspaceInfo.isReadable() || !workspaceInfo.isWritable()) { - if (errorMessage) - *errorMessage = errorWithPath( - QStringLiteral("A readable and writable workspace is required for non-image attachments."), - workspace.trimmed()); - return false; - } - return true; -} - -bool AttachmentManager::prepare(const QList& attachments, - const QString& workspace, - const QString& threadId, - AttachmentPreparation* result, - QString* errorMessage, - CancellationCheck cancelled) -{ - if (!result) { - if (errorMessage) - *errorMessage = QStringLiteral("No attachment preparation result object was provided."); - return false; - } - *result = {}; - if (cancelled && cancelled()) { - if (errorMessage) - *errorMessage = QStringLiteral("Attachment preparation was cancelled."); - return false; - } - if (!validateForWorkspace(attachments, workspace, errorMessage)) - return false; - - const bool hasGenericFile = std::ranges::any_of(attachments, [](const auto& attachment) { - return attachment.kind == AttachmentInfo::Kind::File; - }); - QString stagingDirectory; - const QString canonicalWorkspace = QFileInfo(workspace).canonicalFilePath(); - if (hasGenericFile) { - QString stagingRoot; - if (!ensurePrivateStagingRoot(canonicalWorkspace, &stagingRoot, errorMessage)) - return false; - stagingDirectory = QDir(stagingRoot).filePath( - QStringLiteral("%1-%2-%3") - .arg(stagingThreadToken(threadId), - QDateTime::currentDateTimeUtc().toString(QStringLiteral("yyyyMMdd-HHmmss-zzz")), - QUuid::createUuid().toString(QUuid::WithoutBraces))); - if (!ensurePrivateDirectory( - stagingDirectory, - QStringLiteral("Unable to create a private directory for staged attachments."), - errorMessage)) { - (void)QDir().rmdir(stagingDirectory); - return false; - } - result->stagingDirectory = stagingDirectory; - result->stagingLease = std::shared_ptr( - new AttachmentStagingLease(canonicalWorkspace, stagingDirectory)); - } - - QSet occupiedNames; - QStringList promptLines; - for (const AttachmentInfo& attachment : attachments) { - if (cancelled && cancelled()) { - if (errorMessage) - *errorMessage = QStringLiteral("Attachment preparation was cancelled."); - if (result->stagingLease) - (void)result->stagingLease->cleanup(); - *result = {}; - return false; - } - PreparedAttachment prepared; - prepared.source = attachment; - if (attachment.kind == AttachmentInfo::Kind::Image) { - prepared.effectivePath = attachment.sourcePath; - result->imagePaths.append(prepared.effectivePath); - } else { - const QString destinationName = uniqueDestinationName( - safeFileName(attachment.displayName), occupiedNames); - prepared.effectivePath = QDir(stagingDirectory).filePath(destinationName); - prepared.staged = true; - if (!copyFileAtomically(attachment.sourcePath, - prepared.effectivePath, - errorMessage, - cancelled)) { - (void)result->stagingLease->cleanup(); - *result = {}; - return false; - } - result->stagingLease->trackFile(prepared.effectivePath); - prepared.workspaceRelativePath = QDir::fromNativeSeparators( - QDir(canonicalWorkspace).relativeFilePath(prepared.effectivePath)); - if (!prepared.workspaceRelativePath.startsWith(QStringLiteral("./"))) - prepared.workspaceRelativePath.prepend(QStringLiteral("./")); - promptLines.append(QStringLiteral("- `%1` (original `%2`, %3, `%4`)") - .arg(prepared.workspaceRelativePath, - attachment.displayName, - formatSize(attachment.sizeBytes), - attachment.mimeType)); - } - result->items.append(std::move(prepared)); - } - if (!promptLines.isEmpty()) { - result->genericFilePrompt = QStringLiteral( - "The following local files were attached by the user and copied into the workspace by CodexUI. " - "Read them as input for this turn. Archives are not unpacked automatically; inspect or extract " - "them only when needed and permitted.\n%1") - .arg(promptLines.join(QLatin1Char('\n'))); - } - return true; -} - -QString AttachmentManager::composePrompt(const QString& userPrompt, - const AttachmentPreparation& preparation) -{ - QString result = userPrompt; - if (!preparation.genericFilePrompt.isEmpty()) { - if (!result.trimmed().isEmpty()) - result += QStringLiteral("\n\n"); - else - result.clear(); - result += preparation.genericFilePrompt; - } - return result; -} - -QString AttachmentManager::formatSize(qint64 sizeBytes) -{ - static const QStringList units{QStringLiteral("B"), QStringLiteral("KiB"), - QStringLiteral("MiB"), QStringLiteral("GiB")}; - double value = static_cast(std::max(0, sizeBytes)); - qsizetype unit = 0; - while (value >= 1024.0 && unit + 1 < units.size()) { - value /= 1024.0; - ++unit; - } - const int precision = unit == 0 ? 0 : (value >= 100.0 ? 0 : 1); - return QStringLiteral("%1 %2").arg(value, 0, 'f', precision).arg(units.at(unit)); -} - -qint64 AttachmentManager::totalSize(const QList& attachments) -{ - qint64 total = 0; - for (const auto& attachment : attachments) { - if (attachment.sizeBytes > 0 && total > MaximumTotalBytes - attachment.sizeBytes) - return MaximumTotalBytes + 1; - total += std::max(0, attachment.sizeBytes); - } - return total; -} - -QString AttachmentManager::createStagingRegistryId() -{ - return QUuid::createUuid().toString(QUuid::WithoutBraces); -} - -bool AttachmentManager::persistDispatchedStaging( - QSettings& settings, - const QString& registryId, - const QString& threadId, - const QString& turnId, - const AttachmentStagingLeasePtr& stagingLease, - QString* errorMessage) -{ - if (!stagingLease || !validRegistryId(registryId) || threadId.isEmpty() - || !isSafeStagingPath(stagingLease->workspaceDirectory, - stagingLease->stagingDirectory, - stagingLease->stagedFiles)) { - if (errorMessage) - *errorMessage = QStringLiteral("Invalid attachment staging ownership metadata."); - return false; - } - - settings.beginGroup(QString::fromLatin1(StagingRegistryGroup)); - settings.beginGroup(registryId); - settings.setValue(QStringLiteral("workspace"), stagingLease->workspaceDirectory); - settings.setValue(QStringLiteral("directory"), stagingLease->stagingDirectory); - settings.setValue(QStringLiteral("files"), stagingLease->stagedFiles); - settings.setValue(QStringLiteral("threadId"), threadId); - settings.setValue(QStringLiteral("turnId"), turnId); - settings.endGroup(); - settings.endGroup(); - if (!synchronizePrivateSettings(settings, errorMessage)) - return false; - stagingLease->markDispatched(); - return true; -} - -bool AttachmentManager::recoverDispatchedStaging( - QSettings& settings, - QList* result, - QString* errorMessage) -{ - if (!result) { - if (errorMessage) - *errorMessage = QStringLiteral("No attachment staging recovery result was provided."); - return false; - } - result->clear(); - if (!synchronizePrivateSettings(settings, errorMessage)) - return false; - - settings.beginGroup(QString::fromLatin1(StagingRegistryGroup)); - const QStringList registryIds = settings.childGroups(); - bool removedStaleRecord = false; - for (const QString& registryId : registryIds) { - if (!validRegistryId(registryId)) - continue; - settings.beginGroup(registryId); - const QString workspace = settings.value(QStringLiteral("workspace")).toString(); - const QString directory = settings.value(QStringLiteral("directory")).toString(); - const QStringList files = settings.value(QStringLiteral("files")).toStringList(); - const QString threadId = settings.value(QStringLiteral("threadId")).toString(); - const QString turnId = settings.value(QStringLiteral("turnId")).toString(); - settings.endGroup(); - if (stagingArtifactsAreAbsent(directory, files)) { - settings.remove(registryId); - removedStaleRecord = true; - continue; - } - if (threadId.isEmpty() || !isSafeStagingPath(workspace, directory, files)) - continue; - - auto stagingLease = std::shared_ptr( - new AttachmentStagingLease(workspace, directory)); - for (const QString& file : files) - stagingLease->trackFile(cleanAbsolutePath(file)); - stagingLease->markDispatched(); - result->append(PersistedAttachmentStaging{ - registryId, threadId, turnId, std::move(stagingLease)}); - } - settings.endGroup(); - return !removedStaleRecord || synchronizePrivateSettings(settings, errorMessage); -} - -bool AttachmentManager::forgetDispatchedStaging(QSettings& settings, - const QString& registryId, - QString* errorMessage) -{ - if (!validRegistryId(registryId)) { - if (errorMessage) - *errorMessage = QStringLiteral("Invalid attachment staging registry identity."); - return false; - } - settings.beginGroup(QString::fromLatin1(StagingRegistryGroup)); - settings.remove(registryId); - settings.endGroup(); - return synchronizePrivateSettings(settings, errorMessage); -} - -bool AttachmentManager::isSupportedLocalImage(const QString& path, const QString& mimeType) -{ - static const QSet extensions{QStringLiteral("png"), QStringLiteral("jpg"), - QStringLiteral("jpeg"), QStringLiteral("webp"), - QStringLiteral("gif"), QStringLiteral("bmp")}; - return mimeType.startsWith(QStringLiteral("image/")) - && extensions.contains(QFileInfo(path).suffix().toLower()); -} - -} // namespace codexui diff --git a/src/app/AttachmentManager.h b/src/app/AttachmentManager.h deleted file mode 100644 index 023fdf6..0000000 --- a/src/app/AttachmentManager.h +++ /dev/null @@ -1,134 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#ifndef CODEXUI_APP_ATTACHMENTMANAGER_H -#define CODEXUI_APP_ATTACHMENTMANAGER_H - -#include -#include -#include - -#include -#include - -class QSettings; - -namespace codexui { - -struct AttachmentInfo -{ - enum class Kind { File, Image }; - - QString sourcePath; - QString displayName; - QString mimeType; - qint64 sizeBytes = 0; - Kind kind = Kind::File; - - bool operator==(const AttachmentInfo&) const = default; -}; - -struct PreparedAttachment -{ - AttachmentInfo source; - QString effectivePath; - QString workspaceRelativePath; - bool staged = false; -}; - -class AttachmentStagingLease final -{ -public: - ~AttachmentStagingLease(); - - AttachmentStagingLease(const AttachmentStagingLease&) = delete; - AttachmentStagingLease& operator=(const AttachmentStagingLease&) = delete; - - [[nodiscard]] const QString& directory() const noexcept; - // Prepared-but-unsubmitted leases clean up on destruction. Dispatch makes - // cleanup explicit so destroying the frontend cannot break an active turn. - [[nodiscard]] bool cleanup() noexcept; - void markDispatched() noexcept; - void cancelDispatch() noexcept; - -private: - friend class AttachmentManager; - AttachmentStagingLease(QString workspace, QString directory); - void trackFile(QString path); - - QString workspaceDirectory; - QString stagingDirectory; - QStringList stagedFiles; - bool dispatched = false; -}; - -using AttachmentStagingLeasePtr = std::shared_ptr; - -struct AttachmentPreparation -{ - QList items; - QStringList imagePaths; - QString genericFilePrompt; - QString stagingDirectory; - AttachmentStagingLeasePtr stagingLease; -}; - -struct PersistedAttachmentStaging -{ - QString registryId; - QString threadId; - QString turnId; - AttachmentStagingLeasePtr stagingLease; -}; - -class AttachmentManager final -{ -public: - // Generic-file staging is bounded and may run off the GUI thread. Callers - // can cooperatively cancel it between fixed-size copy chunks. Image - // contents travel by local path, not on the frontend protocol wire. - static constexpr qint64 MaximumSingleFileBytes = 64LL * 1024LL * 1024LL; - static constexpr qint64 MaximumTotalBytes = 256LL * 1024LL * 1024LL; - - using CancellationCheck = std::function; - - [[nodiscard]] static bool inspectFile(const QString& path, - AttachmentInfo* result, - QString* errorMessage = nullptr); - [[nodiscard]] static bool validateForWorkspace(const QList& attachments, - const QString& workspace, - QString* errorMessage = nullptr); - [[nodiscard]] static bool prepare(const QList& attachments, - const QString& workspace, - const QString& threadId, - AttachmentPreparation* result, - QString* errorMessage = nullptr, - CancellationCheck cancelled = {}); - [[nodiscard]] static QString composePrompt(const QString& userPrompt, - const AttachmentPreparation& preparation); - [[nodiscard]] static QString formatSize(qint64 sizeBytes); - [[nodiscard]] static qint64 totalSize(const QList& attachments); - [[nodiscard]] static QString createStagingRegistryId(); - [[nodiscard]] static bool persistDispatchedStaging( - QSettings& settings, - const QString& registryId, - const QString& threadId, - const QString& turnId, - const AttachmentStagingLeasePtr& stagingLease, - QString* errorMessage = nullptr); - [[nodiscard]] static bool recoverDispatchedStaging( - QSettings& settings, - QList* result, - QString* errorMessage = nullptr); - [[nodiscard]] static bool forgetDispatchedStaging( - QSettings& settings, - const QString& registryId, - QString* errorMessage = nullptr); - -private: - [[nodiscard]] static bool isSupportedLocalImage(const QString& path, - const QString& mimeType); -}; - -} // namespace codexui - -#endif // CODEXUI_APP_ATTACHMENTMANAGER_H diff --git a/src/app/FrontendSession.cpp b/src/app/FrontendSession.cpp deleted file mode 100644 index 743ed56..0000000 --- a/src/app/FrontendSession.cpp +++ /dev/null @@ -1,1280 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "app/FrontendSession.h" -#include "app/FrontendSessionWorker.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace codexui { - -namespace sdk = ai::openai::codex::frontend::client; - -namespace { - -bool appendUniqueBounded(QStringList& destination, const QStringList& source) -{ - for (const QString& value : source) { - if (destination.contains(value)) - continue; - if (destination.size() - >= detail::maximumCoalescedPresentationIdentities) - return false; - destination.push_back(value); - } - return true; -} - -void mergeScope(detail::StateUpdateScope& destination, - const detail::StateUpdateScope& source) -{ - // A newer exact update for an identity supersedes an older removal. Apply - // this before appending the source tombstones so remove-after-upsert still - // wins while upsert-after-remove cannot clear a live selection. - for (const QString& threadId : source.affectedThreadIds) { - if (!source.removedThreadIds.contains(threadId)) - destination.removedThreadIds.removeAll(threadId); - } - destination.allThreadsAffected |= source.allThreadsAffected; - destination.allInspectorsAffected |= source.allInspectorsAffected; - destination.allSidebarThreadsAffected |= source.allSidebarThreadsAffected; - destination.sidebarAffected |= source.sidebarAffected; - destination.hasPresentationChange |= source.hasPresentationChange; - destination.removedThreadIdsOverflowed |= - source.removedThreadIdsOverflowed; - if (!appendUniqueBounded(destination.removedThreadIds, - source.removedThreadIds)) { - destination.removedThreadIdsOverflowed = true; - destination.allThreadsAffected = true; - } - if (!destination.allThreadsAffected) { - if (!appendUniqueBounded(destination.affectedThreadIds, - source.affectedThreadIds) - || !appendUniqueBounded(destination.fullyAffectedThreadIds, - source.fullyAffectedThreadIds)) { - destination.allThreadsAffected = true; - } else { - for (const QString& threadId : destination.fullyAffectedThreadIds) - destination.structurallyAffectedThreadIds.removeAll(threadId); - for (const QString& threadId : - source.structurallyAffectedThreadIds) { - if (destination.fullyAffectedThreadIds.contains(threadId) - || destination.structurallyAffectedThreadIds.contains( - threadId)) - continue; - if (destination.structurallyAffectedThreadIds.size() - >= detail::maximumCoalescedPresentationIdentities) { - destination.allThreadsAffected = true; - break; - } - destination.structurallyAffectedThreadIds.push_back(threadId); - } - } - } - if (!destination.allInspectorsAffected - && !appendUniqueBounded(destination.affectedInspectorThreadIds, - source.affectedInspectorThreadIds)) - destination.allInspectorsAffected = true; - if (!destination.allSidebarThreadsAffected - && !appendUniqueBounded(destination.affectedSidebarThreadIds, - source.affectedSidebarThreadIds)) - destination.allSidebarThreadsAffected = true; - - const auto sameContent = [](const auto& left, const auto& right) { - return left.threadId == right.threadId && left.turnId == right.turnId - && left.itemId == right.itemId && left.channel == right.channel; - }; - if (!destination.allThreadsAffected) { - for (const auto& identity : source.affectedItemContents) { - auto existing = std::ranges::find_if( - destination.affectedItemContents, - [&identity, &sameContent](const auto& candidate) { - return sameContent(candidate, identity); - }); - if (existing == destination.affectedItemContents.end()) { - if (static_cast( - destination.affectedItemContents.size()) - >= detail::maximumCoalescedPresentationIdentities) { - destination.allThreadsAffected = true; - break; - } - auto bounded = identity; - if (bounded.append) { - const std::uint64_t bytes = - static_cast( - bounded.append->deltaUtf8.size()); - if (destination.coalescedContentDeltaBytes - > detail::maximumCoalescedContentDeltaBytes - || bytes - > detail::maximumCoalescedContentDeltaBytes - - destination.coalescedContentDeltaBytes) { - bounded.append.reset(); - } else { - destination.coalescedContentDeltaBytes += bytes; - } - } - destination.affectedItemContents.push_back( - std::move(bounded)); - continue; - } - - // A replacement is already authoritative. Two exact append hints - // remain mergeable only while their byte bases form one contiguous - // no-discard range; every ambiguous sequence degrades to a bounded - // replacement refresh of the newest State. - const auto discardAccumulated = [&destination, &existing] { - if (existing->append) { - const std::uint64_t bytes = - static_cast( - existing->append->deltaUtf8.size()); - destination.coalescedContentDeltaBytes = - bytes <= destination.coalescedContentDeltaBytes - ? destination.coalescedContentDeltaBytes - bytes - : 0; - } - existing->append.reset(); - }; - if (!existing->append || !identity.append) - discardAccumulated(); - else { - auto& accumulated = *existing->append; - const auto& next = *identity.append; - const std::uint64_t accumulatedBytes = - static_cast(accumulated.deltaUtf8.size()); - const bool baseFits = - accumulated.baseContentBytes - <= std::numeric_limits::max() - - accumulatedBytes; - const bool contiguous = - accumulated.discardPrefixBytes == 0 - && next.discardPrefixBytes == 0 && baseFits - && next.baseContentBytes - == accumulated.baseContentBytes + accumulatedBytes; - const std::uint64_t nextBytes = - static_cast(next.deltaUtf8.size()); - const bool deltaFits = - destination.coalescedContentDeltaBytes - <= detail::maximumCoalescedContentDeltaBytes - && nextBytes - <= detail::maximumCoalescedContentDeltaBytes - - destination.coalescedContentDeltaBytes; - if (contiguous && deltaFits) { - accumulated.deltaUtf8.append(next.deltaUtf8); - destination.coalescedContentDeltaBytes += nextBytes; - } else { - discardAccumulated(); - } - } - } - } - - if (destination.affectedThreadIds.size() - > detail::maximumCoalescedPresentationIdentities - || destination.fullyAffectedThreadIds.size() - > detail::maximumCoalescedPresentationIdentities - || destination.structurallyAffectedThreadIds.size() - > detail::maximumCoalescedPresentationIdentities - || destination.removedThreadIds.size() - > detail::maximumCoalescedPresentationIdentities - || static_cast(destination.affectedItemContents.size()) - > detail::maximumCoalescedPresentationIdentities) { - destination.allThreadsAffected = true; - } - if (destination.affectedInspectorThreadIds.size() - > detail::maximumCoalescedPresentationIdentities) { - destination.allInspectorsAffected = true; - } - if (destination.affectedSidebarThreadIds.size() - > detail::maximumCoalescedPresentationIdentities) { - destination.allSidebarThreadsAffected = true; - } - if (destination.allThreadsAffected) { - destination.affectedThreadIds.clear(); - destination.fullyAffectedThreadIds.clear(); - destination.structurallyAffectedThreadIds.clear(); - // Keep exact removals even when the rest of the presentation scope - // degrades to an all-thread refresh. Global omission provenance makes - // a missing selected ID ambiguous without this bounded evidence. - destination.affectedItemContents.clear(); - destination.coalescedContentDeltaBytes = 0; - } - if (destination.allInspectorsAffected) - destination.affectedInspectorThreadIds.clear(); - if (destination.allSidebarThreadsAffected) - destination.affectedSidebarThreadIds.clear(); -} - -template -struct CompletionGate -{ - explicit CompletionGate(Completion callback) - : completion(std::move(callback)) - { - } - - std::atomic_bool completed = false; - std::uint64_t token = 0; - Completion completion; -}; - -} // namespace - -class FrontendSession::Impl -{ -public: - using WorkerCommand = std::function; - - struct StatePublication { - std::uint64_t generation = 0; - sdk::State state; - detail::StateUpdateScope scope; - ArchivedThreadDiscoveryStatus archivedStatus = - ArchivedThreadDiscoveryStatus::InProgress; - }; - - struct StatusPublication { - std::uint64_t generation = 0; - Lifecycle lifecycle = Lifecycle::Disconnected; - QString status; - bool lifecycleChanged = false; - }; - - struct ModelPublication { - std::uint64_t generation = 0; - std::vector models; - }; - - struct CompletionPublication { - std::uint64_t generation = 0; - std::function invoke; - }; - - using Control = std::variant; - - class WorkerThread final : public QThread - { - public: - explicit WorkerThread(Impl& impl) - : impl(impl) - { - } - - bool post(WorkerCommand command) - { - std::lock_guard lock(mutex); - if (stopping) - return false; - if (!worker) { - pending.push_back(std::move(command)); - return true; - } - auto shared = std::make_shared(std::move(command)); - return QMetaObject::invokeMethod( - worker, - [target = worker, shared] { (*shared)(*target); }, - Qt::QueuedConnection); - } - - void stopAndJoin() - { - FrontendSessionWorker* target = nullptr; - bool alreadyStopping = false; - { - std::lock_guard lock(mutex); - alreadyStopping = stopping; - if (!alreadyStopping) { - stopping = true; - target = worker; - } - } - if (alreadyStopping) { - wait(); - return; - } - if (target) { - const bool shutdownQueued = QMetaObject::invokeMethod( - target, - [target] { - target->shutdown(); - QThread::currentThread()->quit(); - }, - Qt::QueuedConnection); - // The local worker performs the same idempotent shutdown after - // its event loop exits. Do not let a failed queued invocation - // turn facade destruction into an unbounded join. - if (!shutdownQueued) - quit(); - } - wait(); - } - - protected: - void run() override - { - FrontendSessionWorker localWorker; - impl.attach(localWorker); - - std::deque initial; - bool stopImmediately = false; - { - std::lock_guard lock(mutex); - worker = &localWorker; - initial.swap(pending); - stopImmediately = stopping; - } - if (!stopImmediately) { - for (auto& command : initial) - command(localWorker); - { - std::lock_guard lock(mutex); - stopImmediately = stopping; - } - if (!stopImmediately) - exec(); - } - - localWorker.shutdown(); - { - std::lock_guard lock(mutex); - worker = nullptr; - } - } - - private: - Impl& impl; - std::mutex mutex; - FrontendSessionWorker* worker = nullptr; - std::deque pending; - bool stopping = false; - }; - - explicit Impl(FrontendSession& owner) - : owner(owner) - , workerThread(*this) - { - workerThread.setObjectName(QStringLiteral("CodexUI frontend session")); - workerThread.start(); - } - - ~Impl() - { - shutdown(); - } - - void shutdown() - { - if (shutdownComplete.exchange(true)) - return; - workerThread.stopAndJoin(); - { - std::lock_guard lock(publicationMutex); - acceptingPublications = false; - } - drainPublications(); - failPendingCompletions( - QStringLiteral("Frontend session closed before the operation completed")); - { - std::lock_guard lock(publicationMutex); - latestState.reset(); - controls.clear(); - wakeScheduled = false; - } - } - - bool post(WorkerCommand command) - { - return workerThread.post(std::move(command)); - } - - template - std::optional postOperation( - std::optional validation, - Completion completion, - Failure failure, - Invocation invocation) - { - if (currentLifecycle != Lifecycle::Ready) - return QStringLiteral("Backend is not ready"); - if (validation) - return validation; - - auto gate = std::make_shared>( - std::move(completion)); - trackCompletion(gate, std::move(failure)); - if (!post([this, gate, invocation = std::move(invocation)]( - FrontendSessionWorker& worker) mutable { - const std::uint64_t generation = worker.generation(); - auto done = [this, gate, generation](auto&&... values) { - enqueueCompletion(generation, gate, - std::forward(values)...); - }; - invocation(worker, std::move(done)); - })) { - cancelCompletion(gate); - return QStringLiteral("Frontend worker is shutting down"); - } - return std::nullopt; - } - - template - std::optional postUnitOperation( - std::optional validation, - OperationCompletion completion, - Invocation invocation) - { - return postOperation( - std::move(validation), - std::move(completion), - [](auto& callback, const QString& error) { callback(error); }, - [invocation = std::move(invocation)]( - FrontendSessionWorker& worker, auto done) mutable { - if (const auto error = invocation(worker, done)) - done(*error); - }); - } - - template - void trackCompletion( - const std::shared_ptr>& gate, - Failure failure) - { - std::lock_guard lock(completionMutex); - gate->token = ++nextCompletionToken; - pendingCompletions.emplace( - gate->token, - [gate, failure = std::move(failure)](const QString& error) mutable { - if (gate->completed.exchange(true)) - return; - failure(gate->completion, error); - }); - } - - template - void cancelCompletion( - const std::shared_ptr>& gate) - { - gate->completed.store(true); - std::lock_guard lock(completionMutex); - pendingCompletions.erase(gate->token); - } - - void untrackCompletion(std::uint64_t token) - { - std::lock_guard lock(completionMutex); - pendingCompletions.erase(token); - } - - void failPendingCompletions(const QString& error) - { - std::map> pending; - { - std::lock_guard lock(completionMutex); - pending.swap(pendingCompletions); - } - for (auto& [token, fail] : pending) { - Q_UNUSED(token); - fail(error); - } - } - - void attach(FrontendSessionWorker& worker) - { - workerAffinityValidated.store( - worker.transportAffinityIsCurrentThread(), - std::memory_order_release); - QObject::connect( - &worker, - &FrontendSessionWorker::stateChanged, - &worker, - [this, &worker](const detail::StateUpdateScope& scope) { - enqueueState(StatePublication{ - worker.generation(), - worker.state(), - scope, - worker.archivedThreadDiscoveryStatus(), - }); - }, - Qt::DirectConnection); - QObject::connect( - &worker, - &FrontendSessionWorker::lifecycleChanged, - &worker, - [this, &worker] { - enqueueControl(StatusPublication{ - worker.generation(), - worker.lifecycle(), - worker.statusText(), - true, - }); - }, - Qt::DirectConnection); - QObject::connect( - &worker, - &FrontendSessionWorker::statusChanged, - &worker, - [this, &worker] { - enqueueControl(StatusPublication{ - worker.generation(), - worker.lifecycle(), - worker.statusText(), - false, - }); - }, - Qt::DirectConnection); - QObject::connect( - &worker, - &FrontendSessionWorker::modelCatalogChanged, - &worker, - [this, &worker] { - enqueueControl(ModelPublication{ - worker.generation(), - worker.modelCatalog(), - }); - }, - Qt::DirectConnection); - } - - void scheduleWakeIfNeeded(bool& scheduleWake) - { - if (!wakeScheduled) { - wakeScheduled = true; - scheduleWake = true; - } - } - - void postWake(bool scheduleWake) - { - if (scheduleWake) { - postedWakeCount.fetch_add(1, std::memory_order_relaxed); - QMetaObject::invokeMethod( - &owner, - [this] { drainPublications(); }, - Qt::QueuedConnection); - } - } - - void enqueueState(StatePublication publication) - { - bool scheduleWake = false; - { - std::lock_guard lock(publicationMutex); - if (!acceptingPublications) - return; - if (latestState - && latestState->generation == publication.generation) { - latestState->state = std::move(publication.state); - latestState->archivedStatus = publication.archivedStatus; - mergeScope(latestState->scope, publication.scope); - // The newest immutable State is the final authority for any - // identity it actually retains. Capacity-omitted identities - // remain ambiguous and therefore keep their exact tombstone. - for (auto iterator = latestState->scope.removedThreadIds.begin(); - iterator != latestState->scope.removedThreadIds.end();) { - if (latestState->state.thread(iterator->toStdString())) - iterator = latestState->scope.removedThreadIds.erase(iterator); - else - ++iterator; - } - } else { - latestState = std::move(publication); - } - scheduleWakeIfNeeded(scheduleWake); - } - postWake(scheduleWake); - } - - [[nodiscard]] static bool isControlBarrier(const Control& control) - { - if (std::holds_alternative(control)) - return true; - const auto* status = std::get_if(&control); - return status && status->lifecycleChanged; - } - - template - void appendReplaceableControl(Value value) - { - auto existing = controls.end(); - while (existing != controls.begin()) { - --existing; - if (isControlBarrier(*existing)) - break; - const auto* candidate = std::get_if(&*existing); - if (!candidate) - continue; - if (candidate->generation > value.generation) - return; - controls.erase(existing); - break; - } - // Append instead of replacing in place so the surviving publication - // keeps its last-occurrence ordering relative to the other - // replaceable control kind. - controls.push_back(Control{std::move(value)}); - } - - void appendControl(StatusPublication publication) - { - if (publication.lifecycleChanged) { - controls.push_back(Control{std::move(publication)}); - return; - } - appendReplaceableControl(std::move(publication)); - } - - void appendControl(ModelPublication publication) - { - appendReplaceableControl(std::move(publication)); - } - - void appendControl(CompletionPublication publication) - { - controls.push_back(Control{std::move(publication)}); - } - - template - void enqueueControl(Value value) - { - bool scheduleWake = false; - { - std::lock_guard lock(publicationMutex); - if (!acceptingPublications) - return; - appendControl(std::move(value)); - scheduleWakeIfNeeded(scheduleWake); - } - postWake(scheduleWake); - } - - void drainPublications() - { - std::optional state; - std::deque readyControls; - { - std::lock_guard lock(publicationMutex); - state.swap(latestState); - readyControls.swap(controls); - wakeScheduled = false; - } - // A one-slot State mailbox cannot replay superseded intermediate - // States around controls. Publish the newest authoritative State - // first, then deliver every control/result in original FIFO order, so - // no callback can observe State older than the worker boundary at - // which the GUI caught up. - if (state) - apply(*state); - for (Control& publication : readyControls) - std::visit([this](auto& value) { apply(value); }, publication); - } - - template - void enqueueCompletion(std::uint64_t generation, - const std::shared_ptr>& gate, - Args&&... args) - { - if (gate->completed.exchange(true)) - return; - untrackCompletion(gate->token); - auto arguments = - std::make_tuple(std::forward(args)...); - enqueueControl(CompletionPublication{ - generation, - [gate, arguments = std::move(arguments)]() mutable { - std::apply(gate->completion, std::move(arguments)); - }, - }); - } - - void apply(StatePublication& publication) - { - if (publication.generation < appliedGeneration) - return; - appliedGeneration = publication.generation; - currentState = std::move(publication.state); - archivedStatus = publication.archivedStatus; - if (publication.scope.hasPresentationChange) - emit owner.stateChanged(publication.scope); - } - - void apply(StatusPublication& publication) - { - if (publication.generation < appliedGeneration) - return; - appliedGeneration = std::max(appliedGeneration, publication.generation); - const Lifecycle previousLifecycle = currentLifecycle; - const QString previousStatus = status; - currentLifecycle = publication.lifecycle; - status = std::move(publication.status); - if (publication.lifecycleChanged && previousLifecycle != currentLifecycle) - emit owner.lifecycleChanged(); - else if (publication.lifecycleChanged && previousStatus != status) - emit owner.lifecycleChanged(); - if (!publication.lifecycleChanged && previousStatus != status) - emit owner.statusChanged(); - } - - void apply(ModelPublication& publication) - { - if (publication.generation < appliedGeneration) - return; - appliedGeneration = publication.generation; - if (models == publication.models) - return; - models = std::move(publication.models); - emit owner.modelCatalogChanged(); - } - - void apply(CompletionPublication& publication) - { - // Operation results are deliberately never discarded merely because a - // reconnect advanced the generation. They own user-visible completion. - appliedGeneration = std::max(appliedGeneration, publication.generation); - if (publication.invoke) - publication.invoke(); - } - - FrontendSession& owner; - WorkerThread workerThread; - - std::mutex publicationMutex; - std::optional latestState; - std::deque controls; - bool wakeScheduled = false; - bool acceptingPublications = true; - std::atomic_size_t postedWakeCount = 0; - std::atomic_bool workerAffinityValidated = false; - std::atomic_bool shutdownComplete = false; - - std::mutex completionMutex; - std::map> - pendingCompletions; - std::uint64_t nextCompletionToken = 0; - - sdk::State currentState; - std::vector models; - Lifecycle currentLifecycle = Lifecycle::Disconnected; - ArchivedThreadDiscoveryStatus archivedStatus = - ArchivedThreadDiscoveryStatus::InProgress; - QString status = QStringLiteral("Disconnected"); - std::uint64_t appliedGeneration = 0; -}; - -FrontendSession::FrontendSession(QObject* parent) - : QObject(parent) - , impl(std::make_unique(*this)) -{ -} - -FrontendSession::~FrontendSession() = default; - -void FrontendSession::shutdown() -{ - impl->shutdown(); -} - -void FrontendSession::enqueueStateForTest( - std::uint64_t generation, - detail::StateUpdateScope scope) -{ - impl->enqueueState(Impl::StatePublication{ - generation, - impl->currentState, - std::move(scope), - impl->archivedStatus, - }); -} - -void FrontendSession::enqueueStatusForTest(std::uint64_t generation, - QString status) -{ - enqueueStatusForTest( - generation, impl->currentLifecycle, std::move(status)); -} - -void FrontendSession::enqueueStatusForTest(std::uint64_t generation, - Lifecycle lifecycle, - QString status) -{ - impl->enqueueControl(Impl::StatusPublication{ - generation, - lifecycle, - std::move(status), - false, - }); -} - -void FrontendSession::enqueueLifecycleForTest(std::uint64_t generation, - Lifecycle lifecycle, - QString status) -{ - impl->enqueueControl(Impl::StatusPublication{ - generation, - lifecycle, - std::move(status), - true, - }); -} - -void FrontendSession::enqueueModelsForTest( - std::uint64_t generation, - std::vector models) -{ - impl->enqueueControl(Impl::ModelPublication{ - generation, - std::move(models), - }); -} - -std::size_t FrontendSession::pendingStateCountForTest() const -{ - std::lock_guard lock(impl->publicationMutex); - return impl->latestState ? 1U : 0U; -} - -std::size_t FrontendSession::pendingControlCountForTest() const -{ - std::lock_guard lock(impl->publicationMutex); - return impl->controls.size(); -} - -std::size_t FrontendSession::postedWakeCountForTest() const noexcept -{ - return impl->postedWakeCount.load(std::memory_order_relaxed); -} - -bool FrontendSession::workerAffinityValidatedForTest() const noexcept -{ - return impl->workerAffinityValidated.load(std::memory_order_acquire); -} - -void FrontendSession::trackOperationForTest(OperationCompletion completion) -{ - auto gate = std::make_shared>( - std::move(completion)); - impl->trackCompletion(gate, [](auto& callback, const QString& error) { - callback(error); - }); -} - -void FrontendSession::completeOperationForTest( - std::uint64_t generation, - OperationCompletion completion, - QString error) -{ - auto gate = std::make_shared>( - std::move(completion)); - impl->trackCompletion(gate, [](auto& callback, const QString& value) { - callback(value); - }); - impl->enqueueCompletion(generation, gate, error); - // Exercise the same gate against a duplicate provider callback: exactly - // one GUI publication owns the completion. - impl->enqueueCompletion(generation, gate, std::move(error)); -} - -void FrontendSession::connectToBackend() -{ - impl->post([](FrontendSessionWorker& worker) { - worker.connectToBackend(); - }); -} - -void FrontendSession::reconnectToBackend() -{ - impl->post([](FrontendSessionWorker& worker) { - worker.reconnectToBackend(); - }); -} - -FrontendSession::Lifecycle FrontendSession::lifecycle() const noexcept -{ - return impl->currentLifecycle; -} - -QString FrontendSession::statusText() const -{ - return impl->status; -} - -std::optional FrontendSession::promptValidationError(const QString& prompt) -{ - return FrontendSessionWorker::promptValidationError(prompt); -} - -const sdk::State& FrontendSession::state() const noexcept -{ - return impl->currentState; -} - -const std::vector& -FrontendSession::modelCatalog() const noexcept -{ - return impl->models; -} - -bool FrontendSession::archivedThreadDiscoveryComplete() const noexcept -{ - return impl->archivedStatus == ArchivedThreadDiscoveryStatus::Complete; -} - -bool FrontendSession::archivedThreadDiscoveryTerminal() const noexcept -{ - return impl->archivedStatus != ArchivedThreadDiscoveryStatus::InProgress; -} - -FrontendSession::ArchivedThreadDiscoveryStatus -FrontendSession::archivedThreadDiscoveryStatus() const noexcept -{ - return impl->archivedStatus; -} - -bool FrontendSession::ownsController() const noexcept -{ - const auto& projection = impl->currentState.controller(); - return projection.value && projection.value->ownedByThisClient; -} - -void FrontendSession::loadThread(const QString& threadId, bool retryIncomplete) -{ - if (impl->currentLifecycle != Lifecycle::Ready || threadId.isEmpty()) - return; - impl->post([threadId, retryIncomplete](FrontendSessionWorker& worker) { - worker.loadThread(threadId, retryIncomplete); - }); -} - -std::optional -FrontendSession::acquireController(OperationCompletion completion) -{ - return impl->postOperation( - std::nullopt, - std::move(completion), - [](auto& callback, const QString& error) { callback(error); }, - [](FrontendSessionWorker& worker, auto done) { - if (const auto error = worker.acquireController(done)) - done(*error); - }); -} - -std::optional -FrontendSession::startThread(ThreadStartCompletion completion) -{ - return startThread(ai::openai::codex::typed::ThreadStartParams{}, - std::move(completion)); -} - -std::optional -FrontendSession::startThread(ai::openai::codex::typed::ThreadStartParams parameters, - ThreadStartCompletion completion) -{ - return impl->postOperation( - std::nullopt, - std::move(completion), - [](auto& callback, const QString& error) { callback({}, error); }, - [parameters = std::move(parameters)]( - FrontendSessionWorker& worker, auto done) mutable { - if (const auto error = - worker.startThread(std::move(parameters), done)) - done(QString{}, *error); - }); -} - -std::optional -FrontendSession::resumeThread(const QString& threadId, - ThreadStartCompletion completion) -{ - if (threadId.isEmpty()) - return QStringLiteral("Thread attach requires a thread ID"); - ai::openai::codex::typed::ThreadResumeParams parameters; - parameters.threadId = - ai::openai::codex::typed::ThreadId{threadId.toStdString()}; - return resumeThread(std::move(parameters), std::move(completion)); -} - -std::optional -FrontendSession::resumeThread( - ai::openai::codex::typed::ThreadResumeParams parameters, - ThreadStartCompletion completion) -{ - const auto validation = parameters.threadId.value.empty() - ? std::optional(QStringLiteral( - "Thread attach requires a thread ID")) - : std::nullopt; - return impl->postOperation( - validation, - std::move(completion), - [](auto& callback, const QString& error) { callback({}, error); }, - [parameters = std::move(parameters)]( - FrontendSessionWorker& worker, auto done) mutable { - if (const auto error = - worker.resumeThread(std::move(parameters), done)) - done(QString{}, *error); - }); -} - -std::optional -FrontendSession::startTurn(const QString& threadId, - const QString& prompt, - OperationCompletion completion) -{ - ai::openai::codex::typed::TurnStartParams parameters; - parameters.threadId = - ai::openai::codex::typed::ThreadId{threadId.toStdString()}; - return startTurn( - std::move(parameters), - prompt, - [completion = std::move(completion)](const QString&, - const QString& error) { - completion(error); - }); -} - -std::optional -FrontendSession::startTurn(ai::openai::codex::typed::TurnStartParams parameters, - const QString& prompt, - TurnStartCompletion completion) -{ - return startTurn(std::move(parameters), prompt, {}, - std::move(completion)); -} - -std::optional -FrontendSession::startTurn(ai::openai::codex::typed::TurnStartParams parameters, - const QString& prompt, - const QStringList& localImagePaths, - TurnStartCompletion completion) -{ - std::optional validation = promptValidationError(prompt); - if (!validation && parameters.threadId.value.empty()) - validation = QStringLiteral("Turn submission requires a thread ID"); - if (!validation && prompt.trimmed().isEmpty() && localImagePaths.isEmpty()) - validation = QStringLiteral( - "Turn submission requires a prompt or attachment"); - return impl->postOperation( - std::move(validation), - std::move(completion), - [](auto& callback, const QString& error) { callback({}, error); }, - [parameters = std::move(parameters), prompt, localImagePaths]( - FrontendSessionWorker& worker, auto done) mutable { - if (const auto error = worker.startTurn( - std::move(parameters), prompt, localImagePaths, done)) - done(QString{}, *error); - }); -} - -std::optional -FrontendSession::steerTurn(const QString& threadId, - const QString& expectedTurnId, - const QString& prompt, - OperationCompletion completion) -{ - return steerTurn(threadId, expectedTurnId, prompt, {}, - std::move(completion)); -} - -std::optional -FrontendSession::steerTurn(const QString& threadId, - const QString& expectedTurnId, - const QString& prompt, - const QStringList& localImagePaths, - OperationCompletion completion) -{ - std::optional validation = promptValidationError(prompt); - if (!validation && (threadId.isEmpty() || expectedTurnId.isEmpty())) - validation = QStringLiteral( - "Steering requires the active thread and turn identities"); - if (!validation && prompt.trimmed().isEmpty() && localImagePaths.isEmpty()) - validation = QStringLiteral("Steering requires a prompt or attachment"); - return impl->postOperation( - std::move(validation), - std::move(completion), - [](auto& callback, const QString& error) { callback(error); }, - [threadId, expectedTurnId, prompt, localImagePaths]( - FrontendSessionWorker& worker, auto done) mutable { - if (const auto error = worker.steerTurn( - threadId, expectedTurnId, prompt, localImagePaths, done)) - done(*error); - }); -} - -std::optional -FrontendSession::forkThread(ai::openai::codex::typed::ThreadForkParams parameters, - ThreadStartCompletion completion) -{ - const auto validation = parameters.threadId.value.empty() - ? std::optional(QStringLiteral( - "Fork requires a source thread ID")) - : std::nullopt; - return impl->postOperation( - validation, - std::move(completion), - [](auto& callback, const QString& error) { callback({}, error); }, - [parameters = std::move(parameters)]( - FrontendSessionWorker& worker, auto done) mutable { - if (const auto error = - worker.forkThread(std::move(parameters), done)) - done(QString{}, *error); - }); -} - -std::optional -FrontendSession::renameThread(const QString& threadId, - const QString& name, - OperationCompletion completion) -{ - const auto validation = - threadId.isEmpty() || name.trimmed().isEmpty() - ? std::optional( - QStringLiteral("Rename requires a thread ID and non-empty name")) - : std::nullopt; - auto invocation = [threadId, name](FrontendSessionWorker& worker, - OperationCompletion done) { - return worker.renameThread(threadId, name, std::move(done)); - }; - return impl->postUnitOperation(validation, std::move(completion), - std::move(invocation)); -} - -std::optional -FrontendSession::archiveThread(const QString& threadId, - OperationCompletion completion) -{ - const auto validation = - threadId.isEmpty() - ? std::optional(QStringLiteral("Archive requires a thread ID")) - : std::nullopt; - auto invocation = [threadId](FrontendSessionWorker& worker, - OperationCompletion done) { - return worker.archiveThread(threadId, std::move(done)); - }; - return impl->postUnitOperation(validation, std::move(completion), - std::move(invocation)); -} - -std::optional -FrontendSession::unarchiveThread(const QString& threadId, - OperationCompletion completion) -{ - const auto validation = - threadId.isEmpty() - ? std::optional( - QStringLiteral("Unarchive requires a thread ID")) - : std::nullopt; - auto invocation = [threadId](FrontendSessionWorker& worker, - OperationCompletion done) { - return worker.unarchiveThread(threadId, std::move(done)); - }; - return impl->postUnitOperation(validation, std::move(completion), - std::move(invocation)); -} - -std::optional -FrontendSession::deleteThread(const QString& threadId, - OperationCompletion completion) -{ - const auto validation = - threadId.isEmpty() - ? std::optional(QStringLiteral("Delete requires a thread ID")) - : std::nullopt; - auto invocation = [threadId](FrontendSessionWorker& worker, - OperationCompletion done) { - return worker.deleteThread(threadId, std::move(done)); - }; - return impl->postUnitOperation(validation, std::move(completion), - std::move(invocation)); -} - -std::optional -FrontendSession::interruptTurn(const QString& threadId, - const QString& turnId, - OperationCompletion completion) -{ - const auto validation = - threadId.isEmpty() || turnId.isEmpty() - ? std::optional( - QStringLiteral("Interrupt requires thread and turn IDs")) - : std::nullopt; - auto invocation = [threadId, turnId](FrontendSessionWorker& worker, - OperationCompletion done) { - return worker.interruptTurn(threadId, turnId, std::move(done)); - }; - return impl->postUnitOperation(validation, std::move(completion), - std::move(invocation)); -} - -std::optional -FrontendSession::respondApproval(const sdk::PendingRequestId& requestId, - ai::openai::codex::typed::ApprovalDecision decision, - OperationCompletion completion) -{ - auto invocation = - [requestId, decision = std::move(decision)]( - FrontendSessionWorker& worker, OperationCompletion done) mutable { - return worker.respondApproval(requestId, std::move(decision), - std::move(done)); - }; - return impl->postUnitOperation(std::nullopt, std::move(completion), - std::move(invocation)); -} - -std::optional -FrontendSession::respondApplyPatchApproval( - const sdk::PendingRequestId& requestId, - ai::openai::codex::typed::ApplyPatchApprovalResponse response, - OperationCompletion completion) -{ - auto invocation = - [requestId, response = std::move(response)]( - FrontendSessionWorker& worker, OperationCompletion done) mutable { - return worker.respondApplyPatchApproval( - requestId, std::move(response), std::move(done)); - }; - return impl->postUnitOperation(std::nullopt, std::move(completion), - std::move(invocation)); -} - -std::optional -FrontendSession::respondExecCommandApproval( - const sdk::PendingRequestId& requestId, - ai::openai::codex::typed::ExecCommandApprovalResponse response, - OperationCompletion completion) -{ - auto invocation = - [requestId, response = std::move(response)]( - FrontendSessionWorker& worker, OperationCompletion done) mutable { - return worker.respondExecCommandApproval( - requestId, std::move(response), std::move(done)); - }; - return impl->postUnitOperation(std::nullopt, std::move(completion), - std::move(invocation)); -} - -std::optional -FrontendSession::respondUserInput( - const sdk::PendingRequestId& requestId, - std::vector answers, - OperationCompletion completion) -{ - auto invocation = - [requestId, answers = std::move(answers)]( - FrontendSessionWorker& worker, OperationCompletion done) mutable { - return worker.respondUserInput(requestId, std::move(answers), - std::move(done)); - }; - return impl->postUnitOperation(std::nullopt, std::move(completion), - std::move(invocation)); -} - -} // namespace codexui diff --git a/src/app/FrontendSession.h b/src/app/FrontendSession.h deleted file mode 100644 index 53e7fab..0000000 --- a/src/app/FrontendSession.h +++ /dev/null @@ -1,219 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#ifndef CODEXUI_APP_FRONTENDSESSION_H -#define CODEXUI_APP_FRONTENDSESSION_H - -#include -#include -#include -#include - -#include -#include - -#include -#include -#include -#include -#include -#include - -namespace codexui::detail { - -// This bounds only optional GUI presentation metadata. Exceeding it is -// lossless: the newest immutable State remains in the mailbox and the view -// performs an authoritative replacement refresh instead of retaining deltas. -inline constexpr std::uint64_t maximumCoalescedContentDeltaBytes = - 1024U * 1024U; -inline constexpr qsizetype maximumCoalescedPresentationIdentities = 1'024; - -struct StateUpdateScope { - struct ItemContentAppend { - std::uint64_t baseContentBytes = 0; - std::uint64_t discardPrefixBytes = 0; - QByteArray deltaUtf8; - - bool operator==(const ItemContentAppend&) const = default; - }; - - struct ItemContentIdentity { - QString threadId; - QString turnId; - QString itemId; - ai::openai::codex::frontend::client::ItemContentChannel channel = - ai::openai::codex::frontend::client::ItemContentChannel::AgentText; - std::optional append; - - bool operator==(const ItemContentIdentity&) const = default; - }; - - QStringList affectedThreadIds; - QStringList fullyAffectedThreadIds; - // Pure descendant additions: always a subset of affectedThreadIds. A - // full/deletion-capable scope for the same thread always dominates. - QStringList structurallyAffectedThreadIds; - // Exact authoritative removals must survive mailbox coalescing. An - // omitted thread is otherwise indistinguishable from one deleted by an - // authoritative thread/read while the global snapshot remains bounded. - QStringList removedThreadIds; - QStringList affectedInspectorThreadIds; - QStringList affectedSidebarThreadIds; - std::vector affectedItemContents; - std::uint64_t coalescedContentDeltaBytes = 0; - // The bounded list omitted at least one exact removal identity. The UI - // must verify any missing retained selection instead of treating global - // snapshot omission as either presence or deletion. - bool removedThreadIdsOverflowed = false; - bool allThreadsAffected = false; - bool allInspectorsAffected = false; - bool allSidebarThreadsAffected = false; - bool sidebarAffected = false; - bool hasPresentationChange = false; -}; - -[[nodiscard]] StateUpdateScope -stateUpdateScope(const ai::openai::codex::frontend::client::StateUpdate& update); - -} // namespace codexui::detail - -namespace codexui { - -struct FrontendSessionFacadeTestAccess; - -class FrontendSession : public QObject -{ - Q_OBJECT - -public: - enum class Lifecycle { Disconnected, Connecting, Authenticating, Synchronizing, Ready, Failed }; - enum class ArchivedThreadDiscoveryStatus { - InProgress, - Complete, - CompleteWithTruncation, - Failed, - }; - - using OperationCompletion = std::function; - using ThreadStartCompletion = std::function; - using TurnStartCompletion = std::function; - - explicit FrontendSession(QObject* parent = nullptr); - ~FrontendSession() override; - - FrontendSession(const FrontendSession&) = delete; - FrontendSession& operator=(const FrontendSession&) = delete; - - void connectToBackend(); - void reconnectToBackend(); - void shutdown(); - [[nodiscard]] Lifecycle lifecycle() const noexcept; - [[nodiscard]] QString statusText() const; - [[nodiscard]] static std::optional promptValidationError(const QString& prompt); - [[nodiscard]] const ai::openai::codex::frontend::client::State& state() const noexcept; - [[nodiscard]] const std::vector& modelCatalog() const noexcept; - [[nodiscard]] bool archivedThreadDiscoveryComplete() const noexcept; - [[nodiscard]] bool archivedThreadDiscoveryTerminal() const noexcept; - [[nodiscard]] ArchivedThreadDiscoveryStatus archivedThreadDiscoveryStatus() const noexcept; - [[nodiscard]] bool ownsController() const noexcept; - void loadThread(const QString& threadId, bool retryIncomplete = false); - [[nodiscard]] std::optional acquireController(OperationCompletion completion); - [[nodiscard]] std::optional startThread(ThreadStartCompletion completion); - [[nodiscard]] std::optional - startThread(ai::openai::codex::typed::ThreadStartParams parameters, - ThreadStartCompletion completion); - [[nodiscard]] std::optional - resumeThread(const QString& threadId, ThreadStartCompletion completion); - [[nodiscard]] std::optional - resumeThread(ai::openai::codex::typed::ThreadResumeParams parameters, - ThreadStartCompletion completion); - [[nodiscard]] std::optional - startTurn(const QString& threadId, const QString& prompt, OperationCompletion completion); - [[nodiscard]] std::optional - startTurn(ai::openai::codex::typed::TurnStartParams parameters, - const QString& prompt, - TurnStartCompletion completion); - [[nodiscard]] std::optional - startTurn(ai::openai::codex::typed::TurnStartParams parameters, - const QString& prompt, - const QStringList& localImagePaths, - TurnStartCompletion completion); - [[nodiscard]] std::optional - steerTurn(const QString& threadId, - const QString& expectedTurnId, - const QString& prompt, - OperationCompletion completion); - [[nodiscard]] std::optional - steerTurn(const QString& threadId, - const QString& expectedTurnId, - const QString& prompt, - const QStringList& localImagePaths, - OperationCompletion completion); - [[nodiscard]] std::optional - forkThread(ai::openai::codex::typed::ThreadForkParams parameters, - ThreadStartCompletion completion); - [[nodiscard]] std::optional - renameThread(const QString& threadId, const QString& name, OperationCompletion completion); - [[nodiscard]] std::optional - archiveThread(const QString& threadId, OperationCompletion completion); - [[nodiscard]] std::optional - unarchiveThread(const QString& threadId, OperationCompletion completion); - [[nodiscard]] std::optional - deleteThread(const QString& threadId, OperationCompletion completion); - [[nodiscard]] std::optional - interruptTurn(const QString& threadId, const QString& turnId, OperationCompletion completion); - [[nodiscard]] std::optional - respondApproval(const ai::openai::codex::frontend::client::PendingRequestId& requestId, - ai::openai::codex::typed::ApprovalDecision decision, - OperationCompletion completion); - [[nodiscard]] std::optional - respondApplyPatchApproval( - const ai::openai::codex::frontend::client::PendingRequestId& requestId, - ai::openai::codex::typed::ApplyPatchApprovalResponse response, - OperationCompletion completion); - [[nodiscard]] std::optional - respondExecCommandApproval( - const ai::openai::codex::frontend::client::PendingRequestId& requestId, - ai::openai::codex::typed::ExecCommandApprovalResponse response, - OperationCompletion completion); - [[nodiscard]] std::optional - respondUserInput(const ai::openai::codex::frontend::client::PendingRequestId& requestId, - std::vector answers, - OperationCompletion completion); - -signals: - void lifecycleChanged(); - void statusChanged(); - void stateChanged(const codexui::detail::StateUpdateScope& scope); - void modelCatalogChanged(); - -private: - friend struct FrontendSessionFacadeTestAccess; - - void enqueueStateForTest(std::uint64_t generation, - detail::StateUpdateScope scope); - void enqueueStatusForTest(std::uint64_t generation, QString status); - void enqueueStatusForTest(std::uint64_t generation, - Lifecycle lifecycle, - QString status); - void enqueueLifecycleForTest(std::uint64_t generation, - Lifecycle lifecycle, - QString status); - void enqueueModelsForTest( - std::uint64_t generation, - std::vector models); - [[nodiscard]] std::size_t pendingStateCountForTest() const; - [[nodiscard]] std::size_t pendingControlCountForTest() const; - [[nodiscard]] std::size_t postedWakeCountForTest() const noexcept; - [[nodiscard]] bool workerAffinityValidatedForTest() const noexcept; - void trackOperationForTest(OperationCompletion completion); - void completeOperationForTest(std::uint64_t generation, - OperationCompletion completion, - QString error); - - class Impl; - std::unique_ptr impl; -}; - -} // namespace codexui - -#endif // CODEXUI_APP_FRONTENDSESSION_H diff --git a/src/app/FrontendSessionWorker.cpp b/src/app/FrontendSessionWorker.cpp deleted file mode 100644 index 02672d5..0000000 --- a/src/app/FrontendSessionWorker.cpp +++ /dev/null @@ -1,1780 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "app/FrontendSessionWorker.h" - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -namespace codexui { -namespace sdk = ai::openai::codex::frontend::client; -namespace frontend = ai::openai::codex::frontend; - -namespace detail { - -std::optional unixPeerCredentialError(qintptr socketDescriptor, uid_t expectedUserId) noexcept -{ - if (socketDescriptor < 0 || socketDescriptor > std::numeric_limits::max()) - return QStringLiteral("Could not authenticate the Unix backend peer"); - ucred credentials{}; - socklen_t credentialsSize = sizeof(credentials); - if (::getsockopt(static_cast(socketDescriptor), SOL_SOCKET, SO_PEERCRED, &credentials, &credentialsSize) != 0 - || credentialsSize != sizeof(credentials)) - return QStringLiteral("Could not authenticate the Unix backend peer"); - if (credentials.uid != expectedUserId) - return QStringLiteral("Unix backend peer belongs to a different user"); - return std::nullopt; -} - -StateUpdateScope stateUpdateScope(const sdk::StateUpdate& update) -{ - StateUpdateScope scope; - const auto addUnique = [](QStringList& ids, std::string_view id) - { - const QString threadId = QString::fromUtf8(id.data(), static_cast(id.size())); - if (ids.contains(threadId)) - return true; - if (ids.size() >= maximumCoalescedPresentationIdentities) - return false; - ids.append(threadId); - return true; - }; - const auto addThread = [&scope, &addUnique](std::string_view id) { - if (!scope.allThreadsAffected - && !addUnique(scope.affectedThreadIds, id)) - scope.allThreadsAffected = true; - }; - const auto addFullyAffectedThread = [&scope, &addThread, &addUnique](std::string_view id) { - addThread(id); - if (scope.allThreadsAffected) - return; - if (!addUnique(scope.fullyAffectedThreadIds, id)) { - scope.allThreadsAffected = true; - return; - } - scope.structurallyAffectedThreadIds.removeAll( - QString::fromUtf8(id.data(), static_cast(id.size()))); - }; - const auto addStructurallyAffectedThread = - [&scope, &addThread, &addUnique](std::string_view id) { - addThread(id); - if (scope.allThreadsAffected) - return; - const QString threadId = QString::fromUtf8( - id.data(), static_cast(id.size())); - if (scope.fullyAffectedThreadIds.contains(threadId)) - return; - if (!addUnique(scope.structurallyAffectedThreadIds, id)) - scope.allThreadsAffected = true; - }; - const auto addInspectorThread = [&scope, &addUnique](std::string_view id) { - if (!scope.allInspectorsAffected - && !addUnique(scope.affectedInspectorThreadIds, id)) - scope.allInspectorsAffected = true; - }; - const auto addSidebarThread = [&scope, &addUnique](std::string_view id) { - scope.sidebarAffected = true; - if (!scope.allSidebarThreadsAffected - && !addUnique(scope.affectedSidebarThreadIds, id)) - scope.allSidebarThreadsAffected = true; - }; - const auto markThreadAndInspector = [&addFullyAffectedThread, &addInspectorThread](std::string_view id) { - addFullyAffectedThread(id); - addInspectorThread(id); - }; - const auto addItemContent = [&scope, &addThread](const auto& value, - auto append) { - const auto asQString = [](std::string_view id) { - return QString::fromUtf8(id.data(), static_cast(id.size())); - }; - const QString threadId = asQString(value.threadId->value); - const QString turnId = asQString(value.turnId->value); - const QString itemId = asQString(value.itemId.value); - addThread(value.threadId->value); - if (scope.allThreadsAffected) - return; - StateUpdateScope::ItemContentIdentity identity{ - threadId, - turnId, - itemId, - value.channel, - std::move(append), - }; - const auto existing = std::find_if( - scope.affectedItemContents.begin(), - scope.affectedItemContents.end(), - [&identity](const auto& candidate) { - return candidate.threadId == identity.threadId - && candidate.turnId == identity.turnId - && candidate.itemId == identity.itemId - && candidate.channel == identity.channel; - }); - if (existing == scope.affectedItemContents.end()) { - if (static_cast(scope.affectedItemContents.size()) - >= maximumCoalescedPresentationIdentities) { - scope.allThreadsAffected = true; - return; - } - if (identity.append) { - const std::uint64_t bytes = - static_cast( - identity.append->deltaUtf8.size()); - if (scope.coalescedContentDeltaBytes - > maximumCoalescedContentDeltaBytes - || bytes - > maximumCoalescedContentDeltaBytes - - scope.coalescedContentDeltaBytes) { - identity.append.reset(); - } else { - scope.coalescedContentDeltaBytes += bytes; - } - } - scope.affectedItemContents.push_back(std::move(identity)); - } else { - if (existing->append) { - const std::uint64_t bytes = - static_cast( - existing->append->deltaUtf8.size()); - scope.coalescedContentDeltaBytes = - bytes <= scope.coalescedContentDeltaBytes - ? scope.coalescedContentDeltaBytes - bytes - : 0; - } - existing->append.reset(); - } - }; - - if (update.changes.empty()) - { - scope.allThreadsAffected = true; - scope.allInspectorsAffected = true; - scope.allSidebarThreadsAffected = true; - scope.sidebarAffected = true; - scope.hasPresentationChange = true; - return scope; - } - - for (const auto& change : update.changes) - { - std::visit( - [&](const auto& value) - { - using Change = std::decay_t; - if constexpr (std::is_same_v) - { - // A cursor-only update changes only the Inspector's cheap - // revision value; it must not dirty any expensive pane. - } - else if constexpr (std::is_same_v) - { - scope.allThreadsAffected = true; - scope.allInspectorsAffected = true; - scope.allSidebarThreadsAffected = true; - scope.sidebarAffected = true; - } - else if constexpr (std::is_same_v) - { - addFullyAffectedThread(value.threadId.value); - addSidebarThread(value.threadId.value); - // The selected Inspector can show status/model facts from - // a linked subagent thread even when its conversation is - // not selected. Workbench resolves this identity against - // its retained Inspector dependency set. - addInspectorThread(value.threadId.value); - } - else if constexpr (std::is_same_v) - { - addFullyAffectedThread(value.threadId.value); - if (!addUnique(scope.removedThreadIds, - value.threadId.value)) { - scope.removedThreadIdsOverflowed = true; - scope.allThreadsAffected = true; - } - addSidebarThread(value.threadId.value); - addInspectorThread(value.threadId.value); - } - else if constexpr (std::is_same_v) - { - if (const auto* turn = update.state.turn(value.turnId)) { - markThreadAndInspector(turn->threadId.value); - addSidebarThread(turn->threadId.value); - } else { - scope.allThreadsAffected = true; - scope.allInspectorsAffected = true; - scope.allSidebarThreadsAffected = true; - scope.sidebarAffected = true; - } - } - else if constexpr (std::is_same_v) - { - if (value.threadId) { - addStructurallyAffectedThread(value.threadId->value); - addInspectorThread(value.threadId->value); - } - else if (value.turnId) { - if (const auto* turn = update.state.turn(*value.turnId)) { - addStructurallyAffectedThread( - turn->threadId.value); - addInspectorThread(turn->threadId.value); - } - else { - scope.allThreadsAffected = true; - scope.allInspectorsAffected = true; - } - } - else { - scope.allThreadsAffected = true; - scope.allInspectorsAffected = true; - } - } - else if constexpr (std::is_same_v - || std::is_same_v) - { - // Streaming text/output changes affect the conversation, - // not Sidebar thread facts or Inspector semantics. The SDK - // distinguishes an authoritative exact append hint from a - // replacement fallback in its canonical public changes. - if (value.threadId && value.turnId) { - if constexpr (std::is_same_v) { - if (value.delta.size() - <= maximumCoalescedContentDeltaBytes) { - addItemContent( - value, - StateUpdateScope::ItemContentAppend{ - value.baseContentBytes, - value.discardPrefixBytes, - QByteArray( - value.delta.data(), - static_cast( - value.delta.size())), - }); - } else { - addItemContent( - value, - std::optional{}); - } - } else { - addItemContent(value, - std::optional{}); - } - } - else if (value.threadId) - addFullyAffectedThread(value.threadId->value); - else if (value.turnId) { - if (const auto* turn = update.state.turn(*value.turnId)) - addFullyAffectedThread(turn->threadId.value); - else { - scope.allThreadsAffected = true; - scope.allInspectorsAffected = true; - } - } - else { - scope.allThreadsAffected = true; - scope.allInspectorsAffected = true; - } - } - else if constexpr (std::is_same_v) - { - // Pending requests contribute to activity-card status. The - // change has no thread identity, especially on removal. - scope.allThreadsAffected = true; - scope.allInspectorsAffected = true; - scope.allSidebarThreadsAffected = true; - scope.sidebarAffected = true; - } - else if constexpr (std::is_same_v) - { - // A list replacement has no per-thread removal identity. - scope.allThreadsAffected = true; - scope.allInspectorsAffected = true; - scope.allSidebarThreadsAffected = true; - scope.sidebarAffected = true; - } - else - { - // Controller, pending-request, provider, capacity, and other - // domain projections can affect the Inspector and controls, - // but do not require rebuilding an unchanged conversation. - scope.allInspectorsAffected = true; - } - scope.hasPresentationChange = true; - }, - change); - } - if (scope.allThreadsAffected) { - scope.affectedThreadIds.clear(); - scope.fullyAffectedThreadIds.clear(); - scope.structurallyAffectedThreadIds.clear(); - scope.affectedItemContents.clear(); - scope.coalescedContentDeltaBytes = 0; - } - if (scope.allInspectorsAffected) - scope.affectedInspectorThreadIds.clear(); - if (scope.allSidebarThreadsAffected) - scope.affectedSidebarThreadIds.clear(); - return scope; -} - -} // namespace detail - -namespace { - -constexpr qsizetype maximumReceiveBatchBytes = 1024 * 1024; -constexpr qsizetype inboundCompactionThreshold = 256 * 1024; -constexpr int minimumReceiveBatchFrames = 1; -constexpr int maximumReceiveBatchFrames = 256; -constexpr qint64 maximumReceiveBatchTimeMs = 4; -constexpr std::uint32_t archivedThreadListPageSize = 100; -constexpr std::size_t maximumArchivedThreadListPages = 64; -constexpr std::uint32_t modelListPageSize = 100; -constexpr std::size_t maximumModelListPages = 64; - -bool synchronizedStateOmitsThreads(const sdk::State& state) -{ - const auto capacity = state.capacityProvenance(); - return capacity && capacity->omittedThreads > 0; -} - -QString operationError(const std::optional& error, const QString& fallback) -{ - if (error && !error->message.empty()) - return QString::fromStdString(error->message); - return fallback; -} - -std::optional submissionError(const sdk::Submission& submission, const QString& fallback) -{ - if (submission) - return std::nullopt; - return operationError(submission.error, fallback); -} - -void eraseFrame(std::string& frame) noexcept -{ - try { - frame.resize(frame.capacity(), '\0'); - } catch (...) { - } - volatile char* bytes = frame.empty() ? nullptr : frame.data(); - for (std::size_t index = 0; index < frame.size(); ++index) - bytes[index] = '\0'; - frame.clear(); -} - -} // namespace - -FrontendSessionWorker::FrontendSessionWorker(QObject* parent) - : QObject(parent) -{ - sdk::ClientOptions options; - // CodexUI consumes includeTurns thread/read results through AISuite's - // authoritative State publication. This is an observed mechanism, not a - // representation request: requiring it validates the server's Welcome - // while leaving Hello's representation selection unchanged. - options.requiredCapabilities.push_back( - frontend::FrontendCapability::ThreadReadStateEffects); - maximumFrameBytes = options.maximumInboundMessageBytes; - options.credentialProvider = [] { - return sdk::AuthenticationContext{ - frontend::NoCredential{}, - std::string{"verified-local:"} + std::to_string(::geteuid()), - }; - }; - - sdk::ClientCallbacks callbacks; - callbacks.onConnectionStateChanged = [this](const sdk::ConnectionStateChange& change) { - handleConnectionStateChange(change); - }; - callbacks.onStateUpdated = [this](const sdk::StateUpdate& update) { - handleStateUpdate(update); - }; - callbacks.onSynchronized = [this](const sdk::SynchronizationInfo& info) { - synchronizedCurrentConnection = true; - connectionStabilityTimer.start(stableConnectionDwellMs); - automaticReconnectEnabled = true; - currentState = info.state; - reconcileIncompleteThreadReadAttempts(); - const bool clearReadyDiagnostic = currentLifecycle == Lifecycle::Ready - && detail.isEmpty() - && !diagnosticDetail.isEmpty(); - if (clearReadyDiagnostic) - diagnosticDetail.clear(); - setLifecycle(Lifecycle::Ready); - if (clearReadyDiagnostic) - emit statusChanged(); - detail::StateUpdateScope scope; - scope.allThreadsAffected = true; - scope.allInspectorsAffected = true; - scope.allSidebarThreadsAffected = true; - scope.sidebarAffected = true; - scope.hasPresentationChange = true; - emit stateChanged(scope); - beginArchivedThreadRefresh(); - beginModelCatalogRefresh(); - }; - callbacks.onDiagnostic = [this](const sdk::Diagnostic& diagnostic) { - if (diagnostic.severity == sdk::Diagnostic::Severity::Error) - reportDiagnostic(QString::fromStdString(diagnostic.message)); - }; - client = std::make_unique(std::move(options), std::move(callbacks)); - - connect(&socket, &QLocalSocket::connected, this, &FrontendSessionWorker::socketConnected); - connect(&socket, &QLocalSocket::readyRead, this, &FrontendSessionWorker::socketReadyRead); - connect(&socket, &QLocalSocket::bytesWritten, this, &FrontendSessionWorker::socketBytesWritten); - connect(&socket, &QLocalSocket::disconnected, this, &FrontendSessionWorker::socketDisconnected); - connect(&socket, &QLocalSocket::errorOccurred, this, &FrontendSessionWorker::socketFailed); - reconnectTimer.setSingleShot(true); - connect(&reconnectTimer, &QTimer::timeout, this, &FrontendSessionWorker::retryConnection); - connectionStabilityTimer.setSingleShot(true); - connect(&connectionStabilityTimer, &QTimer::timeout, - this, &FrontendSessionWorker::markConnectionStable); - outboundDrainTimer.setSingleShot(true); - connect(&outboundDrainTimer, &QTimer::timeout, this, &FrontendSessionWorker::drainSocketWrites); -} - -FrontendSessionWorker::~FrontendSessionWorker() -{ - shutdown(); -} - -void FrontendSessionWorker::shutdown() -{ - if (localShutdown) - return; - localShutdown = true; - reconnectTimer.stop(); - connectionStabilityTimer.stop(); - clearOutbound(); - if (connection.isOpen()) - connection.close("CodexUI is closing"); - client->close("CodexUI is closing"); - socket.abort(); -} - -void FrontendSessionWorker::connectToBackend() -{ - resetReconnectPolicy(); - startConnection(); -} - -void FrontendSessionWorker::reconnectToBackend() -{ - reconnectTimer.stop(); - clearOutbound(); - if (connection.isOpen()) - connection.close("User requested reconnect"); - connection = Connection{}; - clearInbound(); - receiveContinuationScheduled = false; - threadReadsInFlight.clear(); - attemptedIncompleteThreadReads.clear(); - if (socket.state() != QLocalSocket::UnconnectedState) { - socket.abort(); - resetReconnectPolicy(); - reconnectTimer.start(0); - return; - } - resetReconnectPolicy(); - startConnection(); -} - -void FrontendSessionWorker::startConnection() -{ - if (socket.state() != QLocalSocket::UnconnectedState || connection.isOpen()) - return; - ++connectionGeneration; - synchronizedCurrentConnection = false; - preReadyFailureRecordedCurrentConnection = false; - archivedThreadListCursors.clear(); - archivedThreadListInFlight = false; - archivedThreadListStatus = ArchivedThreadDiscoveryStatus::InProgress; - modelListCursors.clear(); - pendingModelCatalog.clear(); - modelListInFlight = false; - modelListComplete = false; - if (!availableModelCatalog.empty()) { - availableModelCatalog.clear(); - emit modelCatalogChanged(); - } - localShutdown = false; - setLifecycle(Lifecycle::Connecting); - socket.connectToServer(defaultSocketPath(), QIODevice::ReadWrite); -} - -FrontendSessionWorker::Lifecycle FrontendSessionWorker::lifecycle() const noexcept -{ - return currentLifecycle; -} - -QString FrontendSessionWorker::statusText() const -{ - if (!detail.isEmpty()) - return detail; - if (!diagnosticDetail.isEmpty()) - return diagnosticDetail; - switch (currentLifecycle) { - case Lifecycle::Disconnected: - return QStringLiteral("Disconnected"); - case Lifecycle::Connecting: - return QStringLiteral("Connecting…"); - case Lifecycle::Authenticating: - return QStringLiteral("Authenticating…"); - case Lifecycle::Synchronizing: - return QStringLiteral("Synchronizing…"); - case Lifecycle::Ready: - return QStringLiteral("State synced"); - case Lifecycle::Failed: - return QStringLiteral("Connection failed"); - } - return QStringLiteral("Disconnected"); -} - -std::optional FrontendSessionWorker::promptValidationError(const QString& prompt) -{ - std::size_t scalarCount = 0; - for (qsizetype index = 0; index < prompt.size(); ++index) - { - const QChar current = prompt.at(index); - if (current.isHighSurrogate() && index + 1 < prompt.size() - && prompt.at(index + 1).isLowSurrogate()) - ++index; - ++scalarCount; - if (scalarCount > ai::openai::codex::typed::MaximumTurnInputTextUnicodeScalars) - return QStringLiteral("Prompt exceeds Codex's %1 Unicode-scalar text input limit") - .arg(static_cast( - ai::openai::codex::typed::MaximumTurnInputTextUnicodeScalars)); - } - return std::nullopt; -} - -const sdk::State& FrontendSessionWorker::state() const noexcept -{ - return currentState; -} - -const std::vector& FrontendSessionWorker::modelCatalog() const noexcept -{ - return availableModelCatalog; -} - -bool FrontendSessionWorker::archivedThreadDiscoveryComplete() const noexcept -{ - return archivedThreadListStatus == ArchivedThreadDiscoveryStatus::Complete; -} - -bool FrontendSessionWorker::archivedThreadDiscoveryTerminal() const noexcept -{ - return archivedThreadListStatus != ArchivedThreadDiscoveryStatus::InProgress; -} - -FrontendSessionWorker::ArchivedThreadDiscoveryStatus -FrontendSessionWorker::archivedThreadDiscoveryStatus() const noexcept -{ - return archivedThreadListStatus; -} - -bool FrontendSessionWorker::ownsController() const noexcept -{ - const auto& projection = currentState.controller(); - return projection.value && projection.value->ownedByThisClient; -} - -std::uint64_t FrontendSessionWorker::generation() const noexcept -{ - return connectionGeneration; -} - -bool FrontendSessionWorker::transportAffinityIsCurrentThread() const noexcept -{ - QThread* current = QThread::currentThread(); - return thread() == current && socket.thread() == current - && reconnectTimer.thread() == current - && connectionStabilityTimer.thread() == current - && outboundDrainTimer.thread() == current; -} - -void FrontendSessionWorker::loadThread(const QString& threadId, - bool retryIncomplete) -{ - if (currentLifecycle != Lifecycle::Ready || threadId.isEmpty()) - return; - const std::string id = threadId.toStdString(); - const auto* thread = currentState.thread(id); - const bool missingFromBoundedState = !thread && synchronizedStateOmitsThreads(currentState); - if (retryIncomplete) - attemptedIncompleteThreadReads.erase(id); - const auto attempted = attemptedIncompleteThreadReads.find(id); - const bool attemptedCurrentRecoveryEpoch = - attempted != attemptedIncompleteThreadReads.end() - && attempted->second == incompleteReadRecoveryEpoch; - if ((!thread && !missingFromBoundedState) || (thread && thread->fullyLoaded) - || threadReadsInFlight.contains(id) - || attemptedCurrentRecoveryEpoch) - return; - if (threadReadsInFlight.size() - >= static_cast( - detail::maximumCoalescedPresentationIdentities)) - return; - - threadReadsInFlight.insert(id); - sdk::Submission submission = client->threads().read( - {ai::openai::codex::typed::ThreadId{id}, true}, - [this, id](const sdk::OperationResult& result) { - threadReadsInFlight.erase(id); - reconcileIncompleteThreadReadAttempts(); - const auto* resolved = currentState.thread(id); - const bool remainsIncomplete = - (!resolved && synchronizedStateOmitsThreads(currentState)) - || (resolved && !resolved->fullyLoaded); - // CapacityExceeded is a negotiated result, not proof that another - // immediate full read can succeed. It consumes this replacement - // epoch just like a successful read whose projection remains - // incomplete; explicit user/reconnect recovery can still retry. - if (!result) { - if (remainsIncomplete) - rememberIncompleteThreadReadAttempt(id); - return; - } - const bool authoritativelyAbsent = result.value - && result.value->stateEffect - && result.value->stateEffect->authority - == frontend::ThreadReadStateEffectAuthority::Absent; - if (authoritativelyAbsent) - return; - if (remainsIncomplete) - rememberIncompleteThreadReadAttempt(id); - }); - if (!submission) - threadReadsInFlight.erase(id); -} - -void FrontendSessionWorker::handleStateUpdate(const sdk::StateUpdate& update) -{ - currentState = update.state; - const bool stateReplaced = std::ranges::any_of( - update.changes, - [](const sdk::Change& change) { - return std::holds_alternative(change); - }); - if (stateReplaced) - ++incompleteReadRecoveryEpoch; - for (const auto& change : update.changes) { - if (const auto* removed = std::get_if(&change)) - attemptedIncompleteThreadReads.erase(removed->threadId.value); - } - reconcileIncompleteThreadReadAttempts(); - const detail::StateUpdateScope scope = detail::stateUpdateScope(update); - if (scope.hasPresentationChange) - emit stateChanged(scope); -} - -std::optional FrontendSessionWorker::acquireController(OperationCompletion completion) -{ - if (currentLifecycle != Lifecycle::Ready) - return QStringLiteral("Backend is not ready"); - - sdk::Submission submission = client->controller().acquire( - [completion = std::move(completion)](const sdk::OperationResult& result) { - if (!result) { - completion(operationError(result.error, QStringLiteral("Controller acquisition failed"))); - return; - } - if (!result.value->ownedByThisClient) { - completion(QStringLiteral("Controller is owned by another frontend")); - return; - } - completion({}); - }); - return submissionError(submission, QStringLiteral("Controller acquisition was not accepted")); -} - -std::optional FrontendSessionWorker::startThread(ThreadStartCompletion completion) -{ - return startThread(ai::openai::codex::typed::ThreadStartParams{}, std::move(completion)); -} - -std::optional -FrontendSessionWorker::startThread(ai::openai::codex::typed::ThreadStartParams parameters, - ThreadStartCompletion completion) -{ - if (currentLifecycle != Lifecycle::Ready) - return QStringLiteral("Backend is not ready"); - - sdk::Submission submission = client->threads().start( - std::move(parameters), - [completion = std::move(completion)](const sdk::OperationResult& result) { - if (!result) { - completion({}, operationError(result.error, QStringLiteral("New thread could not be created"))); - return; - } - if (result.value->threadId.value.empty()) { - completion({}, QStringLiteral("New thread response did not contain a stable thread ID")); - return; - } - completion(QString::fromStdString(result.value->threadId.value), {}); - }); - return submissionError(submission, QStringLiteral("New thread submission was not accepted")); -} - -std::optional FrontendSessionWorker::resumeThread(const QString& threadId, ThreadStartCompletion completion) -{ - ai::openai::codex::typed::ThreadResumeParams parameters; - parameters.threadId = ai::openai::codex::typed::ThreadId{threadId.toStdString()}; - return resumeThread(std::move(parameters), std::move(completion)); -} - -std::optional -FrontendSessionWorker::resumeThread(ai::openai::codex::typed::ThreadResumeParams parameters, - ThreadStartCompletion completion) -{ - if (currentLifecycle != Lifecycle::Ready) - return QStringLiteral("Backend is not ready"); - - const std::string requestedId = parameters.threadId.value; - sdk::Submission submission = client->threads().resume( - std::move(parameters), - [completion = std::move(completion), requestedId](const sdk::OperationResult& result) { - if (!result) { - completion({}, operationError(result.error, QStringLiteral("Thread could not be attached"))); - return; - } - if (result.value->threadId.value != requestedId) { - completion({}, QStringLiteral("Thread attach returned an unexpected thread ID")); - return; - } - completion(QString::fromStdString(result.value->threadId.value), {}); - }); - return submissionError(submission, QStringLiteral("Thread attach submission was not accepted")); -} - -std::optional FrontendSessionWorker::startTurn(const QString& threadId, - const QString& prompt, - OperationCompletion completion) -{ - ai::openai::codex::typed::TurnStartParams parameters; - parameters.threadId = ai::openai::codex::typed::ThreadId{threadId.toStdString()}; - return startTurn( - std::move(parameters), - prompt, - [completion = std::move(completion)](const QString&, const QString& error) { - completion(error); - }); -} - -std::optional -FrontendSessionWorker::startTurn(ai::openai::codex::typed::TurnStartParams parameters, - const QString& prompt, - TurnStartCompletion completion) -{ - return startTurn(std::move(parameters), prompt, {}, std::move(completion)); -} - -std::optional -FrontendSessionWorker::startTurn(ai::openai::codex::typed::TurnStartParams parameters, - const QString& prompt, - const QStringList& localImagePaths, - TurnStartCompletion completion) -{ - if (currentLifecycle != Lifecycle::Ready) - return QStringLiteral("Backend is not ready"); - if (const auto error = promptValidationError(prompt)) - return error; - - if (parameters.threadId.value.empty()) - return QStringLiteral("Turn submission requires a thread ID"); - if (!prompt.trimmed().isEmpty()) { - ai::openai::codex::typed::TextInput input; - const QByteArray promptUtf8 = prompt.toUtf8(); - input.text.assign(promptUtf8.constData(), static_cast(promptUtf8.size())); - parameters.input.emplace_back(std::move(input)); - } - for (const QString& path : localImagePaths) { - ai::openai::codex::typed::LocalImageInput input; - const QByteArray pathUtf8 = path.toUtf8(); - input.path.assign(pathUtf8.constData(), static_cast(pathUtf8.size())); - parameters.input.emplace_back(std::move(input)); - } - if (parameters.input.empty()) - return QStringLiteral("Turn submission requires a prompt or attachment"); - sdk::Submission submission = client->turns().start( - std::move(parameters), - [completion = std::move(completion)](const sdk::OperationResult& result) { - if (!result) { - completion({}, operationError(result.error, QStringLiteral("Turn could not be started"))); - return; - } - completion(QString::fromStdString(result.value->turnId.value), {}); - }); - return submissionError(submission, QStringLiteral("Turn submission was not accepted")); -} - -std::optional FrontendSessionWorker::steerTurn(const QString& threadId, - const QString& expectedTurnId, - const QString& prompt, - OperationCompletion completion) -{ - return steerTurn(threadId, expectedTurnId, prompt, {}, std::move(completion)); -} - -std::optional FrontendSessionWorker::steerTurn(const QString& threadId, - const QString& expectedTurnId, - const QString& prompt, - const QStringList& localImagePaths, - OperationCompletion completion) -{ - if (currentLifecycle != Lifecycle::Ready) - return QStringLiteral("Backend is not ready"); - if (const auto error = promptValidationError(prompt)) - return error; - if (threadId.isEmpty() || expectedTurnId.isEmpty()) - return QStringLiteral("Steering requires the active thread and turn identities"); - - ai::openai::codex::typed::TurnSteerParams parameters; - parameters.threadId = ai::openai::codex::typed::ThreadId{threadId.toStdString()}; - parameters.expectedTurnId = ai::openai::codex::typed::TurnId{expectedTurnId.toStdString()}; - if (!prompt.trimmed().isEmpty()) { - ai::openai::codex::typed::TextInput input; - const QByteArray promptUtf8 = prompt.toUtf8(); - input.text.assign(promptUtf8.constData(), static_cast(promptUtf8.size())); - parameters.input.emplace_back(std::move(input)); - } - for (const QString& path : localImagePaths) { - ai::openai::codex::typed::LocalImageInput input; - const QByteArray pathUtf8 = path.toUtf8(); - input.path.assign(pathUtf8.constData(), static_cast(pathUtf8.size())); - parameters.input.emplace_back(std::move(input)); - } - if (parameters.input.empty()) - return QStringLiteral("Steering requires a prompt or attachment"); - - const std::string expectedId = parameters.expectedTurnId.value; - sdk::Submission submission = client->turns().steer( - std::move(parameters), - [completion = std::move(completion), expectedId]( - const sdk::OperationResult& result) { - if (!result) { - completion(operationError(result.error, QStringLiteral("Turn could not be steered"))); - return; - } - if (result.value->turnId.value != expectedId) { - completion(QStringLiteral("Turn steering returned an unexpected turn ID")); - return; - } - completion({}); - }); - return submissionError(submission, QStringLiteral("Turn steering was not accepted")); -} - -std::optional -FrontendSessionWorker::forkThread(ai::openai::codex::typed::ThreadForkParams parameters, - ThreadStartCompletion completion) -{ - if (currentLifecycle != Lifecycle::Ready) - return QStringLiteral("Backend is not ready"); - if (parameters.threadId.value.empty()) - return QStringLiteral("Fork requires a source thread ID"); - - sdk::Submission submission = client->threads().fork( - std::move(parameters), - [completion = std::move(completion)]( - const sdk::OperationResult& result) - { - if (!result) - { - completion({}, operationError(result.error, QStringLiteral("Thread could not be forked"))); - return; - } - if (result.value->thread.id.value.empty()) - { - completion({}, QStringLiteral("Fork response did not contain a stable thread ID")); - return; - } - completion(QString::fromStdString(result.value->thread.id.value), {}); - }); - return submissionError(submission, QStringLiteral("Fork submission was not accepted")); -} - -std::optional FrontendSessionWorker::renameThread(const QString& threadId, - const QString& name, - OperationCompletion completion) -{ - if (currentLifecycle != Lifecycle::Ready) - return QStringLiteral("Backend is not ready"); - if (threadId.isEmpty() || name.trimmed().isEmpty()) - return QStringLiteral("Rename requires a thread ID and non-empty name"); - - sdk::Submission submission = client->threads().setName( - {ai::openai::codex::typed::ThreadId{threadId.toStdString()}, name.trimmed().toStdString()}, - [completion = std::move(completion)]( - const sdk::OperationResult& result) - { - completion(result ? QString{} - : operationError(result.error, QStringLiteral("Thread could not be renamed"))); - }); - return submissionError(submission, QStringLiteral("Rename submission was not accepted")); -} - -std::optional FrontendSessionWorker::archiveThread(const QString& threadId, - OperationCompletion completion) -{ - if (currentLifecycle != Lifecycle::Ready) - return QStringLiteral("Backend is not ready"); - sdk::Submission submission = client->threads().archive( - {ai::openai::codex::typed::ThreadId{threadId.toStdString()}}, - [completion = std::move(completion)]( - const sdk::OperationResult& result) - { - completion(result ? QString{} - : operationError(result.error, QStringLiteral("Thread could not be archived"))); - }); - return submissionError(submission, QStringLiteral("Archive submission was not accepted")); -} - -std::optional FrontendSessionWorker::unarchiveThread(const QString& threadId, - OperationCompletion completion) -{ - if (currentLifecycle != Lifecycle::Ready) - return QStringLiteral("Backend is not ready"); - sdk::Submission submission = client->threads().unarchive( - {ai::openai::codex::typed::ThreadId{threadId.toStdString()}}, - [completion = std::move(completion)]( - const sdk::OperationResult& result) - { - completion(result ? QString{} - : operationError(result.error, QStringLiteral("Thread could not be unarchived"))); - }); - return submissionError(submission, QStringLiteral("Unarchive submission was not accepted")); -} - -std::optional FrontendSessionWorker::deleteThread(const QString& threadId, - OperationCompletion completion) -{ - if (currentLifecycle != Lifecycle::Ready) - return QStringLiteral("Backend is not ready"); - sdk::Submission submission = client->threads().remove( - {ai::openai::codex::typed::ThreadId{threadId.toStdString()}}, - [completion = std::move(completion)]( - const sdk::OperationResult& result) - { - completion(result ? QString{} - : operationError(result.error, QStringLiteral("Thread could not be deleted"))); - }); - return submissionError(submission, QStringLiteral("Delete submission was not accepted")); -} - -std::optional FrontendSessionWorker::interruptTurn(const QString& threadId, - const QString& turnId, - OperationCompletion completion) -{ - if (currentLifecycle != Lifecycle::Ready) - return QStringLiteral("Backend is not ready"); - - ai::openai::codex::typed::TurnInterruptParams parameters{ - ai::openai::codex::typed::ThreadId{threadId.toStdString()}, - ai::openai::codex::typed::TurnId{turnId.toStdString()}, - }; - sdk::Submission submission = client->turns().interrupt( - std::move(parameters), - [completion = std::move(completion)](const sdk::OperationResult& result) { - completion(result ? QString{} : operationError(result.error, QStringLiteral("Turn could not be interrupted"))); - }); - return submissionError(submission, QStringLiteral("Interrupt submission was not accepted")); -} - -std::optional FrontendSessionWorker::respondApproval(const sdk::PendingRequestId& requestId, - ai::openai::codex::typed::ApprovalDecision decision, - OperationCompletion completion) -{ - if (currentLifecycle != Lifecycle::Ready) - return QStringLiteral("Backend is not ready"); - sdk::Submission submission = client->requests().respond( - {requestId, std::move(decision)}, - [completion = std::move(completion)](const sdk::OperationResult& result) { - completion(result ? QString{} : operationError(result.error, QStringLiteral("Approval response failed"))); - }); - return submissionError(submission, QStringLiteral("Approval response was not accepted")); -} - -std::optional -FrontendSessionWorker::respondApplyPatchApproval(const sdk::PendingRequestId& requestId, - ai::openai::codex::typed::ApplyPatchApprovalResponse response, - OperationCompletion completion) -{ - if (currentLifecycle != Lifecycle::Ready) - return QStringLiteral("Backend is not ready"); - sdk::Submission submission = client->requests().respond( - {requestId, std::move(response)}, - [completion = std::move(completion)](const sdk::OperationResult& result) { - completion(result ? QString{} : operationError(result.error, QStringLiteral("Patch approval response failed"))); - }); - return submissionError(submission, QStringLiteral("Patch approval response was not accepted")); -} - -std::optional -FrontendSessionWorker::respondExecCommandApproval(const sdk::PendingRequestId& requestId, - ai::openai::codex::typed::ExecCommandApprovalResponse response, - OperationCompletion completion) -{ - if (currentLifecycle != Lifecycle::Ready) - return QStringLiteral("Backend is not ready"); - sdk::Submission submission = client->requests().respond( - {requestId, std::move(response)}, - [completion = std::move(completion)](const sdk::OperationResult& result) { - completion(result ? QString{} : operationError(result.error, QStringLiteral("Command approval response failed"))); - }); - return submissionError(submission, QStringLiteral("Command approval response was not accepted")); -} - -std::optional FrontendSessionWorker::respondUserInput(const sdk::PendingRequestId& requestId, - std::vector answers, - OperationCompletion completion) -{ - if (currentLifecycle != Lifecycle::Ready) - return QStringLiteral("Backend is not ready"); - sdk::Submission submission = client->requests().respond( - {requestId, std::move(answers)}, - [completion = std::move(completion)](const sdk::OperationResult& result) { - completion(result ? QString{} : operationError(result.error, QStringLiteral("User-input response failed"))); - }); - return submissionError(submission, QStringLiteral("User-input response was not accepted")); -} - -QString FrontendSessionWorker::defaultSocketPath() -{ - const QString runtimeDirectory = qEnvironmentVariable("XDG_RUNTIME_DIR"); - if (!runtimeDirectory.isEmpty()) - return QDir(runtimeDirectory).filePath(QStringLiteral("snodec-codex-backend.sock")); - return QStringLiteral("/tmp/snodec-codex-backend-%1.sock").arg(::getuid()); -} - -void FrontendSessionWorker::socketConnected() -{ - reconnectTimer.stop(); - clearOutbound(); - clearInbound(); - receiveContinuationScheduled = false; - if (const auto error = detail::unixPeerCredentialError(socket.socketDescriptor(), ::geteuid())) { - failWithoutReconnect(*error); - socket.abort(); - return; - } - connection = client->openConnection({ - [this](OutboundMessage message) { return send(std::move(message)); }, - [this](std::string reason) { closeTransport(QString::fromStdString(reason)); }, - }); - if (!connection.isOpen()) { - failWithoutReconnect(QStringLiteral("Frontend SDK rejected the Unix connection")); - socket.abort(); - return; - } - connection.transportConnected(); -} - -void FrontendSessionWorker::socketReadyRead() -{ - receiveContinuationScheduled = false; - const auto rejectOversizedFrame = [this] { - clearInbound(); - failWithoutReconnect(QStringLiteral("Backend frame exceeds the SDK input limit")); - socket.abort(); - }; - - QElapsedTimer processingTime; - processingTime.start(); - qsizetype consumedBytes = inboundOffset; - qsizetype receivedBytes = 0; - int receivedFrames = 0; - while (receivedFrames < maximumReceiveBatchFrames - && receivedBytes < maximumReceiveBatchBytes - && (receivedFrames < minimumReceiveBatchFrames - || processingTime.elapsed() < maximumReceiveBatchTimeMs)) - { - const qsizetype scanStart = qMax(consumedBytes, inboundScanOffset); - const qsizetype newline = inboundBuffer.indexOf('\n', scanStart); - if (newline < 0) - { - inboundScanOffset = inboundBuffer.size(); - const qsizetype available = socket.bytesAvailable(); - const qsizetype budget = maximumReceiveBatchBytes - receivedBytes; - if (available <= 0 || budget <= 0) - break; - const QByteArray chunk = socket.read(qMin(available, budget)); - if (chunk.isEmpty()) - break; - inboundBuffer.append(chunk); - receivedBytes += chunk.size(); - continue; - } - inboundScanOffset = newline; - - const qsizetype payloadEnd = newline > consumedBytes && inboundBuffer.at(newline - 1) == '\r' - ? newline - 1 - : newline; - const qsizetype payloadBytes = payloadEnd - consumedBytes; - if (static_cast(payloadBytes) > maximumFrameBytes) { - rejectOversizedFrame(); - return; - } - - ++receivedFrames; - if (payloadBytes == 0) - { - consumedBytes = newline + 1; - inboundScanOffset = consumedBytes; - continue; - } - const sdk::ReceiveResult result = connection.receive( - std::string_view(inboundBuffer.constData() + consumedBytes, - static_cast(payloadBytes))); - consumedBytes = newline + 1; - inboundScanOffset = consumedBytes; - if (!result.accepted) { - const QString reason = currentLifecycle == Lifecycle::Failed && !detail.isEmpty() - ? detail - : result.error ? QString::fromStdString(result.error->message) - : QStringLiteral("Frontend SDK rejected a server message"); - if (!result.error || !result.error->retryable) - failWithoutReconnect(reason); - else - setLifecycle(Lifecycle::Failed, reason); - socket.abort(); - return; - } - if (!connection.isOpen()) - break; - } - - inboundOffset = consumedBytes; - compactInbound(); - const qsizetype bufferedBytes = inboundBuffer.size() - inboundOffset; - if (!hasCompleteInboundFrame() && bufferedBytes > 0 - && static_cast(bufferedBytes - 1) > maximumFrameBytes) { - rejectOversizedFrame(); - return; - } - if (connection.isOpen() - && (socket.bytesAvailable() > 0 || hasCompleteInboundFrame())) - scheduleSocketRead(); -} - -void FrontendSessionWorker::clearInbound() noexcept -{ - inboundBuffer.clear(); - inboundOffset = 0; - inboundScanOffset = 0; -} - -bool FrontendSessionWorker::hasCompleteInboundFrame() const noexcept -{ - const qsizetype scanStart = qMax(inboundOffset, inboundScanOffset); - const qsizetype newline = inboundBuffer.indexOf('\n', scanStart); - inboundScanOffset = newline >= 0 ? newline : inboundBuffer.size(); - return newline >= 0; -} - -void FrontendSessionWorker::compactInbound() noexcept -{ - if (inboundOffset <= 0) - return; - if (inboundOffset >= inboundBuffer.size()) { - clearInbound(); - return; - } - if (inboundOffset < inboundCompactionThreshold - || inboundOffset < inboundBuffer.size() / 2) - return; - inboundBuffer.remove(0, inboundOffset); - inboundScanOffset = qMax(qsizetype{0}, inboundScanOffset - inboundOffset); - inboundOffset = 0; -} - -void FrontendSessionWorker::rememberIncompleteThreadReadAttempt( - const std::string& threadId) -{ - // The UI has one active selection. Retain the newest bounded recovery - // identity if pathological rapid selection has left more unresolved - // omitted IDs than presentation can track. - if (!attemptedIncompleteThreadReads.contains(threadId) - && attemptedIncompleteThreadReads.size() - >= static_cast( - detail::maximumCoalescedPresentationIdentities)) - attemptedIncompleteThreadReads.clear(); - attemptedIncompleteThreadReads.insert_or_assign( - threadId, incompleteReadRecoveryEpoch); -} - -void FrontendSessionWorker::scheduleSocketRead() -{ - if (receiveContinuationScheduled || localShutdown) - return; - receiveContinuationScheduled = true; - QTimer::singleShot(0, this, - [this] - { - if (!receiveContinuationScheduled) - return; - receiveContinuationScheduled = false; - if (connection.isOpen() - && (socket.bytesAvailable() > 0 || hasCompleteInboundFrame())) - socketReadyRead(); - }); -} - -void FrontendSessionWorker::reconcileIncompleteThreadReadAttempts() -{ - const bool threadListComplete = currentState.threadList().value - && currentState.threadList().value->complete; - const bool boundedStateOmitsThreads = synchronizedStateOmitsThreads(currentState); - const auto resolved = [&](const std::string& id) { - const auto* thread = currentState.thread(id); - return (thread && thread->fullyLoaded) - || (!thread && threadListComplete && !boundedStateOmitsThreads); - }; - std::erase_if(attemptedIncompleteThreadReads, - [&resolved](const auto& entry) { - return resolved(entry.first); - }); -} - -void FrontendSessionWorker::beginArchivedThreadRefresh() -{ - if (currentLifecycle != Lifecycle::Ready || archivedThreadListInFlight - || archivedThreadDiscoveryTerminal()) - return; - requestArchivedThreadPage(connectionGeneration, std::nullopt); -} - -void FrontendSessionWorker::requestArchivedThreadPage(std::uint64_t generation, - std::optional cursor) -{ - if (generation != connectionGeneration || currentLifecycle != Lifecycle::Ready - || archivedThreadDiscoveryTerminal() || archivedThreadListInFlight) - return; - const std::string cursorIdentity = cursor.value_or(std::string{}); - if (archivedThreadListCursors.size() >= maximumArchivedThreadListPages - || !archivedThreadListCursors.insert(cursorIdentity).second) { - finishArchivedThreadRefresh( - ArchivedThreadDiscoveryStatus::CompleteWithTruncation, - QStringLiteral("Archived thread listing stopped at an invalid pagination boundary")); - return; - } - - ai::openai::codex::typed::ThreadListParams parameters; - parameters.archived = true; - parameters.limit = archivedThreadListPageSize; - if (cursor) - parameters.cursor = std::move(*cursor); - - archivedThreadListInFlight = true; - const auto submission = client->threads().list( - std::move(parameters), - [this, generation](const sdk::OperationResult& result) { - if (generation != connectionGeneration || currentLifecycle != Lifecycle::Ready) - return; - archivedThreadListInFlight = false; - if (!result || !result.value) { - finishArchivedThreadRefresh( - ArchivedThreadDiscoveryStatus::Failed, - operationError(result.error, - QStringLiteral("Archived threads could not be restored"))); - return; - } - if (result.value->nextCursor) { - requestArchivedThreadPage(generation, result.value->nextCursor); - return; - } - finishArchivedThreadRefresh(ArchivedThreadDiscoveryStatus::Complete); - }); - if (const auto error = submissionError(submission, - QStringLiteral("Archived thread listing could not be submitted"))) { - archivedThreadListInFlight = false; - finishArchivedThreadRefresh(ArchivedThreadDiscoveryStatus::Failed, *error); - } -} - -void FrontendSessionWorker::finishArchivedThreadRefresh(ArchivedThreadDiscoveryStatus status, - QString diagnostic) -{ - archivedThreadListInFlight = false; - archivedThreadListStatus = status; - if (!diagnostic.isEmpty()) - reportDiagnostic(std::move(diagnostic)); - - detail::StateUpdateScope scope; - scope.allThreadsAffected = true; - scope.allInspectorsAffected = true; - scope.allSidebarThreadsAffected = true; - scope.sidebarAffected = true; - scope.hasPresentationChange = true; - emit stateChanged(scope); -} - -void FrontendSessionWorker::beginModelCatalogRefresh() -{ - if (currentLifecycle != Lifecycle::Ready || modelListInFlight || modelListComplete) - return; - requestModelCatalogPage(connectionGeneration, std::nullopt); -} - -void FrontendSessionWorker::requestModelCatalogPage(std::uint64_t generation, - std::optional cursor) -{ - if (generation != connectionGeneration || currentLifecycle != Lifecycle::Ready - || modelListInFlight || modelListComplete) - return; - const std::string cursorIdentity = cursor.value_or(std::string{}); - if (modelListCursors.size() >= maximumModelListPages - || !modelListCursors.insert(cursorIdentity).second) { - finishModelCatalogRefresh( - QStringLiteral("Model listing stopped at an invalid pagination boundary")); - return; - } - - ai::openai::codex::typed::ModelListParams parameters; - parameters.includeHidden = false; - parameters.limit = modelListPageSize; - if (cursor) - parameters.cursor = std::move(*cursor); - - modelListInFlight = true; - const auto submission = client->models().list( - std::move(parameters), - [this, generation]( - const sdk::OperationResult& result) { - if (generation != connectionGeneration || currentLifecycle != Lifecycle::Ready) - return; - modelListInFlight = false; - if (!result || !result.value) { - finishModelCatalogRefresh(operationError( - result.error, QStringLiteral("Available models could not be loaded"))); - return; - } - for (const auto& model : result.value->data) { - if (model.hidden || model.model.value.empty()) - continue; - const bool duplicate = std::ranges::any_of( - pendingModelCatalog, - [&model](const auto& existing) { - return existing.model.value == model.model.value; - }); - if (!duplicate) - pendingModelCatalog.push_back(model); - } - if (result.value->nextCursor.hasValue()) { - requestModelCatalogPage(generation, *result.value->nextCursor); - return; - } - finishModelCatalogRefresh(); - }); - if (const auto error = submissionError( - submission, QStringLiteral("Available-model listing could not be submitted"))) { - modelListInFlight = false; - finishModelCatalogRefresh(*error); - } -} - -void FrontendSessionWorker::finishModelCatalogRefresh(QString diagnostic) -{ - modelListInFlight = false; - modelListComplete = true; - if (!diagnostic.isEmpty()) { - pendingModelCatalog.clear(); - reportDiagnostic(std::move(diagnostic)); - return; - } - if (availableModelCatalog == pendingModelCatalog) - return; - availableModelCatalog = std::move(pendingModelCatalog); - QTimer::singleShot(0, this, [this] { emit modelCatalogChanged(); }); -} - -void FrontendSessionWorker::socketDisconnected() -{ - const bool unstableReadyConnection = synchronizedCurrentConnection - && connectionStabilityTimer.isActive(); - connectionStabilityTimer.stop(); - if (unstableReadyConnection) - synchronizedCurrentConnection = false; - if (connection.isOpen()) { - if (localShutdown || !automaticReconnectEnabled) - connection.transportDisconnected(); - else - connection.transportDisconnected(sdk::TransportError{"Unix backend disconnected", true}); - } - connection = Connection{}; - clearOutbound(); - clearInbound(); - receiveContinuationScheduled = false; - threadReadsInFlight.clear(); - attemptedIncompleteThreadReads.clear(); - if (!localShutdown) { - if (recordPreReadyTransportFailure()) - return; - if (currentLifecycle != Lifecycle::Failed) - setLifecycle(Lifecycle::Disconnected); - scheduleReconnect(); - } -} - -void FrontendSessionWorker::socketFailed(QLocalSocket::LocalSocketError) -{ - if (localShutdown) - return; - const bool unstableReadyConnection = synchronizedCurrentConnection - && connectionStabilityTimer.isActive(); - connectionStabilityTimer.stop(); - if (unstableReadyConnection) - synchronizedCurrentConnection = false; - if (connection.isOpen()) { - if (!automaticReconnectEnabled) - connection.transportDisconnected(); - else - connection.transportDisconnected(sdk::TransportError{socket.errorString().toStdString(), true}); - } - connection = Connection{}; - clearOutbound(); - clearInbound(); - receiveContinuationScheduled = false; - threadReadsInFlight.clear(); - attemptedIncompleteThreadReads.clear(); - if (recordPreReadyTransportFailure()) - return; - if (automaticReconnectEnabled && currentLifecycle != Lifecycle::Failed) - setLifecycle(Lifecycle::Disconnected, socket.errorString()); - scheduleReconnect(); -} - -void FrontendSessionWorker::handleConnectionStateChange(const sdk::ConnectionStateChange& change) -{ - if (change.error) { - if (!change.error->retryable) { - automaticReconnectEnabled = false; - reconnectTimer.stop(); - setLifecycle(Lifecycle::Failed, - QString::fromStdString(change.error->message)); - } else { - setLifecycle(Lifecycle::Disconnected, - QString::fromStdString(change.error->message)); - } - return; - } - - switch (change.current) { - case sdk::ConnectionState::Connecting: - setLifecycle(Lifecycle::Connecting); - break; - case sdk::ConnectionState::Authenticating: - setLifecycle(Lifecycle::Authenticating); - break; - case sdk::ConnectionState::Synchronizing: - setLifecycle(Lifecycle::Synchronizing); - break; - case sdk::ConnectionState::Ready: - // onSynchronized owns the Ready transition so consumers never - // treat a transient reconnect projection as authoritative. - break; - case sdk::ConnectionState::Disconnected: - case sdk::ConnectionState::Closed: - if (currentLifecycle != Lifecycle::Failed) - setLifecycle(Lifecycle::Disconnected); - break; - case sdk::ConnectionState::Closing: - break; - } -} - -void FrontendSessionWorker::reportDiagnostic(QString message) -{ - if (message.isEmpty() || diagnosticDetail == message) - return; - diagnosticDetail = std::move(message); - emit statusChanged(); -} - -void FrontendSessionWorker::failWithoutReconnect(QString reason) -{ - // Retrying the same stream after a protocol/state rejection only replays - // the offending suffix. The explicit reconnect action remains available - // after the underlying incompatibility is corrected. - automaticReconnectEnabled = false; - reconnectTimer.stop(); - clearOutbound(); - setLifecycle(Lifecycle::Failed, std::move(reason)); -} - -void FrontendSessionWorker::scheduleReconnect() -{ - if (localShutdown || !automaticReconnectEnabled || reconnectTimer.isActive()) - return; - reconnectTimer.start(reconnectDelayMs); - reconnectDelayMs = std::min(reconnectDelayMs * 2, maximumReconnectDelayMs); -} - -void FrontendSessionWorker::retryConnection() -{ - if (localShutdown) - return; - if (socket.state() != QLocalSocket::UnconnectedState) { - scheduleReconnect(); - return; - } - startConnection(); -} - -void FrontendSessionWorker::resetReconnectPolicy() -{ - automaticReconnectEnabled = true; - reconnectTimer.stop(); - connectionStabilityTimer.stop(); - reconnectDelayMs = initialReconnectDelayMs; - consecutivePreReadyDisconnects = 0; - synchronizedCurrentConnection = false; - preReadyFailureRecordedCurrentConnection = false; -} - -void FrontendSessionWorker::markConnectionStable() -{ - if (!localShutdown && synchronizedCurrentConnection - && currentLifecycle == Lifecycle::Ready) { - reconnectDelayMs = initialReconnectDelayMs; - consecutivePreReadyDisconnects = 0; - } -} - -bool FrontendSessionWorker::recordPreReadyTransportFailure() -{ - if (localShutdown || synchronizedCurrentConnection) - return false; - if (!automaticReconnectEnabled) - return true; - if (preReadyFailureRecordedCurrentConnection) - return false; - - preReadyFailureRecordedCurrentConnection = true; - ++consecutivePreReadyDisconnects; - if (consecutivePreReadyDisconnects < maximumConsecutivePreReadyDisconnects) - return false; - - failWithoutReconnect( - QStringLiteral("Backend connection failed before reaching a stable synchronized state on %1 consecutive connections") - .arg(consecutivePreReadyDisconnects)); - return true; -} - -FrontendSessionWorker::SendResult FrontendSessionWorker::send(OutboundMessage&& message) -{ - SendResult result = sendToTransport( - std::move(message), - socket.state() == QLocalSocket::ConnectedState, - socket.bytesToWrite(), - [this](const char* bytes, qint64 size) { return socket.write(bytes, size); }); - if (result.status == sdk::SendStatus::Failed && result.error && !socket.errorString().isEmpty()) - result.error->message = socket.errorString().toStdString(); - return result; -} - -FrontendSessionWorker::SendResult FrontendSessionWorker::sendToTransport(OutboundMessage&& message, - bool transportConnected, - qint64 socketBufferedBytes, - const OutboundWriter& writer) noexcept -{ - if (!transportConnected) { - eraseFrame(message.compactJson); - return {sdk::SendStatus::Closed, sdk::TransportError{"Unix backend socket is closed", true}}; - } - SendResult result = acceptOutbound(std::move(message), socketBufferedBytes, writer); - if (result.status == sdk::SendStatus::Accepted && !pendingWrites.empty()) - scheduleOutboundDrain(); - return result; -} - -FrontendSessionWorker::SendResult FrontendSessionWorker::acceptOutbound(OutboundMessage&& message, - qint64 socketBufferedBytes, - const OutboundWriter& writer) noexcept -{ - if (outboundClearPending) { - eraseFrame(message.compactJson); - return {sdk::SendStatus::Closed, - sdk::TransportError{"Unix backend connection is closing", true}}; - } - std::string frame = std::move(message.compactJson); - eraseFrame(message.compactJson); - try { - frame.push_back('\n'); - } catch (...) { - eraseFrame(frame); - return {sdk::SendStatus::Failed, - sdk::TransportError{"Unix backend frame allocation failed", true}}; - } - - const qint64 frameBytes = static_cast(frame.size()); - const qint64 bufferedBytes = std::max(0, socketBufferedBytes); - const bool lacksCapacity = bufferedBytes > maximumBufferedOutboundBytes - || pendingWriteBytes > maximumBufferedOutboundBytes - bufferedBytes - || frameBytes > maximumBufferedOutboundBytes - bufferedBytes - pendingWriteBytes; - if (lacksCapacity) { - eraseFrame(frame); - return {sdk::SendStatus::Backpressure, - sdk::TransportError{"Unix backend output queue is full", true}}; - } - - const bool wasEmpty = pendingWrites.empty(); - try { - pendingWrites.emplace_back(); - pendingWrites.back().frame = std::move(frame); - eraseFrame(frame); - } catch (...) { - eraseFrame(frame); - return {sdk::SendStatus::Failed, - sdk::TransportError{"Unix backend output queue allocation failed", true}}; - } - pendingWriteBytes += frameBytes; - if (!wasEmpty) - return {sdk::SendStatus::Accepted, std::nullopt}; - - const std::uint64_t acceptedEpoch = outboundEpoch; - const DrainResult drained = drainOutbound(writer); - if (outboundEpoch != acceptedEpoch || drained == DrainResult::Reset) - return {sdk::SendStatus::Closed, - sdk::TransportError{"Unix backend connection changed during write", true}}; - if (drained == DrainResult::Failed) { - clearOutbound(); - return {sdk::SendStatus::Failed, - sdk::TransportError{"Unix backend socket write failed", true}}; - } - return {sdk::SendStatus::Accepted, std::nullopt}; -} - -FrontendSessionWorker::DrainResult FrontendSessionWorker::drainOutbound(const OutboundWriter& writer) noexcept -{ - if (drainingOutbound || pendingWrites.empty()) - return DrainResult::Blocked; - drainingOutbound = true; - const std::uint64_t drainingEpoch = outboundEpoch; - const qint64 offset = pendingWrites.front().offset; - const qint64 remaining = static_cast(pendingWrites.front().frame.size()) - offset; - qint64 written = -1; - try { - written = writer(pendingWrites.front().frame.data() + offset, remaining); - } catch (...) { - written = -1; - } - drainingOutbound = false; - - if (outboundClearPending) { - clearOutbound(); - return DrainResult::Reset; - } - if (outboundEpoch != drainingEpoch) - return DrainResult::Reset; - if (written < 0 || written > remaining) - return DrainResult::Failed; - if (written == 0) - return DrainResult::Blocked; - - PendingWrite& pending = pendingWrites.front(); - pending.offset += written; - pendingWriteBytes -= written; - if (pending.offset == static_cast(pending.frame.size())) { - eraseFrame(pending.frame); - pendingWrites.pop_front(); - if (pendingWrites.empty()) - outboundDrainTimer.stop(); - } - return DrainResult::Progress; -} - -void FrontendSessionWorker::socketBytesWritten(qint64) -{ - drainSocketWrites(); -} - -void FrontendSessionWorker::drainSocketWrites() -{ - outboundDrainTimer.stop(); - if (socket.state() != QLocalSocket::ConnectedState || pendingWrites.empty()) - return; - const DrainResult result = drainOutbound( - [this](const char* bytes, qint64 size) { return socket.write(bytes, size); }); - if (result == DrainResult::Failed) { - socketFailed(socket.error()); - if (socket.state() != QLocalSocket::UnconnectedState) - socket.abort(); - return; - } - if (!pendingWrites.empty()) - scheduleOutboundDrain(); -} - -void FrontendSessionWorker::scheduleOutboundDrain() -{ - if (!pendingWrites.empty() - && !outboundDrainTimer.isActive()) - outboundDrainTimer.start(outboundDrainRetryMs); -} - -void FrontendSessionWorker::clearOutbound() noexcept -{ - outboundDrainTimer.stop(); - ++outboundEpoch; - if (drainingOutbound) { - outboundClearPending = true; - return; - } - outboundClearPending = false; - for (PendingWrite& pending : pendingWrites) - eraseFrame(pending.frame); - pendingWrites.clear(); - pendingWriteBytes = 0; -} - -void FrontendSessionWorker::closeTransport(QString reason) noexcept -{ - clearOutbound(); - if (!reason.isEmpty()) - detail = std::move(reason); - socket.disconnectFromServer(); - if (socket.state() != QLocalSocket::UnconnectedState) - socket.abort(); -} - -void FrontendSessionWorker::setLifecycle(Lifecycle value, QString newDetail) -{ - QString nextDetail; - if (value == Lifecycle::Failed || !newDetail.isEmpty()) - nextDetail = std::move(newDetail); - if (currentLifecycle == value && detail == nextDetail) - return; - currentLifecycle = value; - detail = std::move(nextDetail); - diagnosticDetail.clear(); - emit lifecycleChanged(); -} - -} // namespace codexui diff --git a/src/app/FrontendSessionWorker.h b/src/app/FrontendSessionWorker.h deleted file mode 100644 index ab25a0e..0000000 --- a/src/app/FrontendSessionWorker.h +++ /dev/null @@ -1,262 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#ifndef CODEXUI_APP_FRONTENDSESSIONWORKER_H -#define CODEXUI_APP_FRONTENDSESSIONWORKER_H - -#include "app/FrontendSession.h" - -#include -#include -#include -#include -#include -#include - -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace codexui::detail { - -[[nodiscard]] std::optional -unixPeerCredentialError(qintptr socketDescriptor, - uid_t expectedUserId) noexcept; - -} // namespace codexui::detail - -namespace codexui { - -struct FrontendSessionWorkerTestAccess; - -class FrontendSessionWorker : public QObject -{ - Q_OBJECT - -public: - using Lifecycle = FrontendSession::Lifecycle; - using ArchivedThreadDiscoveryStatus = FrontendSession::ArchivedThreadDiscoveryStatus; - using OperationCompletion = FrontendSession::OperationCompletion; - using ThreadStartCompletion = FrontendSession::ThreadStartCompletion; - using TurnStartCompletion = FrontendSession::TurnStartCompletion; - - explicit FrontendSessionWorker(QObject* parent = nullptr); - ~FrontendSessionWorker() override; - - void shutdown(); - void connectToBackend(); - void reconnectToBackend(); - [[nodiscard]] Lifecycle lifecycle() const noexcept; - [[nodiscard]] QString statusText() const; - [[nodiscard]] static std::optional promptValidationError(const QString& prompt); - [[nodiscard]] const ai::openai::codex::frontend::client::State& state() const noexcept; - [[nodiscard]] const std::vector& modelCatalog() const noexcept; - [[nodiscard]] bool archivedThreadDiscoveryComplete() const noexcept; - [[nodiscard]] bool archivedThreadDiscoveryTerminal() const noexcept; - [[nodiscard]] ArchivedThreadDiscoveryStatus archivedThreadDiscoveryStatus() const noexcept; - [[nodiscard]] bool ownsController() const noexcept; - [[nodiscard]] std::uint64_t generation() const noexcept; - [[nodiscard]] bool transportAffinityIsCurrentThread() const noexcept; - void loadThread(const QString& threadId, bool retryIncomplete = false); - [[nodiscard]] std::optional acquireController(OperationCompletion completion); - [[nodiscard]] std::optional startThread(ThreadStartCompletion completion); - [[nodiscard]] std::optional - startThread(ai::openai::codex::typed::ThreadStartParams parameters, - ThreadStartCompletion completion); - [[nodiscard]] std::optional resumeThread(const QString& threadId, ThreadStartCompletion completion); - [[nodiscard]] std::optional - resumeThread(ai::openai::codex::typed::ThreadResumeParams parameters, - ThreadStartCompletion completion); - [[nodiscard]] std::optional startTurn(const QString& threadId, - const QString& prompt, - OperationCompletion completion); - [[nodiscard]] std::optional - startTurn(ai::openai::codex::typed::TurnStartParams parameters, - const QString& prompt, - TurnStartCompletion completion); - [[nodiscard]] std::optional - startTurn(ai::openai::codex::typed::TurnStartParams parameters, - const QString& prompt, - const QStringList& localImagePaths, - TurnStartCompletion completion); - [[nodiscard]] std::optional steerTurn(const QString& threadId, - const QString& expectedTurnId, - const QString& prompt, - OperationCompletion completion); - [[nodiscard]] std::optional steerTurn(const QString& threadId, - const QString& expectedTurnId, - const QString& prompt, - const QStringList& localImagePaths, - OperationCompletion completion); - [[nodiscard]] std::optional - forkThread(ai::openai::codex::typed::ThreadForkParams parameters, - ThreadStartCompletion completion); - [[nodiscard]] std::optional renameThread(const QString& threadId, - const QString& name, - OperationCompletion completion); - [[nodiscard]] std::optional archiveThread(const QString& threadId, - OperationCompletion completion); - [[nodiscard]] std::optional unarchiveThread(const QString& threadId, - OperationCompletion completion); - [[nodiscard]] std::optional deleteThread(const QString& threadId, - OperationCompletion completion); - [[nodiscard]] std::optional interruptTurn(const QString& threadId, - const QString& turnId, - OperationCompletion completion); - [[nodiscard]] std::optional - respondApproval(const ai::openai::codex::frontend::client::PendingRequestId& requestId, - ai::openai::codex::typed::ApprovalDecision decision, - OperationCompletion completion); - [[nodiscard]] std::optional - respondApplyPatchApproval(const ai::openai::codex::frontend::client::PendingRequestId& requestId, - ai::openai::codex::typed::ApplyPatchApprovalResponse response, - OperationCompletion completion); - [[nodiscard]] std::optional - respondExecCommandApproval(const ai::openai::codex::frontend::client::PendingRequestId& requestId, - ai::openai::codex::typed::ExecCommandApprovalResponse response, - OperationCompletion completion); - [[nodiscard]] std::optional - respondUserInput(const ai::openai::codex::frontend::client::PendingRequestId& requestId, - std::vector answers, - OperationCompletion completion); - -signals: - void lifecycleChanged(); - void statusChanged(); - void stateChanged(const codexui::detail::StateUpdateScope& scope); - void modelCatalogChanged(); - -private: - friend struct FrontendSessionWorkerTestAccess; - - static constexpr int initialReconnectDelayMs = 250; - static constexpr int maximumReconnectDelayMs = 5'000; - static constexpr int maximumConsecutivePreReadyDisconnects = 5; - static constexpr int stableConnectionDwellMs = 10'000; - static constexpr int outboundDrainRetryMs = 10; - static constexpr qint64 maximumBufferedOutboundBytes = static_cast( - 4U * (ai::openai::codex::frontend::DefaultFrontendMaximumInboundMessageBytes + 1U)); - - using Client = ai::openai::codex::frontend::client::Client; - using Connection = ai::openai::codex::frontend::client::Connection; - using OutboundMessage = ai::openai::codex::frontend::client::OutboundMessage; - using SendResult = ai::openai::codex::frontend::client::SendResult; - using OutboundWriter = std::function; - - struct PendingWrite - { - std::string frame; - qint64 offset = 0; - }; - - enum class DrainResult { Progress, Blocked, Failed, Reset }; - - static QString defaultSocketPath(); - void socketConnected(); - void socketReadyRead(); - void socketBytesWritten(qint64 bytes); - void socketDisconnected(); - void socketFailed(QLocalSocket::LocalSocketError error); - void handleStateUpdate( - const ai::openai::codex::frontend::client::StateUpdate& update); - void handleConnectionStateChange(const ai::openai::codex::frontend::client::ConnectionStateChange& change); - void reportDiagnostic(QString message); - void scheduleSocketRead(); - void clearInbound() noexcept; - [[nodiscard]] bool hasCompleteInboundFrame() const noexcept; - void compactInbound() noexcept; - void rememberIncompleteThreadReadAttempt(const std::string& threadId); - void reconcileIncompleteThreadReadAttempts(); - void beginArchivedThreadRefresh(); - void requestArchivedThreadPage(std::uint64_t generation, - std::optional cursor); - void finishArchivedThreadRefresh(ArchivedThreadDiscoveryStatus status, - QString diagnostic = {}); - void beginModelCatalogRefresh(); - void requestModelCatalogPage(std::uint64_t generation, - std::optional cursor); - void finishModelCatalogRefresh(QString diagnostic = {}); - void startConnection(); - void scheduleReconnect(); - void retryConnection(); - void resetReconnectPolicy(); - void markConnectionStable(); - [[nodiscard]] bool recordPreReadyTransportFailure(); - void failWithoutReconnect(QString reason); - [[nodiscard]] SendResult send(OutboundMessage&& message); - [[nodiscard]] SendResult sendToTransport(OutboundMessage&& message, - bool transportConnected, - qint64 socketBufferedBytes, - const OutboundWriter& writer) noexcept; - [[nodiscard]] SendResult acceptOutbound(OutboundMessage&& message, - qint64 socketBufferedBytes, - const OutboundWriter& writer) noexcept; - [[nodiscard]] DrainResult drainOutbound(const OutboundWriter& writer) noexcept; - void drainSocketWrites(); - void scheduleOutboundDrain(); - void clearOutbound() noexcept; - void closeTransport(QString reason) noexcept; - void setLifecycle(Lifecycle value, QString detail = {}); - - QLocalSocket socket; - QTimer reconnectTimer; - QTimer connectionStabilityTimer; - QTimer outboundDrainTimer; - QByteArray inboundBuffer; - qsizetype inboundOffset = 0; - // Bytes before this cursor were already checked for a line terminator. - // Keeping it independent from the consumed-frame offset makes receiving - // one large partial JSONL document linear across bounded socket reads. - mutable qsizetype inboundScanOffset = 0; - std::size_t maximumFrameBytes = 0; - std::unique_ptr client; - Connection connection; - ai::openai::codex::frontend::client::State currentState; - Lifecycle currentLifecycle = Lifecycle::Disconnected; - QString detail; - QString diagnosticDetail; - std::set threadReadsInFlight; - // One successful incomplete read is enough for one immutable replacement - // epoch. A later StateReplaced publication may have evicted that - // requester-local cache population and therefore earns exactly one new - // recovery attempt without turning ordinary live revisions into polling. - std::map attemptedIncompleteThreadReads; - std::uint64_t incompleteReadRecoveryEpoch = 0; - std::set archivedThreadListCursors; - std::set modelListCursors; - std::vector pendingModelCatalog; - std::vector availableModelCatalog; - std::deque pendingWrites; - qint64 pendingWriteBytes = 0; - std::uint64_t outboundEpoch = 0; - std::uint64_t connectionGeneration = 0; - int reconnectDelayMs = initialReconnectDelayMs; - int consecutivePreReadyDisconnects = 0; - bool receiveContinuationScheduled = false; - bool drainingOutbound = false; - bool outboundClearPending = false; - bool automaticReconnectEnabled = true; - bool synchronizedCurrentConnection = false; - bool preReadyFailureRecordedCurrentConnection = false; - bool archivedThreadListInFlight = false; - ArchivedThreadDiscoveryStatus archivedThreadListStatus = - ArchivedThreadDiscoveryStatus::InProgress; - bool modelListInFlight = false; - bool modelListComplete = false; - bool localShutdown = false; -}; - -} // namespace codexui - -#endif // CODEXUI_APP_FRONTENDSESSIONWORKER_H diff --git a/src/codex/ClientRuntime.cpp b/src/codex/ClientRuntime.cpp new file mode 100644 index 0000000..1caffd8 --- /dev/null +++ b/src/codex/ClientRuntime.cpp @@ -0,0 +1,957 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/ClientRuntime.h" + +#include "codex/Configuration.h" +#include "codex/PresentationProtocol.h" +#include "codex/ProtocolNormalizer.h" +#include "codex/ipc/SNodeSocketPairEndpoint.h" + +#include +#include +#include +#if defined(CODEXUI_CODEX_FRONTEND_WEBSOCKET) +#include +#endif +#include +#include +#include +#include +#include +#include +#include +#if defined(CODEXUI_CODEX_FRONTEND_TLS) +#include +#include +#endif +#if defined(CODEXUI_CODEX_FRONTEND_RFCOMM) +#include +#include +#endif +#if defined(CODEXUI_CODEX_FRONTEND_WEBSOCKET) +#include +#endif +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace codexui::codex { +namespace { + +namespace codex = ai::openai::codex; +namespace client = ai::openai::codex::frontend::client; + +constexpr std::size_t MaximumIpcReadBytesPerEvent = 256U * 1024U; + +template +void configureStreamClient(Client &configuredClient, bool disabled) { + configuredClient.getConfig()->Instance::setDisabled(disabled); + configuredClient.getConfig()->Connection::setReadTimeout( + utils::Timeval({0, 0})); + configuredClient.getConfig()->Connection::setWriteTimeout( + utils::Timeval({0, 0})); + configuredClient.getConfig()->Connection::setMaximumWriteQueueBytes( + DefaultMaximumWriteQueueBytes); +} + +template +void dispatchRequest(codex::frontend::CodexBridge &sdk, + const nlohmann::json ¶meters, std::string action, + std::string correlationId, + ProtocolNormalizer &normalizer) { + sdk.request( + typename Operation::Params{parameters}, + [action = std::move(action), correlationId = std::move(correlationId), + context = parameters, + &normalizer](typename Operation::Response &response) mutable { + normalizer.operationResult(std::move(action), std::move(correlationId), + std::move(context), response.getRaw()); + }); +} + +} // namespace + +int runClientRuntime(int socketPairDescriptor, Configuration &configuration, + bool connectBridge) { + using StreamFactory = client::StreamSocketContextFactory; + + const std::size_t maximumFrameBytes = configuration.maximumFrameBytes(); + + auto *ipcEndpoint = ipc::SNodeSocketPairEndpoint::create( + socketPairDescriptor, DefaultMaximumWriteQueueBytes, + MaximumIpcReadBytesPerEvent); + if (!ipcEndpoint) + return 1; + + codex::protocol::JsonLineFramer ipcFramer(maximumFrameBytes); + codex::frontend::CodexBridge sdk({}); + + const auto sendToQt = [&ipcEndpoint, + maximumFrameBytes](const nlohmann::json &message) { + if (!ipcEndpoint) + return false; + try { + return ipcEndpoint->send( + codex::protocol::JsonLineFramer::encode(message, maximumFrameBytes)); + } catch (...) { + return false; + } + }; + + ProtocolNormalizer normalizer(sendToQt); + + std::function requestReconnect; + std::function requestShutdown; + + std::string expectedDisconnectReason; + bool desiredConnected = connectBridge; + client::ClientConnection connection( + sdk, client::ClientConnectionCallbacks{ + .onConnected = + [&normalizer] { normalizer.transportEvent("connected"); }, + .onDisconnected = + [&normalizer, &expectedDisconnectReason, + &desiredConnected] { + std::string reason = + std::exchange(expectedDisconnectReason, {}); + normalizer.transportEvent( + desiredConnected ? "retrying" : "disconnected", + std::move(reason)); + }, + .onFailure = + [&normalizer](std::string reason) { + normalizer.transportEvent("failure", std::move(reason)); + }}); + + sdk.onRawJson([&normalizer](codex::protocol::AppServerDirection direction, + const nlohmann::json &message) { + if (direction == codex::protocol::AppServerDirection::FromAppServer) + normalizer.observeRawInbound(message); + }); + sdk.onBridgeEvent([&normalizer](const nlohmann::json &message) { + normalizer.bridgeEvent(message); + }); + +#define CODEXUI_REGISTER_SERVER_REQUEST(OperationName, methodName) \ + sdk.on##OperationName( \ + [&normalizer]( \ + codex::generated::server_requests::OperationName::Params &request) { \ + normalizer.serverRequest( \ + codex::generated::server_requests::OperationName::method, \ + request.jsonRpcId(), request.getPayload()); \ + }); + AI_OPENAI_CODEX_SERVER_REQUESTS(CODEXUI_REGISTER_SERVER_REQUEST) +#undef CODEXUI_REGISTER_SERVER_REQUEST + +#define CODEXUI_REGISTER_SERVER_NOTIFICATION(OperationName, methodName) \ + sdk.on##OperationName( \ + [&normalizer]( \ + codex::generated::server_notifications::OperationName::Params \ + ¬ification) { \ + normalizer.serverNotification( \ + codex::generated::server_notifications::OperationName::method, \ + notification.getPayload()); \ + }); + AI_OPENAI_CODEX_SERVER_NOTIFICATIONS(CODEXUI_REGISTER_SERVER_NOTIFICATION) +#undef CODEXUI_REGISTER_SERVER_NOTIFICATION + + net::un::stream::legacy::SocketClient + unixClient("codex-ui-unix", connection, std::size_t(maximumFrameBytes)); + unixClient.getConfig()->Remote::setSunPath("/tmp/codex-bridge.sock"); + configureStreamClient(unixClient, false); + + net::in::stream::legacy::SocketClient + ipv4Client("codex-ui-ipv4", connection, std::size_t(maximumFrameBytes)); + configureStreamClient(ipv4Client, true); + ipv4Client.getConfig()->Remote::setHost("127.0.0.1"); + + net::in6::stream::legacy::SocketClient< + StreamFactory, client::ClientConnection &, std::size_t> + ipv6Client("codex-ui-ipv6", connection, std::size_t(maximumFrameBytes)); + configureStreamClient(ipv6Client, true); + ipv6Client.getConfig()->Remote::setHost("::1"); + +#if defined(CODEXUI_CODEX_FRONTEND_TLS) + net::in::stream::tls::SocketClient + tlsIpv4Client("codex-ui-tls-ipv4", connection, + std::size_t(maximumFrameBytes)); + configureStreamClient(tlsIpv4Client, true); + tlsIpv4Client.getConfig()->Remote::setHost("127.0.0.1"); + + net::in6::stream::tls::SocketClient + tlsIpv6Client("codex-ui-tls-ipv6", connection, + std::size_t(maximumFrameBytes)); + configureStreamClient(tlsIpv6Client, true); + tlsIpv6Client.getConfig()->Remote::setHost("::1"); +#endif + +#if defined(CODEXUI_CODEX_FRONTEND_RFCOMM) + net::rc::stream::legacy::SocketClient + rfcommClient("codex-ui-rfcomm", connection, + std::size_t(maximumFrameBytes)); + configureStreamClient(rfcommClient, true); + + net::rc::stream::tls::SocketClient + rfcommTlsClient("codex-ui-rfcomm-tls", connection, + std::size_t(maximumFrameBytes)); + configureStreamClient(rfcommTlsClient, true); +#endif + +#if defined(CODEXUI_CODEX_FRONTEND_WEBSOCKET) + client::linkWebSocketClient(); + std::string currentWebSocketEndpoint = configuration.webSocketEndpoint(); + auto webSocketBinding = + std::make_shared(connection, maximumFrameBytes); + const auto beginWebSocket = + [webSocketBinding, ¤tWebSocketEndpoint]( + const std::shared_ptr &request) { + webSocketBinding->beginUpgrade(request, currentWebSocketEndpoint); + }; + const auto endWebSocket = + [webSocketBinding]( + const std::shared_ptr &request) { + webSocketBinding->httpDisconnected(request); + }; + + client::WebSocketHttpClient + webSocketIpv4Client("codex-ui-websocket-ipv4", beginWebSocket, + endWebSocket, webSocketBinding); + configureStreamClient(webSocketIpv4Client, true); + webSocketIpv4Client.getConfig()->Remote::setHost("127.0.0.1"); + + client::WebSocketHttpClient + webSocketIpv6Client("codex-ui-websocket-ipv6", beginWebSocket, + endWebSocket, webSocketBinding); + configureStreamClient(webSocketIpv6Client, true); + webSocketIpv6Client.getConfig()->Remote::setHost("::1"); + +#if defined(CODEXUI_CODEX_FRONTEND_TLS) + client::WebSocketHttpClient wssIpv4Client( + "codex-ui-wss-ipv4", beginWebSocket, endWebSocket, webSocketBinding); + configureStreamClient(wssIpv4Client, true); + wssIpv4Client.getConfig()->Remote::setHost("127.0.0.1"); + + client::WebSocketHttpClient + wssIpv6Client("codex-ui-wss-ipv6", beginWebSocket, endWebSocket, + webSocketBinding); + configureStreamClient(wssIpv6Client, true); + wssIpv6Client.getConfig()->Remote::setHost("::1"); +#endif +#endif + + std::function connectSelected; + std::function terminateSelected; + std::function selectedFlowTerminated; + std::function disableSelected; + std::string selectedTransport; + std::string selectedTransportLabel; + bool transitionPending = false; + bool shutdownRequested = false; + bool eventLoopRunning = false; + std::function continueTransition; + std::function terminatingFlowTerminated; + std::function pendingSelection; + + const auto selectClient = [&](auto &configuredClient, std::string transport, + std::string label) { + if (disableSelected) + disableSelected(); + configuredClient.getConfig()->Instance::setDisabled(false); + auto *const clientHandle = &configuredClient; + auto *const flow = configuredClient.getFlowController(); + auto *const config = configuredClient.getConfig(); + selectedTransport = std::move(transport); + selectedTransportLabel = std::move(label); + const std::string connectionLabel = selectedTransportLabel; + connectSelected = [&, clientHandle, flow, connectionLabel] { + normalizer.transportEvent("retrying", + "Connecting using " + connectionLabel); + clientHandle->connect([&, flow, connectionLabel]( + const auto &, core::socket::State state) { + if (state == core::socket::State::OK || + state == core::socket::State::DISABLED) + return; + const std::string failure = + "failed to connect using " + connectionLabel + ": " + state.what(); + core::EventReceiver::atNextTick([&, flow, failure] { + if (eventLoopRunning && !shutdownRequested && flow->isTerminated()) + normalizer.transportEvent("failure", failure); + }); + }); + }; + terminateSelected = [flow] { static_cast(flow->terminateFlow()); }; + selectedFlowTerminated = [flow] { return flow->isTerminated(); }; + disableSelected = [config] { config->Instance::setDisabled(true); }; + }; + + const auto connectionSettings = [&] { + nlohmann::json available = nlohmann::json::array(); + available.push_back({{"key", "unix"}, + {"label", "Unix socket"}, + {"kind", "unix"}, + {"path", unixClient.getConfig()->Remote::getSunPath()}, + {"tls", false}}); + const auto addNetwork = [&available](const auto &configuredClient, + const char *key, const char *label, + const char *kind, bool tls, + std::string webSocketPath = {}) { + nlohmann::json entry{ + {"key", key}, + {"label", label}, + {"kind", kind}, + {"host", configuredClient.getConfig()->Remote::getHost()}, + {"port", configuredClient.getConfig()->Remote::getPort()}, + {"tls", tls}}; + if (!webSocketPath.empty()) + entry["webSocketPath"] = std::move(webSocketPath); + available.push_back(std::move(entry)); + }; + addNetwork(ipv4Client, "ipv4", "IPv4", "network", false); + addNetwork(ipv6Client, "ipv6", "IPv6", "network", false); +#if defined(CODEXUI_CODEX_FRONTEND_TLS) + addNetwork(tlsIpv4Client, "tls-ipv4", "IPv4 TLS", "network", true); + addNetwork(tlsIpv6Client, "tls-ipv6", "IPv6 TLS", "network", true); +#endif +#if defined(CODEXUI_CODEX_FRONTEND_RFCOMM) + available.push_back( + {{"key", "rfcomm"}, + {"label", "RFCOMM"}, + {"kind", "rfcomm"}, + {"address", rfcommClient.getConfig()->Remote::getBtAddress()}, + {"channel", rfcommClient.getConfig()->Remote::getChannel()}, + {"tls", false}}); + available.push_back( + {{"key", "rfcomm-tls"}, + {"label", "RFCOMM TLS"}, + {"kind", "rfcomm"}, + {"address", rfcommTlsClient.getConfig()->Remote::getBtAddress()}, + {"channel", rfcommTlsClient.getConfig()->Remote::getChannel()}, + {"tls", true}}); +#endif +#if defined(CODEXUI_CODEX_FRONTEND_WEBSOCKET) + addNetwork(webSocketIpv4Client, "websocket-ipv4", "WebSocket IPv4", + "websocket", false, currentWebSocketEndpoint); + addNetwork(webSocketIpv6Client, "websocket-ipv6", "WebSocket IPv6", + "websocket", false, currentWebSocketEndpoint); +#if defined(CODEXUI_CODEX_FRONTEND_TLS) + addNetwork(wssIpv4Client, "wss-ipv4", "WSS IPv4", "websocket", true, + currentWebSocketEndpoint); + addNetwork(wssIpv6Client, "wss-ipv6", "WSS IPv6", "websocket", true, + currentWebSocketEndpoint); +#endif +#endif + return nlohmann::json{{"selected", selectedTransport}, + {"available", std::move(available)}}; + }; + + const auto publishConnectionSettings = [&] { + normalizer.connectionSettings(connectionSettings()); + }; + + continueTransition = [&] { + if (shutdownRequested || !transitionPending) + return; + if ((terminatingFlowTerminated && !terminatingFlowTerminated()) || + connection.attached()) { + core::EventReceiver::atNextTick(continueTransition); + return; + } + transitionPending = false; + terminatingFlowTerminated = {}; + if (pendingSelection) { + std::function selection = std::move(pendingSelection); + pendingSelection = {}; + selection(); + publishConnectionSettings(); + } + if (desiredConnected && connectSelected) + connectSelected(); + }; + + const auto beginTransition = [&](bool connectAfterwards, + std::function selection = {}, + std::string disconnectReason = {}) { + if (shutdownRequested || transitionPending) + return; + desiredConnected = connectAfterwards; + pendingSelection = std::move(selection); + terminatingFlowTerminated = selectedFlowTerminated; + if (!terminateSelected || + ((!terminatingFlowTerminated || terminatingFlowTerminated()) && + !connection.attached())) { + if (pendingSelection) { + std::function selected = std::move(pendingSelection); + pendingSelection = {}; + selected(); + publishConnectionSettings(); + } + if (desiredConnected && connectSelected) + connectSelected(); + return; + } + transitionPending = true; + expectedDisconnectReason = + connection.attached() ? std::move(disconnectReason) : std::string{}; + connection.disconnect("CodexUI connection transition"); + terminateSelected(); + core::EventReceiver::atNextTick(continueTransition); + }; + + requestReconnect = [&] { beginTransition(true, {}, "local-user-reconnect"); }; + + const auto requestConnect = [&] { + if (shutdownRequested) + return; + desiredConnected = true; + if (transitionPending || connection.attached()) + return; + if (!selectedFlowTerminated || selectedFlowTerminated()) { + if (connectSelected) + connectSelected(); + } + }; + + const auto requestDisconnect = [&] { + beginTransition(false, {}, "local-user-disconnect"); + }; + + requestShutdown = [&] { + if (shutdownRequested) + return; + shutdownRequested = true; + transitionPending = false; + desiredConnected = false; + connection.shutdown(); + if (terminateSelected) + terminateSelected(); + if (eventLoopRunning) + core::SNodeC::stop(); + }; + + const auto dispatchCommand = [&](nlohmann::json command) { + if (!presentation::isPresentationFrame(command) || + presentation::stringMember(command, "kind") != "command") { + normalizer.transportEvent("failure", + "invalid CodexUI presentation command"); + return; + } + + const std::string action = presentation::stringMember(command, "action"); + const std::string correlationId = + presentation::stringMember(command, "correlationId"); + const nlohmann::json parameters = + presentation::member(command, "data", nlohmann::json::object()); + + if (action == "runtime.shutdown") { + requestShutdown(); + return; + } + if (action == "connection.reconnect") { + requestReconnect(); + return; + } + if (action == "connection.connect") { + requestConnect(); + return; + } + if (action == "connection.disconnect") { + requestDisconnect(); + return; + } + if (action == "connection.configure") { + if (transitionPending) { + normalizer.localOperationResult( + action, correlationId, false, + {{"code", -32000}, + {"message", "connection transition in progress"}}); + return; + } + const std::string transport = + presentation::stringMember(parameters, "transport"); + std::function selection; + const auto networkEndpoint = + [&]() -> std::optional> { + const std::string host = presentation::stringMember(parameters, "host"); + const auto port = parameters.find("port"); + if (host.empty() || port == parameters.end() || + !port->is_number_integer()) + return std::nullopt; + const std::int64_t value = port->get(); + if (value <= 0 || value > 65535) + return std::nullopt; + return std::pair{host, static_cast(value)}; + }; + if (transport == "unix") { + const std::string path = presentation::stringMember(parameters, "path"); + if (!path.empty()) + selection = [&, path] { + unixClient.getConfig()->Remote::setSunPath(path); + selectClient(unixClient, "unix", "Unix socket"); + }; + } else if (transport == "ipv4") { + if (const auto endpoint = networkEndpoint()) + selection = [&, endpoint] { + ipv4Client.getConfig() + ->Remote::setHost(endpoint->first) + ->setPort(endpoint->second); + selectClient(ipv4Client, "ipv4", "IPv4"); + }; + } else if (transport == "ipv6") { + if (const auto endpoint = networkEndpoint()) + selection = [&, endpoint] { + ipv6Client.getConfig() + ->Remote::setHost(endpoint->first) + ->setPort(endpoint->second); + selectClient(ipv6Client, "ipv6", "IPv6"); + }; +#if defined(CODEXUI_CODEX_FRONTEND_TLS) + } else if (transport == "tls-ipv4") { + if (const auto endpoint = networkEndpoint()) + selection = [&, endpoint] { + tlsIpv4Client.getConfig() + ->Remote::setHost(endpoint->first) + ->setPort(endpoint->second); + selectClient(tlsIpv4Client, "tls-ipv4", "IPv4 TLS"); + }; + } else if (transport == "tls-ipv6") { + if (const auto endpoint = networkEndpoint()) + selection = [&, endpoint] { + tlsIpv6Client.getConfig() + ->Remote::setHost(endpoint->first) + ->setPort(endpoint->second); + selectClient(tlsIpv6Client, "tls-ipv6", "IPv6 TLS"); + }; +#endif +#if defined(CODEXUI_CODEX_FRONTEND_RFCOMM) + } else if (transport == "rfcomm" || transport == "rfcomm-tls") { + const std::string address = + presentation::stringMember(parameters, "address"); + const auto channel = parameters.find("channel"); + if (!address.empty() && channel != parameters.end() && + channel->is_number_integer() && channel->get() > 0 && + channel->get() <= 30) { + const auto value = static_cast(channel->get()); + if (transport == "rfcomm") { + selection = [&, address, value] { + rfcommClient.getConfig() + ->Remote::setBtAddress(address) + ->setChannel(value); + selectClient(rfcommClient, "rfcomm", "RFCOMM"); + }; + } else { + selection = [&, address, value] { + rfcommTlsClient.getConfig() + ->Remote::setBtAddress(address) + ->setChannel(value); + selectClient(rfcommTlsClient, "rfcomm-tls", "RFCOMM TLS"); + }; + } + } +#endif +#if defined(CODEXUI_CODEX_FRONTEND_WEBSOCKET) + } else if (transport == "websocket-ipv4" || transport == "websocket-ipv6" +#if defined(CODEXUI_CODEX_FRONTEND_TLS) + || transport == "wss-ipv4" || transport == "wss-ipv6" +#endif + ) { + const auto endpoint = networkEndpoint(); + const std::string path = + presentation::stringMember(parameters, "webSocketPath"); + if (endpoint && !path.empty() && path.front() == '/') { + if (transport == "websocket-ipv4") { + selection = [&, endpoint, path] { + currentWebSocketEndpoint = path; + webSocketIpv4Client.getConfig() + ->Remote::setHost(endpoint->first) + ->setPort(endpoint->second); + selectClient(webSocketIpv4Client, "websocket-ipv4", + "WebSocket IPv4"); + }; + } else if (transport == "websocket-ipv6") { + selection = [&, endpoint, path] { + currentWebSocketEndpoint = path; + webSocketIpv6Client.getConfig() + ->Remote::setHost(endpoint->first) + ->setPort(endpoint->second); + selectClient(webSocketIpv6Client, "websocket-ipv6", + "WebSocket IPv6"); + }; +#if defined(CODEXUI_CODEX_FRONTEND_TLS) + } else if (transport == "wss-ipv4") { + selection = [&, endpoint, path] { + currentWebSocketEndpoint = path; + wssIpv4Client.getConfig() + ->Remote::setHost(endpoint->first) + ->setPort(endpoint->second); + selectClient(wssIpv4Client, "wss-ipv4", "WSS IPv4"); + }; + } else { + selection = [&, endpoint, path] { + currentWebSocketEndpoint = path; + wssIpv6Client.getConfig() + ->Remote::setHost(endpoint->first) + ->setPort(endpoint->second); + selectClient(wssIpv6Client, "wss-ipv6", "WSS IPv6"); + }; +#endif + } + } +#endif + } + if (!selection) { + normalizer.localOperationResult( + action, correlationId, false, + {{"code", -32602}, {"message", "invalid connection settings"}}); + return; + } + beginTransition(true, std::move(selection), "local-transport-switch"); + normalizer.localOperationResult(action, correlationId, true, + {{"accepted", true}}); + return; + } + if (action == "controller.claim") { + static_cast(sdk.claimController()); + return; + } + if (action == "controller.release") { + static_cast(sdk.releaseController()); + return; + } + + if (action == "diagnostic.raw.send") { + const auto message = parameters.find("message"); + if (message == parameters.end() || !sdk.sendRawJson(*message)) + normalizer.transportEvent("failure", + "raw app-server message was rejected"); + return; + } + + if (action == "pending-request.resolve") { + const auto requestIdMember = parameters.find("requestId"); + const nlohmann::json requestId = requestIdMember == parameters.end() + ? nlohmann::json(nullptr) + : *requestIdMember; + nlohmann::json response{{"jsonrpc", "2.0"}, {"id", requestId}}; + if (parameters.contains("error")) + response["error"] = parameters["error"]; + else + response["result"] = + parameters.value("result", nlohmann::json::object()); + if (requestId.is_null() || !sdk.sendRawJson(response)) + normalizer.transportEvent("failure", + "server-request response was rejected"); + return; + } + + using namespace codex::generated::client_requests; + if (action == "threads.list") + dispatchRequest(sdk, parameters, action, correlationId, + normalizer); + else if (action == "thread.read") + dispatchRequest(sdk, parameters, action, correlationId, + normalizer); + else if (action == "thread.create") + dispatchRequest(sdk, parameters, action, correlationId, + normalizer); + else if (action == "thread.resume") + dispatchRequest(sdk, parameters, action, correlationId, + normalizer); + else if (action == "thread.fork") + dispatchRequest(sdk, parameters, action, correlationId, + normalizer); + else if (action == "thread.rename") + dispatchRequest(sdk, parameters, action, correlationId, + normalizer); + else if (action == "thread.archive") + dispatchRequest(sdk, parameters, action, correlationId, + normalizer); + else if (action == "thread.unarchive") + dispatchRequest(sdk, parameters, action, correlationId, + normalizer); + else if (action == "thread.delete") + dispatchRequest(sdk, parameters, action, correlationId, + normalizer); + else if (action == "models.list") + dispatchRequest(sdk, parameters, action, correlationId, + normalizer); + else if (action == "model-provider-capabilities.read") + dispatchRequest(sdk, parameters, action, + correlationId, normalizer); + else if (action == "account.read") + dispatchRequest(sdk, parameters, action, correlationId, + normalizer); + else if (action == "account.rate-limits.read") + dispatchRequest(sdk, parameters, action, + correlationId, normalizer); + else if (action == "account.token-usage.read") + dispatchRequest(sdk, parameters, action, + correlationId, normalizer); + else if (action == "config.read") + dispatchRequest(sdk, parameters, action, correlationId, + normalizer); + else if (action == "permission-profiles.list") + dispatchRequest(sdk, parameters, action, + correlationId, normalizer); + else if (action == "experimental-features.list") + dispatchRequest(sdk, parameters, action, + correlationId, normalizer); + else if (action == "skills.list") + dispatchRequest(sdk, parameters, action, correlationId, + normalizer); + else if (action == "hooks.list") + dispatchRequest(sdk, parameters, action, correlationId, + normalizer); + else if (action == "plugins.list") + dispatchRequest(sdk, parameters, action, correlationId, + normalizer); + else if (action == "apps.list") + dispatchRequest(sdk, parameters, action, correlationId, + normalizer); + else if (action == "mcp-servers.list") + dispatchRequest(sdk, parameters, action, + correlationId, normalizer); +#define CODEXUI_DISPATCH_PRESENTATION_REQUEST(ActionName, OperationName) \ + else if (action == ActionName) dispatchRequest( \ + sdk, parameters, action, correlationId, normalizer); + CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.unsubscribe", + ThreadUnsubscribe) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.goal.set", ThreadGoalSet) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.goal.get", ThreadGoalGet) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.goal.clear", ThreadGoalClear) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.metadata.update", + ThreadMetadataUpdate) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.section.move", + ThreadSectionMove) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.compact.start", + ThreadCompactStart) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.shell-command.start", + ThreadShellCommand) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.guardian-denial.approve", + ThreadApproveGuardianDeniedAction) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.rollback", ThreadRollback) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.sections.list", + ThreadSectionList) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.section.create", + ThreadSectionCreate) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.section.update", + ThreadSectionUpdate) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.section.delete", + ThreadSectionDelete) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("threads.loaded.list", + ThreadLoadedList) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.items.inject", + ThreadInjectItems) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("skills.extra-roots.set", + SkillsExtraRootsSet) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("marketplace.add", MarketplaceAdd) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("marketplace.remove", + MarketplaceRemove) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("marketplace.upgrade", + MarketplaceUpgrade) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("plugins.installed", PluginInstalled) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("plugin.read", PluginRead) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("plugin.skill.read", PluginSkillRead) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("plugin.share.save", PluginShareSave) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("plugin.share.targets.update", + PluginShareUpdateTargets) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("plugin.shares.list", PluginShareList) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("plugin.share.checkout", + PluginShareCheckout) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("plugin.share.delete", + PluginShareDelete) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("apps.read", AppsRead) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("apps.installed", AppsInstalled) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("filesystem.file.read", FsReadFile) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("filesystem.file.write", FsWriteFile) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("filesystem.directory.create", + FsCreateDirectory) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("filesystem.metadata.read", + FsGetMetadata) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("filesystem.directory.read", + FsReadDirectory) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("filesystem.remove", FsRemove) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("filesystem.copy", FsCopy) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("filesystem.watch", FsWatch) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("filesystem.unwatch", FsUnwatch) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("skills.config.write", + SkillsConfigWrite) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("plugin.install", PluginInstall) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("plugin.uninstall", PluginUninstall) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("review.start", ReviewStart) + CODEXUI_DISPATCH_PRESENTATION_REQUEST( + "experimental-features.enablement.set", + ExperimentalFeatureEnablementSet) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("mcp-server.oauth-login.start", + McpServerOauthLogin) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("mcp-servers.refresh", + McpServerRefresh) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("mcp-resource.read", McpResourceRead) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("mcp-server.tool.call", + McpServerToolCall) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("windows-sandbox.setup.start", + WindowsSandboxSetupStart) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("windows-sandbox.readiness", + WindowsSandboxReadiness) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("account.login.start", LoginAccount) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("account.login.cancel", + CancelLoginAccount) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("account.logout", LogoutAccount) + CODEXUI_DISPATCH_PRESENTATION_REQUEST( + "account.rate-limit-reset-credit.consume", + ConsumeAccountRateLimitResetCredit) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("workspace.messages.read", + GetWorkspaceMessages) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("account.credits-nudge-email.send", + SendAddCreditsNudgeEmail) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("feedback.upload", FeedbackUpload) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("command.execute", OneOffCommandExec) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("command.stdin.write", + CommandExecWrite) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("command.terminate", + CommandExecTerminate) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("command.resize", CommandExecResize) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("external-agent-config.detect", + ExternalAgentConfigDetect) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("external-agent-config.import", + ExternalAgentConfigImport) + CODEXUI_DISPATCH_PRESENTATION_REQUEST( + "external-agent-config.import-history.record", + ExternalAgentConfigImportHistoryRecord) + CODEXUI_DISPATCH_PRESENTATION_REQUEST( + "external-agent-config.import-histories.read", + ExternalAgentConfigImportHistoriesRead) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("config.value.write", + ConfigValueWrite) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("config.batch.write", + ConfigBatchWrite) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("config.requirements.read", + ConfigRequirementsRead) + CODEXUI_DISPATCH_PRESENTATION_REQUEST("workspace.search.start", + FuzzyFileSearch) +#undef CODEXUI_DISPATCH_PRESENTATION_REQUEST + else if (action == "turn.start") dispatchRequest( + sdk, parameters, action, correlationId, normalizer); + else if (action == "turn.steer") dispatchRequest( + sdk, parameters, action, correlationId, normalizer); + else if (action == "turn.interrupt") dispatchRequest( + sdk, parameters, action, correlationId, normalizer); + else normalizer.operationRejected( + action, correlationId, -32601, + "unsupported CodexUI presentation action"); + }; + + ipcEndpoint->setOnData([&](const char *data, std::size_t size) { + const bool accepted = ipcFramer.consume( + std::string_view(data, size), dispatchCommand, + [&normalizer, &requestShutdown](std::string message) { + normalizer.transportEvent("failure", std::move(message)); + requestShutdown(); + }); + if (!accepted) + requestShutdown(); + }); + ipcEndpoint->setOnError([&normalizer, &requestShutdown](int errorNumber) { + normalizer.transportEvent("failure", std::string("socketpair failure: ") + + std::to_string(errorNumber)); + requestShutdown(); + }); + ipcEndpoint->setOnClosed([&ipcEndpoint, &requestShutdown] { + ipcEndpoint = nullptr; + requestShutdown(); + }); + + eventLoopRunning = true; + core::EventReceiver::atNextTick([&] { + normalizer.transportEvent("runtime-started"); + const std::array disabled{ + unixClient.getConfig()->Instance::getDisabled(), + ipv4Client.getConfig()->Instance::getDisabled(), + ipv6Client.getConfig()->Instance::getDisabled(), +#if defined(CODEXUI_CODEX_FRONTEND_TLS) + tlsIpv4Client.getConfig()->Instance::getDisabled(), + tlsIpv6Client.getConfig()->Instance::getDisabled(), +#endif +#if defined(CODEXUI_CODEX_FRONTEND_RFCOMM) + rfcommClient.getConfig()->Instance::getDisabled(), + rfcommTlsClient.getConfig()->Instance::getDisabled(), +#endif +#if defined(CODEXUI_CODEX_FRONTEND_WEBSOCKET) + webSocketIpv4Client.getConfig()->Instance::getDisabled(), + webSocketIpv6Client.getConfig()->Instance::getDisabled(), +#if defined(CODEXUI_CODEX_FRONTEND_TLS) + wssIpv4Client.getConfig()->Instance::getDisabled(), + wssIpv6Client.getConfig()->Instance::getDisabled(), +#endif +#endif + }; + const std::size_t enabled = static_cast( + std::count(disabled.begin(), disabled.end(), false)); + if (enabled != 1) { + normalizer.transportEvent( + "failure", + "exactly one outgoing bridge transport must be enabled; found " + + std::to_string(enabled)); + requestShutdown(); + return; + } + + if (!unixClient.getConfig()->Instance::getDisabled()) + selectClient(unixClient, "unix", "Unix socket"); + else if (!ipv4Client.getConfig()->Instance::getDisabled()) + selectClient(ipv4Client, "ipv4", "IPv4"); + else if (!ipv6Client.getConfig()->Instance::getDisabled()) + selectClient(ipv6Client, "ipv6", "IPv6"); +#if defined(CODEXUI_CODEX_FRONTEND_TLS) + else if (!tlsIpv4Client.getConfig()->Instance::getDisabled()) + selectClient(tlsIpv4Client, "tls-ipv4", "IPv4 TLS"); + else if (!tlsIpv6Client.getConfig()->Instance::getDisabled()) + selectClient(tlsIpv6Client, "tls-ipv6", "IPv6 TLS"); +#endif +#if defined(CODEXUI_CODEX_FRONTEND_RFCOMM) + else if (!rfcommClient.getConfig()->Instance::getDisabled()) + selectClient(rfcommClient, "rfcomm", "RFCOMM"); + else if (!rfcommTlsClient.getConfig()->Instance::getDisabled()) + selectClient(rfcommTlsClient, "rfcomm-tls", "RFCOMM TLS"); +#endif +#if defined(CODEXUI_CODEX_FRONTEND_WEBSOCKET) + else if (!webSocketIpv4Client.getConfig()->Instance::getDisabled()) + selectClient(webSocketIpv4Client, "websocket-ipv4", "WebSocket IPv4"); + else if (!webSocketIpv6Client.getConfig()->Instance::getDisabled()) + selectClient(webSocketIpv6Client, "websocket-ipv6", "WebSocket IPv6"); +#if defined(CODEXUI_CODEX_FRONTEND_TLS) + else if (!wssIpv4Client.getConfig()->Instance::getDisabled()) + selectClient(wssIpv4Client, "wss-ipv4", "WSS IPv4"); + else if (!wssIpv6Client.getConfig()->Instance::getDisabled()) + selectClient(wssIpv6Client, "wss-ipv6", "WSS IPv6"); +#endif +#endif + publishConnectionSettings(); + if (connectBridge && connectSelected) + connectSelected(); + }); + + const int result = core::SNodeC::start(); + eventLoopRunning = false; +#if defined(CODEXUI_CODEX_FRONTEND_WEBSOCKET) + webSocketBinding->shutdown(); +#endif + if (terminateSelected) + terminateSelected(); + connection.shutdown(); + return result; +} + +} // namespace codexui::codex diff --git a/src/codex/ClientRuntime.h b/src/codex/ClientRuntime.h new file mode 100644 index 0000000..91d0da7 --- /dev/null +++ b/src/codex/ClientRuntime.h @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_CLIENTRUNTIME_H +#define CODEXUI_CODEX_CLIENTRUNTIME_H + +namespace codexui::codex { + +class Configuration; + +int runClientRuntime(int socketPairDescriptor, Configuration &configuration, + bool connectBridge); + +} // namespace codexui::codex + +#endif // CODEXUI_CODEX_CLIENTRUNTIME_H diff --git a/src/codex/Configuration.cpp b/src/codex/Configuration.cpp new file mode 100644 index 0000000..ec746bb --- /dev/null +++ b/src/codex/Configuration.cpp @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/Configuration.h" + +namespace codexui::codex { + +Configuration::Configuration(utils::SubCommand *parent) + : utils::SubCommand(parent, this, "Applications") { + maximumFrameBytesOption = + setConfigurable(addOption("--bridge-maximum-frame-bytes", + "Maximum encoded bridge envelope size", "BYTES", + DefaultMaximumFrameBytes, CLI::PositiveNumber), + true); + webSocketEndpointOption = setConfigurable( + addOption("--bridge-websocket-endpoint", + "HTTP path used for a Codex bridge WebSocket connection", + "PATH", std::string{"/codex"}, CLI::Validator{}), + true); +} + +Configuration::~Configuration() = default; + +std::size_t Configuration::maximumFrameBytes() const { + return maximumFrameBytesOption->as(); +} + +std::string Configuration::webSocketEndpoint() const { + return webSocketEndpointOption->as(); +} + +} // namespace codexui::codex diff --git a/src/codex/Configuration.h b/src/codex/Configuration.h new file mode 100644 index 0000000..1bad24c --- /dev/null +++ b/src/codex/Configuration.h @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_CONFIGURATION_H +#define CODEXUI_CODEX_CONFIGURATION_H + +#include + +#include +#include +#include + +namespace CLI { +class Option; +} + +namespace codexui::codex { + +inline constexpr std::size_t DefaultMaximumFrameBytes = 64U * 1024U * 1024U; +inline constexpr std::size_t DefaultMaximumWriteQueueBytes = + 128U * 1024U * 1024U; + +class Configuration final : public utils::SubCommand { +public: + constexpr static std::string_view NAME{"codex-ui"}; + constexpr static std::string_view DESCRIPTION{"Codex bridge Qt client"}; + + explicit Configuration(utils::SubCommand *parent); + ~Configuration() override; + + std::size_t maximumFrameBytes() const; + std::string webSocketEndpoint() const; + +private: + CLI::Option *maximumFrameBytesOption = nullptr; + CLI::Option *webSocketEndpointOption = nullptr; +}; + +} // namespace codexui::codex + +#endif // CODEXUI_CODEX_CONFIGURATION_H diff --git a/src/codex/ConnectionDialog.cpp b/src/codex/ConnectionDialog.cpp new file mode 100644 index 0000000..a746a48 --- /dev/null +++ b/src/codex/ConnectionDialog.cpp @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/ConnectionDialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace codexui::codex { +namespace { + +QString text(const std::string &value) { + return QString::fromUtf8(value.data(), static_cast(value.size())); +} + +std::string stringValue(const nlohmann::json &object, const char *key) { + if (!object.is_object()) + return {}; + const auto iterator = object.find(key); + return iterator != object.end() && iterator->is_string() + ? iterator->get() + : std::string{}; +} + +QLabel *dialogLabel(QString value, const char *kind) { + auto *label = new QLabel(std::move(value)); + label->setProperty("kind", kind); + label->setWordWrap(true); + return label; +} + +} // namespace + +ConnectionDialog::ConnectionDialog(nlohmann::json settings, QWidget *parent) + : QDialog(parent), settings(std::move(settings)) { + setModal(true); + setWindowTitle(QStringLiteral("Bridge connection")); + resize(520, 410); + setMinimumWidth(460); + + auto *root = new QVBoxLayout(this); + root->setContentsMargins(24, 22, 24, 20); + root->setSpacing(14); + root->addWidget(dialogLabel(QStringLiteral("Bridge connection"), "heading")); + root->addWidget(dialogLabel( + QStringLiteral( + "Command-line and SNode.C configuration provide the startup " + "defaults. These values override the current CodexUI session only."), + "muted")); + + auto *form = new QFormLayout; + form->setHorizontalSpacing(14); + form->setVerticalSpacing(12); + form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + transport = new QComboBox; + const nlohmann::json available = + this->settings.value("available", nlohmann::json::array()); + if (available.is_array()) { + for (const auto &entry : available) { + transport->addItem(text(stringValue(entry, "label")), + text(stringValue(entry, "key"))); + } + } + const QString selected = text(stringValue(this->settings, "selected")); + const int selectedIndex = transport->findData(selected); + if (selectedIndex >= 0) + transport->setCurrentIndex(selectedIndex); + form->addRow(QStringLiteral("Transport"), transport); + + address = new QLineEdit; + addressLabel = new QLabel; + form->addRow(addressLabel, address); + port = new QSpinBox; + port->setRange(1, 65535); + portLabel = new QLabel(QStringLiteral("Port")); + form->addRow(portLabel, port); + webSocketPath = new QLineEdit; + webSocketPathLabel = new QLabel(QStringLiteral("WebSocket path")); + form->addRow(webSocketPathLabel, webSocketPath); + root->addLayout(form); + + tlsNotice = + dialogLabel(QStringLiteral("TLS certificates and verification continue " + "to use the effective SNode.C configuration."), + "meta"); + root->addWidget(tlsNotice); + errorLabel = dialogLabel({}, "meta"); + errorLabel->setStyleSheet(QStringLiteral("color:#b83a3a;")); + errorLabel->hide(); + root->addWidget(errorLabel); + root->addStretch(); + + auto *footer = new QHBoxLayout; + footer->addStretch(); + auto *cancel = new QPushButton(QStringLiteral("Cancel")); + cancel->setProperty("kind", "subtle"); + cancel->setFixedHeight(34); + auto *apply = new QPushButton(QStringLiteral("Apply and connect")); + apply->setProperty("kind", "primary"); + apply->setFixedHeight(34); + footer->addWidget(cancel); + footer->addWidget(apply); + root->addLayout(footer); + + connect(transport, &QComboBox::currentIndexChanged, this, + [this] { loadTransport(); }); + connect(cancel, &QPushButton::clicked, this, &QDialog::reject); + connect(apply, &QPushButton::clicked, this, [this] { acceptSelection(); }); + loadTransport(); +} + +nlohmann::json ConnectionDialog::selection() const { + const QString key = transport->currentData().toString(); + const nlohmann::json available = + settings.value("available", nlohmann::json::array()); + std::string kind; + for (const auto &entry : available) { + if (text(stringValue(entry, "key")) == key) { + kind = stringValue(entry, "kind"); + break; + } + } + nlohmann::json result{{"transport", key.toStdString()}}; + if (kind == "unix") { + result["path"] = address->text().trimmed().toStdString(); + } else if (kind == "rfcomm") { + result["address"] = address->text().trimmed().toStdString(); + result["channel"] = port->value(); + } else { + result["host"] = address->text().trimmed().toStdString(); + result["port"] = port->value(); + if (kind == "websocket") + result["webSocketPath"] = webSocketPath->text().trimmed().toStdString(); + } + return result; +} + +void ConnectionDialog::loadTransport() { + const QString key = transport->currentData().toString(); + const nlohmann::json available = + settings.value("available", nlohmann::json::array()); + nlohmann::json selected = nlohmann::json::object(); + for (const auto &entry : available) { + if (text(stringValue(entry, "key")) == key) { + selected = entry; + break; + } + } + const std::string kind = stringValue(selected, "kind"); + const bool network = kind == "network" || kind == "websocket"; + const bool bluetooth = kind == "rfcomm"; + addressLabel->setText(kind == "unix" ? QStringLiteral("Socket path") + : bluetooth ? QStringLiteral("Bluetooth address") + : QStringLiteral("Host")); + address->setText(text(kind == "unix" ? stringValue(selected, "path") + : bluetooth ? stringValue(selected, "address") + : stringValue(selected, "host"))); + portLabel->setText(bluetooth ? QStringLiteral("Channel") + : QStringLiteral("Port")); + port->setRange(bluetooth ? 1 : 1, bluetooth ? 30 : 65535); + port->setValue(selected.value(bluetooth ? "channel" : "port", 1)); + port->setVisible(network || bluetooth); + portLabel->setVisible(network || bluetooth); + const bool webSocket = kind == "websocket"; + webSocketPath->setText(text(stringValue(selected, "webSocketPath"))); + webSocketPath->setVisible(webSocket); + webSocketPathLabel->setVisible(webSocket); + tlsNotice->setVisible(selected.value("tls", false)); + errorLabel->hide(); +} + +void ConnectionDialog::acceptSelection() { + const nlohmann::json selected = selection(); + const std::string kind = [&] { + const nlohmann::json available = + settings.value("available", nlohmann::json::array()); + for (const auto &entry : available) { + if (stringValue(entry, "key") == stringValue(selected, "transport")) + return stringValue(entry, "kind"); + } + return std::string{}; + }(); + const QString endpoint = + text(stringValue(selected, kind == "unix" ? "path" + : kind == "rfcomm" ? "address" + : "host")); + if (endpoint.trimmed().isEmpty()) { + errorLabel->setText(QStringLiteral("Enter a connection endpoint.")); + errorLabel->show(); + return; + } + if (kind == "websocket" && + !text(stringValue(selected, "webSocketPath")).startsWith('/')) { + errorLabel->setText( + QStringLiteral("The WebSocket path must start with '/'.")); + errorLabel->show(); + return; + } + accept(); +} + +} // namespace codexui::codex diff --git a/src/codex/ConnectionDialog.h b/src/codex/ConnectionDialog.h new file mode 100644 index 0000000..5da5c60 --- /dev/null +++ b/src/codex/ConnectionDialog.h @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_CONNECTIONDIALOG_H +#define CODEXUI_CODEX_CONNECTIONDIALOG_H + +#include + +#include + +class QComboBox; +class QLabel; +class QLineEdit; +class QSpinBox; + +namespace codexui::codex { + +class ConnectionDialog final : public QDialog { +public: + explicit ConnectionDialog(nlohmann::json settings, QWidget *parent = nullptr); + + [[nodiscard]] nlohmann::json selection() const; + +private: + void loadTransport(); + void acceptSelection(); + + nlohmann::json settings; + QComboBox *transport = nullptr; + QLineEdit *address = nullptr; + QSpinBox *port = nullptr; + QLineEdit *webSocketPath = nullptr; + QLabel *addressLabel = nullptr; + QLabel *portLabel = nullptr; + QLabel *webSocketPathLabel = nullptr; + QLabel *tlsNotice = nullptr; + QLabel *errorLabel = nullptr; +}; + +} // namespace codexui::codex + +#endif // CODEXUI_CODEX_CONNECTIONDIALOG_H diff --git a/src/codex/DiffViewer.cpp b/src/codex/DiffViewer.cpp new file mode 100644 index 0000000..a42551f --- /dev/null +++ b/src/codex/DiffViewer.cpp @@ -0,0 +1,265 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/DiffViewer.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace codexui::codex { +namespace { + +class DiffHighlighter final : public QSyntaxHighlighter { +public: + explicit DiffHighlighter(QTextDocument *document) + : QSyntaxHighlighter(document) {} + +protected: + void highlightBlock(const QString &text) override { + QTextCharFormat format; + if (text.startsWith(QStringLiteral("@@"))) { + format.setForeground(QColor(QStringLiteral("#2f6feb"))); + format.setBackground(QColor(QStringLiteral("#edf3ff"))); + format.setFontWeight(QFont::DemiBold); + } else if (text.startsWith(QLatin1Char('+')) && + !text.startsWith(QStringLiteral("+++"))) { + format.setForeground(QColor(QStringLiteral("#176b45"))); + format.setBackground(QColor(QStringLiteral("#e9f7f0"))); + } else if (text.startsWith(QLatin1Char('-')) && + !text.startsWith(QStringLiteral("---"))) { + format.setForeground(QColor(QStringLiteral("#9d2e2e"))); + format.setBackground(QColor(QStringLiteral("#fff1f1"))); + } else if (text.startsWith(QStringLiteral("diff --git")) || + text.startsWith(QStringLiteral("---")) || + text.startsWith(QStringLiteral("+++"))) { + format.setForeground(QColor(QStringLiteral("#344054"))); + format.setFontWeight(QFont::DemiBold); + } else { + return; + } + setFormat(0, text.size(), format); + } +}; + +QLabel *label(QString value, const char *kind) { + auto *result = new QLabel(std::move(value)); + result->setProperty("kind", kind); + result->setWordWrap(true); + return result; +} + +QString pathFromHeader(QString line) { + if (line.startsWith(QStringLiteral("+++ "))) + line.remove(0, 4); + line = line.section(QLatin1Char('\t'), 0, 0).trimmed(); + if (line.startsWith(QStringLiteral("b/"))) + line.remove(0, 2); + return line == QStringLiteral("/dev/null") ? QString{} : line; +} + +void countLines(const QString &content, int &additions, int &deletions) { + additions = 0; + deletions = 0; + const QStringList lines = content.split(QLatin1Char('\n')); + for (const QString &line : lines) { + if (line.startsWith(QLatin1Char('+')) && + !line.startsWith(QStringLiteral("+++"))) + ++additions; + else if (line.startsWith(QLatin1Char('-')) && + !line.startsWith(QStringLiteral("---"))) + ++deletions; + } +} + +} // namespace + +DiffViewer::DiffViewer(QWidget *parent) : QWidget(parent) { + auto *root = new QVBoxLayout(this); + root->setContentsMargins(10, 10, 10, 10); + root->setSpacing(8); + auto *header = new QHBoxLayout; + summary = label(QStringLiteral("No file changes"), "title"); + authority = label({}, "meta"); + auto *headerText = new QVBoxLayout; + headerText->setSpacing(1); + headerText->addWidget(summary); + headerText->addWidget(authority); + header->addLayout(headerText, 1); + copyButton = new QPushButton(QStringLiteral("Copy")); + copyButton->setProperty("kind", "subtle"); + copyButton->setFixedHeight(28); + expandButton = new QPushButton(QStringLiteral("Expand")); + expandButton->setFixedHeight(28); + header->addWidget(copyButton); + header->addWidget(expandButton); + root->addLayout(header); + + files = new QListWidget; + files->setObjectName(QStringLiteral("codexDiffFiles")); + files->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + files->setMaximumHeight(150); + root->addWidget(files); + + diff = new QPlainTextEdit; + diff->setObjectName(QStringLiteral("codexDiffText")); + diff->setReadOnly(true); + diff->setLineWrapMode(QPlainTextEdit::NoWrap); + diff->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); + diff->setPlaceholderText(QStringLiteral("Select a changed file.")); + new DiffHighlighter(diff->document()); + root->addWidget(diff, 1); + + connect(files, &QListWidget::currentRowChanged, this, + [this] { showSelectedFile(); }); + connect(copyButton, &QPushButton::clicked, this, [this] { + if (!diff->toPlainText().isEmpty()) + QApplication::clipboard()->setText(diff->toPlainText()); + }); + connect(expandButton, &QPushButton::clicked, this, + [this] { showExpanded(); }); + copyButton->setEnabled(false); + expandButton->setEnabled(false); +} + +void DiffViewer::setChanges(QString liveDiff, + std::vector retainedChanges) { + QByteArray fingerprintInput = liveDiff.toUtf8(); + for (const DiffFilePresentation &change : retainedChanges) { + fingerprintInput += '\0'; + fingerprintInput += change.path.toUtf8(); + fingerprintInput += '\0'; + fingerprintInput += change.kind.toUtf8(); + fingerprintInput += '\0'; + fingerprintInput += change.diff.toUtf8(); + } + const QByteArray fingerprint = + QCryptographicHash::hash(fingerprintInput, QCryptographicHash::Sha256); + if (fingerprint == contentFingerprint) + return; + contentFingerprint = fingerprint; + + const bool live = !liveDiff.isEmpty(); + fileDiffs = live ? parseUnifiedDiff(liveDiff) : std::vector{}; + if (!live) { + fileDiffs.reserve(retainedChanges.size()); + for (DiffFilePresentation &change : retainedChanges) { + FileDiff file{std::move(change.path), std::move(change.kind), + std::move(change.diff)}; + countLines(file.content, file.additions, file.deletions); + fileDiffs.push_back(std::move(file)); + } + } + + files->clear(); + int additions = 0; + int deletions = 0; + for (const FileDiff &file : fileDiffs) { + additions += file.additions; + deletions += file.deletions; + const QString path = + file.path.isEmpty() ? QStringLiteral("Turn diff") : file.path; + auto *item = new QListWidgetItem(QStringLiteral("%1 +%2 -%3") + .arg(path) + .arg(file.additions) + .arg(file.deletions)); + item->setToolTip(path); + files->addItem(item); + } + summary->setText(fileDiffs.empty() ? QStringLiteral("No file changes") + : QStringLiteral("%1 files +%2 -%3") + .arg(fileDiffs.size()) + .arg(additions) + .arg(deletions)); + authority->setText( + fileDiffs.empty() ? QString{} + : live ? QStringLiteral("Authoritative live turn diff") + : QStringLiteral("Reconstructed from retained file-change items")); + if (!fileDiffs.empty()) + files->setCurrentRow(0); + else + diff->clear(); + copyButton->setEnabled(!fileDiffs.empty()); + expandButton->setEnabled(!fileDiffs.empty()); +} + +std::vector +DiffViewer::parseUnifiedDiff(const QString &diff) { + std::vector result; + FileDiff current; + const auto flush = [&] { + if (current.content.isEmpty()) + return; + countLines(current.content, current.additions, current.deletions); + result.push_back(std::move(current)); + current = FileDiff{}; + }; + const QStringList lines = diff.split(QLatin1Char('\n')); + for (const QString &line : lines) { + if (line.startsWith(QStringLiteral("diff --git ")) && + !current.content.isEmpty()) + flush(); + if (line.startsWith(QStringLiteral("+++ "))) { + const QString path = pathFromHeader(line); + if (!path.isEmpty()) + current.path = path; + } + current.content += line; + current.content += QLatin1Char('\n'); + } + flush(); + if (result.empty() && !diff.isEmpty()) { + FileDiff file{QStringLiteral("Turn diff"), {}, diff}; + countLines(file.content, file.additions, file.deletions); + result.push_back(std::move(file)); + } + return result; +} + +void DiffViewer::showSelectedFile() { + const int index = files->currentRow(); + if (index < 0 || static_cast(index) >= fileDiffs.size()) { + diff->clear(); + return; + } + diff->setPlainText(fileDiffs[static_cast(index)].content); + diff->moveCursor(QTextCursor::Start); +} + +void DiffViewer::showExpanded() { + const int index = files->currentRow(); + if (index < 0 || static_cast(index) >= fileDiffs.size()) + return; + const FileDiff &file = fileDiffs[static_cast(index)]; + QDialog dialog(this); + dialog.setWindowTitle(file.path.isEmpty() ? QStringLiteral("Turn diff") + : file.path); + dialog.resize(1100, 760); + auto *layout = new QVBoxLayout(&dialog); + layout->setContentsMargins(16, 16, 16, 16); + auto *view = new QPlainTextEdit(file.content); + view->setReadOnly(true); + view->setLineWrapMode(QPlainTextEdit::NoWrap); + view->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); + new DiffHighlighter(view->document()); + layout->addWidget(view, 1); + auto *close = new QPushButton(QStringLiteral("Close")); + close->setFixedHeight(34); + auto *footer = new QHBoxLayout; + footer->addStretch(); + footer->addWidget(close); + layout->addLayout(footer); + connect(close, &QPushButton::clicked, &dialog, &QDialog::accept); + dialog.exec(); +} + +} // namespace codexui::codex diff --git a/src/codex/DiffViewer.h b/src/codex/DiffViewer.h new file mode 100644 index 0000000..1d14686 --- /dev/null +++ b/src/codex/DiffViewer.h @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_DIFFVIEWER_H +#define CODEXUI_CODEX_DIFFVIEWER_H + +#include +#include + +#include + +class QLabel; +class QListWidget; +class QPlainTextEdit; +class QPushButton; + +namespace codexui::codex { + +struct DiffFilePresentation { + QString path; + QString kind; + QString diff; +}; + +class DiffViewer final : public QWidget { +public: + explicit DiffViewer(QWidget *parent = nullptr); + + void setChanges(QString liveDiff, + std::vector retainedChanges); + +private: + struct FileDiff { + QString path; + QString kind; + QString content; + int additions = 0; + int deletions = 0; + }; + + static std::vector parseUnifiedDiff(const QString &diff); + void showSelectedFile(); + void showExpanded(); + + QLabel *summary = nullptr; + QLabel *authority = nullptr; + QListWidget *files = nullptr; + QPlainTextEdit *diff = nullptr; + QPushButton *copyButton = nullptr; + QPushButton *expandButton = nullptr; + std::vector fileDiffs; + QByteArray contentFingerprint; +}; + +} // namespace codexui::codex + +#endif // CODEXUI_CODEX_DIFFVIEWER_H diff --git a/src/codex/FileSelectionDialog.cpp b/src/codex/FileSelectionDialog.cpp new file mode 100644 index 0000000..77ca9cc --- /dev/null +++ b/src/codex/FileSelectionDialog.cpp @@ -0,0 +1,335 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/FileSelectionDialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace codexui::codex { +namespace { + +constexpr int MaximumAttachments = 16; + +QLabel *dialogLabel(QString text, const char *kind) { + auto *label = new QLabel(std::move(text)); + label->setProperty("kind", kind); + label->setWordWrap(true); + return label; +} + +QString readableSize(std::int64_t bytes) { + constexpr std::int64_t KiB = 1024; + constexpr std::int64_t MiB = KiB * 1024; + if (bytes >= MiB) + return QStringLiteral("%1 MB").arg(static_cast(bytes) / MiB, 0, 'f', + 1); + if (bytes >= KiB) + return QStringLiteral("%1 KB").arg(static_cast(bytes) / KiB, 0, 'f', + 1); + return QStringLiteral("%1 B").arg(bytes); +} + +} // namespace + +FileSelectionDialog::FileSelectionDialog( + Mode mode, QString initialDirectory, + std::vector initialAttachments, QWidget *parent) + : QDialog(parent), mode(mode) { + setModal(true); + setWindowTitle(mode == Mode::Workspace ? QStringLiteral("Select workspace") + : QStringLiteral("Attach files")); + resize(720, mode == Mode::Workspace ? 560 : 680); + setMinimumSize(560, 460); + + auto *root = new QVBoxLayout(this); + root->setContentsMargins(24, 22, 24, 20); + root->setSpacing(14); + root->addWidget(dialogLabel(mode == Mode::Workspace + ? QStringLiteral("Select workspace") + : QStringLiteral("Attach files"), + "heading")); + root->addWidget(dialogLabel( + mode == Mode::Workspace + ? QStringLiteral( + "Choose the directory Codex should use for the new thread.") + : QStringLiteral( + "Choose local files to include with the next message."), + "muted")); + + auto *locationRow = new QHBoxLayout; + locationRow->setSpacing(8); + auto *up = new QPushButton(QStringLiteral("Up")); + up->setProperty("kind", "subtle"); + up->setFixedHeight(34); + location = new QLineEdit; + location->setAccessibleName(QStringLiteral("Current directory")); + auto *go = new QPushButton(QStringLiteral("Go")); + go->setFixedHeight(34); + locationRow->addWidget(up); + locationRow->addWidget(location, 1); + locationRow->addWidget(go); + root->addLayout(locationRow); + + fileSystem = new QFileSystemModel(this); + fileSystem->setFilter( + mode == Mode::Workspace + ? QDir::AllDirs | QDir::NoDotAndDotDot | QDir::Drives + : QDir::AllEntries | QDir::NoDotAndDotDot | QDir::Drives); + fileSystem->setRootPath(QStringLiteral("/")); + browser = new QTreeView; + browser->setObjectName(QStringLiteral("codexFileBrowser")); + browser->setModel(fileSystem); + browser->setRootIsDecorated(false); + browser->setItemsExpandable(false); + browser->setSortingEnabled(true); + browser->sortByColumn(0, Qt::AscendingOrder); + browser->setSelectionBehavior(QAbstractItemView::SelectRows); + browser->setSelectionMode(mode == Mode::Workspace + ? QAbstractItemView::SingleSelection + : QAbstractItemView::ExtendedSelection); + browser->setHorizontalScrollMode(QAbstractItemView::ScrollPerPixel); + browser->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + browser->header()->setStretchLastSection(false); + browser->header()->setSectionResizeMode(0, QHeaderView::Stretch); + browser->header()->setSectionResizeMode(1, QHeaderView::ResizeToContents); + browser->header()->setSectionResizeMode(2, QHeaderView::ResizeToContents); + browser->header()->setSectionResizeMode(3, QHeaderView::ResizeToContents); + root->addWidget(browser, 1); + + if (mode == Mode::Attachments) { + auto *selectionHeader = new QHBoxLayout; + selectionHeader->addWidget( + dialogLabel(QStringLiteral("Selected files"), "title")); + selectionHeader->addStretch(); + addButton = new QPushButton(QStringLiteral("Add selected")); + addButton->setFixedHeight(30); + removeButton = new QPushButton(QStringLiteral("Remove")); + removeButton->setProperty("kind", "subtle"); + removeButton->setFixedHeight(30); + selectionHeader->addWidget(addButton); + selectionHeader->addWidget(removeButton); + root->addLayout(selectionHeader); + + attachments = new QListWidget; + attachments->setObjectName(QStringLiteral("codexAttachmentList")); + attachments->setSelectionMode(QAbstractItemView::ExtendedSelection); + attachments->setMaximumHeight(128); + root->addWidget(attachments); + for (const AttachmentDraft &attachment : initialAttachments) { + auto *item = new QListWidgetItem( + QStringLiteral("%1 | %2") + .arg(attachment.name, readableSize(attachment.size))); + item->setData(Qt::UserRole, attachment.path); + item->setData(Qt::UserRole + 1, attachment.mimeType); + item->setData(Qt::UserRole + 2, + QVariant::fromValue(attachment.size)); + item->setToolTip(attachment.path); + attachments->addItem(item); + } + } + + errorLabel = dialogLabel({}, "meta"); + errorLabel->setStyleSheet(QStringLiteral("color:#b83a3a;")); + errorLabel->hide(); + root->addWidget(errorLabel); + + auto *footer = new QHBoxLayout; + selectionSummary = dialogLabel({}, "meta"); + footer->addWidget(selectionSummary); + footer->addStretch(); + auto *cancel = new QPushButton(QStringLiteral("Cancel")); + cancel->setProperty("kind", "subtle"); + cancel->setFixedHeight(34); + acceptButton = new QPushButton(mode == Mode::Workspace + ? QStringLiteral("Select workspace") + : QStringLiteral("Attach")); + acceptButton->setProperty("kind", "primary"); + acceptButton->setFixedHeight(34); + footer->addWidget(cancel); + footer->addWidget(acceptButton); + root->addLayout(footer); + + connect(up, &QPushButton::clicked, this, [this] { + navigateTo(QFileInfo(currentDirectory).dir().absolutePath()); + }); + connect(go, &QPushButton::clicked, this, [this] { navigateFromLocation(); }); + connect(location, &QLineEdit::returnPressed, this, + [this] { navigateFromLocation(); }); + connect(browser, &QTreeView::doubleClicked, this, + [this](const QModelIndex &index) { + const QFileInfo info = fileSystem->fileInfo(index); + if (info.isDir()) + navigateTo(info.absoluteFilePath()); + else if (this->mode == Mode::Attachments) + addSelectedFiles(); + }); + connect(browser->selectionModel(), &QItemSelectionModel::selectionChanged, + this, [this] { updateActions(); }); + if (addButton) + connect(addButton, &QPushButton::clicked, this, + [this] { addSelectedFiles(); }); + if (removeButton) + connect(removeButton, &QPushButton::clicked, this, + [this] { removeSelectedFiles(); }); + if (attachments) + connect(attachments, &QListWidget::itemSelectionChanged, this, + [this] { updateActions(); }); + connect(cancel, &QPushButton::clicked, this, &QDialog::reject); + connect(acceptButton, &QPushButton::clicked, this, + [this] { acceptSelection(); }); + + if (initialDirectory.isEmpty() || !QFileInfo(initialDirectory).isDir()) + initialDirectory = QDir::homePath(); + navigateTo(std::move(initialDirectory)); +} + +QString FileSelectionDialog::selectedDirectory() const { + const QModelIndex index = browser->currentIndex(); + if (index.isValid()) { + const QFileInfo info = fileSystem->fileInfo(index); + if (info.isDir()) + return info.absoluteFilePath(); + } + return currentDirectory; +} + +std::vector FileSelectionDialog::selectedAttachments() const { + std::vector result; + if (!attachments) + return result; + result.reserve(static_cast(attachments->count())); + for (int index = 0; index < attachments->count(); ++index) { + const QListWidgetItem *item = attachments->item(index); + result.push_back(AttachmentDraft{ + item->data(Qt::UserRole).toString(), + QFileInfo(item->data(Qt::UserRole).toString()).fileName(), + item->data(Qt::UserRole + 1).toString(), + item->data(Qt::UserRole + 2).toLongLong()}); + } + return result; +} + +void FileSelectionDialog::navigateTo(QString path) { + const QFileInfo info(path); + if (!info.exists() || !info.isDir()) { + errorLabel->setText( + QStringLiteral("The selected directory does not exist.")); + errorLabel->show(); + return; + } + currentDirectory = info.absoluteFilePath(); + location->setText(QDir::toNativeSeparators(currentDirectory)); + browser->clearSelection(); + browser->setCurrentIndex({}); + browser->setRootIndex(fileSystem->index(currentDirectory)); + errorLabel->hide(); + updateActions(); +} + +void FileSelectionDialog::navigateFromLocation() { + navigateTo(QDir::fromNativeSeparators(location->text().trimmed())); +} + +void FileSelectionDialog::addSelectedFiles() { + if (!attachments) + return; + const QModelIndexList rows = browser->selectionModel()->selectedRows(0); + QMimeDatabase mimeDatabase; + for (const QModelIndex &index : rows) { + const QFileInfo info = fileSystem->fileInfo(index); + if (!info.isFile()) + continue; + bool duplicate = false; + for (int itemIndex = 0; itemIndex < attachments->count(); ++itemIndex) { + if (attachments->item(itemIndex)->data(Qt::UserRole).toString() == + info.absoluteFilePath()) { + duplicate = true; + break; + } + } + if (duplicate) + continue; + if (attachments->count() >= MaximumAttachments) { + errorLabel->setText( + QStringLiteral("A message can contain at most %1 attachments.") + .arg(MaximumAttachments)); + errorLabel->show(); + break; + } + const QString mime = + mimeDatabase.mimeTypeForFile(info, QMimeDatabase::MatchContent).name(); + auto *item = new QListWidgetItem( + QStringLiteral("%1 | %2") + .arg(info.fileName(), readableSize(info.size()))); + item->setData(Qt::UserRole, info.absoluteFilePath()); + item->setData(Qt::UserRole + 1, mime); + item->setData(Qt::UserRole + 2, + QVariant::fromValue(info.size())); + item->setToolTip(info.absoluteFilePath()); + attachments->addItem(item); + } + updateActions(); +} + +void FileSelectionDialog::removeSelectedFiles() { + if (!attachments) + return; + const QList selected = attachments->selectedItems(); + for (QListWidgetItem *item : selected) + delete attachments->takeItem(attachments->row(item)); + updateActions(); +} + +void FileSelectionDialog::updateActions() { + if (mode == Mode::Workspace) { + selectionSummary->setText(QDir::toNativeSeparators(selectedDirectory())); + acceptButton->setEnabled(QFileInfo(selectedDirectory()).isDir()); + return; + } + const int count = attachments ? attachments->count() : 0; + selectionSummary->setText( + count == 1 ? QStringLiteral("1 file selected") + : QStringLiteral("%1 files selected").arg(count)); + acceptButton->setEnabled(count > 0); + if (addButton) { + const QModelIndexList rows = browser->selectionModel()->selectedRows(0); + addButton->setEnabled( + std::any_of(rows.begin(), rows.end(), [this](const QModelIndex &index) { + return fileSystem->fileInfo(index).isFile(); + })); + } + if (removeButton) + removeButton->setEnabled(attachments && + !attachments->selectedItems().isEmpty()); +} + +void FileSelectionDialog::acceptSelection() { + if (mode == Mode::Workspace && !QFileInfo(selectedDirectory()).isDir()) { + errorLabel->setText(QStringLiteral("Select an existing directory.")); + errorLabel->show(); + return; + } + if (mode == Mode::Attachments && + (!attachments || attachments->count() == 0)) { + errorLabel->setText(QStringLiteral("Select at least one file.")); + errorLabel->show(); + return; + } + accept(); +} + +} // namespace codexui::codex diff --git a/src/codex/FileSelectionDialog.h b/src/codex/FileSelectionDialog.h new file mode 100644 index 0000000..25fbe90 --- /dev/null +++ b/src/codex/FileSelectionDialog.h @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_FILESELECTIONDIALOG_H +#define CODEXUI_CODEX_FILESELECTIONDIALOG_H + +#include +#include + +#include +#include +#include + +class QFileSystemModel; +class QLabel; +class QLineEdit; +class QListWidget; +class QPushButton; +class QTreeView; + +namespace codexui::codex { + +struct AttachmentDraft { + QString path; + QString name; + QString mimeType; + std::int64_t size = 0; +}; + +class FileSelectionDialog final : public QDialog { +public: + enum class Mode { Workspace, Attachments }; + + explicit FileSelectionDialog(Mode mode, QString initialDirectory, + std::vector attachments = {}, + QWidget *parent = nullptr); + + [[nodiscard]] QString selectedDirectory() const; + [[nodiscard]] std::vector selectedAttachments() const; + +private: + void navigateTo(QString path); + void navigateFromLocation(); + void addSelectedFiles(); + void removeSelectedFiles(); + void updateActions(); + void acceptSelection(); + + Mode mode; + QFileSystemModel *fileSystem = nullptr; + QTreeView *browser = nullptr; + QLineEdit *location = nullptr; + QListWidget *attachments = nullptr; + QLabel *selectionSummary = nullptr; + QLabel *errorLabel = nullptr; + QPushButton *addButton = nullptr; + QPushButton *removeButton = nullptr; + QPushButton *acceptButton = nullptr; + QString currentDirectory; +}; + +} // namespace codexui::codex + +#endif // CODEXUI_CODEX_FILESELECTIONDIALOG_H diff --git a/src/codex/FrontendSession.cpp b/src/codex/FrontendSession.cpp new file mode 100644 index 0000000..ae51d75 --- /dev/null +++ b/src/codex/FrontendSession.cpp @@ -0,0 +1,364 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/FrontendSession.h" + +#include "codex/ClientRuntime.h" +#include "codex/PresentationProtocol.h" +#include "codex/ipc/QtSocketPairEndpoint.h" +#include "codex/ipc/SocketPair.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace codexui::codex { +namespace { + +constexpr std::size_t MaximumFrameBytes = 64U * 1024U * 1024U; +constexpr std::size_t MaximumWriteQueueBytes = 128U * 1024U * 1024U; + +} // namespace + +FrontendSession::FrontendSession(Configuration &configuration) + : framer(std::make_unique( + MaximumFrameBytes)), + configuration(configuration) { + ipc::SocketPair pair; + if (!pair.isValid()) + throw std::system_error(pair.error(), std::generic_category(), + "unable to create CodexUI socketpair"); + + endpoint = std::make_unique( + pair.releaseFirstEndpoint(), MaximumWriteQueueBytes); + clientDescriptor = pair.releaseSecondEndpoint(); + endpoint->setOnData([this](const char *data, std::size_t size) { + framer->consume( + std::string_view(data, size), + [this](nlohmann::json message) { receiveMessage(std::move(message)); }, + [this](std::string message) { reportLocalError(std::move(message)); }); + }); + endpoint->setOnError([this](int errorNumber) { + reportLocalError(std::string("Qt socketpair failure: ") + + std::strerror(errorNumber)); + }); + endpoint->setOnClosed([this] { + if (!stopping) { + reportLocalError("SNode.C client thread disconnected"); + if (runtimeStoppedHandler) + runtimeStoppedHandler(); + } + }); +} + +FrontendSession::~FrontendSession() { shutdown(); } + +void FrontendSession::start(bool connectBridge) { + if (started) + return; + started = true; + const int descriptor = std::exchange(clientDescriptor, -1); + clientThread = std::thread([this, descriptor, connectBridge] { + static_cast( + runClientRuntime(descriptor, configuration, connectBridge)); + }); +} + +void FrontendSession::wait() { + if (clientThread.joinable()) + clientThread.join(); +} + +void FrontendSession::shutdown() { + if (stopping) + return; + if (started) + static_cast(sendMessage(presentation::command("runtime.shutdown"))); + stopping = true; + if (endpoint) + endpoint->close(); + if (clientDescriptor >= 0) { + ::close(clientDescriptor); + clientDescriptor = -1; + } + wait(); + pending.clear(); +} + +void FrontendSession::setEventHandler(EventHandler handler) { + eventHandler = std::move(handler); +} + +void FrontendSession::setRuntimeStoppedHandler(RuntimeStoppedHandler handler) { + runtimeStoppedHandler = std::move(handler); +} + +std::string FrontendSession::request(std::string operation, + nlohmann::json parameters, + ResponseHandler handler) { + const std::string requestId = "ui-request-" + std::to_string(nextOperation++); + if (handler) + pending.emplace(requestId, std::move(handler)); + if (!sendMessage(presentation::command(std::move(operation), + std::move(parameters), requestId))) { + const auto iterator = pending.find(requestId); + if (iterator != pending.end()) { + ResponseHandler failed = std::move(iterator->second); + pending.erase(iterator); + failed({{"protocol", presentation::ProtocolName}, + {"version", presentation::ProtocolVersion}, + {"kind", "result"}, + {"correlationId", requestId}, + {"ok", false}, + {"error", + {{"code", -32020}, + {"message", "CodexUI IPC rejected operation"}}}}); + } + } + return requestId; +} + +std::string FrontendSession::listThreads(nlohmann::json options, + ResponseHandler handler) { + return request("threads.list", std::move(options), std::move(handler)); +} + +std::string FrontendSession::readThread(std::string threadId, + ResponseHandler handler) { + return request("thread.read", + {{"threadId", std::move(threadId)}, {"includeTurns", true}}, + std::move(handler)); +} + +std::string FrontendSession::createThread(nlohmann::json options, + ResponseHandler handler) { + return request("thread.create", std::move(options), std::move(handler)); +} + +std::string FrontendSession::resumeThread(std::string threadId, + nlohmann::json options, + ResponseHandler handler) { + options["threadId"] = std::move(threadId); + return request("thread.resume", std::move(options), std::move(handler)); +} + +std::string FrontendSession::forkThread(std::string threadId, + nlohmann::json options, + ResponseHandler handler) { + options["threadId"] = std::move(threadId); + return request("thread.fork", std::move(options), std::move(handler)); +} + +std::string FrontendSession::renameThread(std::string threadId, + std::string name, + ResponseHandler handler) { + return request("thread.rename", + {{"threadId", std::move(threadId)}, {"name", std::move(name)}}, + std::move(handler)); +} + +std::string FrontendSession::archiveThread(std::string threadId, + ResponseHandler handler) { + return request("thread.archive", {{"threadId", std::move(threadId)}}, + std::move(handler)); +} + +std::string FrontendSession::unarchiveThread(std::string threadId, + ResponseHandler handler) { + return request("thread.unarchive", {{"threadId", std::move(threadId)}}, + std::move(handler)); +} + +std::string FrontendSession::deleteThread(std::string threadId, + ResponseHandler handler) { + return request("thread.delete", {{"threadId", std::move(threadId)}}, + std::move(handler)); +} + +std::string FrontendSession::listModels(nlohmann::json options, + ResponseHandler handler) { + return request("models.list", std::move(options), std::move(handler)); +} + +std::string +FrontendSession::readModelProviderCapabilities(nlohmann::json options, + ResponseHandler handler) { + return request("model-provider-capabilities.read", std::move(options), + std::move(handler)); +} + +std::string FrontendSession::readAccount(nlohmann::json options, + ResponseHandler handler) { + return request("account.read", std::move(options), std::move(handler)); +} + +std::string FrontendSession::readAccountRateLimits(ResponseHandler handler) { + return request("account.rate-limits.read", nlohmann::json::object(), + std::move(handler)); +} + +std::string FrontendSession::readAccountTokenUsage(ResponseHandler handler) { + return request("account.token-usage.read", nlohmann::json::object(), + std::move(handler)); +} + +std::string FrontendSession::readConfig(nlohmann::json options, + ResponseHandler handler) { + return request("config.read", std::move(options), std::move(handler)); +} + +std::string FrontendSession::listPermissionProfiles(nlohmann::json options, + ResponseHandler handler) { + return request("permission-profiles.list", std::move(options), + std::move(handler)); +} + +std::string FrontendSession::listExperimentalFeatures(nlohmann::json options, + ResponseHandler handler) { + return request("experimental-features.list", std::move(options), + std::move(handler)); +} + +std::string FrontendSession::listSkills(nlohmann::json options, + ResponseHandler handler) { + return request("skills.list", std::move(options), std::move(handler)); +} + +std::string FrontendSession::listHooks(nlohmann::json options, + ResponseHandler handler) { + return request("hooks.list", std::move(options), std::move(handler)); +} + +std::string FrontendSession::listPlugins(nlohmann::json options, + ResponseHandler handler) { + return request("plugins.list", std::move(options), std::move(handler)); +} + +std::string FrontendSession::listApps(nlohmann::json options, + ResponseHandler handler) { + return request("apps.list", std::move(options), std::move(handler)); +} + +std::string FrontendSession::listMcpServers(nlohmann::json options, + ResponseHandler handler) { + return request("mcp-servers.list", std::move(options), std::move(handler)); +} + +std::string FrontendSession::startTurn(std::string threadId, + nlohmann::json input, + nlohmann::json options, + ResponseHandler handler) { + options["threadId"] = std::move(threadId); + options["input"] = std::move(input); + return request("turn.start", std::move(options), std::move(handler)); +} + +std::string FrontendSession::steerTurn(std::string threadId, + std::string expectedTurnId, + nlohmann::json input, + ResponseHandler handler) { + return request("turn.steer", + {{"threadId", std::move(threadId)}, + {"expectedTurnId", std::move(expectedTurnId)}, + {"input", std::move(input)}}, + std::move(handler)); +} + +std::string FrontendSession::interruptTurn(std::string threadId, + std::string turnId, + ResponseHandler handler) { + return request( + "turn.interrupt", + {{"threadId", std::move(threadId)}, {"turnId", std::move(turnId)}}, + std::move(handler)); +} + +bool FrontendSession::respondToServerRequest(nlohmann::json requestId, + nlohmann::json result, + nlohmann::json error) { + nlohmann::json data{{"requestId", std::move(requestId)}}; + if (!error.is_null()) + data["error"] = std::move(error); + else + data["result"] = std::move(result); + return sendMessage( + presentation::command("pending-request.resolve", std::move(data))); +} + +bool FrontendSession::sendRaw(nlohmann::json appServerMessage) { + return sendMessage(presentation::command( + "diagnostic.raw.send", {{"message", std::move(appServerMessage)}})); +} + +bool FrontendSession::reconnect() { + return sendMessage(presentation::command("connection.reconnect")); +} + +bool FrontendSession::connectTransport() { + return sendMessage(presentation::command("connection.connect")); +} + +bool FrontendSession::disconnectTransport() { + return sendMessage(presentation::command("connection.disconnect")); +} + +std::string FrontendSession::configureConnection(nlohmann::json settings, + ResponseHandler handler) { + return request("connection.configure", std::move(settings), + std::move(handler)); +} + +bool FrontendSession::claimController() { + return sendMessage(presentation::command("controller.claim")); +} + +bool FrontendSession::releaseController() { + return sendMessage(presentation::command("controller.release")); +} + +bool FrontendSession::sendMessage(const nlohmann::json &message) { + if (!endpoint || stopping || !endpoint->isOpen()) + return false; + try { + return endpoint->send(ai::openai::codex::protocol::JsonLineFramer::encode( + message, MaximumFrameBytes)); + } catch (const std::exception &exception) { + reportLocalError(exception.what()); + return false; + } +} + +void FrontendSession::receiveMessage(nlohmann::json message) { + if (!presentation::isPresentationFrame(message)) { + reportLocalError( + "SNode.C client emitted an incompatible presentation frame"); + return; + } + if (presentation::stringMember(message, "kind") == "result") { + const std::string requestId = + presentation::stringMember(message, "correlationId"); + const auto iterator = pending.find(requestId); + if (iterator != pending.end()) { + ResponseHandler handler = std::move(iterator->second); + pending.erase(iterator); + if (handler) + handler(message); + } + } + if (eventHandler) + eventHandler(message); +} + +void FrontendSession::reportLocalError(std::string message) { + if (eventHandler) + eventHandler(presentation::event(0, 0, "system.local-diagnostic", + {{"source", "qt"}, + {"code", "local-ipc-error"}, + {"message", std::move(message)}})); +} + +} // namespace codexui::codex diff --git a/src/codex/FrontendSession.h b/src/codex/FrontendSession.h new file mode 100644 index 0000000..872c116 --- /dev/null +++ b/src/codex/FrontendSession.h @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_FRONTENDSESSION_H +#define CODEXUI_CODEX_FRONTENDSESSION_H + +#include + +#include +#include +#include +#include +#include +#include + +namespace ai::openai::codex::protocol { +class JsonLineFramer; +} + +namespace codexui::codex::ipc { +class QtSocketPairEndpoint; +} + +namespace codexui::codex { + +class Configuration; +class FrontendSessionTestPeer; + +class FrontendSession final { +public: + using EventHandler = std::function; + using ResponseHandler = std::function; + using RuntimeStoppedHandler = std::function; + + explicit FrontendSession(Configuration &configuration); + ~FrontendSession(); + + FrontendSession(const FrontendSession &) = delete; + FrontendSession &operator=(const FrontendSession &) = delete; + + void start(bool connectBridge = true); + void wait(); + void shutdown(); + void setEventHandler(EventHandler handler); + void setRuntimeStoppedHandler(RuntimeStoppedHandler handler); + + std::string request(std::string operation, nlohmann::json parameters, + ResponseHandler handler = {}); + std::string listThreads(nlohmann::json options = nlohmann::json::object(), + ResponseHandler handler = {}); + std::string readThread(std::string threadId, ResponseHandler handler = {}); + std::string createThread(nlohmann::json options, + ResponseHandler handler = {}); + std::string resumeThread(std::string threadId, + nlohmann::json options = nlohmann::json::object(), + ResponseHandler handler = {}); + std::string forkThread(std::string threadId, + nlohmann::json options = nlohmann::json::object(), + ResponseHandler handler = {}); + std::string renameThread(std::string threadId, std::string name, + ResponseHandler handler = {}); + std::string archiveThread(std::string threadId, ResponseHandler handler = {}); + std::string unarchiveThread(std::string threadId, + ResponseHandler handler = {}); + std::string deleteThread(std::string threadId, ResponseHandler handler = {}); + std::string listModels(nlohmann::json options = nlohmann::json::object(), + ResponseHandler handler = {}); + std::string readModelProviderCapabilities( + nlohmann::json options = nlohmann::json::object(), + ResponseHandler handler = {}); + std::string readAccount(nlohmann::json options = nlohmann::json::object(), + ResponseHandler handler = {}); + std::string readAccountRateLimits(ResponseHandler handler = {}); + std::string readAccountTokenUsage(ResponseHandler handler = {}); + std::string readConfig(nlohmann::json options = nlohmann::json::object(), + ResponseHandler handler = {}); + std::string + listPermissionProfiles(nlohmann::json options = nlohmann::json::object(), + ResponseHandler handler = {}); + std::string + listExperimentalFeatures(nlohmann::json options = nlohmann::json::object(), + ResponseHandler handler = {}); + std::string listSkills(nlohmann::json options = nlohmann::json::object(), + ResponseHandler handler = {}); + std::string listHooks(nlohmann::json options = nlohmann::json::object(), + ResponseHandler handler = {}); + std::string listPlugins(nlohmann::json options = nlohmann::json::object(), + ResponseHandler handler = {}); + std::string listApps(nlohmann::json options = nlohmann::json::object(), + ResponseHandler handler = {}); + std::string listMcpServers(nlohmann::json options = nlohmann::json::object(), + ResponseHandler handler = {}); + std::string startTurn(std::string threadId, nlohmann::json input, + nlohmann::json options = nlohmann::json::object(), + ResponseHandler handler = {}); + std::string steerTurn(std::string threadId, std::string expectedTurnId, + nlohmann::json input, ResponseHandler handler = {}); + std::string interruptTurn(std::string threadId, std::string turnId, + ResponseHandler handler = {}); + bool respondToServerRequest(nlohmann::json requestId, nlohmann::json result, + nlohmann::json error = nullptr); + bool sendRaw(nlohmann::json appServerMessage); + bool reconnect(); + bool connectTransport(); + bool disconnectTransport(); + std::string configureConnection(nlohmann::json settings, + ResponseHandler handler = {}); + bool claimController(); + bool releaseController(); + +private: + friend class FrontendSessionTestPeer; + + bool sendMessage(const nlohmann::json &message); + void receiveMessage(nlohmann::json message); + void reportLocalError(std::string message); + + std::unique_ptr endpoint; + std::unique_ptr framer; + std::thread clientThread; + int clientDescriptor = -1; + std::uint64_t nextOperation = 1; + std::unordered_map pending; + EventHandler eventHandler; + RuntimeStoppedHandler runtimeStoppedHandler; + bool started = false; + bool stopping = false; + Configuration &configuration; +}; + +} // namespace codexui::codex + +#endif // CODEXUI_CODEX_FRONTENDSESSION_H diff --git a/src/codex/MainWindow.cpp b/src/codex/MainWindow.cpp new file mode 100644 index 0000000..b98a37f --- /dev/null +++ b/src/codex/MainWindow.cpp @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/MainWindow.h" + +#ifdef CODEXUI_DEVELOPMENT_HARNESS +#include "codex/WorkbenchWidget.h" +#else +#include "codex/ShellWidget.h" +#endif +#include "codex/ui/BrandMark.h" +#include "codex/ui/UiStyle.h" + +#include + +namespace codexui::codex { + +MainWindow::MainWindow(FrontendSession &session, QWidget *parent) + : QMainWindow(parent) { +#ifdef CODEXUI_DEVELOPMENT_HARNESS + setWindowTitle(QStringLiteral("CodexUI - codex Harness")); +#else + setWindowTitle(QStringLiteral("CodexUI")); +#endif + setMinimumSize(1100, 700); + resize(1536, 960); + + const QIcon applicationIcon = codexui::BrandMark::icon(); + qApp->setWindowIcon(applicationIcon); + setWindowIcon(applicationIcon); + qApp->setStyleSheet(codexui::UiStyle::applicationStyleSheet()); +#ifdef CODEXUI_DEVELOPMENT_HARNESS + setCentralWidget(new WorkbenchWidget(session, this)); +#else + setCentralWidget(new ShellWidget(session, this)); +#endif +} + +} // namespace codexui::codex diff --git a/src/codex/MainWindow.h b/src/codex/MainWindow.h new file mode 100644 index 0000000..5656057 --- /dev/null +++ b/src/codex/MainWindow.h @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_MAINWINDOW_H +#define CODEXUI_CODEX_MAINWINDOW_H + +#include + +namespace codexui::codex { + +class FrontendSession; + +class MainWindow final : public QMainWindow { +public: + explicit MainWindow(FrontendSession &session, QWidget *parent = nullptr); +}; + +} // namespace codexui::codex + +#endif // CODEXUI_CODEX_MAINWINDOW_H diff --git a/src/codex/NewThreadDialog.cpp b/src/codex/NewThreadDialog.cpp new file mode 100644 index 0000000..0341826 --- /dev/null +++ b/src/codex/NewThreadDialog.cpp @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/NewThreadDialog.h" + +#include "codex/FileSelectionDialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace codexui::codex { +namespace { + +QLabel *label(QString text, const char *kind = "body") { + auto *value = new QLabel(std::move(text)); + value->setProperty("kind", kind); + value->setWordWrap(true); + return value; +} + +QWidget *field(QString caption, QWidget *control) { + auto *widget = new QWidget; + auto *layout = new QVBoxLayout(widget); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(6); + auto *captionLabel = label(std::move(caption), "title"); + captionLabel->setBuddy(control); + layout->addWidget(captionLabel); + layout->addWidget(control); + return widget; +} + +} // namespace + +NewThreadDialog::NewThreadDialog(QString initialWorkspace, QWidget *parent) + : QDialog(parent) { + setModal(true); + setWindowTitle(QStringLiteral("New thread")); + setMinimumSize(540, 480); + + auto *root = new QVBoxLayout(this); + root->setContentsMargins(24, 22, 24, 20); + root->setSpacing(14); + root->addWidget(label(QStringLiteral("New thread"), "heading")); + root->addWidget( + label(QStringLiteral("Set the thread context. Model, access, reasoning, " + "and style remain in the upcoming-turn controls."), + "muted")); + + auto *scroll = new QScrollArea; + scroll->setWidgetResizable(true); + scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scroll->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + auto *content = new QWidget; + auto *form = new QVBoxLayout(content); + form->setContentsMargins(0, 2, 8, 2); + form->setSpacing(16); + + workspace = new QLineEdit(std::move(initialWorkspace)); + workspace->setPlaceholderText(QDir::homePath()); + auto *workspaceRow = new QWidget; + auto *workspaceLayout = new QHBoxLayout(workspaceRow); + workspaceLayout->setContentsMargins(0, 0, 0, 0); + workspaceLayout->setSpacing(8); + auto *browse = new QPushButton(QStringLiteral("Browse")); + browse->setFixedHeight(34); + workspaceLayout->addWidget(workspace, 1); + workspaceLayout->addWidget(browse); + form->addWidget(field(QStringLiteral("Workspace"), workspaceRow)); + + name = new QLineEdit; + name->setPlaceholderText(QStringLiteral("Optional thread name")); + form->addWidget(field(QStringLiteral("Name"), name)); + + baseInstructions = new QPlainTextEdit; + baseInstructions->setPlaceholderText( + QStringLiteral("Optional base instructions")); + baseInstructions->setMaximumHeight(110); + baseInstructions->setProperty("kind", "dialogEditor"); + form->addWidget(field(QStringLiteral("Base instructions"), baseInstructions)); + + developerInstructions = new QPlainTextEdit; + developerInstructions->setPlaceholderText( + QStringLiteral("Optional developer instructions")); + developerInstructions->setMaximumHeight(110); + developerInstructions->setProperty("kind", "dialogEditor"); + form->addWidget( + field(QStringLiteral("Developer instructions"), developerInstructions)); + + auto *ephemeralSurface = new QFrame; + ephemeralSurface->setProperty("kind", "summary"); + auto *ephemeralLayout = new QVBoxLayout(ephemeralSurface); + ephemeralLayout->setContentsMargins(12, 10, 12, 10); + ephemeral = new QCheckBox(QStringLiteral("Temporary thread")); + ephemeralLayout->addWidget(ephemeral); + ephemeralLayout->addWidget( + label(QStringLiteral( + "Temporary threads are not retained in normal Codex history."), + "meta")); + form->addWidget(ephemeralSurface); + form->addStretch(); + scroll->setWidget(content); + root->addWidget(scroll, 1); + + errorLabel = label({}, "meta"); + errorLabel->setStyleSheet(QStringLiteral("color:#b83a3a;")); + errorLabel->hide(); + root->addWidget(errorLabel); + + auto *footer = new QHBoxLayout; + footer->addStretch(); + auto *cancel = new QPushButton(QStringLiteral("Cancel")); + cancel->setProperty("kind", "cancel"); + cancel->setFixedHeight(34); + auto *create = new QPushButton(QStringLiteral("Continue")); + create->setProperty("kind", "primary"); + create->setFixedHeight(34); + footer->addWidget(cancel); + footer->addWidget(create); + root->addLayout(footer); + + connect(browse, &QPushButton::clicked, this, [this] { chooseWorkspace(); }); + connect(cancel, &QPushButton::clicked, this, &QDialog::reject); + connect(create, &QPushButton::clicked, this, [this] { acceptDraft(); }); + + QScreen *targetScreen = + parent ? parent->screen() : QGuiApplication::primaryScreen(); + const QRect available = + targetScreen ? targetScreen->availableGeometry() : QRect(0, 0, 1280, 800); + constexpr int ScreenMargin = 64; + const int maximumWidth = + std::max(minimumWidth(), available.width() - ScreenMargin); + const int targetWidth = std::clamp(680, minimumWidth(), maximumWidth); + resize(targetWidth, minimumHeight()); + root->activate(); + form->activate(); + const int desiredScrollHeight = + content->sizeHint().height() + 2 * scroll->frameWidth(); + const int desiredHeight = root->sizeHint().height() - + scroll->sizeHint().height() + desiredScrollHeight; + const int maximumHeight = + std::max(minimumHeight(), available.height() - ScreenMargin); + resize(targetWidth, + std::clamp(desiredHeight, minimumHeight(), maximumHeight)); +} + +NewThreadDraft NewThreadDialog::draft() const { + return {QDir::fromNativeSeparators(workspace->text().trimmed()), + name->text().trimmed(), baseInstructions->toPlainText().trimmed(), + developerInstructions->toPlainText().trimmed(), + ephemeral->isChecked()}; +} + +void NewThreadDialog::chooseWorkspace() { + FileSelectionDialog dialog(FileSelectionDialog::Mode::Workspace, + draft().workspace, {}, this); + if (dialog.exec() == QDialog::Accepted) + workspace->setText(QDir::toNativeSeparators(dialog.selectedDirectory())); +} + +void NewThreadDialog::acceptDraft() { + const QFileInfo selectedWorkspace(draft().workspace); + if (!selectedWorkspace.exists() || !selectedWorkspace.isDir()) { + errorLabel->setText( + QStringLiteral("Select an existing workspace directory.")); + errorLabel->show(); + return; + } + accept(); +} + +} // namespace codexui::codex diff --git a/src/codex/NewThreadDialog.h b/src/codex/NewThreadDialog.h new file mode 100644 index 0000000..ec47ed8 --- /dev/null +++ b/src/codex/NewThreadDialog.h @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_NEWTHREADDIALOG_H +#define CODEXUI_CODEX_NEWTHREADDIALOG_H + +#include +#include + +class QCheckBox; +class QLabel; +class QLineEdit; +class QPlainTextEdit; + +namespace codexui::codex { + +struct NewThreadDraft { + QString workspace; + QString name; + QString baseInstructions; + QString developerInstructions; + bool ephemeral = false; +}; + +class NewThreadDialog final : public QDialog { +public: + explicit NewThreadDialog(QString initialWorkspace, QWidget *parent = nullptr); + + [[nodiscard]] NewThreadDraft draft() const; + +private: + void chooseWorkspace(); + void acceptDraft(); + + QLineEdit *workspace = nullptr; + QLineEdit *name = nullptr; + QPlainTextEdit *baseInstructions = nullptr; + QPlainTextEdit *developerInstructions = nullptr; + QCheckBox *ephemeral = nullptr; + QLabel *errorLabel = nullptr; +}; + +} // namespace codexui::codex + +#endif // CODEXUI_CODEX_NEWTHREADDIALOG_H diff --git a/src/codex/PendingRequestDialog.cpp b/src/codex/PendingRequestDialog.cpp new file mode 100644 index 0000000..a62ad11 --- /dev/null +++ b/src/codex/PendingRequestDialog.cpp @@ -0,0 +1,388 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/PendingRequestDialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace codexui::codex { +namespace { + +QString text(const std::string &value) { + return QString::fromUtf8(value.data(), static_cast(value.size())); +} + +std::string stringValue(const nlohmann::json &object, const char *key) { + if (!object.is_object()) + return {}; + const auto iterator = object.find(key); + return iterator != object.end() && iterator->is_string() + ? iterator->get() + : std::string{}; +} + +QLabel *wrapped(QString value, const char *kind = "body") { + auto *label = new QLabel(std::move(value)); + label->setProperty("kind", kind); + label->setWordWrap(true); + label->setMinimumWidth(0); + label->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + label->setTextInteractionFlags(Qt::TextSelectableByMouse | + Qt::LinksAccessibleByMouse); + label->setOpenExternalLinks(true); + return label; +} + +QString titleFor(const std::string &kind) { + if (kind == "command-approval") + return QStringLiteral("Command approval"); + if (kind == "file-change-approval") + return QStringLiteral("File-change approval"); + if (kind == "user-input") + return QStringLiteral("Codex needs input"); + if (kind == "mcp-elicitation") + return QStringLiteral("MCP server request"); + if (kind == "permissions-approval") + return QStringLiteral("Permission request"); + if (kind == "dynamic-tool-call") + return QStringLiteral("Dynamic tool request"); + if (kind == "authentication-refresh") + return QStringLiteral("Authentication refresh"); + if (kind == "attestation") + return QStringLiteral("Attestation request"); + if (kind == "legacy-patch-approval") + return QStringLiteral("Legacy patch approval"); + if (kind == "legacy-command-approval") + return QStringLiteral("Legacy command approval"); + return QStringLiteral("Unsupported Codex request"); +} + +void addDetail(QVBoxLayout *layout, const QString &label, + const std::string &value) { + if (!value.empty()) + layout->addWidget( + wrapped(QStringLiteral("%1: %2").arg(label, text(value)), "meta")); +} + +void addChoice(QComboBox *combo, const QString &label, const char *value) { + if (combo->findData(QString::fromLatin1(value)) < 0) + combo->addItem(label, QString::fromLatin1(value)); +} + +nlohmann::json jsonRpcError(std::string message) { + return {{"code", -32601}, {"message", std::move(message)}}; +} + +struct QuestionEditor { + std::string id; + std::vector> choices; + QLineEdit *other = nullptr; +}; + +} // namespace + +std::optional +PendingRequestDialog::present(const PendingRequestPresentation &request, + QWidget *parent) { + QDialog dialog(parent); + dialog.setWindowTitle(titleFor(request.kind)); + dialog.setModal(true); + dialog.resize(620, 560); + auto *root = new QVBoxLayout(&dialog); + root->setContentsMargins(18, 16, 18, 16); + root->setSpacing(10); + root->addWidget(wrapped(titleFor(request.kind), "heading")); + root->addWidget(wrapped(QStringLiteral("Thread %1 | request %2") + .arg(text(request.threadId), text(request.id)), + "meta")); + + auto *scroll = new QScrollArea; + scroll->setWidgetResizable(true); + scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + auto *content = new QWidget; + auto *contentLayout = new QVBoxLayout(content); + contentLayout->setContentsMargins(0, 0, 8, 0); + contentLayout->setSpacing(8); + scroll->setWidget(content); + root->addWidget(scroll, 1); + + QComboBox *decision = nullptr; + QPlainTextEdit *structuredContent = nullptr; + std::vector questions; + const nlohmann::json &raw = request.raw; + + if (request.kind == "command-approval") { + addDetail(contentLayout, QStringLiteral("Command"), + stringValue(raw, "command")); + addDetail(contentLayout, QStringLiteral("Working directory"), + stringValue(raw, "cwd")); + addDetail(contentLayout, QStringLiteral("Reason"), + stringValue(raw, "reason")); + decision = new QComboBox; + const nlohmann::json available = + raw.value("availableDecisions", nlohmann::json::array()); + if (available.is_array()) { + for (const auto &entry : available) { + if (!entry.is_string()) + continue; + const std::string value = entry.get(); + addChoice(decision, text(value), value.c_str()); + } + } + if (decision->count() == 0) { + addChoice(decision, QStringLiteral("Approve"), "accept"); + addChoice(decision, QStringLiteral("Approve for this session"), + "acceptForSession"); + addChoice(decision, QStringLiteral("Decline"), "decline"); + addChoice(decision, QStringLiteral("Cancel"), "cancel"); + } + contentLayout->addWidget(wrapped(QStringLiteral("Decision"), "title")); + contentLayout->addWidget(decision); + } else if (request.kind == "file-change-approval") { + addDetail(contentLayout, QStringLiteral("Reason"), + stringValue(raw, "reason")); + addDetail(contentLayout, QStringLiteral("Grant root"), + stringValue(raw, "grantRoot")); + decision = new QComboBox; + addChoice(decision, QStringLiteral("Approve"), "accept"); + addChoice(decision, QStringLiteral("Approve for this session"), + "acceptForSession"); + addChoice(decision, QStringLiteral("Decline"), "decline"); + addChoice(decision, QStringLiteral("Cancel"), "cancel"); + contentLayout->addWidget(wrapped(QStringLiteral("Decision"), "title")); + contentLayout->addWidget(decision); + } else if (request.kind == "user-input") { + const nlohmann::json requestedQuestions = + raw.value("questions", nlohmann::json::array()); + if (requestedQuestions.is_array()) { + for (const auto &question : requestedQuestions) { + QuestionEditor editor; + editor.id = stringValue(question, "id"); + auto *section = new QFrame; + section->setProperty("kind", "summary"); + auto *sectionLayout = new QVBoxLayout(section); + sectionLayout->setContentsMargins(12, 10, 12, 10); + sectionLayout->setSpacing(6); + const std::string header = stringValue(question, "header"); + if (!header.empty()) + sectionLayout->addWidget(wrapped(text(header), "title")); + sectionLayout->addWidget( + wrapped(text(stringValue(question, "question")))); + const nlohmann::json options = + question.value("options", nlohmann::json::array()); + if (options.is_array()) { + for (const auto &option : options) { + const std::string label = stringValue(option, "label"); + if (label.empty()) + continue; + auto *choice = new QCheckBox(text(label)); + const std::string description = stringValue(option, "description"); + if (!description.empty()) + choice->setToolTip(text(description)); + sectionLayout->addWidget(choice); + if (!description.empty()) + sectionLayout->addWidget(wrapped(text(description), "meta")); + editor.choices.emplace_back(label, choice); + } + } + if (options.empty() || question.value("isOther", false)) { + editor.other = new QLineEdit; + editor.other->setPlaceholderText( + options.empty() ? QStringLiteral("Type your answer") + : QStringLiteral("Other answer")); + if (question.value("isSecret", false)) + editor.other->setEchoMode(QLineEdit::Password); + sectionLayout->addWidget(editor.other); + } + questions.push_back(std::move(editor)); + contentLayout->addWidget(section); + } + } + } else if (request.kind == "mcp-elicitation") { + const std::string message = stringValue(raw, "message"); + if (!message.empty()) + contentLayout->addWidget(wrapped(text(message))); + const std::string url = stringValue(raw, "url"); + if (!url.empty()) { + auto *link = wrapped( + QStringLiteral("%1").arg(text(url)), "body"); + link->setTextFormat(Qt::RichText); + contentLayout->addWidget(link); + } + decision = new QComboBox; + addChoice(decision, QStringLiteral("Accept"), "accept"); + addChoice(decision, QStringLiteral("Decline"), "decline"); + addChoice(decision, QStringLiteral("Cancel"), "cancel"); + contentLayout->addWidget(decision); + if (raw.contains("requestedSchema")) { + contentLayout->addWidget(wrapped( + QStringLiteral("Structured response (JSON object)"), "title")); + structuredContent = new QPlainTextEdit(QStringLiteral("{}")); + structuredContent->setMinimumHeight(150); + structuredContent->setLineWrapMode(QPlainTextEdit::WidgetWidth); + structuredContent->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + structuredContent->setStyleSheet(QStringLiteral( + "background:#f8fafc;border:1px solid #d7dee8;border-radius:6px;" + "padding:7px;font-family:monospace;")); + contentLayout->addWidget(structuredContent); + } + } else if (request.kind == "permissions-approval") { + addDetail(contentLayout, QStringLiteral("Reason"), + stringValue(raw, "reason")); + addDetail(contentLayout, QStringLiteral("Working directory"), + stringValue(raw, "cwd")); + contentLayout->addWidget(wrapped( + QStringLiteral("Codex requests additional filesystem or network " + "permissions. Review the reason before approving."))); + decision = new QComboBox; + addChoice(decision, QStringLiteral("Approve for this turn"), "turn"); + addChoice(decision, QStringLiteral("Approve for this session"), "session"); + addChoice(decision, QStringLiteral("Decline"), "decline"); + contentLayout->addWidget(decision); + } else if (request.kind == "legacy-patch-approval" || + request.kind == "legacy-command-approval") { + contentLayout->addWidget(wrapped( + QStringLiteral("This is a legacy approval request. Prefer the current " + "typed approval path when available."))); + decision = new QComboBox; + addChoice(decision, QStringLiteral("Approve"), "approved"); + addChoice(decision, QStringLiteral("Approve for this session"), + "approved_for_session"); + addChoice(decision, QStringLiteral("Deny"), "denied"); + addChoice(decision, QStringLiteral("Abort"), "abort"); + contentLayout->addWidget(decision); + } else { + contentLayout->addWidget(wrapped( + request.kind == "dynamic-tool-call" + ? QStringLiteral("CodexUI does not implement the requested dynamic " + "tool. Submitting will return a typed failed tool " + "result.") + : QStringLiteral("CodexUI cannot safely produce this capability. " + "Submitting will return an explicit JSON-RPC " + "unsupported error."))); + } + contentLayout->addStretch(); + + auto *buttons = + new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + buttons->button(QDialogButtonBox::Ok)->setText(QStringLiteral("Submit")); + QObject::connect(buttons, &QDialogButtonBox::accepted, &dialog, + &QDialog::accept); + QObject::connect(buttons, &QDialogButtonBox::rejected, &dialog, + &QDialog::reject); + root->addWidget(buttons); + if (dialog.exec() != QDialog::Accepted) + return std::nullopt; + + PendingRequestResponse response; + if (request.kind == "command-approval" || + request.kind == "file-change-approval") { + response.result = { + {"decision", decision->currentData().toString().toStdString()}}; + } else if (request.kind == "user-input") { + nlohmann::json answers = nlohmann::json::object(); + for (const QuestionEditor &question : questions) { + nlohmann::json values = nlohmann::json::array(); + for (const auto &[label, choice] : question.choices) { + if (choice->isChecked()) + values.push_back(label); + } + if (question.other && !question.other->text().trimmed().isEmpty()) + values.push_back(question.other->text().toStdString()); + if (values.empty()) { + QMessageBox::warning(parent, QStringLiteral("Incomplete response"), + QStringLiteral("Answer every question before " + "submitting.")); + return std::nullopt; + } + answers[question.id] = {{"answers", std::move(values)}}; + } + response.result = {{"answers", std::move(answers)}}; + } else if (request.kind == "mcp-elicitation") { + const std::string action = decision->currentData().toString().toStdString(); + nlohmann::json content = nullptr; + if (action == "accept" && structuredContent) { + content = nlohmann::json::parse( + structuredContent->toPlainText().toStdString(), nullptr, false); + if (content.is_discarded() || !content.is_object()) { + QMessageBox::warning(parent, QStringLiteral("Invalid response"), + QStringLiteral("The MCP response must be a valid " + "JSON object.")); + return std::nullopt; + } + } + response.result = {{"action", action}, + {"content", std::move(content)}, + {"_meta", nullptr}}; + } else if (request.kind == "permissions-approval") { + const std::string scope = decision->currentData().toString().toStdString(); + if (scope == "decline") { + response.error = jsonRpcError("Permission request declined by user"); + } else { + response.result = { + {"permissions", raw.value("permissions", nlohmann::json::object())}, + {"scope", scope}}; + } + } else if (request.kind == "legacy-patch-approval" || + request.kind == "legacy-command-approval") { + const std::string selected = + decision->currentData().toString().toStdString(); + if (selected == "approved" || selected == "approved_for_session") + response.result = {{"decision", selected}}; + else if (selected == "denied") + response.result = { + {"decision", {{"denied", {{"rejection", "Denied by user"}}}}}}; + else + response.result = {{"decision", "abort"}}; + } else if (request.kind == "dynamic-tool-call") { + response.result = { + {"contentItems", + nlohmann::json::array( + {{{"type", "inputText"}, + {"text", "CodexUI does not provide this dynamic tool"}}})}, + {"success", false}}; + } else { + response.error = + jsonRpcError("CodexUI does not support this server request"); + } + return response; +} + +PendingRequestResponse PendingRequestDialog::negativeResponse( + const PendingRequestPresentation &request) { + PendingRequestResponse response; + if (request.kind == "command-approval" || + request.kind == "file-change-approval") { + response.result = {{"decision", "decline"}}; + } else if (request.kind == "mcp-elicitation") { + response.result = { + {"action", "decline"}, {"content", nullptr}, {"_meta", nullptr}}; + } else if (request.kind == "legacy-patch-approval" || + request.kind == "legacy-command-approval") { + response.result = { + {"decision", {{"denied", {{"rejection", "Denied by user"}}}}}}; + } else if (request.kind == "dynamic-tool-call") { + response.result = { + {"contentItems", + nlohmann::json::array( + {{{"type", "inputText"}, {"text", "Request declined by user"}}})}, + {"success", false}}; + } else { + response.error = jsonRpcError("Request declined by user"); + } + return response; +} + +} // namespace codexui::codex diff --git a/src/codex/PendingRequestDialog.h b/src/codex/PendingRequestDialog.h new file mode 100644 index 0000000..ff51626 --- /dev/null +++ b/src/codex/PendingRequestDialog.h @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_PENDINGREQUESTDIALOG_H +#define CODEXUI_CODEX_PENDINGREQUESTDIALOG_H + +#include "codex/PresentationModel.h" + +#include + +#include + +class QWidget; + +namespace codexui::codex { + +struct PendingRequestResponse { + nlohmann::json result = nlohmann::json::object(); + nlohmann::json error = nullptr; +}; + +class PendingRequestDialog final { +public: + [[nodiscard]] static std::optional + present(const PendingRequestPresentation &request, QWidget *parent); + + [[nodiscard]] static PendingRequestResponse + negativeResponse(const PendingRequestPresentation &request); +}; + +} // namespace codexui::codex + +#endif // CODEXUI_CODEX_PENDINGREQUESTDIALOG_H diff --git a/src/codex/PresentationModel.cpp b/src/codex/PresentationModel.cpp new file mode 100644 index 0000000..d60fd4f --- /dev/null +++ b/src/codex/PresentationModel.cpp @@ -0,0 +1,762 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/PresentationModel.h" + +#include "codex/PresentationProtocol.h" + +#include + +namespace codexui::codex { +namespace { + +constexpr std::size_t MaximumRetainedTelemetry = 256; +constexpr std::size_t MaximumIndexedTextParts = 4096; + +std::string stringValue(const nlohmann::json &object, const char *key) { + if (!object.is_object()) + return {}; + const auto iterator = object.find(key); + return iterator != object.end() && iterator->is_string() + ? iterator->get() + : std::string{}; +} + +nlohmann::json memberValue(const nlohmann::json &object, const char *key, + nlohmann::json fallback = nullptr) { + if (!object.is_object()) + return fallback; + const auto iterator = object.find(key); + return iterator == object.end() ? std::move(fallback) : *iterator; +} + +std::string statusValue(const nlohmann::json &value) { + if (value.is_string()) + return value.get(); + if (value.is_object()) + return stringValue(value, "type"); + return {}; +} + +std::string requestKey(const nlohmann::json &value) { + return value.is_null() ? std::string{} : value.dump(); +} + +bool isSpawnActivity(const nlohmann::json &activity) { + const std::string type = stringValue(activity, "type"); + if (type == "subAgentActivity") + return true; + if (type != "collabAgentToolCall") + return false; + const std::string tool = stringValue(activity, "tool"); + return tool == "spawn_agent" || tool == "spawnAgent" || + tool == "spawn_agents_on_csv" || tool == "spawnAgentsOnCsv"; +} + +std::string childThreadIdentity(const nlohmann::json &activity) { + const std::string childThreadId = stringValue(activity, "agentThreadId"); + if (!childThreadId.empty()) + return childThreadId; + const nlohmann::json receivers = + memberValue(activity, "receiverThreadIds", nlohmann::json::array()); + if (receivers.is_array() && receivers.size() == 1 && + receivers.front().is_string()) + return receivers.front().get(); + return {}; +} + +std::string agentIdentity(const nlohmann::json &activity, + const nlohmann::json &scope) { + const std::string childThreadId = childThreadIdentity(activity); + if (!childThreadId.empty()) + return childThreadId; + const std::string itemId = stringValue(scope, "itemId"); + return itemId.empty() ? stringValue(activity, "id") : itemId; +} + +void mergePreservingCompleteness(nlohmann::json &target, + const nlohmann::json &update) { + if (!target.is_object() || !update.is_object()) { + if (!update.is_null() || target.is_null()) + target = update; + return; + } + for (const auto &[key, value] : update.items()) { + auto current = target.find(key); + if (current == target.end()) { + target[key] = value; + } else if (current->is_object() && value.is_object()) { + mergePreservingCompleteness(*current, value); + } else if (!value.is_null() || current->is_null()) { + *current = value; + } + } +} + +void appendText(nlohmann::json &item, const char *field, + const nlohmann::json ¶ms) { + const std::string delta = stringValue(params, "delta"); + if (delta.empty()) + return; + std::string existing = stringValue(item, field); + existing += delta; + item[field] = std::move(existing); +} + +void appendIndexedText(nlohmann::json &item, const char *field, + const nlohmann::json ¶ms, const char *indexField) { + const auto index = params.find(indexField); + const bool hasIndex = index != params.end() && index->is_number_integer() && + index->get() >= 0; + const std::size_t position = hasIndex ? index->get() : 0; + if (position >= MaximumIndexedTextParts) + return; + nlohmann::json &parts = item[field]; + if (!parts.is_array()) + parts = nlohmann::json::array(); + while (parts.size() <= position) + parts.push_back(""); + if (!parts[position].is_string()) + parts[position] = ""; + std::string delta = stringValue(params, "delta"); + if (delta.empty()) + delta = stringValue(params, "text"); + parts[position] = parts[position].get() + delta; +} + +void applyDomainAuthority( + std::unordered_map &domains, + const std::string &type, const nlohmann::json &data, + const std::string &authority) { + if (authority == "none") + return; + if (authority == "remove") { + domains.erase(type); + return; + } + if (authority == "replace" || !domains.contains(type)) { + domains[type] = data; + return; + } + mergePreservingCompleteness(domains[type], data); +} + +} // namespace + +void PresentationModel::applyEvent(const nlohmann::json &event) { + if (!presentation::isPresentationFrame(event)) + return; + + const std::uint64_t sequence = event.value("sequence", 0ULL); + if (sequence != 0) { + if (sequence <= lastSequence) + return; + lastSequence = sequence; + } + + const std::string kind = presentation::stringMember(event, "kind"); + const nlohmann::json data = + presentation::member(event, "data", nlohmann::json::object()); + const nlohmann::json scope = + presentation::member(event, "scope", nlohmann::json::object()); + if (kind == "result") { + if (!event.value("ok", false)) + return; + const std::string action = presentation::stringMember(event, "action"); + if (action == "threads.list") { + const nlohmann::json threads = + data.value("threads", nlohmann::json::array()); + mergeThreadList(threads); + } else if (action == "thread.read") { + const nlohmann::json thread = + data.value("thread", nlohmann::json::object()); + ThreadPresentation &hydrated = + upsertThread(thread, stringValue(event, "authority") == "replace"); + correlateAgentThread(hydrated.id); + } else if (action == "thread.create" || action == "thread.resume" || + action == "thread.fork") { + upsertThread(data.value("thread", nlohmann::json::object()), false); + } else if (action == "turn.start") { + const std::string threadId = stringValue(scope, "threadId"); + const auto thread = threads.find(threadId); + if (thread != threads.end()) + upsertTurn(thread->second, data.value("turn", nlohmann::json::object()), + false); + } else if (action == "models.list") { + const nlohmann::json listedModels = + data.value("models", nlohmann::json::array()); + if (listedModels.is_array()) + models = listedModels; + } else { + retainDomainEvent("operation." + action, data, scope, + presentation::stringMember(event, "authority")); + } + return; + } + + if (kind != "event") + return; + + const std::string type = presentation::stringMember(event, "type"); + if (presentation::stringMember(event, "authority") == "none") { + if (retainedTelemetry.size() == MaximumRetainedTelemetry) + retainedTelemetry.erase(retainedTelemetry.begin()); + retainedTelemetry.push_back(TelemetryPresentation{ + sequence, event.value("generation", 0ULL), type, data, scope}); + } + if (type == "connection.lifecycle") { + connectionState.generation = + event.value("generation", connectionState.generation); + const std::string lifecycle = stringValue(data, "state"); + if (lifecycle == "connected") { + connectionState.connected = true; + connectionState.retrying = false; + connectionState.detail.clear(); + } else if (lifecycle == "connecting" || lifecycle == "retrying") { + connectionState.connected = false; + connectionState.retrying = true; + connectionState.connectionId.clear(); + connectionState.role.clear(); + connectionState.controllerConnectionId.clear(); + connectionState.detail = stringValue(data, "detail"); + pendingRequests.clear(); + } else if (lifecycle == "disconnected" || lifecycle == "failure") { + connectionState.connected = false; + connectionState.retrying = false; + connectionState.connectionId.clear(); + connectionState.role.clear(); + connectionState.controllerConnectionId.clear(); + connectionState.detail = stringValue(data, "detail"); + pendingRequests.clear(); + } + return; + } + if (type == "connection.bridge") { + connectionState.connectionId = stringValue(data, "connectionId"); + connectionState.role = stringValue(data, "role"); + return; + } + if (type == "connection.controller") { + connectionState.controllerConnectionId = + stringValue(data, "controllerConnectionId"); + if (!connectionState.connectionId.empty()) + connectionState.role = + connectionState.controllerConnectionId == connectionState.connectionId + ? "controller" + : "observer"; + return; + } + if (type == "connection.settings.changed") { + connectionState.settings = data; + return; + } + if (type == "thread.upsert") { + upsertThread(data.value("thread", nlohmann::json::object()), false); + return; + } + if (type == "thread.name.changed") { + const auto thread = threads.find(stringValue(scope, "threadId")); + if (thread != threads.end() && data.contains("name") && + data["name"].is_string()) { + thread->second.title = data["name"].get(); + thread->second.raw["name"] = data["name"]; + } + return; + } + if (type == "thread.status.changed") { + const auto thread = threads.find(stringValue(scope, "threadId")); + if (thread != threads.end()) { + thread->second.status = statusValue(memberValue(data, "status")); + thread->second.raw["status"] = memberValue(data, "status"); + correlateAgentThread(thread->first); + } + return; + } + if (type == "thread.lifecycle") { + const auto thread = threads.find(stringValue(scope, "threadId")); + if (thread != threads.end()) { + thread->second.status = stringValue(data, "state"); + const std::string lifecycle = stringValue(data, "state"); + if (lifecycle == "archived") + thread->second.archived = true; + else if (lifecycle == "unarchived") + thread->second.archived = false; + thread->second.raw["presentationLifecycle"] = lifecycle; + } + return; + } + if (type == "thread.removed") { + removeThread(stringValue(scope, "threadId")); + return; + } + + if (type == "pending-request.upsert") { + const auto id = data.find("requestId"); + if (id == data.end() || id->is_null()) + return; + const std::string key = requestKey(*id); + pendingRequests[key] = PendingRequestPresentation{ + key, stringValue(data, "category"), stringValue(scope, "threadId"), + event.value("generation", 0ULL), memberValue(data, "request")}; + return; + } + if (type == "pending-request.removed") { + const auto id = scope.find("requestId"); + if (id != scope.end()) + pendingRequests.erase(requestKey(*id)); + return; + } + + const std::string threadId = stringValue(scope, "threadId"); + if (threadId.empty()) { + retainDomainEvent(type, data, scope, + presentation::stringMember(event, "authority")); + return; + } + auto threadIterator = threads.find(threadId); + if (threadIterator == threads.end()) { + nlohmann::json minimal{{"id", threadId}}; + upsertThread(minimal, false); + threadIterator = threads.find(threadId); + if (threadIterator == threads.end()) + return; + } + ThreadPresentation &thread = threadIterator->second; + + retainDomainEvent(type, data, scope, + presentation::stringMember(event, "authority")); + + if (type == "turn.upsert") { + upsertTurn(thread, data.value("turn", nlohmann::json::object()), false); + correlateAgentThread(threadId); + return; + } + if (type == "plan.replaced") { + const std::string turnId = stringValue(scope, "turnId"); + nlohmann::json minimalTurn{{"id", turnId}}; + TurnPresentation &turn = upsertTurn(thread, minimalTurn, false); + turn.plan = {{"explanation", memberValue(data, "explanation")}, + {"steps", data.value("steps", nlohmann::json::array())}}; + return; + } + if (type == "conversation.item.upsert") { + const std::string turnId = stringValue(scope, "turnId"); + if (data.contains("item")) { + nlohmann::json minimalTurn{{"id", turnId}}; + TurnPresentation &turn = upsertTurn(thread, minimalTurn, false); + upsertItem(thread, turn, data["item"], true); + correlateAgentThread(threadId); + } + return; + } + if (type == "agents.activity.upsert") { + upsertAgentActivity(thread, scope, + data.value("activity", nlohmann::json::object())); + return; + } + if (type == "conversation.reasoning.part-added") { + if (ItemPresentation *item = findItem(scope)) { + const auto index = data.find("summaryIndex"); + if (index != data.end() && index->is_number_integer() && + index->get() >= 0 && + index->get() < MaximumIndexedTextParts) { + nlohmann::json &parts = item->raw["summary"]; + if (!parts.is_array()) + parts = nlohmann::json::array(); + while (parts.size() <= index->get()) + parts.push_back(""); + } + } + return; + } + if (type == "conversation.file-change.output-appended") { + if (ItemPresentation *item = findItem(scope)) { + nlohmann::json delta{{"delta", stringValue(data, "delta")}}; + appendText(item->raw, "output", delta); + } + return; + } + if (type == "conversation.file-change.patch-replaced") { + if (ItemPresentation *item = findItem(scope)) + item->raw["changes"] = + memberValue(data, "changes", nlohmann::json::array()); + return; + } + if (type == "conversation.mcp.progress") { + if (ItemPresentation *item = findItem(scope)) { + nlohmann::json &progress = item->raw["progress"]; + if (!progress.is_array()) + progress = nlohmann::json::array(); + if (progress.size() < MaximumIndexedTextParts) + progress.push_back(stringValue(data, "message")); + } + return; + } + if (type != "conversation.item.append") + return; + + nlohmann::json identity = scope; + identity["delta"] = data.value("text", std::string{}); + ItemPresentation *item = findItem(identity); + if (!item) + return; + const std::string field = stringValue(data, "field"); + if (field == "summary") + appendIndexedText(item->raw, "summary", data, "summaryIndex"); + else if (field == "content") + appendIndexedText(item->raw, "content", data, "contentIndex"); + else if (!field.empty()) + appendText(item->raw, field.c_str(), identity); +} + +const std::vector & +PresentationModel::threadOrder() const noexcept { + return orderedThreads; +} + +const ThreadPresentation * +PresentationModel::thread(const std::string &threadId) const noexcept { + const auto iterator = threads.find(threadId); + return iterator == threads.end() ? nullptr : &iterator->second; +} + +std::optional +PresentationModel::activeTurnId(const std::string &threadId) const { + const ThreadPresentation *value = thread(threadId); + if (!value) + return std::nullopt; + for (auto iterator = value->turnOrder.rbegin(); + iterator != value->turnOrder.rend(); ++iterator) { + const auto turn = value->turns.find(*iterator); + if (turn != value->turns.end() && (turn->second.status == "inProgress" || + turn->second.status == "active")) + return turn->first; + } + return std::nullopt; +} + +std::size_t PresentationModel::pendingRequestCount() const noexcept { + return pendingRequests.size(); +} + +std::size_t PresentationModel::pendingRequestCount( + const std::string &threadId) const noexcept { + return static_cast( + std::count_if(pendingRequests.begin(), pendingRequests.end(), + [&threadId](const auto &entry) { + return entry.second.threadId == threadId; + })); +} + +const ConnectionPresentation &PresentationModel::connection() const noexcept { + return connectionState; +} + +const nlohmann::json &PresentationModel::modelCatalog() const noexcept { + return models; +} + +const std::unordered_map & +PresentationModel::globalDomains() const noexcept { + return retainedGlobalDomains; +} + +const std::vector & +PresentationModel::telemetry() const noexcept { + return retainedTelemetry; +} + +const std::unordered_map & +PresentationModel::pendingRequestPresentations() const noexcept { + return pendingRequests; +} + +void PresentationModel::mergeThreadList(const nlohmann::json &listedThreads) { + if (!listedThreads.is_array()) + return; + + std::vector listedIds; + listedIds.reserve(listedThreads.size()); + for (const auto &raw : listedThreads) { + const std::string id = stringValue(raw, "id"); + if (id.empty()) + continue; + upsertThread(raw, false); + listedIds.push_back(id); + } + + for (const std::string &id : listedIds) + std::erase(orderedThreads, id); + orderedThreads.insert(orderedThreads.begin(), listedIds.begin(), + listedIds.end()); +} + +ThreadPresentation &PresentationModel::upsertThread(const nlohmann::json &raw, + bool replaceTurns) { + const std::string id = stringValue(raw, "id"); + if (id.empty()) { + static ThreadPresentation ignored; + return ignored; + } + auto [iterator, inserted] = threads.try_emplace(id); + ThreadPresentation &result = iterator->second; + if (inserted) { + result.id = id; + orderedThreads.insert(orderedThreads.begin(), id); + } + nlohmann::json threadFields = raw; + threadFields.erase("turns"); + if (replaceTurns) + result.raw = std::move(threadFields); + else + mergePreservingCompleteness(result.raw, threadFields); + const std::string name = stringValue(raw, "name"); + const std::string preview = stringValue(raw, "preview"); + if (!name.empty()) + result.title = name; + else if (!preview.empty()) + result.title = preview.substr(0, 80); + else if (result.title.empty()) + result.title = id.empty() ? "Untitled thread" : id.substr(0, 12); + if (!preview.empty()) + result.preview = preview; + const std::string cwd = stringValue(raw, "cwd"); + if (!cwd.empty()) + result.cwd = cwd; + const auto status = raw.find("status"); + if (status != raw.end()) + result.status = statusValue(*status); + result.archived = raw.value("archived", result.archived); + + const auto turns = raw.find("turns"); + if (turns != raw.end() && turns->is_array()) { + if (replaceTurns) { + result.turnOrder.clear(); + result.turns.clear(); + result.agentOrder.clear(); + result.agents.clear(); + } + for (const auto &turn : *turns) + upsertTurn(result, turn, replaceTurns); + } + return result; +} + +TurnPresentation &PresentationModel::upsertTurn(ThreadPresentation &thread, + const nlohmann::json &raw, + bool replaceItems) { + const std::string id = stringValue(raw, "id"); + if (id.empty()) { + static TurnPresentation ignored; + return ignored; + } + auto [iterator, inserted] = thread.turns.try_emplace(id); + TurnPresentation &result = iterator->second; + if (inserted) { + result.id = id; + thread.turnOrder.push_back(id); + } + nlohmann::json turnFields = raw; + turnFields.erase("items"); + if (replaceItems) + result.raw = std::move(turnFields); + else + mergePreservingCompleteness(result.raw, turnFields); + const std::string status = statusValue(memberValue(raw, "status")); + if (!status.empty()) + result.status = status; + const auto items = raw.find("items"); + if (items != raw.end() && items->is_array()) { + if (replaceItems) { + result.itemOrder.clear(); + result.items.clear(); + } + for (const auto &item : *items) + upsertItem(thread, result, item); + } + return result; +} + +ItemPresentation &PresentationModel::upsertItem(ThreadPresentation &thread, + TurnPresentation &turn, + const nlohmann::json &raw, + bool live) { + const std::string id = stringValue(raw, "id"); + if (id.empty()) { + static ItemPresentation ignored; + return ignored; + } + auto [iterator, inserted] = turn.items.try_emplace(id); + ItemPresentation &result = iterator->second; + if (inserted) { + result.id = id; + result.raw = raw; + turn.itemOrder.push_back(id); + } else { + mergePreservingCompleteness(result.raw, raw); + } + const std::string type = stringValue(result.raw, "type"); + if (type == "subAgentActivity" || type == "collabAgentToolCall") { + upsertAgentActivity( + thread, + {{"threadId", thread.id}, {"turnId", turn.id}, {"itemId", result.id}}, + result.raw, live); + } + return result; +} + +void PresentationModel::upsertAgentActivity(ThreadPresentation &owner, + const nlohmann::json &scope, + const nlohmann::json &activity, + bool live) { + const std::string type = stringValue(activity, "type"); + if (type == "collabAgentToolCall" && !isSpawnActivity(activity)) { + const nlohmann::json states = + memberValue(activity, "agentsStates", nlohmann::json::object()); + if (!states.is_object()) + return; + for (const auto &[childThreadId, state] : states.items()) { + const auto existing = owner.agents.find(childThreadId); + if (existing == owner.agents.end() || !state.is_object()) + continue; + const std::string status = stringValue(state, "status"); + const std::string message = stringValue(state, "message"); + if (!status.empty()) + existing->second.status = status; + if (!message.empty()) + existing->second.raw["resultText"] = message; + existing->second.raw["agentState"] = state; + correlateAgentThread(childThreadId); + } + return; + } + + const std::string childThreadId = childThreadIdentity(activity); + if (type == "collabAgentToolCall" && childThreadId.empty()) + return; + + const std::string id = agentIdentity(activity, scope); + if (id.empty()) + return; + + auto [iterator, inserted] = owner.agents.try_emplace(id); + AgentPresentation &agent = iterator->second; + if (inserted) { + agent.id = id; + owner.agentOrder.push_back(id); + } + agent.itemId = stringValue(scope, "itemId"); + agent.ownerTurnId = stringValue(scope, "turnId"); + mergePreservingCompleteness(agent.raw, activity); + + if (!childThreadId.empty()) + agent.childThreadId = childThreadId; + + const std::string activityStatus = stringValue(activity, "status"); + const std::string activityKind = stringValue(activity, "kind"); + if (!activityStatus.empty()) + agent.status = activityStatus; + else if (live && activityKind == "started") + agent.status = "inProgress"; + else if (!activityKind.empty()) + agent.status = activityKind; + + if (!agent.childThreadId.empty()) { + auto [child, childInserted] = threads.try_emplace(agent.childThreadId); + if (childInserted) + child->second.id = agent.childThreadId; + child->second.agentThread = true; + std::erase(orderedThreads, agent.childThreadId); + correlateAgentThread(agent.childThreadId); + } +} + +void PresentationModel::correlateAgentThread(const std::string &childThreadId) { + const auto child = threads.find(childThreadId); + if (child == threads.end()) + return; + + std::string childStatus = child->second.status; + std::string resultText; + for (const std::string &turnId : child->second.turnOrder) { + const auto turn = child->second.turns.find(turnId); + if (turn == child->second.turns.end()) + continue; + if (!turn->second.status.empty()) + childStatus = turn->second.status; + for (const std::string &itemId : turn->second.itemOrder) { + const auto item = turn->second.items.find(itemId); + if (item == turn->second.items.end() || + stringValue(item->second.raw, "type") != "agentMessage") + continue; + const std::string text = stringValue(item->second.raw, "text"); + if (!text.empty()) + resultText = text; + } + } + + for (auto &[ownerId, owner] : threads) { + static_cast(ownerId); + for (auto &[agentId, agent] : owner.agents) { + static_cast(agentId); + if (agent.childThreadId != childThreadId) + continue; + if (!childStatus.empty()) + agent.status = childStatus; + if (!resultText.empty()) + agent.raw["resultText"] = resultText; + agent.raw["childThreadId"] = childThreadId; + } + } +} + +void PresentationModel::removeThread(const std::string &threadId) { + threads.erase(threadId); + std::erase(orderedThreads, threadId); +} + +void PresentationModel::retainDomainEvent(const std::string &type, + const nlohmann::json &data, + const nlohmann::json &scope, + const std::string &authority) { + const std::string threadId = stringValue(scope, "threadId"); + const std::string turnId = stringValue(scope, "turnId"); + const std::string itemId = stringValue(scope, "itemId"); + if (!itemId.empty()) { + if (ItemPresentation *item = findItem(scope)) + applyDomainAuthority(item->domains, type, data, authority); + return; + } + if (!turnId.empty()) { + if (TurnPresentation *turn = findTurn(threadId, turnId)) + applyDomainAuthority(turn->domains, type, data, authority); + return; + } + if (!threadId.empty()) { + const auto thread = threads.find(threadId); + if (thread != threads.end()) + applyDomainAuthority(thread->second.domains, type, data, authority); + return; + } + applyDomainAuthority(retainedGlobalDomains, type, data, authority); +} + +TurnPresentation *PresentationModel::findTurn(const std::string &threadId, + const std::string &turnId) { + auto thread = threads.find(threadId); + if (thread == threads.end()) + return nullptr; + auto turn = thread->second.turns.find(turnId); + return turn == thread->second.turns.end() ? nullptr : &turn->second; +} + +ItemPresentation *PresentationModel::findItem(const nlohmann::json ¶ms) { + TurnPresentation *turn = + findTurn(stringValue(params, "threadId"), stringValue(params, "turnId")); + if (!turn) + return nullptr; + const std::string itemId = stringValue(params, "itemId"); + auto item = turn->items.find(itemId); + return item == turn->items.end() ? nullptr : &item->second; +} + +} // namespace codexui::codex diff --git a/src/codex/PresentationModel.h b/src/codex/PresentationModel.h new file mode 100644 index 0000000..ca39ac5 --- /dev/null +++ b/src/codex/PresentationModel.h @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_PRESENTATIONMODEL_H +#define CODEXUI_CODEX_PRESENTATIONMODEL_H + +#include + +#include +#include +#include +#include +#include + +namespace codexui::codex { + +struct ItemPresentation { + std::string id; + nlohmann::json raw = nlohmann::json::object(); + std::unordered_map domains; +}; + +struct TurnPresentation { + std::string id; + std::string status; + std::vector itemOrder; + std::unordered_map items; + nlohmann::json plan = nlohmann::json::object(); + nlohmann::json raw = nlohmann::json::object(); + std::unordered_map domains; +}; + +struct AgentPresentation { + std::string id; + std::string itemId; + std::string ownerTurnId; + std::string childThreadId; + std::string status; + nlohmann::json raw = nlohmann::json::object(); +}; + +struct ThreadPresentation { + std::string id; + std::string title; + std::string preview; + std::string cwd; + std::string status; + std::vector turnOrder; + std::unordered_map turns; + nlohmann::json raw = nlohmann::json::object(); + std::unordered_map domains; + std::vector agentOrder; + std::unordered_map agents; + bool archived = false; + bool agentThread = false; +}; + +struct PendingRequestPresentation { + std::string id; + std::string kind; + std::string threadId; + std::uint64_t generation = 0; + nlohmann::json raw; +}; + +struct ConnectionPresentation { + bool connected = false; + bool retrying = false; + std::uint64_t generation = 0; + std::string connectionId; + std::string role; + std::string controllerConnectionId; + std::string detail; + nlohmann::json settings = nlohmann::json::object(); +}; + +struct TelemetryPresentation { + std::uint64_t sequence = 0; + std::uint64_t generation = 0; + std::string type; + nlohmann::json data = nlohmann::json::object(); + nlohmann::json scope = nlohmann::json::object(); +}; + +class PresentationModel final { +public: + void applyEvent(const nlohmann::json &event); + + [[nodiscard]] const std::vector &threadOrder() const noexcept; + [[nodiscard]] const ThreadPresentation * + thread(const std::string &threadId) const noexcept; + [[nodiscard]] std::optional + activeTurnId(const std::string &threadId) const; + [[nodiscard]] std::size_t pendingRequestCount() const noexcept; + [[nodiscard]] std::size_t + pendingRequestCount(const std::string &threadId) const noexcept; + [[nodiscard]] const ConnectionPresentation &connection() const noexcept; + [[nodiscard]] const nlohmann::json &modelCatalog() const noexcept; + [[nodiscard]] const std::unordered_map & + globalDomains() const noexcept; + [[nodiscard]] const std::vector & + telemetry() const noexcept; + [[nodiscard]] const std::unordered_map & + pendingRequestPresentations() const noexcept; + +private: + void mergeThreadList(const nlohmann::json &listedThreads); + ThreadPresentation &upsertThread(const nlohmann::json &raw, + bool replaceTurns); + TurnPresentation &upsertTurn(ThreadPresentation &thread, + const nlohmann::json &raw, bool replaceItems); + ItemPresentation &upsertItem(ThreadPresentation &thread, + TurnPresentation &turn, + const nlohmann::json &raw, bool live = false); + void upsertAgentActivity(ThreadPresentation &owner, + const nlohmann::json &scope, + const nlohmann::json &activity, bool live = true); + void correlateAgentThread(const std::string &childThreadId); + void removeThread(const std::string &threadId); + void retainDomainEvent(const std::string &type, const nlohmann::json &data, + const nlohmann::json &scope, + const std::string &authority); + TurnPresentation *findTurn(const std::string &threadId, + const std::string &turnId); + ItemPresentation *findItem(const nlohmann::json ¶ms); + + std::vector orderedThreads; + std::unordered_map threads; + std::unordered_map pendingRequests; + ConnectionPresentation connectionState; + nlohmann::json models = nlohmann::json::array(); + std::unordered_map retainedGlobalDomains; + std::vector retainedTelemetry; + std::uint64_t lastSequence = 0; +}; + +} // namespace codexui::codex + +#endif // CODEXUI_CODEX_PRESENTATIONMODEL_H diff --git a/src/codex/PresentationProtocol.cpp b/src/codex/PresentationProtocol.cpp new file mode 100644 index 0000000..9c771d5 --- /dev/null +++ b/src/codex/PresentationProtocol.cpp @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/PresentationProtocol.h" + +#include + +namespace codexui::codex::presentation { +namespace { + +nlohmann::json baseFrame(std::string kind) { + return {{"protocol", ProtocolName}, + {"version", ProtocolVersion}, + {"kind", std::move(kind)}}; +} + +void addAuthorityAndScope(nlohmann::json &frame, Authority authority, + nlohmann::json scope) { + frame["authority"] = authorityName(authority); + if (scope.is_object() && !scope.empty()) + frame["scope"] = std::move(scope); +} + +} // namespace + +std::string_view authorityName(Authority authority) noexcept { + switch (authority) { + case Authority::Merge: + return "merge"; + case Authority::Replace: + return "replace"; + case Authority::Remove: + return "remove"; + case Authority::None: + return "none"; + } + return "none"; +} + +nlohmann::json command(std::string action, nlohmann::json data, + std::string correlationId) { + nlohmann::json frame = baseFrame("command"); + frame["action"] = std::move(action); + frame["data"] = std::move(data); + if (!correlationId.empty()) + frame["correlationId"] = std::move(correlationId); + return frame; +} + +nlohmann::json result(std::uint64_t sequence, std::uint64_t generation, + std::string action, std::string correlationId, bool ok, + nlohmann::json data, Authority authority, + nlohmann::json scope) { + nlohmann::json frame = baseFrame("result"); + frame["sequence"] = sequence; + frame["generation"] = generation; + frame["action"] = std::move(action); + frame["correlationId"] = std::move(correlationId); + frame["ok"] = ok; + frame[ok ? "data" : "error"] = std::move(data); + addAuthorityAndScope(frame, authority, std::move(scope)); + return frame; +} + +nlohmann::json event(std::uint64_t sequence, std::uint64_t generation, + std::string type, nlohmann::json data, Authority authority, + nlohmann::json scope) { + nlohmann::json frame = baseFrame("event"); + frame["sequence"] = sequence; + frame["generation"] = generation; + frame["type"] = std::move(type); + frame["data"] = std::move(data); + addAuthorityAndScope(frame, authority, std::move(scope)); + return frame; +} + +bool isPresentationFrame(const nlohmann::json &value) noexcept { + return value.is_object() && stringMember(value, "protocol") == ProtocolName && + value.value("version", 0U) == ProtocolVersion; +} + +std::string stringMember(const nlohmann::json &value, const char *name) { + if (!value.is_object()) + return {}; + const auto iterator = value.find(name); + return iterator != value.end() && iterator->is_string() + ? iterator->get() + : std::string{}; +} + +nlohmann::json member(const nlohmann::json &value, const char *name, + nlohmann::json fallback) { + if (!value.is_object()) + return fallback; + const auto iterator = value.find(name); + return iterator == value.end() ? std::move(fallback) : *iterator; +} + +} // namespace codexui::codex::presentation diff --git a/src/codex/PresentationProtocol.h b/src/codex/PresentationProtocol.h new file mode 100644 index 0000000..1d0d59c --- /dev/null +++ b/src/codex/PresentationProtocol.h @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_PRESENTATIONPROTOCOL_H +#define CODEXUI_CODEX_PRESENTATIONPROTOCOL_H + +#include + +#include +#include +#include + +namespace codexui::codex::presentation { + +inline constexpr std::string_view ProtocolName = "codexui.presentation"; +inline constexpr std::uint32_t ProtocolVersion = 1; + +enum class Authority { + None, + Merge, + Replace, + Remove, +}; + +[[nodiscard]] std::string_view authorityName(Authority authority) noexcept; + +[[nodiscard]] nlohmann::json +command(std::string action, nlohmann::json data = nlohmann::json::object(), + std::string correlationId = {}); + +[[nodiscard]] nlohmann::json +result(std::uint64_t sequence, std::uint64_t generation, std::string action, + std::string correlationId, bool ok, nlohmann::json data, + Authority authority = Authority::None, + nlohmann::json scope = nlohmann::json::object()); + +[[nodiscard]] nlohmann::json +event(std::uint64_t sequence, std::uint64_t generation, std::string type, + nlohmann::json data = nlohmann::json::object(), + Authority authority = Authority::None, + nlohmann::json scope = nlohmann::json::object()); + +[[nodiscard]] bool isPresentationFrame(const nlohmann::json &value) noexcept; +[[nodiscard]] std::string stringMember(const nlohmann::json &value, + const char *name); +[[nodiscard]] nlohmann::json member(const nlohmann::json &value, + const char *name, + nlohmann::json fallback = nullptr); + +} // namespace codexui::codex::presentation + +#endif // CODEXUI_CODEX_PRESENTATIONPROTOCOL_H diff --git a/src/codex/ProtocolNormalizer.cpp b/src/codex/ProtocolNormalizer.cpp new file mode 100644 index 0000000..8912e58 --- /dev/null +++ b/src/codex/ProtocolNormalizer.cpp @@ -0,0 +1,488 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/ProtocolNormalizer.h" + +#include +#include + +#include +#include +#include + +namespace codexui::codex { +namespace { + +using ai::openai::codex::protocol::JsonRpcKind; +using presentation::Authority; +using namespace std::string_view_literals; + +nlohmann::json errorValue(const nlohmann::json &response) { + const auto iterator = response.find("error"); + return iterator != response.end() + ? *iterator + : nlohmann::json{{"code", -32000}, + {"message", "operation failed"}}; +} + +nlohmann::json stableScope(const nlohmann::json &value) { + nlohmann::json scope = nlohmann::json::object(); + if (!value.is_object()) + return scope; + constexpr std::array keys{"threadId", "turnId", "itemId", "processId", + "requestId"}; + for (const char *key : keys) { + const auto iterator = value.find(key); + if (iterator != value.end() && !iterator->is_null()) + scope[key] = *iterator; + } + return scope; +} + +std::string requestKind(std::string_view method) { + if (method == "item/commandExecution/requestApproval") + return "command-approval"; + if (method == "item/fileChange/requestApproval") + return "file-change-approval"; + if (method == "item/tool/requestUserInput") + return "user-input"; + if (method == "mcpServer/elicitation/request") + return "mcp-elicitation"; + if (method == "item/permissions/requestApproval") + return "permissions-approval"; + if (method == "item/tool/call") + return "dynamic-tool-call"; + if (method == "account/chatgptAuthTokens/refresh") + return "authentication-refresh"; + if (method == "attestation/generate") + return "attestation"; + if (method == "applyPatchApproval") + return "legacy-patch-approval"; + if (method == "execCommandApproval") + return "legacy-command-approval"; + return "unsupported"; +} + +struct EventDescriptor { + std::string_view type; + Authority authority = Authority::None; +}; + +std::optional remainingNotification(std::string_view method) { + // Every generated notification without a richer reducer above has one stable + // presentation-domain event name. Native app-server method names stop here. + static constexpr std::array descriptors{ + std::pair{"thread/reverted"sv, + EventDescriptor{"thread.reverted", Authority::Merge}}, + std::pair{"skills/changed"sv, + EventDescriptor{"catalog.skills.invalidated", Authority::None}}, + std::pair{"thread/goal/updated"sv, + EventDescriptor{"thread.goal.changed", Authority::Replace}}, + std::pair{"thread/goal/cleared"sv, + EventDescriptor{"thread.goal.removed", Authority::Remove}}, + std::pair{"thread/queue/changed"sv, + EventDescriptor{"thread.queue.changed", Authority::Replace}}, + std::pair{"project/changed"sv, + EventDescriptor{"workspace.project.changed", Authority::Merge}}, + std::pair{"thread/project/updated"sv, + EventDescriptor{"thread.project.changed", Authority::Replace}}, + std::pair{ + "thread/environment/connected"sv, + EventDescriptor{"thread.environment.connected", Authority::Merge}}, + std::pair{ + "thread/environment/disconnected"sv, + EventDescriptor{"thread.environment.disconnected", Authority::Merge}}, + std::pair{"thread/settings/updated"sv, + EventDescriptor{"thread.settings.changed", Authority::Merge}}, + std::pair{"hook/started"sv, + EventDescriptor{"activity.hook.started", Authority::Merge}}, + std::pair{"hook/completed"sv, + EventDescriptor{"activity.hook.completed", Authority::Merge}}, + std::pair{"turn/diff/updated"sv, + EventDescriptor{"turn.diff.changed", Authority::Replace}}, + std::pair{"item/autoApprovalReview/started"sv, + EventDescriptor{"approval.review.started", Authority::Merge}}, + std::pair{"item/autoApprovalReview/completed"sv, + EventDescriptor{"approval.review.completed", Authority::Merge}}, + std::pair{ + "autoApprovalReview/strictReviewRequired"sv, + EventDescriptor{"approval.strict-review.required", Authority::Merge}}, + std::pair{"command/exec/outputDelta"sv, + EventDescriptor{"terminal.command.output-appended", + Authority::Merge}}, + std::pair{"process/outputDelta"sv, + EventDescriptor{"terminal.process.output-appended", + Authority::Merge}}, + std::pair{ + "process/exited"sv, + EventDescriptor{"terminal.process.completed", Authority::Merge}}, + std::pair{"item/commandExecution/terminalInteraction"sv, + EventDescriptor{"conversation.command.interaction", + Authority::Merge}}, + std::pair{"item/fileChange/outputDelta"sv, + EventDescriptor{"conversation.file-change.output-appended", + Authority::Merge}}, + std::pair{"item/fileChange/patchUpdated"sv, + EventDescriptor{"conversation.file-change.patch-replaced", + Authority::Replace}}, + std::pair{"item/mcpToolCall/progress"sv, + EventDescriptor{"conversation.mcp.progress", Authority::Merge}}, + std::pair{ + "mcpServer/oauthLogin/completed"sv, + EventDescriptor{"integration.mcp.login-completed", Authority::Merge}}, + std::pair{ + "mcpServer/startupStatus/updated"sv, + EventDescriptor{"integration.mcp.status-changed", Authority::Merge}}, + std::pair{"mcpServer/event/stream/notification"sv, + EventDescriptor{"integration.mcp.event", Authority::None}}, + std::pair{"app/list/updated"sv, + EventDescriptor{"catalog.apps.changed", Authority::Replace}}, + std::pair{"remoteControl/status/changed"sv, + EventDescriptor{"connection.remote-control.changed", + Authority::Replace}}, + std::pair{"externalAgentConfig/import/progress"sv, + EventDescriptor{"settings.external-agent-import.progress", + Authority::Merge}}, + std::pair{"externalAgentConfig/import/completed"sv, + EventDescriptor{"settings.external-agent-import.completed", + Authority::Merge}}, + std::pair{"fs/changed"sv, + EventDescriptor{"workspace.files.changed", Authority::Merge}}, + std::pair{"item/reasoning/summaryPartAdded"sv, + EventDescriptor{"conversation.reasoning.part-added", + Authority::Merge}}, + std::pair{"thread/compacted"sv, + EventDescriptor{"thread.compacted", Authority::Merge}}, + std::pair{"model/rerouted"sv, + EventDescriptor{"model.rerouted", Authority::Merge}}, + std::pair{ + "model/verification"sv, + EventDescriptor{"model.verification.changed", Authority::Merge}}, + std::pair{"turn/moderationMetadata"sv, + EventDescriptor{"turn.moderation.changed", Authority::Replace}}, + std::pair{"model/safetyBuffering/updated"sv, + EventDescriptor{"model.safety-buffering.changed", + Authority::Replace}}, + std::pair{"fuzzyFileSearch/sessionUpdated"sv, + EventDescriptor{"workspace.search.changed", Authority::Merge}}, + std::pair{ + "fuzzyFileSearch/sessionCompleted"sv, + EventDescriptor{"workspace.search.completed", Authority::Merge}}, + std::pair{"thread/realtime/started"sv, + EventDescriptor{"realtime.session.started", Authority::Merge}}, + std::pair{"thread/realtime/itemAdded"sv, + EventDescriptor{"realtime.item.added", Authority::Merge}}, + std::pair{ + "thread/realtime/transcript/delta"sv, + EventDescriptor{"realtime.transcript.appended", Authority::Merge}}, + std::pair{ + "thread/realtime/transcript/done"sv, + EventDescriptor{"realtime.transcript.completed", Authority::Merge}}, + std::pair{"thread/realtime/outputAudio/delta"sv, + EventDescriptor{"realtime.audio.appended", Authority::Merge}}, + std::pair{"thread/realtime/sdp"sv, + EventDescriptor{"realtime.session-description.changed", + Authority::Replace}}, + std::pair{"thread/realtime/error"sv, + EventDescriptor{"realtime.session.failed", Authority::Merge}}, + std::pair{"thread/realtime/closed"sv, + EventDescriptor{"realtime.session.closed", Authority::Merge}}, + std::pair{"windows/worldWritableWarning"sv, + EventDescriptor{"system.windows-permission.warning", + Authority::None}}, + std::pair{"windowsSandbox/setupCompleted"sv, + EventDescriptor{"system.windows-sandbox.completed", + Authority::Merge}}, + std::pair{"account/login/completed"sv, + EventDescriptor{"account.login.completed", Authority::Merge}}, + }; + for (const auto &[name, descriptor] : descriptors) { + if (method == name) + return descriptor; + } + return std::nullopt; +} + +} // namespace + +ProtocolNormalizer::ProtocolNormalizer(Sink sink) : sink(std::move(sink)) {} + +void ProtocolNormalizer::transportEvent(std::string_view eventName, + std::string detail) { + if (eventName == "connected") + ++connectionGeneration; + nlohmann::json data{{"state", eventName}}; + if (!detail.empty()) + data["detail"] = std::move(detail); + emitEvent("connection.lifecycle", std::move(data)); +} + +void ProtocolNormalizer::connectionSettings(nlohmann::json settings) { + emitEvent("connection.settings.changed", std::move(settings), + Authority::Replace); +} + +void ProtocolNormalizer::localOperationResult(std::string action, + std::string correlationId, + bool ok, nlohmann::json data) { + emit(presentation::result(nextSequence++, connectionGeneration, + std::move(action), std::move(correlationId), ok, + std::move(data))); +} + +void ProtocolNormalizer::bridgeEvent(const nlohmann::json &value) { + const std::string kind = presentation::stringMember(value, "kind"); + if (kind == "bridge.connection") { + emitEvent("connection.bridge", + {{"state", value.value("event", std::string{})}, + {"connectionId", value.value("connectionId", std::string{})}, + {"role", value.value("role", std::string{})}}); + return; + } + if (kind == "bridge.controller") { + emitEvent("connection.controller", + {{"controllerConnectionId", + presentation::member(value, "controllerConnectionId")}}, + Authority::Replace); + return; + } + if (kind == "bridge.diagnostic") { + diagnostic("bridge", value.value("code", std::string{}), + value.value("message", std::string{}), + value.value("details", nlohmann::json::object())); + return; + } + diagnostic("bridge", "unknown-event", kind, value); +} + +void ProtocolNormalizer::serverNotification(std::string_view method, + const nlohmann::json ¶ms) { + const nlohmann::json scope = stableScope(params); + if (method == "thread/started") { + emitEvent("thread.upsert", + {{"thread", params.value("thread", nlohmann::json::object())}}, + Authority::Merge); + } else if (method == "thread/status/changed") { + emitEvent("thread.status.changed", + {{"status", presentation::member(params, "status")}}, + Authority::Merge, scope); + } else if (method == "thread/name/updated") { + emitEvent("thread.name.changed", + {{"name", presentation::member(params, "threadName")}}, + Authority::Replace, scope); + } else if (method == "thread/deleted") { + emitEvent("thread.removed", nlohmann::json::object(), Authority::Remove, + scope); + } else if (method == "thread/archived" || method == "thread/unarchived" || + method == "thread/closed") { + const std::string state = method == "thread/archived" ? "archived" + : method == "thread/unarchived" ? "unarchived" + : "closed"; + emitEvent("thread.lifecycle", {{"state", state}}, Authority::Merge, scope); + } else if (method == "turn/started" || method == "turn/completed") { + emitEvent( + "turn.upsert", + {{"lifecycle", method == "turn/started" ? "started" : "completed"}, + {"turn", params.value("turn", nlohmann::json::object())}}, + Authority::Merge, scope); + } else if (method == "turn/plan/updated") { + emitEvent("plan.replaced", + {{"explanation", presentation::member(params, "explanation")}, + {"steps", params.value("plan", nlohmann::json::array())}}, + Authority::Replace, scope); + } else if (method == "item/started" || method == "item/completed") { + const nlohmann::json item = params.value("item", nlohmann::json::object()); + nlohmann::json itemScope = scope; + if (!itemScope.contains("itemId") && item.contains("id") && + !item["id"].is_null()) + itemScope["itemId"] = item["id"]; + emitEvent( + "conversation.item.upsert", + {{"lifecycle", method == "item/started" ? "started" : "completed"}, + {"item", item}}, + Authority::Merge, itemScope); + const std::string itemType = item.value("type", std::string{}); + if (itemType == "collabAgentToolCall" || itemType == "subAgentActivity") { + emitEvent( + "agents.activity.upsert", + {{"lifecycle", method == "item/started" ? "started" : "completed"}, + {"activity", item}}, + Authority::Merge, itemScope); + } + } else if (method == "item/agentMessage/delta" || + method == "item/plan/delta" || + method == "item/reasoning/summaryTextDelta" || + method == "item/reasoning/textDelta" || + method == "item/commandExecution/outputDelta") { + std::string field = "text"; + if (method == "item/commandExecution/outputDelta") + field = "aggregatedOutput"; + else if (method == "item/reasoning/summaryTextDelta") + field = "summary"; + else if (method == "item/reasoning/textDelta") + field = "content"; + nlohmann::json data{{"field", std::move(field)}, + {"text", params.value("delta", std::string{})}}; + if (params.contains("summaryIndex")) + data["summaryIndex"] = params["summaryIndex"]; + if (params.contains("contentIndex")) + data["contentIndex"] = params["contentIndex"]; + emitEvent("conversation.item.append", std::move(data), Authority::Merge, + scope); + } else if (method == "serverRequest/resolved") { + emitEvent("pending-request.removed", nlohmann::json::object(), + Authority::Remove, scope); + } else if (method == "error" || method == "warning" || + method == "guardianWarning" || method == "configWarning" || + method == "deprecationNotice") { + emitEvent("notice.added", + {{"severity", method == "error" ? "error" : "warning"}, + {"notice", params}}, + Authority::None, scope); + } else if (method == "thread/tokenUsage/updated") { + emitEvent( + "thread.token-usage.changed", + {{"tokenUsage", params.value("tokenUsage", nlohmann::json::object())}}, + Authority::Replace, scope); + } else if (method == "account/updated") { + emitEvent("account.changed", {{"account", params}}, Authority::Replace); + } else if (method == "account/rateLimits/updated") { + emitEvent("account.rate-limits.changed", {{"rateLimits", params}}, + Authority::Replace); + } else if (const auto descriptor = remainingNotification(method)) { + emitEvent(std::string(descriptor->type), params, descriptor->authority, + scope); + } else { + diagnostic("appserver", "unmapped-notification", std::string(method)); + } +} + +void ProtocolNormalizer::serverRequest(std::string_view method, + const nlohmann::json &requestId, + const nlohmann::json ¶ms) { + nlohmann::json scope = stableScope(params); + scope["requestId"] = requestId; + emitEvent("pending-request.upsert", + {{"requestId", requestId}, + {"category", requestKind(method)}, + {"request", params}}, + Authority::Merge, std::move(scope)); +} + +void ProtocolNormalizer::observeRawInbound(const nlohmann::json &message) { + const auto method = ai::openai::codex::protocol::jsonRpcMethod(message); + const JsonRpcKind kind = + ai::openai::codex::protocol::classifyJsonRpc(message); + if ((kind == JsonRpcKind::Request || kind == JsonRpcKind::Notification) && + method && !knownServerMethod(*method)) + diagnostic("appserver", "unknown-method", *method); +} + +void ProtocolNormalizer::operationResult(std::string action, + std::string correlationId, + nlohmann::json context, + const nlohmann::json &response) { + const bool ok = response.is_object() && response.contains("result"); + nlohmann::json data; + Authority authority = Authority::None; + nlohmann::json scope = stableScope(context); + if (ok) { + const nlohmann::json &value = response["result"]; + if (action == "threads.list") { + data = { + {"threads", value.value("data", nlohmann::json::array())}, + {"nextCursor", presentation::member(value, "nextCursor")}, + {"backwardsCursor", presentation::member(value, "backwardsCursor")}}; + authority = Authority::Merge; + } else if (action == "thread.read") { + const nlohmann::json thread = + value.value("thread", nlohmann::json::object()); + data = {{"thread", thread}}; + authority = Authority::Merge; + const std::string threadId = presentation::stringMember(thread, "id"); + if (!threadId.empty()) + scope["threadId"] = threadId; + } else if (action == "thread.create" || action == "thread.resume" || + action == "thread.fork") { + data = {{"thread", value.value("thread", nlohmann::json::object())}}; + authority = Authority::Merge; + } else if (action == "models.list") { + data = {{"models", value.value("data", nlohmann::json::array())}, + {"nextCursor", presentation::member(value, "nextCursor")}}; + authority = Authority::Replace; + } else if (action == "model-provider-capabilities.read" || + action == "account.read" || + action == "account.rate-limits.read" || + action == "account.token-usage.read" || + action == "config.read" || + action == "permission-profiles.list" || + action == "experimental-features.list" || + action == "skills.list" || action == "hooks.list" || + action == "plugins.list" || action == "apps.list" || + action == "mcp-servers.list") { + data = value; + authority = Authority::Replace; + } else if (action.ends_with(".list") || action.ends_with(".read") || + action.ends_with(".get") || action == "plugins.installed" || + action == "apps.installed" || + action == "windows-sandbox.readiness") { + data = value; + authority = Authority::Replace; + } else if (action == "turn.start") { + data = {{"turn", value.value("turn", nlohmann::json::object())}}; + authority = Authority::Merge; + } else { + data = value; + } + } else { + data = errorValue(response); + } + emit(presentation::result(nextSequence++, connectionGeneration, + std::move(action), std::move(correlationId), ok, + std::move(data), authority, std::move(scope))); +} + +void ProtocolNormalizer::operationRejected(std::string action, + std::string correlationId, int code, + std::string message) { + emit(presentation::result(nextSequence++, connectionGeneration, + std::move(action), std::move(correlationId), false, + {{"code", code}, {"message", std::move(message)}})); +} + +bool ProtocolNormalizer::emit(nlohmann::json frame) const { + return sink && sink(frame); +} + +bool ProtocolNormalizer::emitEvent(std::string type, nlohmann::json data, + Authority authority, nlohmann::json scope) { + return emit(presentation::event(nextSequence++, connectionGeneration, + std::move(type), std::move(data), authority, + std::move(scope))); +} + +void ProtocolNormalizer::diagnostic(std::string source, std::string code, + std::string message, + nlohmann::json details) { + emitEvent("system.diagnostic", {{"source", std::move(source)}, + {"code", std::move(code)}, + {"message", std::move(message)}, + {"details", std::move(details)}}); +} + +bool ProtocolNormalizer::knownServerMethod(std::string_view method) const { +#define CODEXUI_MATCH_SERVER_REQUEST(OperationName, methodName) \ + if (method == \ + ai::openai::codex::generated::server_requests::OperationName::method) \ + return true; + AI_OPENAI_CODEX_SERVER_REQUESTS(CODEXUI_MATCH_SERVER_REQUEST) +#undef CODEXUI_MATCH_SERVER_REQUEST +#define CODEXUI_MATCH_SERVER_NOTIFICATION(OperationName, methodName) \ + if (method == ai::openai::codex::generated::server_notifications:: \ + OperationName::method) \ + return true; + AI_OPENAI_CODEX_SERVER_NOTIFICATIONS(CODEXUI_MATCH_SERVER_NOTIFICATION) +#undef CODEXUI_MATCH_SERVER_NOTIFICATION + return false; +} + +} // namespace codexui::codex diff --git a/src/codex/ProtocolNormalizer.h b/src/codex/ProtocolNormalizer.h new file mode 100644 index 0000000..af66664 --- /dev/null +++ b/src/codex/ProtocolNormalizer.h @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_PROTOCOLNORMALIZER_H +#define CODEXUI_CODEX_PROTOCOLNORMALIZER_H + +#include + +#include "codex/PresentationProtocol.h" + +#include +#include +#include + +namespace codexui::codex { + +class ProtocolNormalizer final { +public: + using Sink = std::function; + + explicit ProtocolNormalizer(Sink sink); + + void transportEvent(std::string_view event, std::string detail = {}); + void connectionSettings(nlohmann::json settings); + void localOperationResult(std::string action, std::string correlationId, + bool ok, nlohmann::json data); + void bridgeEvent(const nlohmann::json &event); + void serverNotification(std::string_view method, + const nlohmann::json ¶ms); + void serverRequest(std::string_view method, const nlohmann::json &requestId, + const nlohmann::json ¶ms); + void observeRawInbound(const nlohmann::json &message); + + void operationResult(std::string action, std::string correlationId, + nlohmann::json context, const nlohmann::json &response); + void operationRejected(std::string action, std::string correlationId, + int code, std::string message); + +private: + bool emit(nlohmann::json frame) const; + bool + emitEvent(std::string type, nlohmann::json data = nlohmann::json::object(), + presentation::Authority authority = presentation::Authority::None, + nlohmann::json scope = nlohmann::json::object()); + void diagnostic(std::string source, std::string code, std::string message, + nlohmann::json details = nlohmann::json::object()); + bool knownServerMethod(std::string_view method) const; + + Sink sink; + std::uint64_t connectionGeneration = 0; + std::uint64_t nextSequence = 1; +}; + +} // namespace codexui::codex + +#endif // CODEXUI_CODEX_PROTOCOLNORMALIZER_H diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp new file mode 100644 index 0000000..f3a7c7c --- /dev/null +++ b/src/codex/ShellWidget.cpp @@ -0,0 +1,3580 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/ShellWidget.h" + +#include "codex/ConnectionDialog.h" +#include "codex/DiffViewer.h" +#include "codex/FileSelectionDialog.h" +#include "codex/FrontendSession.h" +#include "codex/NewThreadDialog.h" +#include "codex/PendingRequestDialog.h" +#include "codex/TurnSettingsWidget.h" +#include "codex/ui/BrandMark.h" +#include "codex/ui/ExpandingPromptEditor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace codexui::codex { +namespace { + +constexpr int UpcomingControlHeight = 32; +constexpr int MaximumCommandOutputHeight = 220; +constexpr int AcknowledgementTransitionMilliseconds = 500; +constexpr auto ConversationAnchorProperty = "conversationAnchorKey"; + +using CommandOutputScrollState = std::pair; + +QLabel *makeLabel(QString value, const char *kind = "body"); + +bool commandOutputIsVisible(QStringView output) { + for (qsizetype index = 0; index < output.size(); ++index) { + const ushort code = output[index].unicode(); + if (code == 0x1b && index + 1 < output.size()) { + const ushort introducer = output[index + 1].unicode(); + if (introducer == '[') { + index += 2; + while (index < output.size()) { + const ushort candidate = output[index].unicode(); + if (candidate >= 0x40 && candidate <= 0x7e) + break; + ++index; + } + continue; + } + if (introducer == ']') { + index += 2; + while (index < output.size()) { + if (output[index].unicode() == 0x07) + break; + if (output[index].unicode() == 0x1b && index + 1 < output.size() && + output[index + 1].unicode() == '\\') { + ++index; + break; + } + ++index; + } + continue; + } + ++index; + continue; + } + if (output[index].isPrint() && !output[index].isSpace()) + return true; + } + return false; +} + +class PendingPromptCard final : public QFrame { +public: + PendingPromptCard(const QString &prompt, int attachmentCount, bool awaiting, + bool acknowledgedTransition, qint64 acknowledgedAt, + bool failed, const QString &error) { + setObjectName(QStringLiteral("pendingPromptCard")); + setStyleSheet(QStringLiteral( + "QFrame#pendingPromptCard{background:transparent;border:0;}")); + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(12, 10, 12, 10); + layout->setSpacing(6); + + const QString foreground = awaiting || acknowledgedTransition + ? QStringLiteral("#536b8f") + : failed ? QStringLiteral("#9b2c2c") + : QStringLiteral("#1d2633"); + auto *title = makeLabel(QStringLiteral("You"), "title"); + title->setStyleSheet( + QStringLiteral("background:transparent;color:%1;").arg(foreground)); + layout->addWidget(title); + auto *body = makeLabel(prompt); + body->setStyleSheet( + QStringLiteral("background:transparent;color:%1;").arg(foreground)); + layout->addWidget(body); + + QString status; + if (awaiting) + status = QStringLiteral("Waiting for app-server acknowledgment"); + else if (acknowledgedTransition) + status = QStringLiteral("Accepted by app-server"); + else if (failed) + status = error.isEmpty() ? QStringLiteral("Not sent") + : QStringLiteral("Not sent: %1").arg(error); + if (attachmentCount > 0) { + const QString attachments = + QStringLiteral("%1 attachment%2") + .arg(attachmentCount) + .arg(attachmentCount == 1 ? QString{} : QStringLiteral("s")); + status = status.isEmpty() + ? attachments + : status + QStringLiteral(" | ") + attachments; + } + if (!status.isEmpty()) { + auto *metadata = makeLabel(status, "meta"); + metadata->setStyleSheet( + QStringLiteral("background:transparent;color:%1;").arg(foreground)); + layout->addWidget(metadata); + } + + if (awaiting || acknowledgedTransition) { + animationTimer.setInterval(32); + connect(&animationTimer, &QTimer::timeout, this, + qOverload<>(&PendingPromptCard::update)); + animationTimer.start(); + } + isAwaiting = awaiting; + isAcknowledgedTransition = acknowledgedTransition; + acknowledgedAtMilliseconds = acknowledgedAt; + hasFailed = failed; + } + +protected: + void paintEvent(QPaintEvent *event) override { + QFrame::paintEvent(event); + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + const QRectF bounds = QRectF(rect()).adjusted(1.5, 1.5, -1.5, -1.5); + const QColor background = isAwaiting || isAcknowledgedTransition + ? QColor(QStringLiteral("#dbe7f8")) + : hasFailed ? QColor(QStringLiteral("#fff1f1")) + : QColor(QStringLiteral("#eaf2ff")); + const QColor border = isAwaiting || isAcknowledgedTransition + ? QColor(QStringLiteral("#9eb9df")) + : hasFailed ? QColor(QStringLiteral("#e5a3a3")) + : QColor(QStringLiteral("#bfd3f9")); + painter.setBrush(background); + painter.setPen(QPen(border, 1.0)); + painter.drawRoundedRect(bounds, 8.0, 8.0); + + if (!isAwaiting && !isAcknowledgedTransition) + return; + constexpr qreal HalfSweepWidth = 0.24; + constexpr qint64 HalfCycleMilliseconds = 850; + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + const qint64 phase = now % (2 * HalfCycleMilliseconds); + const qreal progress = + isAwaiting ? phase <= HalfCycleMilliseconds + ? qreal(phase) / HalfCycleMilliseconds + : qreal(2 * HalfCycleMilliseconds - phase) / + HalfCycleMilliseconds + : std::clamp(qreal(now - acknowledgedAtMilliseconds) / + AcknowledgementTransitionMilliseconds, + 0.0, 1.0); + const qreal center = bounds.left() + progress * bounds.width(); + const qreal radius = std::max(28.0, bounds.width() * HalfSweepWidth); + QLinearGradient sweep(center - radius, 0.0, center + radius, 0.0); + sweep.setColorAt(0.0, QColor(47, 111, 235, 0)); + sweep.setColorAt(0.5, QColor(117, 160, 239, 105)); + sweep.setColorAt(1.0, QColor(47, 111, 235, 0)); + QPainterPath clip; + clip.addRoundedRect(bounds, 8.0, 8.0); + painter.save(); + painter.setClipPath(clip); + painter.fillRect(bounds, sweep); + painter.restore(); + + painter.setBrush(Qt::NoBrush); + painter.setPen(QPen(QColor(QStringLiteral("#79a0d7")), 1.5)); + painter.drawRoundedRect(bounds, 8.0, 8.0); + } + +private: + QTimer animationTimer; + bool isAwaiting = false; + bool isAcknowledgedTransition = false; + qint64 acknowledgedAtMilliseconds = 0; + bool hasFailed = false; +}; + +class CommandOutputView final : public QPlainTextEdit { +public: + explicit CommandOutputView( + const QString &output, + std::optional restoredState = std::nullopt) + : followsLatest(restoredState ? restoredState->first : true), + preservedScrollValue(restoredState ? restoredState->second : 0) { + setReadOnly(true); + setMinimumHeight(0); + setMaximumHeight(MaximumCommandOutputHeight); + setLineWrapMode(QPlainTextEdit::WidgetWidth); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + setProperty("kind", "code"); + setObjectName(QStringLiteral("commandOutputView")); + setStyleSheet(QStringLiteral( + "background:#111827;color:#e5e7eb;border-radius:6px;padding:7px;" + "font-family:monospace;")); + setPlainText(output); + + connect(verticalScrollBar(), &QScrollBar::valueChanged, this, + [this](int value) { + if (adjustingScroll) + return; + preservedScrollValue = value; + followsLatest = value >= verticalScrollBar()->maximum() - 1; + }); + connect(verticalScrollBar(), &QScrollBar::rangeChanged, this, + [this](int, int) { scheduleScrollSettlement(); }); + connect(document()->documentLayout(), + &QAbstractTextDocumentLayout::documentSizeChanged, this, + [this](const QSizeF &) { remeasure(); }); + // Establish a content-derived height before the card enters the + // conversation layout. Otherwise it first appears at zero height and then + // changes the outer scroll range in a visibly separate pass. + remeasure(); + QTimer::singleShot(0, this, [this] { settleScroll(); }); + } + + [[nodiscard]] CommandOutputScrollState scrollState() const { + return {followsLatest, verticalScrollBar()->value()}; + } + + void setOutput(QString output) { + if (toPlainText() == output) + return; + setPlainText(std::move(output)); + remeasure(); + settleScroll(); + } + + QSize sizeHint() const override { + QSize result = QPlainTextEdit::sizeHint(); + result.setHeight(preferredHeight); + return result; + } + + QSize minimumSizeHint() const override { + QSize result = QPlainTextEdit::minimumSizeHint(); + result.setHeight(0); + return result; + } + +protected: + void resizeEvent(QResizeEvent *event) override { + QPlainTextEdit::resizeEvent(event); + remeasure(); + } + +private: + void remeasure() { + if (remeasuring) + return; + remeasuring = true; + const int contentHeight = static_cast( + std::ceil(document()->documentLayout()->documentSize().height())); + const int wantedHeight = std::clamp(contentHeight + 2 * frameWidth() + 14, + 0, MaximumCommandOutputHeight); + if (wantedHeight != preferredHeight) { + preferredHeight = wantedHeight; + updateGeometry(); + } + scheduleScrollSettlement(); + remeasuring = false; + } + + void scheduleScrollSettlement() { + if (scrollSettlementPending) + return; + scrollSettlementPending = true; + QTimer::singleShot(0, this, [this] { + scrollSettlementPending = false; + settleScroll(); + }); + } + + void settleScroll() { + adjustingScroll = true; + QScrollBar *scrollBar = verticalScrollBar(); + scrollBar->setValue( + followsLatest ? scrollBar->maximum() + : std::min(preservedScrollValue, scrollBar->maximum())); + adjustingScroll = false; + } + + bool followsLatest = true; + bool adjustingScroll = false; + bool remeasuring = false; + bool scrollSettlementPending = false; + int preservedScrollValue = 0; + int preferredHeight = 0; +}; + +std::optional +commandOutputScrollState(QWidget *card) { + if (!card) + return std::nullopt; + for (QPlainTextEdit *editor : card->findChildren()) { + if (auto *output = dynamic_cast(editor)) + return output->scrollState(); + } + return std::nullopt; +} + +class BottomOverlayDock final : public QWidget { +public: + BottomOverlayDock(QWidget *anchor, std::function heightChanged) + : QWidget(anchor), anchor(anchor), + heightChanged(std::move(heightChanged)) { + anchor->installEventFilter(this); + } + + void synchronizeGeometry() { + if (!layout()) + return; + layout()->activate(); + constexpr int HorizontalInset = 24; + constexpr int BottomInset = 12; + const int availableHeight = std::max(0, anchor->height() - BottomInset); + const int wantedHeight = std::min(sizeHint().height(), availableHeight); + if (wantedHeight != reportedHeight) { + reportedHeight = wantedHeight; + if (heightChanged) + heightChanged(wantedHeight); + } + setGeometry(HorizontalInset, availableHeight - wantedHeight, + std::max(0, anchor->width() - 2 * HorizontalInset), + wantedHeight); + raise(); + } + +protected: + bool event(QEvent *event) override { + const bool accepted = QWidget::event(event); + if (event->type() == QEvent::LayoutRequest || event->type() == QEvent::Show) + scheduleSynchronization(); + return accepted; + } + + bool eventFilter(QObject *watched, QEvent *event) override { + if (watched == anchor && + (event->type() == QEvent::Resize || event->type() == QEvent::Show || + event->type() == QEvent::LayoutRequest)) + scheduleSynchronization(); + return QWidget::eventFilter(watched, event); + } + +private: + void scheduleSynchronization() { + if (synchronizationPending) + return; + synchronizationPending = true; + QTimer::singleShot(0, this, [this] { + synchronizationPending = false; + synchronizeGeometry(); + }); + } + + QWidget *anchor = nullptr; + std::function heightChanged; + int reportedHeight = -1; + bool synchronizationPending = false; +}; + +QString text(const std::string &value) { + return QString::fromUtf8(value.data(), static_cast(value.size())); +} + +std::string stringValue(const nlohmann::json &object, const char *key) { + if (!object.is_object()) + return {}; + const auto iterator = object.find(key); + return iterator != object.end() && iterator->is_string() + ? iterator->get() + : std::string{}; +} + +QString displayStatus(const std::string &status) { + if (status == "inProgress" || status == "active") + return QStringLiteral("Running"); + if (status == "completed" || status == "idle") + return QStringLiteral("Completed"); + if (status == "failed" || status == "systemError") + return QStringLiteral("Failed"); + if (status.empty()) + return QStringLiteral("Unknown"); + return text(status); +} + +QString commandExecutionMetadata(const nlohmann::json &item) { + QStringList metadata; + metadata << displayStatus(stringValue(item, "status")); + if (item.contains("exitCode") && item["exitCode"].is_number_integer()) + metadata << QStringLiteral("exit %1").arg(item["exitCode"].get()); + const QString cwd = text(stringValue(item, "cwd")); + if (!cwd.isEmpty()) + metadata << cwd; + return metadata.join(QStringLiteral(" | ")); +} + +QLabel *makeLabel(QString value, const char *kind) { + auto *label = new QLabel(std::move(value)); + label->setProperty("kind", kind); + label->setTextFormat(Qt::PlainText); + label->setWordWrap(true); + label->setMinimumWidth(0); + label->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + label->setTextInteractionFlags(Qt::TextSelectableByMouse); + return label; +} + +QLabel *makeMarkdownLabel(const QString &value) { + QTextDocument document; + document.setMarkdown(value, QTextDocument::MarkdownNoHTML); + auto *label = new QLabel(document.toHtml()); + label->setProperty("kind", "body"); + label->setTextFormat(Qt::RichText); + label->setWordWrap(true); + label->setMinimumWidth(0); + label->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + label->setOpenExternalLinks(true); + label->setTextInteractionFlags(Qt::TextSelectableByMouse | + Qt::LinksAccessibleByMouse); + return label; +} + +QFrame *makeDivider() { + auto *divider = new QFrame; + divider->setFixedHeight(1); + divider->setStyleSheet(QStringLiteral("background:#d7dee8;")); + return divider; +} + +QFrame *makeStatusDot() { + auto *dot = new QFrame; + dot->setFixedSize(8, 8); + dot->setStyleSheet(QStringLiteral("background:#98a2b3;border-radius:4px;")); + return dot; +} + +void clearLayout(QLayout *layout) { + while (QLayoutItem *item = layout->takeAt(0)) { + if (QWidget *widget = item->widget()) { + widget->hide(); + widget->deleteLater(); + } + if (QLayout *child = item->layout()) { + clearLayout(child); + delete child; + } + delete item; + } +} + +QString joinedStrings(const nlohmann::json &value) { + if (!value.is_array()) + return {}; + QStringList result; + for (const auto &item : value) { + if (item.is_string()) + result.push_back(text(item.get())); + } + return result.join(QStringLiteral(", ")); +} + +QString messageText(const nlohmann::json &item) { + const std::string type = stringValue(item, "type"); + if (type == "agentMessage" || type == "plan") + return text(stringValue(item, "text")); + if (type == "userMessage") { + QStringList parts; + const nlohmann::json content = + item.value("content", nlohmann::json::array()); + if (content.is_array()) { + for (const auto &entry : content) { + const std::string value = stringValue(entry, "text"); + if (!value.empty()) + parts.push_back(text(value)); + } + } + return parts.join(QStringLiteral("\n")); + } + return {}; +} + +std::string safeMessage(const nlohmann::json &value) { + std::string message = stringValue(value, "message"); + if (message.empty()) + message = stringValue(value, "detail"); + if (!message.empty()) + return message; + const auto error = value.find("error"); + return error != value.end() && error->is_object() + ? stringValue(*error, "message") + : std::string{}; +} + +bool isThreadNotFoundResult(const nlohmann::json &result) { + if (result.value("ok", false)) + return false; + const QString message = + text(safeMessage(result.value("error", nlohmann::json::object()))) + .toLower(); + return message.contains(QStringLiteral("thread")) && + message.contains(QStringLiteral("not found")); +} + +QFrame *itemFrame( + const ItemPresentation &presentation, + std::optional outputScrollState = std::nullopt) { + const nlohmann::json &item = presentation.raw; + const std::string typeName = stringValue(item, "type"); + auto *frame = new QFrame; + frame->setProperty("kind", "raised"); + if (typeName == "userMessage") + frame->setProperty("messageRole", "user"); + else if (typeName == "agentMessage") + frame->setProperty("messageRole", "agent"); + auto *layout = new QVBoxLayout(frame); + layout->setContentsMargins(12, 10, 12, 10); + layout->setSpacing(6); + + QString title; + if (typeName == "userMessage") + title = QStringLiteral("You"); + else if (typeName == "agentMessage") + title = stringValue(item, "phase") == "final_answer" + ? QStringLiteral("Codex") + : QStringLiteral("Codex activity"); + else if (typeName == "commandExecution") + title = QStringLiteral("Command execution"); + else if (typeName == "collabAgentToolCall" || typeName == "subAgentActivity") + title = QStringLiteral("Agent activity"); + else if (typeName == "reasoning") + title = QStringLiteral("Reasoning"); + else if (typeName == "fileChange") + title = QStringLiteral("File changes"); + else + title = text(typeName.empty() ? std::string("Activity") : typeName); + layout->addWidget(makeLabel(title, "title")); + + const QString body = messageText(item); + if (!body.isEmpty()) { + layout->addWidget(typeName == "agentMessage" || typeName == "plan" + ? makeMarkdownLabel(body) + : makeLabel(body)); + } + + if (typeName == "commandExecution") { + const QString command = text(stringValue(item, "command")); + if (!command.isEmpty()) { + auto *commandView = new QPlainTextEdit(command); + commandView->setReadOnly(true); + commandView->setMaximumHeight(90); + commandView->setLineWrapMode(QPlainTextEdit::WidgetWidth); + commandView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + commandView->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + commandView->setProperty("kind", "command"); + commandView->setObjectName(QStringLiteral("commandTextView")); + commandView->setStyleSheet(QStringLiteral( + "background:#f8fafc;border:1px solid #d7dee8;border-radius:6px;" + "padding:7px;font-family:monospace;")); + layout->addWidget(commandView); + } + const QString output = text(stringValue(item, "aggregatedOutput")); + if (commandOutputIsVisible(output)) { + layout->addWidget(new CommandOutputView(output, outputScrollState)); + } + auto *metadata = makeLabel(commandExecutionMetadata(item), "meta"); + metadata->setObjectName(QStringLiteral("commandMetadata")); + layout->addWidget(metadata); + } else if (typeName == "collabAgentToolCall" || + typeName == "subAgentActivity") { + QStringList metadata; + const QString tool = text(stringValue(item, "tool")); + if (!tool.isEmpty()) + metadata << tool; + std::string status = stringValue(item, "status"); + if (status.empty()) + status = stringValue(item, "kind"); + metadata << displayStatus(status); + const QString receivers = + joinedStrings(item.value("receiverThreadIds", nlohmann::json::array())); + if (!receivers.isEmpty()) + metadata << receivers; + layout->addWidget( + makeLabel(metadata.join(QStringLiteral(" | ")), "meta")); + const QString prompt = text(stringValue(item, "prompt")); + if (!prompt.isEmpty()) + layout->addWidget(makeLabel(prompt)); + const QString result = text(stringValue(item, "resultText")); + if (!result.isEmpty()) + layout->addWidget(makeMarkdownLabel(result)); + } else if (typeName == "reasoning") { + const QString summaries = + joinedStrings(item.value("summary", nlohmann::json::array())); + if (!summaries.isEmpty()) + layout->addWidget(makeMarkdownLabel(summaries)); + } else if (typeName == "fileChange") { + QStringList metadata; + metadata << displayStatus(stringValue(item, "status")); + const nlohmann::json changes = + item.value("changes", nlohmann::json::array()); + if (changes.is_array()) + metadata << QStringLiteral("%1 paths") + .arg(static_cast(changes.size())); + layout->addWidget( + makeLabel(metadata.join(QStringLiteral(" | ")), "meta")); + } else if (body.isEmpty()) { + layout->addWidget(makeLabel(text(item.dump(2)), "meta")); + } + return frame; +} + +bool updateCommandExecutionFrame(QWidget *frame, + const ItemPresentation &presentation) { + if (!frame || stringValue(presentation.raw, "type") != "commandExecution") + return false; + auto *layout = qobject_cast(frame->layout()); + auto *metadata = + frame->findChild(QStringLiteral("commandMetadata")); + if (!layout || !metadata) + return false; + + const QString command = text(stringValue(presentation.raw, "command")); + auto *commandView = + frame->findChild(QStringLiteral("commandTextView")); + if (commandView && !command.isEmpty() && + commandView->toPlainText() != command) + commandView->setPlainText(command); + else if ((!commandView && !command.isEmpty()) || + (commandView && command.isEmpty())) + return false; + + const QString output = + text(stringValue(presentation.raw, "aggregatedOutput")); + auto *outputView = dynamic_cast( + frame->findChild(QStringLiteral("commandOutputView"))); + if (commandOutputIsVisible(output)) { + if (outputView) { + outputView->setOutput(output); + } else { + outputView = new CommandOutputView(output); + const int metadataIndex = layout->indexOf(metadata); + layout->insertWidget(std::max(0, metadataIndex), outputView); + } + } else if (outputView) { + layout->removeWidget(outputView); + outputView->hide(); + outputView->deleteLater(); + } + metadata->setText(commandExecutionMetadata(presentation.raw)); + frame->updateGeometry(); + return true; +} + +QFrame *agentFrame(const AgentPresentation &agent) { + const nlohmann::json &activity = agent.raw; + auto *frame = new QFrame; + frame->setProperty("kind", "raised"); + auto *layout = new QVBoxLayout(frame); + layout->setContentsMargins(12, 10, 12, 10); + layout->setSpacing(6); + + const std::string tool = stringValue(activity, "tool"); + const bool childAgent = !agent.childThreadId.empty(); + const QString title = childAgent ? QStringLiteral("Subagent") + : tool.empty() + ? QStringLiteral("Agent activity") + : QStringLiteral("Agent %1").arg(text(tool)); + layout->addWidget(makeLabel(title, "title")); + + QStringList metadata; + metadata << displayStatus(agent.status); + const QString path = text(stringValue(activity, "agentPath")); + if (!path.isEmpty()) + metadata << path; + if (!tool.empty()) + metadata << text(tool); + const QString model = text(stringValue(activity, "model")); + if (!model.isEmpty()) + metadata << model; + const QString effort = text(stringValue(activity, "reasoningEffort")); + if (!effort.isEmpty()) + metadata << effort; + layout->addWidget(makeLabel(metadata.join(QStringLiteral(" | ")), "meta")); + + const QString prompt = text(stringValue(activity, "prompt")); + if (!prompt.isEmpty()) + layout->addWidget(makeLabel(prompt)); + + const QString result = text(stringValue(activity, "resultText")); + if (!result.isEmpty()) + layout->addWidget(makeMarkdownLabel(result)); + + QStringList identities; + if (!agent.childThreadId.empty()) + identities << QStringLiteral("thread %1").arg(text(agent.childThreadId)); + const QString sender = text(stringValue(activity, "senderThreadId")); + if (!sender.isEmpty()) + identities << QStringLiteral("sender %1").arg(sender); + const QString receivers = joinedStrings( + activity.value("receiverThreadIds", nlohmann::json::array())); + if (!receivers.isEmpty()) + identities << QStringLiteral("receivers %1").arg(receivers); + if (!identities.isEmpty()) + layout->addWidget( + makeLabel(identities.join(QStringLiteral(" | ")), "meta")); + return frame; +} + +} // namespace + +ShellWidget::ShellWidget(FrontendSession &session, QWidget *parent) + : QWidget(parent), session(session) { + setObjectName(QStringLiteral("workbench")); + auto *root = new QVBoxLayout(this); + root->setContentsMargins(0, 0, 0, 0); + root->setSpacing(0); + + auto *top = new QFrame; + top->setObjectName(QStringLiteral("topBar")); + top->setStyleSheet(QStringLiteral( + "QFrame#topBar{background:#ffffff;border-bottom:1px solid #d7dee8;}")); + top->setFixedHeight(64); + auto *topLayout = new QHBoxLayout(top); + topLayout->setContentsMargins(18, 0, 18, 0); + topLayout->setSpacing(12); + topLayout->addWidget(codexui::BrandMark::createLockup()); + restoreSidebarButton = new QPushButton(QStringLiteral("Show threads")); + restoreSidebarButton->setProperty("kind", "subtle"); + restoreSidebarButton->setFixedHeight(32); + restoreSidebarButton->hide(); + topLayout->addSpacing(12); + topLayout->addWidget(restoreSidebarButton); + topLayout->addSpacing(18); + workspaceBreadcrumb = makeLabel(QStringLiteral("No workspace"), "muted"); + workspaceBreadcrumb->setWordWrap(false); + workspaceBreadcrumb->setMaximumWidth(280); + workspaceBreadcrumb->setStyleSheet( + QStringLiteral("color:#667085;font-weight:500;")); + topLayout->addWidget(workspaceBreadcrumb); + requestButton = new QPushButton; + requestButton->setProperty("kind", "request"); + requestButton->setFixedHeight(32); + requestButton->hide(); + connect(requestButton, &QPushButton::clicked, this, [this] { + inspector->show(); + restoreInspectorButton->hide(); + inspectorTabs->setCurrentIndex(3); + }); + topLayout->addStretch(); + connectionStatusDot = makeStatusDot(); + connectionStatusDot->setToolTip(QStringLiteral("Not connected")); + connectionButton = new QToolButton; + connectionButton->setText(QStringLiteral("Connection")); + connectionButton->setProperty("kind", "subtle"); + connectionButton->setPopupMode(QToolButton::InstantPopup); + connectionButton->setFixedHeight(32); + auto *connectionMenu = new QMenu(connectionButton); + connectionMenu->addAction(QStringLiteral("Configure..."), this, [this] { + if (!model.connection().settings.is_object() || + model.connection().settings.empty()) { + showNotice(QStringLiteral("Connection settings are not available yet.")); + return; + } + ConnectionDialog dialog(model.connection().settings, this); + if (dialog.exec() != QDialog::Accepted) + return; + this->session.configureConnection( + dialog.selection(), [this](const nlohmann::json &result) { + if (result.value("ok", false)) + return; + const std::string message = + safeMessage(result.value("error", nlohmann::json::object())); + showNotice(text(message.empty() + ? std::string("Connection configuration failed") + : message)); + }); + }); + connectionMenu->addSeparator(); + connectAction = + connectionMenu->addAction(QStringLiteral("Connect"), this, + [this] { this->session.connectTransport(); }); + disconnectAction = + connectionMenu->addAction(QStringLiteral("Disconnect"), this, [this] { + this->session.disconnectTransport(); + }); + reconnectAction = connectionMenu->addAction( + QStringLiteral("Reconnect"), this, [this] { this->session.reconnect(); }); + connectionButton->setMenu(connectionMenu); + controllerButton = new QPushButton(QStringLiteral("Claim control")); + controllerButton->setFixedHeight(32); + restoreInspectorButton = new QPushButton(QStringLiteral("Show inspector")); + restoreInspectorButton->setProperty("kind", "subtle"); + restoreInspectorButton->setFixedHeight(32); + restoreInspectorButton->hide(); + connect(controllerButton, &QPushButton::clicked, this, [this] { + if (model.connection().role == "controller") + this->session.releaseController(); + else + this->session.claimController(); + }); + topLayout->addWidget(restoreInspectorButton); + topLayout->addWidget(requestButton); + topLayout->addWidget(controllerButton); + auto *connectionControl = new QWidget; + auto *connectionLayout = new QHBoxLayout(connectionControl); + connectionLayout->setContentsMargins(0, 0, 0, 0); + connectionLayout->setSpacing(6); + connectionLayout->addWidget(connectionButton); + connectionLayout->addWidget(connectionStatusDot); + topLayout->addWidget(connectionControl); + root->addWidget(top); + + splitter = new QSplitter(Qt::Horizontal); + splitter->setChildrenCollapsible(false); + splitter->setHandleWidth(8); + + sidebar = new QFrame; + sidebar->setObjectName(QStringLiteral("sidebar")); + sidebar->setStyleSheet(QStringLiteral("QFrame#sidebar{background:#f8fafc;}")); + sidebar->setMinimumWidth(220); + sidebar->setMaximumWidth(440); + auto *sidebarLayout = new QVBoxLayout(sidebar); + sidebarLayout->setContentsMargins(10, 14, 10, 17); + sidebarLayout->setSpacing(0); + auto *sidebarHeader = new QHBoxLayout; + sidebarHeader->setContentsMargins(8, 0, 6, 8); + sidebarHeader->addWidget(makeLabel(QStringLiteral("WORK"), "section")); + sidebarHeader->addStretch(); + auto *hideSidebarButton = new QPushButton(QStringLiteral("Hide")); + hideSidebarButton->setProperty("kind", "subtle"); + hideSidebarButton->setFixedSize(52, 24); + sidebarHeader->addWidget(hideSidebarButton); + sidebarLayout->addLayout(sidebarHeader); + + auto *newButton = new QPushButton(QStringLiteral("+ New thread")); + newButton->setFixedHeight(36); + newButton->setStyleSheet(QStringLiteral( + "QPushButton{background:#ffffff;color:#2f6feb;border:1px solid #bfd3f9;" + "border-radius:8px;text-align:left;padding-left:14px;font-weight:600;}" + "QPushButton:hover{background:#e5eeff;border-color:#2f6feb;}" + "QPushButton:disabled{background:#f6f8fb;color:#98a2b3;" + "border-color:#d7dee8;}")); + sidebarLayout->addWidget(newButton); + sidebarLayout->addSpacing(8); + + auto *threadToolbar = new QHBoxLayout; + threadToolbar->setContentsMargins(4, 0, 4, 6); + auto *refreshButton = new QPushButton(QStringLiteral("Refresh")); + refreshButton->setProperty("kind", "subtle"); + refreshButton->setFixedHeight(28); + threadToolbar->addWidget(refreshButton); + threadToolbar->addStretch(); + sidebarLayout->addLayout(threadToolbar); + threadList = new QListWidget; + threadList->setObjectName(QStringLiteral("threadList")); + threadList->setSelectionMode(QAbstractItemView::SingleSelection); + threadList->setContextMenuPolicy(Qt::CustomContextMenu); + threadList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + threadList->setTextElideMode(Qt::ElideRight); + threadList->setStyleSheet(QStringLiteral( + "QListWidget#threadList{background:transparent;border:0;outline:0;}" + "QListWidget#threadList::item{min-height:30px;border:0;border-radius:5px;" + "padding:2px 8px;color:#344054;}" + "QListWidget#threadList::item:hover{background:#eef3fa;}" + "QListWidget#threadList::item:selected{background:#e5eeff;" + "color:#1d2633;font-weight:600;}")); + sidebarLayout->addWidget(threadList); + connect(refreshButton, &QPushButton::clicked, this, + [this] { requestThreads(); }); + connect(newButton, &QPushButton::clicked, this, [this] { beginNewThread(); }); + connect(hideSidebarButton, &QPushButton::clicked, this, [this] { + sidebar->hide(); + restoreSidebarButton->show(); + }); + connect(restoreSidebarButton, &QPushButton::clicked, this, [this] { + sidebar->show(); + restoreSidebarButton->hide(); + }); + connect(threadList, &QListWidget::itemSelectionChanged, this, [this] { + const std::string threadId = visiblySelectedThreadId(); + if (!threadId.empty() && threadId != selectedThreadId) + selectThread(threadId); + }); + connect(threadList, &QListWidget::customContextMenuRequested, this, + [this](const QPoint &position) { + QListWidgetItem *item = threadList->itemAt(position); + if (!item) + return; + const std::string threadId = + item->data(Qt::UserRole).toString().toStdString(); + const ThreadPresentation *thread = model.thread(threadId); + if (!thread) + return; + QMenu menu(threadList); + menu.addAction(QStringLiteral("Reload"), this, + [this, threadId] { readThread(threadId); }); + const bool canControl = model.connection().connected && + model.connection().role == "controller"; + QAction *rename = + menu.addAction(QStringLiteral("Rename"), this, + [this, threadId] { renameThread(threadId); }); + QAction *fork = + menu.addAction(QStringLiteral("Fork"), this, + [this, threadId] { forkThread(threadId); }); + QAction *archive = menu.addAction( + thread->archived ? QStringLiteral("Unarchive") + : QStringLiteral("Archive"), + this, [this, threadId] { toggleThreadArchive(threadId); }); + menu.addSeparator(); + QAction *remove = + menu.addAction(QStringLiteral("Delete"), this, + [this, threadId] { deleteThread(threadId); }); + rename->setEnabled(canControl); + fork->setEnabled(canControl); + archive->setEnabled(canControl); + remove->setEnabled(canControl); + menu.exec(threadList->viewport()->mapToGlobal(position)); + }); + splitter->addWidget(sidebar); + + conversationRegion = new QFrame; + conversationRegion->setObjectName(QStringLiteral("conversation")); + conversationRegion->setStyleSheet( + QStringLiteral("QFrame#conversation{background:#f6f8fb;}")); + conversationRegion->setMinimumWidth(480); + auto *centerLayout = new QVBoxLayout(conversationRegion); + centerLayout->setContentsMargins(24, 14, 24, 12); + centerLayout->setSpacing(0); + auto *context = new QHBoxLayout; + auto *threadBadge = makeLabel(QStringLiteral("THREAD"), "small"); + threadBadge->setAlignment(Qt::AlignCenter); + threadBadge->setFixedSize(58, 20); + threadBadge->setStyleSheet( + QStringLiteral("background:#e5eeff;color:#2f6feb;border-radius:5px;" + "font-weight:600;")); + context->addWidget(threadBadge); + context->addStretch(); + centerLayout->addLayout(context); + centerLayout->addSpacing(2); + conversationTitle = + makeLabel(QStringLiteral("No synchronized thread"), "heading"); + conversationMeta = makeLabel({}, "meta"); + centerLayout->addWidget(conversationTitle); + centerLayout->addSpacing(2); + centerLayout->addWidget(conversationMeta); + centerLayout->addSpacing(7); + centerLayout->addWidget(makeDivider()); + centerLayout->addSpacing(7); + + noticeBar = new QFrame; + noticeBar->setStyleSheet(QStringLiteral( + "background:#fff4f2;border:1px solid #efc2bc;border-radius:6px;")); + auto *noticeLayout = new QHBoxLayout(noticeBar); + noticeLayout->setContentsMargins(10, 6, 8, 6); + noticeLabel = makeLabel({}, "meta"); + noticeLabel->setStyleSheet(QStringLiteral("color:#9d2e2e;")); + auto *dismissNotice = new QPushButton(QStringLiteral("Dismiss")); + dismissNotice->setProperty("kind", "subtle"); + dismissNotice->setFixedHeight(28); + noticeLayout->addWidget(noticeLabel, 1); + noticeLayout->addWidget(dismissNotice); + noticeBar->hide(); + connect(dismissNotice, &QPushButton::clicked, noticeBar, &QWidget::hide); + centerLayout->addWidget(noticeBar); + + conversationScroll = new QScrollArea; + conversationScroll->setWidgetResizable(true); + conversationScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + conversationScroll->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn); + conversationContent = new QWidget; + conversationContent->setMinimumWidth(0); + conversationContent->setSizePolicy(QSizePolicy::Ignored, + QSizePolicy::Preferred); + conversationLayout = new QVBoxLayout(conversationContent); + conversationLayout->setContentsMargins(0, 0, 0, 16); + conversationLayout->setSpacing(8); + emptyConversation = + makeLabel(QStringLiteral("Conversation activity appears here."), "muted"); + conversationLayout->addWidget(emptyConversation); + addConversationTrailingSpace(); + conversationLayout->addStretch(); + conversationScroll->setWidget(conversationContent); + QScrollBar *conversationScrollBar = conversationScroll->verticalScrollBar(); + conversationScrollAnimation = new QVariantAnimation(this); + conversationScrollAnimation->setEasingCurve(QEasingCurve::OutCubic); + connect(conversationScrollAnimation, &QVariantAnimation::valueChanged, this, + [this, conversationScrollBar](const QVariant &value) { + if (!conversationFollowsLatest || conversationScrollRebuilding || + conversationSpacerAdjusting) { + conversationScrollAnimation->stop(); + return; + } + conversationScrollProgrammatic = true; + conversationScrollBar->setValue( + std::max(value.toInt(), conversationSmoothScrollFloor)); + conversationScrollProgrammatic = false; + }); + const auto stopSmoothFollowForUser = [this] { + stopConversationScrollAnimation(); + conversationSmoothScrollFloor = 0; + }; + connect(conversationScrollBar, &QScrollBar::actionTriggered, this, + [this, stopSmoothFollowForUser](int) { + conversationUserScrollPending = true; + stopSmoothFollowForUser(); + }); + connect(conversationScrollBar, &QScrollBar::sliderPressed, this, + [this, stopSmoothFollowForUser] { + conversationUserScrollInteraction = true; + stopSmoothFollowForUser(); + }); + connect(conversationScrollBar, &QScrollBar::sliderReleased, this, [this] { + conversationUserScrollInteraction = false; + conversationUserScrollPending = false; + }); + connect(conversationScrollBar, &QScrollBar::valueChanged, this, + [this, conversationScrollBar](int value) { + if (conversationScrollRebuilding || + conversationScrollProgrammatic || conversationSpacerAdjusting) + return; + const bool userInitiated = conversationUserScrollPending || + conversationUserScrollInteraction; + if (!conversationUserScrollInteraction) + conversationUserScrollPending = false; + if (!userInitiated) { + if (conversationFollowsLatest) + scheduleConversationFollowLatest(); + else + scheduleConversationPausedAnchorRestore(); + return; + } + conversationFollowsLatest = + value >= conversationScrollBar->maximum() - 1; + if (!conversationFollowsLatest) { + conversationSmoothScrollFloor = 0; + conversationPausedAnchor = captureConversationScrollAnchor(); + conversationPausedAnchorValid = true; + } else { + conversationPausedAnchorValid = false; + } + }); + connect(conversationScrollBar, &QScrollBar::rangeChanged, this, + [this](int, int) { + if (conversationScrollRebuilding || conversationSpacerAdjusting) + return; + if (conversationFollowsLatest) + scheduleConversationFollowLatest(); + else + scheduleConversationPausedAnchorRestore(); + }); + centerLayout->addWidget(conversationScroll, 1); + + composerReserve = new QWidget; + composerReserve->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + composerReserve->setFixedHeight(0); + centerLayout->addWidget(composerReserve); + + auto *composerDock = + new BottomOverlayDock(conversationRegion, [this](int height) { + updateComposerDockHeight(height); + }); + auto *composerDockLayout = new QVBoxLayout(composerDock); + composerDockLayout->setContentsMargins(0, 8, 0, 0); + composerDockLayout->setSpacing(0); + + auto *attention = new QFrame; + attention->setProperty("kind", "amberBadge"); + auto *attentionLayout = new QHBoxLayout(attention); + attentionLayout->setContentsMargins(10, 6, 10, 6); + attentionLayout->addWidget(makeLabel( + QStringLiteral("A Codex request needs attention"), "attentionSection")); + attentionLayout->addStretch(); + approveButton = new QPushButton(QStringLiteral("Review")); + denyButton = new QPushButton(QStringLiteral("Deny")); + attentionLayout->addWidget(denyButton); + attentionLayout->addWidget(approveButton); + connect(approveButton, &QPushButton::clicked, this, + [this] { respondToFirstPending(true); }); + connect(denyButton, &QPushButton::clicked, this, + [this] { respondToFirstPending(false); }); + attention->hide(); + composerDockLayout->addWidget(attention); + + turnSettings = new TurnSettingsWidget; + composerDockLayout->addWidget(turnSettings); + + auto *composer = new QFrame; + composer->setProperty("kind", "composer"); + auto *composerLayout = new QVBoxLayout(composer); + composerLayout->setContentsMargins(10, 8, 8, 8); + composerLayout->setSpacing(6); + attachmentPanel = new QFrame; + attachmentPanel->setProperty("kind", "summary"); + auto *attachmentPanelLayout = new QVBoxLayout(attachmentPanel); + attachmentPanelLayout->setContentsMargins(6, 6, 6, 6); + attachmentPanelLayout->setSpacing(4); + attachmentListScroll = new QScrollArea; + attachmentListScroll->setWidgetResizable(true); + attachmentListScroll->setFrameShape(QFrame::NoFrame); + attachmentListScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + auto *attachmentListContent = new QWidget; + attachmentListLayout = new QVBoxLayout(attachmentListContent); + attachmentListLayout->setContentsMargins(0, 0, 0, 0); + attachmentListLayout->setSpacing(4); + attachmentListScroll->setWidget(attachmentListContent); + attachmentPanelLayout->addWidget(attachmentListScroll); + attachmentPanel->hide(); + composerLayout->addWidget(attachmentPanel); + + composerBody = new QWidget; + composerBody->installEventFilter(this); + composerGrid = new QGridLayout(composerBody); + composerGrid->setContentsMargins(0, 0, 0, 0); + composerGrid->setHorizontalSpacing(8); + composerGrid->setVerticalSpacing(6); + composerGrid->setColumnStretch(1, 1); + + attachmentButton = new QToolButton; + attachmentButton->setProperty("kind", "composerAction"); + attachmentButton->setIcon(QIcon::fromTheme(QIcon::ThemeIcon::MailAttachment)); + attachmentButton->setIconSize(QSize(16, 16)); + attachmentButton->setToolTip(QStringLiteral("Attach files")); + attachmentButton->setAccessibleName(QStringLiteral("Attach files")); + attachmentButton->setFixedSize(UpcomingControlHeight, UpcomingControlHeight); + promptEditor = new codexui::ExpandingPromptEditor; + sendButton = new QPushButton(QStringLiteral("Send")); + sendButton->setProperty("kind", "primary"); + sendButton->setFixedSize(62, UpcomingControlHeight); + interruptButton = new QPushButton(QStringLiteral("Stop")); + interruptButton->setProperty("kind", "stop"); + interruptButton->setFixedSize(54, UpcomingControlHeight); + interruptButton->hide(); + composerGrid->addWidget(attachmentButton, 0, 0); + composerGrid->addWidget(promptEditor, 0, 1); + composerGrid->addWidget(sendButton, 0, 2); + composerLayout->addWidget(composerBody); + composerDockLayout->addWidget(composer); + connect(promptEditor, &codexui::ExpandingPromptEditor::editorHeightChanged, + composerDock, + [composerDock] { composerDock->synchronizeGeometry(); }); + QTimer::singleShot(0, composerDock, [composerDock] { + composerDock->layout()->activate(); + composerDock->synchronizeGeometry(); + }); + connect(sendButton, &QPushButton::clicked, this, [this] { submitPrompt(); }); + connect(promptEditor, &codexui::ExpandingPromptEditor::submitRequested, this, + [this] { submitPrompt(); }); + connect(promptEditor, &QPlainTextEdit::textChanged, this, + [this] { scheduleComposerLayout(); }); + connect(interruptButton, &QPushButton::clicked, this, + [this] { interruptActiveTurn(); }); + connect(attachmentButton, &QPushButton::clicked, this, + [this] { chooseAttachments(); }); + splitter->addWidget(conversationRegion); + + inspector = new QFrame; + inspector->setObjectName(QStringLiteral("inspector")); + inspector->setStyleSheet( + QStringLiteral("QFrame#inspector{background:#fbfcfe;}")); + inspector->setMinimumWidth(300); + inspector->setMaximumWidth(520); + auto *inspectorLayout = new QVBoxLayout(inspector); + inspectorLayout->setContentsMargins(18, 14, 20, 0); + inspectorLayout->setSpacing(0); + auto *inspectorHeader = new QHBoxLayout; + inspectorHeader->addWidget(makeLabel(QStringLiteral("INSPECTOR"), "section")); + inspectorHeader->addStretch(); + auto *hideInspectorButton = new QPushButton(QStringLiteral("Hide")); + hideInspectorButton->setProperty("kind", "subtle"); + hideInspectorButton->setFixedSize(58, 24); + inspectorHeader->addWidget(hideInspectorButton); + inspectorLayout->addLayout(inspectorHeader); + inspectorLayout->addSpacing(7); + inspectorTabs = new QTabWidget; + inspectorTabs->setDocumentMode(true); + planContent = new QWidget; + planLayout = new QVBoxLayout(planContent); + planLayout->setContentsMargins(12, 12, 12, 12); + planLayout->setSpacing(8); + agentsContent = new QWidget; + agentsLayout = new QVBoxLayout(agentsContent); + agentsLayout->setContentsMargins(12, 12, 12, 12); + agentsLayout->setSpacing(8); + diffViewer = new DiffViewer; + requestsContent = new QWidget; + requestsLayout = new QVBoxLayout(requestsContent); + requestsLayout->setContentsMargins(12, 12, 12, 12); + requestsLayout->setSpacing(8); + auto *planScroll = new QScrollArea; + planScroll->setWidgetResizable(true); + planScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + planScroll->setWidget(planContent); + auto *agentsScroll = new QScrollArea; + agentsScroll->setWidgetResizable(true); + agentsScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + agentsScroll->setWidget(agentsContent); + auto *requestsScroll = new QScrollArea; + requestsScroll->setWidgetResizable(true); + requestsScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + requestsScroll->setWidget(requestsContent); + auto *protocolContent = new QWidget; + auto *protocolLayout = new QVBoxLayout(protocolContent); + protocolLayout->setContentsMargins(8, 8, 8, 8); + protocolLayout->setSpacing(6); + protocolStats = makeLabel({}, "meta"); + protocolLog = new QPlainTextEdit; + protocolLog->setProperty("kind", "infoViewer"); + protocolLog->setReadOnly(true); + protocolLog->setLineWrapMode(QPlainTextEdit::WidgetWidth); + protocolLog->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + protocolLog->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + protocolLog->verticalScrollBar()->setProperty("kind", "infoViewer"); + protocolLog->document()->setMaximumBlockCount(200); + protocolLayout->addWidget(protocolLog, 1); + protocolLayout->addWidget(protocolStats); + auto *stateContent = new QWidget; + auto *stateLayout = new QVBoxLayout(stateContent); + stateLayout->setContentsMargins(8, 8, 8, 8); + stateView = new QPlainTextEdit; + stateView->setProperty("kind", "infoViewer"); + stateView->setReadOnly(true); + stateView->setLineWrapMode(QPlainTextEdit::WidgetWidth); + stateView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + stateView->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + stateView->verticalScrollBar()->setProperty("kind", "infoViewer"); + stateLayout->addWidget(stateView); + infoTabs = new QTabWidget; + infoTabs->setDocumentMode(true); + infoTabs->addTab(stateContent, QStringLiteral("State")); + infoTabs->addTab(protocolContent, QStringLiteral("Protocol")); + connect(infoTabs, &QTabWidget::currentChanged, this, [this](int index) { + if (index == 0) { + scheduleRefresh(RefreshState); + return; + } + showProtocolTail(); + scheduleRefresh(RefreshProtocolStats); + }); + inspectorTabs->addTab(planScroll, QStringLiteral("Plan")); + inspectorTabs->addTab(agentsScroll, QStringLiteral("Agents")); + inspectorTabs->addTab(diffViewer, QStringLiteral("Changes")); + inspectorTabs->addTab(requestsScroll, QStringLiteral("Requests")); + inspectorTabs->addTab(infoTabs, QStringLiteral("Info")); + connect(inspectorTabs, &QTabWidget::currentChanged, this, [this](int index) { + if (index == 4) { + if (infoTabs && infoTabs->currentIndex() == 1) + showProtocolTail(); + } + scheduleRefresh(RefreshInspector | RefreshState | RefreshProtocolStats); + }); + inspectorLayout->addWidget(inspectorTabs, 1); + connect(hideInspectorButton, &QPushButton::clicked, this, [this] { + inspector->hide(); + restoreInspectorButton->show(); + }); + connect(restoreInspectorButton, &QPushButton::clicked, this, [this] { + inspector->show(); + restoreInspectorButton->hide(); + }); + splitter->addWidget(inspector); + splitter->setStretchFactor(0, 0); + splitter->setStretchFactor(1, 1); + splitter->setStretchFactor(2, 0); + splitter->setSizes({282, 834, 404}); + qApp->installEventFilter(this); + root->addWidget(splitter, 1); + + auto *statusBar = new QFrame; + statusBar->setObjectName(QStringLiteral("customStatusBar")); + statusBar->setStyleSheet( + QStringLiteral("QFrame#customStatusBar{background:#f8fafc;" + "border-top:1px solid #d7dee8;}")); + statusBar->setFixedHeight(40); + auto *statusLayout = new QHBoxLayout(statusBar); + statusLayout->setContentsMargins(18, 0, 24, 0); + statusLayout->setSpacing(8); + threadContextStatus = makeLabel(QStringLiteral("No thread context"), "meta"); + statusLayout->addWidget(threadContextStatus); + statusLayout->addSpacing(42); + agentActivityStatus = makeLabel(QStringLiteral("No agent activity"), "meta"); + statusLayout->addWidget(agentActivityStatus); + statusLayout->addStretch(); + controllerLabel = makeLabel(QStringLiteral("Observer"), "meta"); + statusLayout->addWidget(controllerLabel); + root->addWidget(statusBar); + + refreshTimer = new QTimer(this); + refreshTimer->setSingleShot(true); + refreshTimer->setInterval(32); + connect(refreshTimer, &QTimer::timeout, this, [this] { refresh(); }); + + session.setEventHandler( + [this](const nlohmann::json &event) { handleEvent(event); }); + refresh(); +} + +bool ShellWidget::eventFilter(QObject *watched, QEvent *event) { + if (event->type() == QEvent::Wheel && conversationRegion && + conversationScroll) { + auto *target = qobject_cast(watched); + const bool inConversationRegion = + target && (target == conversationRegion || + conversationRegion->isAncestorOf(target)); + const bool onSplitterHandle = + target && splitter && + (target == splitter->handle(1) || target == splitter->handle(2)); + if (inConversationRegion || onSplitterHandle) { + bool insideScrollableChild = false; + if (inConversationRegion) { + for (QWidget *ancestor = target; + ancestor && ancestor != conversationRegion; + ancestor = ancestor->parentWidget()) { + if (qobject_cast(ancestor)) { + insideScrollableChild = true; + break; + } + } + } + if (!insideScrollableChild) { + auto *wheel = static_cast(event); + QWidget *viewport = conversationScroll->viewport(); + const QPointF localPosition = + viewport->mapFromGlobal(wheel->globalPosition().toPoint()); + QWheelEvent forwarded(localPosition, wheel->globalPosition(), + wheel->pixelDelta(), wheel->angleDelta(), + wheel->buttons(), wheel->modifiers(), + wheel->phase(), wheel->inverted(), + wheel->source(), wheel->pointingDevice()); + QApplication::sendEvent(viewport, &forwarded); + event->accept(); + return true; + } + } + } + if (watched == composerBody && + (event->type() == QEvent::Resize || event->type() == QEvent::Show || + event->type() == QEvent::LayoutRequest)) + scheduleComposerLayout(); + return QWidget::eventFilter(watched, event); +} + +void ShellWidget::scheduleComposerLayout() { + if (composerLayoutRefreshPending) + return; + composerLayoutRefreshPending = true; + QTimer::singleShot(0, this, [this] { + composerLayoutRefreshPending = false; + refreshComposerLayout(); + }); +} + +void ShellWidget::refreshComposerLayout() { + if (!composerBody || !composerGrid || composerBody->width() <= 0) + return; + + const bool active = interruptButton->isVisible(); + const int visibleControls = active ? 3 : 2; + const int controlsWidth = attachmentButton->width() + sendButton->width() + + (active ? interruptButton->width() : 0); + const int compactEditorWidth = + composerBody->contentsRect().width() - controlsWidth - + (visibleControls * composerGrid->horizontalSpacing()); + const bool expand = promptEditor->requiresExpandedLayout(compactEditorWidth); + if (expand == composerExpanded && active == composerActive) + return; + + composerExpanded = expand; + composerActive = active; + composerGrid->removeWidget(attachmentButton); + composerGrid->removeWidget(promptEditor); + composerGrid->removeWidget(sendButton); + composerGrid->removeWidget(interruptButton); + if (composerExpanded) { + composerGrid->addWidget(promptEditor, 0, 0, 1, 4); + composerGrid->addWidget(attachmentButton, 1, 0); + composerGrid->addWidget(sendButton, 1, 2); + if (active) + composerGrid->addWidget(interruptButton, 1, 3); + } else { + composerGrid->addWidget(attachmentButton, 0, 0); + composerGrid->addWidget(promptEditor, 0, 1); + composerGrid->addWidget(sendButton, 0, 2); + if (active) + composerGrid->addWidget(interruptButton, 0, 3); + } + composerGrid->invalidate(); +} + +void ShellWidget::handleEvent(const nlohmann::json &event) { + appendProtocolFrame(event); + const std::string incomingKind = stringValue(event, "kind"); + const std::string incomingType = stringValue(event, "type"); + const nlohmann::json incomingData = + event.value("data", nlohmann::json::object()); + if (incomingKind == "event" && incomingType == "connection.lifecycle" && + stringValue(incomingData, "state") == "connected") { + threadHydration.clear(); + operationReadyThreads.clear(); + } + model.applyEvent(event); + const std::string kind = stringValue(event, "kind"); + const std::string type = stringValue(event, "type"); + const nlohmann::json data = event.value("data", nlohmann::json::object()); + const nlohmann::json incomingScope = + event.value("scope", nlohmann::json::object()); + const std::string incomingThreadId = stringValue(incomingScope, "threadId"); + bool recoveringThreadNotFound = false; + if (isThreadNotFoundResult(event)) { + const auto prompts = pendingPrompts.find(incomingThreadId); + recoveringThreadNotFound = + prompts != pendingPrompts.end() && + std::any_of(prompts->second.begin(), prompts->second.end(), + [](const PendingPrompt &candidate) { + return candidate.status == + PendingPromptStatus::Awaiting && + candidate.readinessRetryAttempted; + }); + } + if (kind == "result" && !event.value("ok", false) && + !recoveringThreadNotFound) { + const nlohmann::json error = event.value("error", nlohmann::json::object()); + const std::string message = safeMessage(error); + showNotice(text(message.empty() ? std::string("Codex operation failed") + : message)); + } else if (kind == "event" && type == "notice.added") { + const nlohmann::json notice = + data.value("notice", nlohmann::json::object()); + const std::string message = safeMessage(notice); + if (!message.empty()) + showNotice(text(message), stringValue(data, "severity") == "error"); + } else if (kind == "event" && type == "system.diagnostic") { + const std::string message = safeMessage(data); + if (!message.empty()) + showNotice(QStringLiteral("Protocol diagnostic: %1").arg(text(message))); + } else if (kind == "event" && type == "connection.lifecycle" && + (stringValue(data, "state") == "failure" || + stringValue(data, "state") == "disconnected")) { + const std::string detail = stringValue(data, "detail"); + if (!detail.starts_with("local-")) + showNotice(detail.empty() ? QStringLiteral("Codex bridge disconnected") + : text(detail)); + } + if (event.value("kind", std::string{}) == "event" && + event.value("type", std::string{}) == "connection.bridge" && + event.value("data", nlohmann::json::object()) + .value("state", std::string{}) == "opened") { + requestThreads(); + requestModels(); + ensureThreadHydrated(selectedThreadId); + session.listPermissionProfiles( + {{"cwd", QDir::currentPath().toStdString()}}); + } + + hydrateHistoricalAgents(); + + if (!selectedThreadId.empty() && !model.thread(selectedThreadId)) { + selectedThreadId.clear(); + resetComposer(); + } + + const nlohmann::json scope = event.value("scope", nlohmann::json::object()); + const std::string eventThreadId = stringValue(scope, "threadId"); + const std::string turnId = stringValue(scope, "turnId"); + const std::string itemId = stringValue(scope, "itemId"); + if (type == "thread.removed" && !eventThreadId.empty()) { + pendingPrompts.erase(eventThreadId); + materializedPromptItemIds.erase(eventThreadId); + threadHydration.erase(eventThreadId); + operationReadyThreads.erase(eventThreadId); + } else if (!eventThreadId.empty()) { + reconcileAcknowledgedPrompts(eventThreadId); + } + if (eventThreadId == selectedThreadId && !turnId.empty() && !itemId.empty() && + (type == "conversation.item.upsert" || + type == "conversation.item.append" || + type == "conversation.reasoning.part-added" || + type == "conversation.file-change.output-appended" || + type == "conversation.file-change.patch-replaced" || + type == "conversation.mcp.progress")) { + const std::string key = turnId + '\x1f' + itemId; + dirtyConversationItems[key] = {turnId, itemId}; + conversationSmoothFollowRequested = true; + } + + const std::string action = stringValue(event, "action"); + if (kind == "result" && !eventThreadId.empty() && action == "thread.read") { + const bool readSucceeded = event.value("ok", false); + threadHydration[eventThreadId] = readSucceeded + ? ThreadHydrationState::Hydrated + : ThreadHydrationState::NotHydrated; + if (readSucceeded) { + if (!threadRequiresResume(eventThreadId)) + operationReadyThreads.insert(eventThreadId); + QTimer::singleShot(0, this, [this, eventThreadId] { + dispatchNextPrompt(eventThreadId); + }); + } else { + const auto prompts = pendingPrompts.find(eventThreadId); + if (prompts != pendingPrompts.end()) { + const auto waiting = std::find_if( + prompts->second.begin(), prompts->second.end(), + [](const PendingPrompt &candidate) { + return candidate.status == PendingPromptStatus::Awaiting && + !candidate.dispatched; + }); + if (waiting != prompts->second.end()) { + const std::uint64_t submissionId = waiting->id; + QTimer::singleShot( + 0, this, [this, eventThreadId, submissionId, event] { + completePromptSubmission(eventThreadId, submissionId, event); + }); + } + } + } + } else if (kind == "result" && !eventThreadId.empty() && + action == "thread.resume" && event.value("ok", false)) { + threadHydration[eventThreadId] = ThreadHydrationState::Hydrated; + operationReadyThreads.insert(eventThreadId); + } + if ((event.value("kind", std::string{}) == "result" && + action == "thread.read" && eventThreadId == selectedThreadId) || + type == "thread.removed") { + conversationRebuildPending = true; + } + scheduleRefresh(refreshAreasForEvent(event)); +} + +std::uint32_t +ShellWidget::refreshAreasForEvent(const nlohmann::json &event) const { + const std::string kind = stringValue(event, "kind"); + if (kind == "result") { + const std::string action = stringValue(event, "action"); + if (action == "thread.read") + return RefreshAll; + if (action == "threads.list") + return RefreshThreads | RefreshProtocolStats | RefreshStatus; + if (action == "thread.create" || action == "thread.resume" || + action == "thread.fork") + return RefreshThreads | RefreshTurnSettings | RefreshStatus; + if (action == "turn.start") + return RefreshThreads | RefreshInspector | RefreshProtocolStats | + RefreshStatus; + if (action == "models.list" || action == "permission-profiles.list") + return RefreshState | RefreshProtocolStats | RefreshTurnSettings; + return RefreshState | RefreshProtocolStats; + } + + const std::string type = stringValue(event, "type"); + if (type.starts_with("connection.")) + return RefreshThreads | RefreshInspector | RefreshProtocolStats | + RefreshStatus; + if (type == "thread.upsert" || type == "thread.name.changed" || + type == "thread.status.changed" || type == "thread.lifecycle") + return RefreshThreads | RefreshTurnSettings | RefreshProtocolStats | + RefreshStatus; + if (type == "thread.removed") + return RefreshThreads | RefreshConversation | RefreshInspector | + RefreshTurnSettings | RefreshProtocolStats | RefreshStatus; + if (type == "turn.upsert") + return RefreshThreads | RefreshInspector | RefreshProtocolStats | + RefreshStatus; + if (type == "plan.replaced") + return RefreshInspector | RefreshProtocolStats; + if (type.starts_with("conversation.")) + return RefreshConversation | RefreshInspector | RefreshProtocolStats; + if (type == "agents.activity.upsert") + return RefreshInspector | RefreshProtocolStats | RefreshStatus; + if (type.starts_with("pending-request.")) + return RefreshThreads | RefreshInspector | RefreshProtocolStats | + RefreshStatus; + if (type == "thread.token-usage.changed") + return RefreshProtocolStats | RefreshStatus; + return RefreshState | RefreshProtocolStats; +} + +void ShellWidget::hydrateHistoricalAgents() { + const ThreadPresentation *thread = model.thread(selectedThreadId); + if (!thread) + return; + for (const std::string &agentId : thread->agentOrder) { + const auto agent = thread->agents.find(agentId); + if (agent == thread->agents.end() || agent->second.childThreadId.empty() || + agent->second.status != "started") + continue; + if (!requestedAgentThreads.insert(agent->second.childThreadId).second) + continue; + session.readThread(agent->second.childThreadId); + } +} + +void ShellWidget::scheduleRefresh(std::uint32_t areas) { + pendingRefreshAreas |= areas; + if (!refreshTimer->isActive()) + refreshTimer->start(); +} + +void ShellWidget::refresh() { + const std::uint32_t areas = pendingRefreshAreas; + pendingRefreshAreas = RefreshNone; + if ((areas & RefreshThreads) != 0) + refreshThreads(); + if ((areas & RefreshConversation) != 0) { + if (conversationRebuildPending) + refreshConversation(); + else + refreshConversationItems(); + dirtyConversationItems.clear(); + conversationRebuildPending = false; + } + if ((areas & RefreshInspector) != 0) + refreshInspector(); + if ((areas & RefreshState) != 0) + refreshStateInspector(); + if ((areas & RefreshProtocolStats) != 0) + refreshProtocolStats(); + if ((areas & RefreshTurnSettings) != 0) + refreshTurnSettings(); + if ((areas & RefreshStatus) != 0) + refreshStatus(); +} + +std::string ShellWidget::conversationItemFingerprint( + const ItemPresentation &presentation) const { + const nlohmann::json &item = presentation.raw; + const std::string typeName = stringValue(item, "type"); + nlohmann::json projected{{"type", typeName}}; + if (typeName == "agentMessage") + projected["phase"] = stringValue(item, "phase"); + const QString body = messageText(item); + if (!body.isEmpty()) + projected["body"] = body.toStdString(); + + if (typeName == "commandExecution") { + projected["command"] = stringValue(item, "command"); + const QString output = text(stringValue(item, "aggregatedOutput")); + projected["output"] = + commandOutputIsVisible(output) ? output.toStdString() : std::string{}; + projected["status"] = stringValue(item, "status"); + projected["cwd"] = stringValue(item, "cwd"); + if (item.contains("exitCode") && item["exitCode"].is_number_integer()) + projected["exitCode"] = item["exitCode"]; + } else if (typeName == "collabAgentToolCall" || + typeName == "subAgentActivity") { + projected["tool"] = stringValue(item, "tool"); + projected["status"] = stringValue(item, "status"); + projected["kind"] = stringValue(item, "kind"); + projected["receivers"] = + item.value("receiverThreadIds", nlohmann::json::array()); + projected["prompt"] = stringValue(item, "prompt"); + projected["resultText"] = stringValue(item, "resultText"); + } else if (typeName == "reasoning") { + projected["summary"] = + joinedStrings(item.value("summary", nlohmann::json::array())) + .toStdString(); + } else if (typeName == "fileChange") { + projected["status"] = stringValue(item, "status"); + const nlohmann::json changes = + item.value("changes", nlohmann::json::array()); + projected["pathCount"] = changes.is_array() ? changes.size() : 0U; + } else if (body.isEmpty()) { + projected["raw"] = item; + } + return projected.dump(); +} + +void ShellWidget::showNotice(QString message, bool error) { + if (message.trimmed().isEmpty()) + return; + noticeLabel->setText(std::move(message)); + noticeBar->setStyleSheet( + error ? QStringLiteral("background:#fff4f2;border:1px solid #efc2bc;" + "border-radius:6px;") + : QStringLiteral("background:#fff8e8;border:1px solid #e5c77d;" + "border-radius:6px;")); + noticeLabel->setStyleSheet(error ? QStringLiteral("color:#9d2e2e;") + : QStringLiteral("color:#8a5a00;")); + noticeBar->show(); +} + +void ShellWidget::refreshProtocolStats() { + if (!infoTabs || inspectorTabs->currentIndex() != 4 || + infoTabs->currentIndex() != 1) + return; + std::size_t turns = 0; + std::size_t items = 0; + if (const ThreadPresentation *thread = model.thread(selectedThreadId)) { + turns = thread->turnOrder.size(); + for (const auto &[turnId, turn] : thread->turns) { + static_cast(turnId); + items += turn.itemOrder.size(); + } + } + protocolStats->setText( + QStringLiteral("seq %1 | threads %2 | models %3 | turns %4 | " + "items %5 | pending %6 | telemetry %7") + .arg(static_cast(observedPresentationSequence)) + .arg(static_cast(model.threadOrder().size())) + .arg(static_cast(model.modelCatalog().size())) + .arg(static_cast(turns)) + .arg(static_cast(items)) + .arg(static_cast(model.pendingRequestCount())) + .arg(static_cast(model.telemetry().size()))); +} + +void ShellWidget::showProtocolTail() { + if (!protocolLog) + return; + QStringList lines; + lines.reserve(static_cast(protocolLines.size())); + for (const QString &line : protocolLines) + lines.push_back(line); + protocolLog->setPlainText(lines.join(QLatin1Char('\n'))); + protocolLog->moveCursor(QTextCursor::End); +} + +void ShellWidget::refreshTurnSettings() { + nlohmann::json canonical = nlohmann::json::object(); + std::string identity = "new-thread"; + if (const ThreadPresentation *thread = model.thread(selectedThreadId)) { + identity = thread->id; + canonical = thread->raw; + const auto settings = thread->domains.find("thread.settings.changed"); + if (settings != thread->domains.end() && settings->second.is_object()) { + nlohmann::json update = settings->second; + if (update.contains("threadSettings") && + update["threadSettings"].is_object()) + update = update["threadSettings"]; + canonical.merge_patch(update); + } + } else { + canonical["cwd"] = + (localNewThreadIntent && !newThreadDraftWorkspace.isEmpty() + ? newThreadDraftWorkspace + : QDir::currentPath()) + .toStdString(); + } + + nlohmann::json permissionProfiles = nlohmann::json::array(); + const auto profiles = + model.globalDomains().find("operation.permission-profiles.list"); + if (profiles != model.globalDomains().end()) + permissionProfiles = profiles->second; + turnSettings->setContext(identity, canonical, model.modelCatalog(), + permissionProfiles); +} + +std::string ShellWidget::visiblySelectedThreadId() const { + if (!threadList) + return {}; + const QList selected = threadList->selectedItems(); + if (selected.size() != 1 || !selected.front()) + return {}; + return selected.front()->data(Qt::UserRole).toString().toStdString(); +} + +void ShellWidget::addConversationTrailingSpace() { + // The spacer belongs to the scroll-area content so QScrollArea derives its + // extended range from normal layout geometry. It is recreated with the + // conversation and never replaces the canonical composer reservation. + conversationTrailingSpace = new QWidget; + conversationTrailingSpace->setObjectName( + QStringLiteral("conversationTrailingSpace")); + conversationTrailingSpace->setSizePolicy(QSizePolicy::Preferred, + QSizePolicy::Fixed); + conversationTrailingSpace->setFixedHeight(conversationTrailingSpaceHeight); + conversationLayout->addWidget(conversationTrailingSpace); +} + +void ShellWidget::updateComposerDockHeight(int height) { + if (!composerReserve || !conversationScroll || !conversationContent || + height <= 0) + return; + + if (composerCanonicalHeight == 0) { + // Only the compact surface participates in the center layout. Later + // growth remains an overlay and is represented by trailing scroll space. + composerCanonicalHeight = height; + composerReserve->setFixedHeight(composerCanonicalHeight); + return; + } + + const int trailingHeight = std::max(0, height - composerCanonicalHeight); + if (trailingHeight == conversationTrailingSpaceHeight) + return; + + QScrollBar *scrollBar = conversationScroll->verticalScrollBar(); + stopConversationScrollAnimation(); + conversationSmoothScrollFloor = 0; + const int preservedValue = scrollBar->value(); + const bool spacerGrew = trailingHeight > conversationTrailingSpaceHeight; + conversationTrailingSpaceHeight = trailingHeight; + const std::uint64_t revision = ++conversationSpacerRevision; + conversationSpacerAdjusting = true; + // A larger range must not pull content toward the newly exposed bottom. The + // user explicitly reaching that bottom will restore follow-latest below. + if (spacerGrew) + conversationFollowsLatest = false; + + if (conversationTrailingSpace) + conversationTrailingSpace->setFixedHeight(trailingHeight); + conversationLayout->invalidate(); + conversationContent->updateGeometry(); + + const auto settle = [this, revision, preservedValue] { + if (revision != conversationSpacerRevision) + return; + QScrollBar *currentScrollBar = conversationScroll->verticalScrollBar(); + conversationScrollProgrammatic = true; + currentScrollBar->setValue( + std::min(preservedValue, currentScrollBar->maximum())); + conversationScrollProgrammatic = false; + }; + QTimer::singleShot(0, this, [this, revision, settle] { + if (revision != conversationSpacerRevision) + return; + settle(); + QTimer::singleShot(0, this, [this, revision, settle] { + if (revision != conversationSpacerRevision) + return; + settle(); + conversationSpacerAdjusting = false; + QScrollBar *currentScrollBar = conversationScroll->verticalScrollBar(); + conversationFollowsLatest = + currentScrollBar->value() >= currentScrollBar->maximum() - 1; + }); + }); +} + +void ShellWidget::stopConversationScrollAnimation() { + if (conversationScrollAnimation) + conversationScrollAnimation->stop(); +} + +ShellWidget::ConversationScrollAnchor +ShellWidget::captureConversationScrollAnchor() const { + ConversationScrollAnchor anchor; + if (!conversationScroll || !conversationLayout) + return anchor; + anchor.absoluteValue = conversationScroll->verticalScrollBar()->value(); + for (int index = 0; index < conversationLayout->count(); ++index) { + QWidget *widget = conversationLayout->itemAt(index)->widget(); + if (!widget) + continue; + const QString key = widget->property(ConversationAnchorProperty).toString(); + if (key.isEmpty() || widget->geometry().bottom() < anchor.absoluteValue) + continue; + anchor.key = key; + anchor.viewportOffset = widget->geometry().top() - anchor.absoluteValue; + break; + } + return anchor; +} + +void ShellWidget::restoreConversationScrollAnchor( + const ConversationScrollAnchor &anchor) { + if (!conversationScroll || !conversationLayout) + return; + int value = anchor.absoluteValue; + if (!anchor.key.isEmpty()) { + for (int index = 0; index < conversationLayout->count(); ++index) { + QWidget *widget = conversationLayout->itemAt(index)->widget(); + if (!widget || + widget->property(ConversationAnchorProperty).toString() != anchor.key) + continue; + value = widget->geometry().top() - anchor.viewportOffset; + break; + } + } + QScrollBar *scrollBar = conversationScroll->verticalScrollBar(); + conversationScrollProgrammatic = true; + scrollBar->setValue( + std::clamp(value, scrollBar->minimum(), scrollBar->maximum())); + conversationScrollProgrammatic = false; +} + +void ShellWidget::scheduleConversationPausedAnchorRestore() { + if (!conversationPausedAnchorValid || conversationPausedAnchorRestorePending) + return; + conversationPausedAnchorRestorePending = true; + QTimer::singleShot(0, this, [this] { + conversationPausedAnchorRestorePending = false; + if (conversationFollowsLatest || conversationScrollRebuilding || + conversationSpacerAdjusting || !conversationPausedAnchorValid) + return; + conversationLayout->activate(); + restoreConversationScrollAnchor(conversationPausedAnchor); + }); +} + +void ShellWidget::scrollConversationToLatest(bool smoothly) { + if (!conversationScroll) + return; + QScrollBar *scrollBar = conversationScroll->verticalScrollBar(); + const int destination = scrollBar->maximum(); + stopConversationScrollAnimation(); + const int start = std::max( + scrollBar->value(), std::min(conversationSmoothScrollFloor, destination)); + conversationScrollProgrammatic = true; + scrollBar->setValue(start); + conversationScrollProgrammatic = false; + const int distance = destination - start; + if (!smoothly || distance <= 3 || !conversationScrollAnimation) { + conversationScrollProgrammatic = true; + scrollBar->setValue(destination); + conversationScrollProgrammatic = false; + return; + } + conversationScrollAnimation->setDuration( + std::clamp(110 + distance / 3, 130, 260)); + conversationScrollAnimation->setStartValue(start); + conversationScrollAnimation->setEndValue(destination); + conversationScrollAnimation->start(); +} + +void ShellWidget::scheduleConversationFollowLatest() { + if (conversationFollowScrollPending) + return; + conversationFollowScrollPending = true; + // Wrapping labels and command output can report several closely spaced + // geometry changes. Retarget one animation after the burst instead of + // moving the viewport for every intermediate range. + QTimer::singleShot(16, this, [this] { + conversationFollowScrollPending = false; + if (conversationFollowsLatest && !conversationScrollRebuilding && + !conversationSpacerAdjusting) + scrollConversationToLatest(true); + }); +} + +void ShellWidget::settleConversationScroll(bool followLatest, + ConversationScrollAnchor anchor, + bool smoothly) { + conversationSmoothScrollFloor = + followLatest + ? std::max(conversationSmoothScrollFloor, anchor.absoluteValue) + : 0; + const std::uint64_t revision = ++conversationScrollSettlementRevision; + const auto settle = [this, revision, followLatest, anchor] { + if (revision != conversationScrollSettlementRevision) + return false; + conversationLayout->activate(); + if (followLatest) { + QScrollBar *scrollBar = conversationScroll->verticalScrollBar(); + conversationScrollProgrammatic = true; + scrollBar->setValue(std::min(anchor.absoluteValue, scrollBar->maximum())); + conversationScrollProgrammatic = false; + } else { + restoreConversationScrollAnchor(anchor); + } + return true; + }; + // Restore the stable coordinate before returning to the event loop. Painting + // remains disabled until the two deferred Qt layout passes settle. + settle(); + QTimer::singleShot( + 0, this, [this, revision, followLatest, anchor, smoothly, settle] { + if (!settle()) + return; + QTimer::singleShot( + 0, this, [this, revision, followLatest, anchor, smoothly, settle] { + if (!settle()) + return; + conversationScrollRebuilding = false; + if (followLatest) { + conversationFollowsLatest = true; + scrollConversationToLatest(smoothly); + } else { + conversationFollowsLatest = false; + conversationPausedAnchor = anchor; + conversationPausedAnchorValid = true; + } + conversationScroll->viewport()->setUpdatesEnabled(true); + conversationScroll->viewport()->update(); + }); + }); +} + +void ShellWidget::appendProtocolFrame(const nlohmann::json &frame) { + if (!protocolLog) + return; + + const auto recordLine = [this](QString line) { + if (protocolLines.size() == 200) + protocolLines.pop_front(); + protocolLines.push_back(line); + if (inspectorTabs->currentIndex() == 4 && infoTabs && + infoTabs->currentIndex() == 1) + protocolLog->appendPlainText(std::move(line)); + }; + + const std::uint64_t sequence = frame.value("sequence", 0ULL); + if (sequence != 0) { + if (observedPresentationSequence != 0 && + sequence != observedPresentationSequence + 1) { + const QString relation = sequence <= observedPresentationSequence + ? QStringLiteral("NON-MONOTONIC") + : QStringLiteral("SEQUENCE GAP"); + recordLine( + QStringLiteral("[%1] %2 expected=%3 received=%4") + .arg(QDateTime::currentDateTime().toString( + QStringLiteral("HH:mm:ss.zzz")), + relation) + .arg(static_cast(observedPresentationSequence + 1)) + .arg(static_cast(sequence))); + } + observedPresentationSequence = + std::max(observedPresentationSequence, sequence); + } + + const std::string kind = stringValue(frame, "kind"); + const std::string subject = kind == "result" ? stringValue(frame, "action") + : stringValue(frame, "type"); + const nlohmann::json scope = frame.value("scope", nlohmann::json::object()); + QStringList parts; + parts << QStringLiteral("[%1]").arg( + QDateTime::currentDateTime().toString(QStringLiteral("HH:mm:ss.zzz"))); + if (sequence != 0) + parts << QStringLiteral("#%1").arg(static_cast(sequence)); + parts << QStringLiteral("g%1").arg( + static_cast(frame.value("generation", 0ULL))); + parts << text(kind); + parts << text(subject); + parts << text(stringValue(frame, "authority")); + if (kind == "result") + parts << (frame.value("ok", false) ? QStringLiteral("ok") + : QStringLiteral("ERROR")); + for (const char *key : + {"threadId", "turnId", "itemId", "requestId", "processId"}) { + const std::string value = stringValue(scope, key); + if (!value.empty()) + parts << QStringLiteral("%1=%2").arg(QString::fromLatin1(key), + text(value)); + } + const std::string correlationId = stringValue(frame, "correlationId"); + if (!correlationId.empty()) + parts << QStringLiteral("correlation=%1").arg(text(correlationId)); + if (kind == "result" && !frame.value("ok", false)) { + const nlohmann::json error = frame.value("error", nlohmann::json::object()); + const std::string message = stringValue(error, "message"); + if (!message.empty()) + parts << text(message); + } + recordLine(parts.join(QStringLiteral(" "))); +} + +void ShellWidget::refreshThreads() { + threadList->blockSignals(true); + threadList->clear(); + for (const std::string &threadId : model.threadOrder()) { + const ThreadPresentation *thread = model.thread(threadId); + if (!thread) + continue; + QString title = text(thread->title); + if (title.isEmpty()) + title = text(threadId.substr(0, 12)); + if (model.pendingRequestCount(threadId) != 0) + title.prepend(QStringLiteral("! ")); + auto *item = new QListWidgetItem(threadList); + item->setSizeHint(QSize(0, 48)); + item->setData(Qt::UserRole, text(threadId)); + item->setToolTip(text(thread->cwd)); + auto *row = new QWidget; + row->setAttribute(Qt::WA_TransparentForMouseEvents); + row->setStyleSheet(QStringLiteral("background:transparent;")); + auto *rowLayout = new QHBoxLayout(row); + rowLayout->setContentsMargins(5, 2, 5, 2); + rowLayout->setSpacing(8); + auto *dot = makeStatusDot(); + QString dotColor = QStringLiteral("#98a2b3"); + if (model.pendingRequestCount(threadId) != 0) + dotColor = QStringLiteral("#a76812"); + else if (thread->status == "active" || thread->status == "inProgress") + dotColor = QStringLiteral("#2f6feb"); + else if (thread->status == "failed" || thread->status == "systemError") + dotColor = QStringLiteral("#b83a3a"); + dot->setStyleSheet( + QStringLiteral("background:%1;border-radius:4px;").arg(dotColor)); + rowLayout->addWidget(dot); + auto *copy = new QVBoxLayout; + copy->setContentsMargins(0, 0, 0, 0); + copy->setSpacing(1); + auto *titleLabel = makeLabel(title, "title"); + titleLabel->setStyleSheet(QStringLiteral("font-weight:500;")); + copy->addWidget(titleLabel); + copy->addWidget(makeLabel(displayStatus(thread->status), "meta")); + rowLayout->addLayout(copy, 1); + threadList->setItemWidget(item, row); + if (threadId == selectedThreadId) + threadList->setCurrentItem(item); + } + threadList->blockSignals(false); +} + +void ShellWidget::refreshConversation() { + const bool followLatest = conversationFollowsLatest; + const ConversationScrollAnchor anchor = captureConversationScrollAnchor(); + const bool smoothly = conversationSmoothFollowRequested; + conversationSmoothFollowRequested = false; + stopConversationScrollAnimation(); + ++conversationSpacerRevision; + conversationSpacerAdjusting = false; + conversationScrollRebuilding = true; + conversationScroll->viewport()->setUpdatesEnabled(false); + for (const auto &[key, card] : conversationCards) { + if (const auto state = commandOutputScrollState(card)) + commandOutputScrollStates[key] = *state; + } + conversationCards.clear(); + conversationCardFingerprints.clear(); + conversationTrailingSpace = nullptr; + clearLayout(conversationLayout); + + const ThreadPresentation *thread = model.thread(selectedThreadId); + if (!thread) { + conversationTitle->setText(localNewThreadIntent + ? QStringLiteral("New thread") + : QStringLiteral("Select a thread")); + conversationMeta->setText(localNewThreadIntent ? QDir::currentPath() + : QString{}); + if (localNewThreadIntent && !newThreadPendingPrompts.empty()) { + emptyConversation = nullptr; + for (const PendingPrompt &pending : newThreadPendingPrompts) { + const bool acknowledged = + pending.status == PendingPromptStatus::Acknowledged && + QDateTime::currentMSecsSinceEpoch() - + pending.acknowledgedAtMilliseconds < + AcknowledgementTransitionMilliseconds; + auto *card = new PendingPromptCard( + pending.prompt, static_cast(pending.attachments.size()), + pending.status == PendingPromptStatus::Awaiting, acknowledged, + pending.acknowledgedAtMilliseconds, + pending.status == PendingPromptStatus::Failed, pending.error); + card->setProperty(ConversationAnchorProperty, + QStringLiteral("pending:new:%1").arg(pending.id)); + conversationLayout->addWidget(card); + } + } else { + emptyConversation = makeLabel( + localNewThreadIntent + ? QStringLiteral("Send a message to create this thread.") + : QStringLiteral("Conversation activity appears here."), + "muted"); + conversationLayout->addWidget(emptyConversation); + } + addConversationTrailingSpace(); + conversationLayout->addStretch(); + settleConversationScroll(followLatest, anchor, smoothly); + return; + } + + reconcileAcknowledgedPrompts(selectedThreadId); + conversationTitle->setText(text(thread->title)); + conversationMeta->setText(text(thread->cwd) + QStringLiteral(" | ") + + displayStatus(thread->status)); + struct VisibleItem { + std::string key; + const ItemPresentation *item = nullptr; + }; + std::vector items; + for (const std::string &turnId : thread->turnOrder) { + const auto turn = thread->turns.find(turnId); + if (turn == thread->turns.end()) + continue; + for (const std::string &itemId : turn->second.itemOrder) { + const auto item = turn->second.items.find(itemId); + if (item == turn->second.items.end()) + continue; + items.push_back({turnId + '\x1f' + itemId, &item->second}); + } + } + std::unordered_set transitioningMaterializedItems; + if (const auto submissions = pendingPrompts.find(selectedThreadId); + submissions != pendingPrompts.end()) { + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + for (const PendingPrompt &submission : submissions->second) { + if (!submission.materializedIdentity.empty() && + submission.status == PendingPromptStatus::Acknowledged && + now - submission.acknowledgedAtMilliseconds < + AcknowledgementTransitionMilliseconds) + transitioningMaterializedItems.insert(submission.materializedIdentity); + } + } + std::size_t first = items.size() > conversationItemLimit + ? items.size() - conversationItemLimit + : 0; + if (!followLatest && !anchor.key.isEmpty()) { + const auto anchored = std::find_if( + items.begin(), items.end(), [this, &anchor](const VisibleItem &item) { + const auto mapped = promptAnchorKeys.find(item.key); + const QString key = mapped == promptAnchorKeys.end() ? text(item.key) + : mapped->second; + return key == anchor.key; + }); + if (anchored != items.end()) + first = + std::min(first, static_cast(anchored - items.begin())); + } + if (first != 0) { + const std::size_t page = std::min(80, first); + auto *loadEarlier = + new QPushButton(QStringLiteral("Load %1 more activities") + .arg(static_cast(page))); + loadEarlier->setProperty("kind", "history"); + loadEarlier->setProperty("historyPage", static_cast(page)); + loadEarlier->setFixedHeight(UpcomingControlHeight); + loadEarlier->setToolTip(QStringLiteral("%1 earlier activities are retained") + .arg(static_cast(first))); + connect(loadEarlier, &QPushButton::clicked, this, [this, loadEarlier] { + const std::size_t page = static_cast( + loadEarlier->property("historyPage").toULongLong()); + conversationItemLimit += page; + conversationRebuildPending = true; + scheduleRefresh(RefreshConversation); + }); + conversationLayout->addWidget(loadEarlier, 0, Qt::AlignHCenter); + } + for (std::size_t index = first; index < items.size(); ++index) { + if (transitioningMaterializedItems.contains(items[index].key)) + continue; + std::optional outputScrollState; + if (const auto retained = commandOutputScrollStates.find(items[index].key); + retained != commandOutputScrollStates.end()) + outputScrollState = retained->second; + QWidget *card = itemFrame(*items[index].item, outputScrollState); + const auto mappedAnchor = promptAnchorKeys.find(items[index].key); + card->setProperty(ConversationAnchorProperty, + mappedAnchor == promptAnchorKeys.end() + ? text(items[index].key) + : mappedAnchor->second); + conversationCards[items[index].key] = card; + conversationCardFingerprints[items[index].key] = + conversationItemFingerprint(*items[index].item); + conversationLayout->addWidget(card); + } + const auto pending = pendingPrompts.find(selectedThreadId); + if (items.empty() && + (pending == pendingPrompts.end() || pending->second.empty())) + conversationLayout->addWidget( + makeLabel(QStringLiteral("No materialized activity."), "muted")); + if (pending != pendingPrompts.end()) { + for (const PendingPrompt &submission : pending->second) { + const bool acknowledged = + submission.status == PendingPromptStatus::Acknowledged && + QDateTime::currentMSecsSinceEpoch() - + submission.acknowledgedAtMilliseconds < + AcknowledgementTransitionMilliseconds; + auto *card = new PendingPromptCard( + submission.prompt, static_cast(submission.attachments.size()), + submission.status == PendingPromptStatus::Awaiting, acknowledged, + submission.acknowledgedAtMilliseconds, + submission.status == PendingPromptStatus::Failed, submission.error); + card->setProperty( + ConversationAnchorProperty, + pendingPromptAnchorKey(selectedThreadId, submission.id)); + conversationLayout->addWidget(card); + } + } + addConversationTrailingSpace(); + conversationLayout->addStretch(); + settleConversationScroll(followLatest, anchor, smoothly); +} + +void ShellWidget::refreshConversationItems() { + if (dirtyConversationItems.empty()) { + conversationSmoothFollowRequested = false; + return; + } + const ThreadPresentation *thread = model.thread(selectedThreadId); + bool requiresRebuild = !thread; + for (const auto &[key, identity] : dirtyConversationItems) { + if (requiresRebuild) + break; + const auto turn = thread->turns.find(identity.first); + requiresRebuild = + turn == thread->turns.end() || + turn->second.items.find(identity.second) == turn->second.items.end(); + } + if (requiresRebuild) { + refreshConversation(); + return; + } + + const bool followLatest = conversationFollowsLatest; + const ConversationScrollAnchor anchor = captureConversationScrollAnchor(); + stopConversationScrollAnimation(); + conversationScrollRebuilding = true; + conversationScroll->viewport()->setUpdatesEnabled(false); + bool changed = false; + for (const auto &[key, identity] : dirtyConversationItems) { + bool itemChanged = false; + if (!refreshConversationItem(key, identity.first, identity.second, + itemChanged)) { + conversationScroll->viewport()->setUpdatesEnabled(true); + conversationScrollRebuilding = false; + refreshConversation(); + return; + } + changed = changed || itemChanged; + } + conversationSmoothFollowRequested = false; + if (!changed) { + conversationScrollRebuilding = false; + conversationScroll->viewport()->setUpdatesEnabled(true); + return; + } + conversationLayout->activate(); + conversationContent->updateGeometry(); + settleConversationScroll(followLatest, anchor, followLatest); +} + +bool ShellWidget::refreshConversationItem(const std::string &key, + const std::string &turnId, + const std::string &itemId, + bool &changed) { + changed = false; + const auto existing = conversationCards.find(key); + const ThreadPresentation *thread = model.thread(selectedThreadId); + if (!thread) + return false; + const auto turn = thread->turns.find(turnId); + if (turn == thread->turns.end()) + return false; + const auto item = turn->second.items.find(itemId); + if (item == turn->second.items.end()) + return false; + const std::string fingerprint = conversationItemFingerprint(item->second); + if (existing == conversationCards.end()) { + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + if (const auto prompts = pendingPrompts.find(selectedThreadId); + prompts != pendingPrompts.end()) { + const bool representedByTransition = std::any_of( + prompts->second.begin(), prompts->second.end(), + [&key, now](const PendingPrompt &submission) { + return submission.materializedIdentity == key && + submission.status == PendingPromptStatus::Acknowledged && + now - submission.acknowledgedAtMilliseconds < + AcknowledgementTransitionMilliseconds; + }); + if (representedByTransition) + return true; + } + + bool evictedCard = false; + if (conversationFollowsLatest && + conversationCards.size() >= conversationItemLimit) { + for (const std::string &orderedTurnId : thread->turnOrder) { + const auto orderedTurn = thread->turns.find(orderedTurnId); + if (orderedTurn == thread->turns.end()) + continue; + for (const std::string &orderedItemId : orderedTurn->second.itemOrder) { + const std::string oldestKey = orderedTurnId + '\x1f' + orderedItemId; + const auto oldest = conversationCards.find(oldestKey); + if (oldest == conversationCards.end()) + continue; + if (const auto state = commandOutputScrollState(oldest->second)) + commandOutputScrollStates[oldestKey] = *state; + conversationLayout->removeWidget(oldest->second); + oldest->second->hide(); + oldest->second->deleteLater(); + conversationCards.erase(oldest); + conversationCardFingerprints.erase(oldestKey); + evictedCard = true; + break; + } + if (evictedCard) + break; + } + } + + QWidget *replacement = itemFrame(item->second, std::nullopt); + const auto mappedAnchor = promptAnchorKeys.find(key); + const QString anchorKey = mappedAnchor == promptAnchorKeys.end() + ? text(key) + : mappedAnchor->second; + replacement->setProperty(ConversationAnchorProperty, anchorKey); + + bool replacedPendingCard = false; + for (int index = 0; index < conversationLayout->count(); ++index) { + QWidget *candidate = conversationLayout->itemAt(index)->widget(); + if (!candidate || + candidate->property(ConversationAnchorProperty).toString() != + anchorKey) + continue; + QLayoutItem *replaced = + conversationLayout->replaceWidget(candidate, replacement); + if (!replaced) { + replacement->deleteLater(); + return false; + } + delete replaced; + candidate->hide(); + candidate->deleteLater(); + replacedPendingCard = true; + break; + } + if (!replacedPendingCard) { + int insertionIndex = -1; + bool afterTarget = false; + for (const std::string &orderedTurnId : thread->turnOrder) { + const auto orderedTurn = thread->turns.find(orderedTurnId); + if (orderedTurn == thread->turns.end()) + continue; + for (const std::string &orderedItemId : orderedTurn->second.itemOrder) { + const std::string orderedKey = orderedTurnId + '\x1f' + orderedItemId; + if (orderedKey == key) { + afterTarget = true; + continue; + } + if (!afterTarget) + continue; + const auto following = conversationCards.find(orderedKey); + if (following == conversationCards.end()) + continue; + insertionIndex = conversationLayout->indexOf(following->second); + break; + } + if (insertionIndex >= 0) + break; + } + if (insertionIndex < 0) { + insertionIndex = conversationLayout->count(); + for (int index = 0; index < conversationLayout->count(); ++index) { + QWidget *candidate = conversationLayout->itemAt(index)->widget(); + if (!candidate) + continue; + if (candidate == conversationTrailingSpace) { + insertionIndex = index; + break; + } + } + } + conversationLayout->insertWidget(insertionIndex, replacement); + } + conversationCards[key] = replacement; + conversationCardFingerprints[key] = fingerprint; + + if (evictedCard) { + QPushButton *historyButton = nullptr; + for (int index = 0; index < conversationLayout->count(); ++index) { + auto *candidate = qobject_cast( + conversationLayout->itemAt(index)->widget()); + if (candidate && candidate->property("kind").toString() == + QStringLiteral("history")) { + historyButton = candidate; + break; + } + } + std::size_t totalItems = 0; + for (const auto &[orderedTurnId, orderedTurn] : thread->turns) { + static_cast(orderedTurnId); + totalItems += orderedTurn.itemOrder.size(); + } + const std::size_t hiddenItems = + totalItems > conversationCards.size() + ? totalItems - conversationCards.size() + : 0; + const std::size_t page = + std::min(conversationItemLimit, hiddenItems); + if (!historyButton) { + historyButton = new QPushButton; + historyButton->setProperty("kind", "history"); + historyButton->setFixedHeight(UpcomingControlHeight); + connect(historyButton, &QPushButton::clicked, this, + [this, historyButton] { + const std::size_t page = static_cast( + historyButton->property("historyPage").toULongLong()); + conversationItemLimit += page; + conversationRebuildPending = true; + scheduleRefresh(RefreshConversation); + }); + conversationLayout->insertWidget(0, historyButton, 0, Qt::AlignHCenter); + } + historyButton->setText(QStringLiteral("Load %1 more activities") + .arg(static_cast(page))); + historyButton->setProperty("historyPage", static_cast(page)); + historyButton->setToolTip( + QStringLiteral("%1 earlier activities are retained") + .arg(static_cast(hiddenItems))); + } + changed = true; + return true; + } + const auto previousFingerprint = conversationCardFingerprints.find(key); + if (previousFingerprint != conversationCardFingerprints.end() && + previousFingerprint->second == fingerprint) + return true; + if (stringValue(item->second.raw, "type") == "commandExecution" && + updateCommandExecutionFrame(existing->second, item->second)) { + conversationCardFingerprints[key] = fingerprint; + changed = true; + return true; + } + std::optional outputScrollState = + commandOutputScrollState(existing->second); + if (outputScrollState) + commandOutputScrollStates[key] = *outputScrollState; + QWidget *replacement = itemFrame(item->second, outputScrollState); + const auto mappedAnchor = promptAnchorKeys.find(key); + replacement->setProperty(ConversationAnchorProperty, + mappedAnchor == promptAnchorKeys.end() + ? text(key) + : mappedAnchor->second); + QLayoutItem *replaced = + conversationLayout->replaceWidget(existing->second, replacement); + if (!replaced) { + replacement->deleteLater(); + return false; + } + delete replaced; + existing->second->hide(); + existing->second->deleteLater(); + existing->second = replacement; + conversationCardFingerprints[key] = fingerprint; + changed = true; + return true; +} + +void ShellWidget::refreshInspector() { + const int activeTab = inspectorTabs->currentIndex(); + QVBoxLayout *activeLayout = nullptr; + if (activeTab == 0) + activeLayout = planLayout; + else if (activeTab == 1) + activeLayout = agentsLayout; + else if (activeTab == 3) + activeLayout = requestsLayout; + const ThreadPresentation *thread = model.thread(selectedThreadId); + if (activeTab == 2) { + QString liveDiff; + std::vector retained; + if (thread) { + for (auto turnId = thread->turnOrder.rbegin(); + turnId != thread->turnOrder.rend() && liveDiff.isEmpty(); ++turnId) { + const auto turn = thread->turns.find(*turnId); + if (turn == thread->turns.end()) + continue; + const auto domain = turn->second.domains.find("turn.diff.changed"); + if (domain != turn->second.domains.end()) + liveDiff = text(stringValue(domain->second, "diff")); + } + if (liveDiff.isEmpty()) { + for (auto turnId = thread->turnOrder.rbegin(); + turnId != thread->turnOrder.rend() && retained.empty(); ++turnId) { + const auto turn = thread->turns.find(*turnId); + if (turn == thread->turns.end()) + continue; + for (auto itemId = turn->second.itemOrder.rbegin(); + itemId != turn->second.itemOrder.rend(); ++itemId) { + const auto item = turn->second.items.find(*itemId); + if (item == turn->second.items.end() || + stringValue(item->second.raw, "type") != "fileChange") + continue; + const nlohmann::json changes = + item->second.raw.value("changes", nlohmann::json::array()); + if (!changes.is_array()) + continue; + for (const auto &change : changes) { + QString kind = text(stringValue(change, "kind")); + if (kind.isEmpty() && change.contains("kind") && + change["kind"].is_object()) + kind = text(stringValue(change["kind"], "type")); + retained.push_back({text(stringValue(change, "path")), + std::move(kind), + text(stringValue(change, "diff"))}); + } + if (!retained.empty()) + break; + } + } + } + } + diffViewer->setChanges(std::move(liveDiff), std::move(retained)); + return; + } + if (!activeLayout) + return; + + clearLayout(activeLayout); + if (!thread && activeTab != 3) { + activeLayout->addWidget( + makeLabel(QStringLiteral("No selected thread."), "muted")); + activeLayout->addStretch(); + return; + } + + if (activeTab == 0) { + const TurnPresentation *planTurn = nullptr; + const ItemPresentation *planItem = nullptr; + for (auto turnId = thread->turnOrder.rbegin(); + turnId != thread->turnOrder.rend(); ++turnId) { + const auto turn = thread->turns.find(*turnId); + if (turn == thread->turns.end()) + continue; + if (turn->second.plan.is_object() && + turn->second.plan.contains("steps")) { + planTurn = &turn->second; + break; + } + for (auto itemId = turn->second.itemOrder.rbegin(); + itemId != turn->second.itemOrder.rend(); ++itemId) { + const auto item = turn->second.items.find(*itemId); + if (item != turn->second.items.end() && + stringValue(item->second.raw, "type") == "plan") { + planItem = &item->second; + break; + } + } + if (planItem) + break; + } + if (planTurn) { + const QString explanation = + text(stringValue(planTurn->plan, "explanation")); + if (!explanation.isEmpty()) + planLayout->addWidget(makeMarkdownLabel(explanation)); + const nlohmann::json steps = + planTurn->plan.value("steps", nlohmann::json::array()); + for (const auto &step : steps) { + auto *row = new QFrame; + row->setProperty("kind", "summary"); + auto *rowLayout = new QVBoxLayout(row); + rowLayout->setContentsMargins(9, 7, 9, 7); + rowLayout->addWidget(makeLabel(text(stringValue(step, "step")))); + rowLayout->addWidget( + makeLabel(displayStatus(stringValue(step, "status")), "meta")); + planLayout->addWidget(row); + } + } else if (planItem) { + const QString planText = text(stringValue(planItem->raw, "text")); + if (planText.isEmpty()) + planLayout->addWidget( + makeLabel(QStringLiteral("Plan is being prepared."), "muted")); + else + planLayout->addWidget(makeMarkdownLabel(planText)); + } else { + planLayout->addWidget( + makeLabel(QStringLiteral("No plan for this thread."), "muted")); + } + planLayout->addStretch(); + return; + } + + if (activeTab == 1) { + std::size_t agentCount = 0; + for (const std::string &agentId : thread->agentOrder) { + const auto agent = thread->agents.find(agentId); + if (agent == thread->agents.end()) + continue; + agentsLayout->addWidget(agentFrame(agent->second)); + ++agentCount; + } + if (agentCount == 0) + agentsLayout->addWidget(makeLabel( + QStringLiteral("No agent activity for this thread."), "muted")); + agentsLayout->addStretch(); + return; + } + + std::size_t requestCount = 0; + for (const auto &[id, request] : model.pendingRequestPresentations()) { + auto *frame = new QFrame; + frame->setProperty("kind", "summary"); + auto *layout = new QVBoxLayout(frame); + layout->setContentsMargins(9, 7, 9, 7); + layout->setSpacing(5); + layout->addWidget(makeLabel(text(request.kind), "title")); + QString threadContext = text(request.threadId); + if (const ThreadPresentation *requestThread = + model.thread(request.threadId); + requestThread && !requestThread->title.empty()) + threadContext = text(requestThread->title); + layout->addWidget( + makeLabel(QStringLiteral("thread %1 | generation %2 | request %3") + .arg(threadContext) + .arg(static_cast(request.generation)) + .arg(text(id)), + "meta")); + const std::string command = stringValue(request.raw, "command"); + const std::string reason = stringValue(request.raw, "reason"); + const std::string message = stringValue(request.raw, "message"); + if (!command.empty()) + layout->addWidget( + makeLabel(QStringLiteral("Command: %1").arg(text(command)), "meta")); + if (!reason.empty()) + layout->addWidget( + makeLabel(QStringLiteral("Reason: %1").arg(text(reason)), "meta")); + if (!message.empty()) + layout->addWidget(makeLabel(text(message), "meta")); + if (request.raw.contains("questions") && + request.raw["questions"].is_array()) + layout->addWidget(makeLabel( + QStringLiteral("%1 questions") + .arg(static_cast(request.raw["questions"].size())), + "meta")); + auto *actions = new QHBoxLayout; + actions->setContentsMargins(0, 2, 0, 0); + auto *deny = new QPushButton(QStringLiteral("Deny")); + auto *review = new QPushButton(QStringLiteral("Review")); + review->setProperty("kind", "primary"); + deny->setFixedHeight(28); + review->setFixedHeight(28); + connect(deny, &QPushButton::clicked, this, + [this, id] { rejectPending(id); }); + connect(review, &QPushButton::clicked, this, + [this, id] { reviewPending(id); }); + actions->addStretch(); + actions->addWidget(deny); + actions->addWidget(review); + layout->addLayout(actions); + requestsLayout->addWidget(frame); + ++requestCount; + } + if (requestCount == 0) + requestsLayout->addWidget( + makeLabel(QStringLiteral("No pending requests."), "muted")); + requestsLayout->addStretch(); +} + +void ShellWidget::resetComposer() { + promptEditor->clear(); + attachmentDrafts.clear(); + ++attachmentRevision; + refreshAttachments(); + refreshComposerEnabledState(); +} + +void ShellWidget::refreshComposerEnabledState() { + if (!promptEditor || !sendButton || !attachmentButton) + return; + const ConnectionPresentation &connection = model.connection(); + const bool canSubmit = + connection.connected && connection.role == "controller"; + promptEditor->setEnabled(true); + sendButton->setEnabled(canSubmit); + attachmentButton->setEnabled(canSubmit); + for (QPushButton *button : attachmentPanel->findChildren()) + button->setEnabled(true); +} + +void ShellWidget::completePromptSubmission(const std::string &threadId, + std::uint64_t submissionId, + const nlohmann::json &result) { + const auto prompts = pendingPrompts.find(threadId); + if (prompts == pendingPrompts.end()) + return; + const auto submission = + std::find_if(prompts->second.begin(), prompts->second.end(), + [submissionId](const PendingPrompt &candidate) { + return candidate.id == submissionId; + }); + if (submission == prompts->second.end()) + return; + if (result.value("ok", false)) { + operationReadyThreads.insert(threadId); + submission->status = PendingPromptStatus::Acknowledged; + submission->acknowledgedAtMilliseconds = + QDateTime::currentMSecsSinceEpoch(); + scheduleAcknowledgementCompletion(threadId, *submission); + } else { + submission->status = PendingPromptStatus::Failed; + const nlohmann::json error = + result.value("error", nlohmann::json::object()); + const std::string message = safeMessage(error); + submission->error = + text(message.empty() ? std::string("Submission failed") : message); + showNotice(text(message.empty() ? std::string("Turn submission failed") + : message)); + } + if (threadId == selectedThreadId) + conversationSmoothFollowRequested = true; + conversationRebuildPending = true; + scheduleRefresh(RefreshConversation | RefreshStatus); + QTimer::singleShot(0, this, + [this, threadId] { dispatchNextPrompt(threadId); }); +} + +QString ShellWidget::pendingPromptAnchorKey(const std::string &threadId, + std::uint64_t submissionId) const { + return QStringLiteral("prompt:%1:%2") + .arg(text(threadId.empty() ? std::string("new") : threadId)) + .arg(submissionId); +} + +void ShellWidget::scheduleAcknowledgementCompletion(const std::string &threadId, + PendingPrompt &submission) { + if (submission.completionRefreshScheduled || + submission.status != PendingPromptStatus::Acknowledged) + return; + const qint64 elapsed = QDateTime::currentMSecsSinceEpoch() - + submission.acknowledgedAtMilliseconds; + const int remaining = static_cast( + std::max(0, AcknowledgementTransitionMilliseconds - elapsed)); + submission.completionRefreshScheduled = true; + const std::uint64_t submissionId = submission.id; + QTimer::singleShot(remaining, this, [this, threadId, submissionId] { + const auto prompts = pendingPrompts.find(threadId); + if (prompts == pendingPrompts.end()) + return; + const auto pending = + std::find_if(prompts->second.begin(), prompts->second.end(), + [submissionId](const PendingPrompt &candidate) { + return candidate.id == submissionId; + }); + if (pending == prompts->second.end()) + return; + pending->completionRefreshScheduled = false; + if (threadId == selectedThreadId) { + conversationRebuildPending = true; + scheduleRefresh(RefreshConversation); + } + }); +} + +std::unordered_set +ShellWidget::materializedUserMessageIds(const std::string &threadId) const { + std::unordered_set result; + const ThreadPresentation *thread = model.thread(threadId); + if (!thread) + return result; + for (const std::string &turnId : thread->turnOrder) { + const auto turn = thread->turns.find(turnId); + if (turn == thread->turns.end()) + continue; + for (const std::string &itemId : turn->second.itemOrder) { + const auto item = turn->second.items.find(itemId); + if (item != turn->second.items.end() && + stringValue(item->second.raw, "type") == "userMessage") + result.insert(turnId + '\x1f' + itemId); + } + } + return result; +} + +void ShellWidget::reconcileAcknowledgedPrompts(const std::string &threadId) { + const auto prompts = pendingPrompts.find(threadId); + const ThreadPresentation *thread = model.thread(threadId); + if (prompts == pendingPrompts.end() || !thread) + return; + auto &claimed = materializedPromptItemIds[threadId]; + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + for (auto submission = prompts->second.begin(); + submission != prompts->second.end();) { + if (submission->status != PendingPromptStatus::Acknowledged) { + ++submission; + continue; + } + std::string matchedId = submission->materializedIdentity; + for (const std::string &turnId : thread->turnOrder) { + if (!matchedId.empty()) + break; + const auto turn = thread->turns.find(turnId); + if (turn == thread->turns.end()) + continue; + for (const std::string &itemId : turn->second.itemOrder) { + const auto item = turn->second.items.find(itemId); + if (item == turn->second.items.end() || + stringValue(item->second.raw, "type") != "userMessage") + continue; + const std::string identity = turnId + '\x1f' + itemId; + if (claimed.contains(identity) || + submission->knownUserMessageIds.contains(identity)) + continue; + if (messageText(item->second.raw).trimmed() == + submission->prompt.trimmed()) { + matchedId = identity; + break; + } + } + if (!matchedId.empty()) + break; + } + if (matchedId.empty()) { + ++submission; + continue; + } + claimed.insert(matchedId); + submission->materializedIdentity = matchedId; + promptAnchorKeys[matchedId] = + pendingPromptAnchorKey(threadId, submission->id); + if (now - submission->acknowledgedAtMilliseconds < + AcknowledgementTransitionMilliseconds) { + scheduleAcknowledgementCompletion(threadId, *submission); + ++submission; + continue; + } + submission = prompts->second.erase(submission); + } + if (prompts->second.empty()) + pendingPrompts.erase(prompts); +} + +void ShellWidget::refreshStatus() { + const ConnectionPresentation &connection = model.connection(); + QString dotStyle; + QString dotToolTip; + if (connection.connected) { + dotStyle = QStringLiteral("background:#23845a;border-radius:4px;"); + dotToolTip = QStringLiteral("Connected"); + } else if (connection.retrying) { + dotStyle = QStringLiteral("background:#d98e1c;border-radius:4px;"); + dotToolTip = QStringLiteral("Disconnected, retrying"); + } else { + dotStyle = QStringLiteral("background:#b83a3a;border-radius:4px;"); + dotToolTip = QStringLiteral("Disconnected"); + } + connectionStatusDot->setStyleSheet(dotStyle); + connectionStatusDot->setToolTip(dotToolTip); + QString selectedTransport; + const std::string selectedKey = stringValue(connection.settings, "selected"); + const nlohmann::json available = + connection.settings.value("available", nlohmann::json::array()); + if (available.is_array()) { + for (const auto &entry : available) { + if (stringValue(entry, "key") == selectedKey) { + selectedTransport = text(stringValue(entry, "label")); + break; + } + } + } + connectionButton->setText(selectedTransport.isEmpty() + ? QStringLiteral("Connection") + : selectedTransport); + connectionButton->setToolTip( + connection.connected ? QStringLiteral("Connected bridge transport") + : QStringLiteral("Disconnected bridge transport")); + connectAction->setEnabled(!connection.connected); + disconnectAction->setEnabled(connection.connected); + reconnectAction->setEnabled(connection.connected); + controllerLabel->setText(connection.role.empty() ? QStringLiteral("No role") + : text(connection.role)); + controllerButton->setText(connection.role == "controller" + ? QStringLiteral("Release control") + : QStringLiteral("Claim control")); + controllerButton->setEnabled(connection.connected); + const std::size_t pending = model.pendingRequestCount(selectedThreadId); + const std::size_t totalPending = model.pendingRequestCount(); + requestButton->setText(QStringLiteral("Requests (%1)") + .arg(static_cast(totalPending))); + requestButton->setVisible(totalPending != 0); + approveButton->parentWidget()->setVisible(pending != 0); + + const ThreadPresentation *thread = model.thread(selectedThreadId); + if (thread) { + const QString workspace = text(thread->cwd); + workspaceBreadcrumb->setToolTip(workspace); + workspaceBreadcrumb->setText(workspaceBreadcrumb->fontMetrics().elidedText( + workspace, Qt::ElideMiddle, workspaceBreadcrumb->maximumWidth())); + threadContextStatus->setText( + QStringLiteral("%1 | %2") + .arg(text(thread->title), displayStatus(thread->status))); + std::size_t runningAgents = 0; + for (const auto &[agentId, agent] : thread->agents) { + static_cast(agentId); + if (agent.status == "inProgress" || agent.status == "running" || + agent.status == "started") + ++runningAgents; + } + agentActivityStatus->setText( + thread->agents.empty() + ? QStringLiteral("No agent activity") + : QStringLiteral("%1 agents | %2 active") + .arg(static_cast(thread->agents.size())) + .arg(static_cast(runningAgents))); + } else { + const QString workspace = + localNewThreadIntent + ? text(turnSettings->workspace(QDir::currentPath().toStdString())) + : QStringLiteral("No workspace"); + workspaceBreadcrumb->setToolTip(workspace); + workspaceBreadcrumb->setText(workspaceBreadcrumb->fontMetrics().elidedText( + workspace, Qt::ElideMiddle, workspaceBreadcrumb->maximumWidth())); + threadContextStatus->setText(localNewThreadIntent + ? QStringLiteral("New thread") + : QStringLiteral("No thread context")); + agentActivityStatus->setText(QStringLiteral("No agent activity")); + } + const bool active = model.activeTurnId(selectedThreadId).has_value(); + interruptButton->setVisible(active); + sendButton->setText(active ? QStringLiteral("Steer") + : QStringLiteral("Send")); + const QString actionKind = + active ? QStringLiteral("steer") : QStringLiteral("primary"); + if (sendButton->property("kind").toString() != actionKind) { + sendButton->setProperty("kind", actionKind); + sendButton->style()->unpolish(sendButton); + sendButton->style()->polish(sendButton); + } + scheduleComposerLayout(); + refreshComposerEnabledState(); + turnSettings->setControlsEnabled(connection.connected && + connection.role == "controller" && !active); +} + +void ShellWidget::refreshStateInspector() { + if (!stateView || !infoTabs || inspectorTabs->currentIndex() != 4 || + infoTabs->currentIndex() != 0) + return; + nlohmann::json domains = nlohmann::json::object(); + for (const auto &[name, value] : model.globalDomains()) + domains[name] = value; + + nlohmann::json pending = nlohmann::json::object(); + for (const auto &[id, request] : model.pendingRequestPresentations()) { + pending[id] = {{"category", request.kind}, + {"threadId", request.threadId}, + {"generation", request.generation}}; + } + + const nlohmann::json state{{"models", model.modelCatalog()}, + {"pendingRequests", std::move(pending)}, + {"domains", std::move(domains)}}; + std::string rendered = state.dump(2); + constexpr std::size_t MaximumRenderedStateBytes = 32U * 1024U; + if (rendered.size() > MaximumRenderedStateBytes) { + const std::size_t totalBytes = rendered.size(); + rendered.resize(MaximumRenderedStateBytes); + rendered += "\n\n[State display truncated at 32 KiB; retained bytes: " + + std::to_string(totalBytes) + "]"; + } + stateView->setPlainText(text(rendered)); +} + +void ShellWidget::selectThread(std::string threadId) { + stopConversationScrollAnimation(); + conversationSmoothScrollFloor = 0; + conversationPausedAnchorValid = false; + ++conversationScrollSettlementRevision; + conversationSmoothFollowRequested = false; + if (threadId != selectedThreadId) + conversationItemLimit = 80; + selectedThreadId = std::move(threadId); + localNewThreadIntent = false; + newThreadDraftOptions = nlohmann::json::object(); + newThreadDraftName.clear(); + newThreadDraftWorkspace.clear(); + conversationRebuildPending = true; + resetComposer(); + ensureThreadHydrated(selectedThreadId); + scheduleRefresh(); +} + +void ShellWidget::beginNewThread() { + if (newThreadCreationInFlight) { + showNotice(QStringLiteral("The current new thread is still being created."), + false); + return; + } + NewThreadDialog dialog( + text(turnSettings->workspace(QDir::currentPath().toStdString())), this); + if (dialog.exec() != QDialog::Accepted) + return; + const NewThreadDraft draft = dialog.draft(); + newThreadPendingPrompts.clear(); + selectedThreadId.clear(); + localNewThreadIntent = true; + newThreadDraftName = draft.name; + newThreadDraftWorkspace = draft.workspace; + newThreadDraftOptions = nlohmann::json::object(); + if (!draft.baseInstructions.isEmpty()) + newThreadDraftOptions["baseInstructions"] = + draft.baseInstructions.toStdString(); + if (!draft.developerInstructions.isEmpty()) + newThreadDraftOptions["developerInstructions"] = + draft.developerInstructions.toStdString(); + if (draft.ephemeral) + newThreadDraftOptions["ephemeral"] = true; + turnSettings->setWorkspace(draft.workspace); + conversationItemLimit = 80; + conversationRebuildPending = true; + threadList->clearSelection(); + resetComposer(); + promptEditor->setFocus(); + scheduleRefresh(); +} + +void ShellWidget::requestThreads() { session.listThreads(); } + +void ShellWidget::requestModels() { session.listModels(); } + +void ShellWidget::readThread(const std::string &threadId) { + if (threadId.empty()) + return; + threadHydration[threadId] = ThreadHydrationState::ReadInFlight; + session.readThread(threadId); +} + +void ShellWidget::ensureThreadHydrated(const std::string &threadId) { + if (threadId.empty() || threadIsHydrated(threadId)) + return; + const auto state = threadHydration.find(threadId); + if (state != threadHydration.end() && + state->second == ThreadHydrationState::ReadInFlight) + return; + readThread(threadId); +} + +bool ShellWidget::threadIsHydrated(const std::string &threadId) const { + const auto state = threadHydration.find(threadId); + return state != threadHydration.end() && + state->second == ThreadHydrationState::Hydrated; +} + +bool ShellWidget::threadRequiresResume(const std::string &threadId) const { + if (operationReadyThreads.contains(threadId)) + return false; + const ThreadPresentation *thread = model.thread(threadId); + return thread && thread->status == "notLoaded"; +} + +void ShellWidget::renameThread(const std::string &threadId) { + const ThreadPresentation *thread = model.thread(threadId); + if (!thread) + return; + bool accepted = false; + const QString name = + QInputDialog::getText(this, QStringLiteral("Rename thread"), + QStringLiteral("Name"), QLineEdit::Normal, + text(thread->title), &accepted) + .trimmed(); + if (accepted && !name.isEmpty()) + session.renameThread(threadId, name.toStdString()); +} + +void ShellWidget::forkThread(const std::string &threadId) { + if (threadId.empty()) + return; + session.forkThread( + threadId, nlohmann::json::object(), [this](const nlohmann::json &result) { + if (!result.value("ok", false)) + return; + const std::string threadId = + stringValue(result.value("data", nlohmann::json::object()) + .value("thread", nlohmann::json::object()), + "id"); + if (!threadId.empty()) + selectThread(threadId); + }); +} + +void ShellWidget::toggleThreadArchive(const std::string &threadId) { + const ThreadPresentation *thread = model.thread(threadId); + if (!thread) + return; + if (thread->archived) + session.unarchiveThread(threadId); + else + session.archiveThread(threadId); +} + +void ShellWidget::deleteThread(const std::string &threadId) { + if (threadId.empty()) + return; + if (QMessageBox::question(this, QStringLiteral("Delete thread"), + QStringLiteral("Delete the selected thread?"), + QMessageBox::Yes | QMessageBox::Cancel, + QMessageBox::Cancel) == QMessageBox::Yes) { + session.deleteThread(threadId); + } +} + +void ShellWidget::submitPrompt() { + const QString promptValue = promptEditor->toPlainText().trimmed(); + if (promptValue.isEmpty()) + return; + const std::string visibleThreadId = visiblySelectedThreadId(); + if (!visibleThreadId.empty() && visibleThreadId != selectedThreadId) { + if (!model.thread(visibleThreadId)) { + showNotice(QStringLiteral("The visibly selected thread is no longer " + "available. Your message was not sent.")); + return; + } + selectThread(visibleThreadId); + } + + PendingPrompt submission; + submission.id = nextPendingPromptId++; + submission.admittedAtMilliseconds = QDateTime::currentMSecsSinceEpoch(); + submission.prompt = promptValue; + submission.attachments = attachmentDrafts; + submission.turnOptions = turnSettings->turnStartOptions(); + + if (!selectedThreadId.empty()) { + submission.knownUserMessageIds = + materializedUserMessageIds(selectedThreadId); + const std::string destination = selectedThreadId; + pendingPrompts[destination].push_back(std::move(submission)); + resetComposer(); + conversationSmoothFollowRequested = true; + conversationRebuildPending = true; + scheduleRefresh(RefreshConversation); + dispatchNextPrompt(destination); + return; + } + if (!localNewThreadIntent) { + showNotice(QStringLiteral("No destination thread is selected. Your " + "message was not sent; select a thread or use " + "New thread.")); + promptEditor->setFocus(); + return; + } + newThreadPendingPrompts.push_back(std::move(submission)); + resetComposer(); + conversationSmoothFollowRequested = true; + conversationRebuildPending = true; + scheduleRefresh(RefreshConversation); + startThreadForPendingPrompts(); +} + +void ShellWidget::startThreadForPendingPrompts() { + if (newThreadCreationInFlight || newThreadPendingPrompts.empty()) + return; + newThreadCreationInFlight = true; + nlohmann::json threadOptions = turnSettings->threadStartOptions(); + threadOptions.update(newThreadDraftOptions); + threadOptions["cwd"] = + turnSettings->workspace(QDir::currentPath().toStdString()); + const QString requestedName = newThreadDraftName; + session.createThread( + std::move(threadOptions), + [this, requestedName](const nlohmann::json &result) { + newThreadCreationInFlight = false; + if (!result.value("ok", false)) { + const nlohmann::json error = + result.value("error", nlohmann::json::object()); + const std::string message = safeMessage(error); + const QString displayedError = + text(message.empty() ? std::string("Thread creation failed") + : message); + for (PendingPrompt &pending : newThreadPendingPrompts) { + if (pending.status == PendingPromptStatus::Awaiting) { + pending.status = PendingPromptStatus::Failed; + pending.error = displayedError; + } + } + showNotice(text(message.empty() + ? std::string("Thread creation failed") + : message)); + conversationRebuildPending = true; + scheduleRefresh(RefreshConversation); + return; + } + const std::string threadId = + stringValue(result.value("data", nlohmann::json::object()) + .value("thread", nlohmann::json::object()), + "id"); + if (threadId.empty()) { + for (PendingPrompt &pending : newThreadPendingPrompts) { + if (pending.status == PendingPromptStatus::Awaiting) { + pending.status = PendingPromptStatus::Failed; + pending.error = QStringLiteral( + "Thread creation returned no thread identifier"); + } + } + showNotice(QStringLiteral("Thread creation returned no thread.")); + conversationRebuildPending = true; + scheduleRefresh(RefreshConversation); + return; + } + auto &threadPrompts = pendingPrompts[threadId]; + threadPrompts.insert( + threadPrompts.end(), + std::make_move_iterator(newThreadPendingPrompts.begin()), + std::make_move_iterator(newThreadPendingPrompts.end())); + newThreadPendingPrompts.clear(); + threadHydration[threadId] = ThreadHydrationState::Hydrated; + operationReadyThreads.insert(threadId); + const bool viewingNewThreadDraft = + selectedThreadId.empty() && localNewThreadIntent; + if (viewingNewThreadDraft) { + selectedThreadId = threadId; + localNewThreadIntent = false; + } + newThreadDraftOptions = nlohmann::json::object(); + newThreadDraftName.clear(); + newThreadDraftWorkspace.clear(); + conversationItemLimit = 80; + conversationRebuildPending = true; + if (!requestedName.isEmpty()) + session.renameThread(threadId, requestedName.toStdString()); + scheduleRefresh(); + QTimer::singleShot(0, this, + [this, threadId] { dispatchNextPrompt(threadId); }); + }); +} + +void ShellWidget::dispatchNextPrompt(const std::string &threadId) { + const auto prompts = pendingPrompts.find(threadId); + if (prompts == pendingPrompts.end()) + return; + if (!threadIsHydrated(threadId)) { + ensureThreadHydrated(threadId); + return; + } + if (std::any_of(prompts->second.begin(), prompts->second.end(), + [](const PendingPrompt &candidate) { + return candidate.status == PendingPromptStatus::Awaiting && + candidate.dispatched; + })) + return; + const auto next = + std::find_if(prompts->second.begin(), prompts->second.end(), + [](const PendingPrompt &candidate) { + return candidate.status == PendingPromptStatus::Awaiting && + !candidate.dispatched; + }); + if (next == prompts->second.end()) + return; + next->dispatched = true; + submitPromptToThread(threadId, next->id, next->prompt.toStdString(), + next->turnOptions, next->attachments); +} + +void ShellWidget::submitPromptToThread( + std::string threadId, std::uint64_t submissionId, std::string prompt, + nlohmann::json options, std::vector attachments) { + nlohmann::json input = + nlohmann::json::array({{{"type", "text"}, + {"text", std::move(prompt)}, + {"text_elements", nlohmann::json::array()}}}); + for (const AttachmentDraft &attachment : attachments) { + if (attachment.mimeType.startsWith(QStringLiteral("image/"))) { + input.push_back( + {{"type", "localImage"}, {"path", attachment.path.toStdString()}}); + } else if (attachment.mimeType.startsWith(QStringLiteral("audio/"))) { + input.push_back( + {{"type", "localAudio"}, {"path", attachment.path.toStdString()}}); + } else { + input.push_back({{"type", "mention"}, + {"name", attachment.name.toStdString()}, + {"path", attachment.path.toStdString()}}); + } + } + const auto completed = [this, threadId, + submissionId](const nlohmann::json &result) { + if (attemptPromptThreadRecovery(threadId, submissionId, result)) + return; + completePromptSubmission(threadId, submissionId, result); + }; + auto sendPrompt = std::make_shared>(); + *sendPrompt = [this, threadId, input = std::move(input), + options = std::move(options), completed]() mutable { + const auto activeTurn = model.activeTurnId(threadId); + if (activeTurn) { + session.steerTurn(threadId, *activeTurn, std::move(input), completed); + } else { + session.startTurn(threadId, std::move(input), std::move(options), + completed); + } + }; + if (threadRequiresResume(threadId)) { + session.resumeThread( + threadId, nlohmann::json::object(), + [this, threadId, sendPrompt, + completed](const nlohmann::json &result) mutable { + if (!result.value("ok", false)) { + completed(result); + return; + } + QTimer::singleShot(0, this, [this, threadId, sendPrompt] { + threadHydration[threadId] = ThreadHydrationState::Hydrated; + operationReadyThreads.insert(threadId); + (*sendPrompt)(); + }); + }); + } else { + (*sendPrompt)(); + } +} + +bool ShellWidget::attemptPromptThreadRecovery(const std::string &threadId, + std::uint64_t submissionId, + const nlohmann::json &result) { + if (!isThreadNotFoundResult(result)) + return false; + const auto prompts = pendingPrompts.find(threadId); + if (prompts == pendingPrompts.end()) + return false; + const auto submission = + std::find_if(prompts->second.begin(), prompts->second.end(), + [submissionId](const PendingPrompt &candidate) { + return candidate.id == submissionId; + }); + if (submission == prompts->second.end() || + submission->readinessRetryAttempted) + return false; + + submission->readinessRetryAttempted = true; + threadHydration[threadId] = ThreadHydrationState::NotHydrated; + operationReadyThreads.erase(threadId); + session.resumeThread( + threadId, nlohmann::json::object(), + [this, threadId, submissionId](const nlohmann::json &resumeResult) { + if (!resumeResult.value("ok", false)) { + completePromptSubmission(threadId, submissionId, resumeResult); + return; + } + QTimer::singleShot(0, this, [this, threadId, submissionId] { + threadHydration[threadId] = ThreadHydrationState::Hydrated; + operationReadyThreads.insert(threadId); + const auto prompts = pendingPrompts.find(threadId); + if (prompts == pendingPrompts.end()) + return; + const auto submission = + std::find_if(prompts->second.begin(), prompts->second.end(), + [submissionId](const PendingPrompt &candidate) { + return candidate.id == submissionId; + }); + if (submission == prompts->second.end()) + return; + submission->dispatched = false; + dispatchNextPrompt(threadId); + }); + }); + return true; +} + +void ShellWidget::chooseAttachments() { + const QString initialDirectory = + text(turnSettings->workspace(QDir::currentPath().toStdString())); + FileSelectionDialog dialog(FileSelectionDialog::Mode::Attachments, + initialDirectory, attachmentDrafts, this); + if (dialog.exec() != QDialog::Accepted) + return; + attachmentDrafts = dialog.selectedAttachments(); + ++attachmentRevision; + refreshAttachments(); +} + +void ShellWidget::refreshAttachments() { + const bool hasAttachments = !attachmentDrafts.empty(); + attachmentPanel->setVisible(hasAttachments); + clearLayout(attachmentListLayout); + if (!hasAttachments) { + attachmentListScroll->setFixedHeight(0); + return; + } + constexpr int AttachmentRowHeight = 28; + constexpr int MaximumVisibleAttachments = 4; + for (std::size_t index = 0; index < attachmentDrafts.size(); ++index) { + const AttachmentDraft &attachment = attachmentDrafts[index]; + auto *row = new QWidget; + row->setFixedHeight(AttachmentRowHeight); + auto *rowLayout = new QHBoxLayout(row); + rowLayout->setContentsMargins(0, 2, 0, 2); + rowLayout->setSpacing(5); + auto *remove = new QPushButton(QStringLiteral("X")); + remove->setAccessibleName(QStringLiteral("Remove %1").arg(attachment.name)); + remove->setToolTip(QStringLiteral("Remove attachment")); + remove->setFixedSize(18, 18); + remove->setStyleSheet( + QStringLiteral("QPushButton{background:#b83a3a;color:#ffffff;border:0;" + "border-radius:4px;padding:0;" + "font-weight:700;}" + "QPushButton:hover{background:#9f2f2f;}" + "QPushButton:pressed{background:#842626;}")); + connect(remove, &QPushButton::clicked, this, [this, index] { + attachmentDrafts.erase(attachmentDrafts.begin() + + static_cast(index)); + ++attachmentRevision; + refreshAttachments(); + }); + auto *fileBox = new QFrame; + fileBox->setObjectName(QStringLiteral("attachmentFileBox")); + fileBox->setStyleSheet( + QStringLiteral("QFrame#attachmentFileBox{background:#ffffff;" + "border:1px solid #d7dee8;border-radius:6px;}")); + auto *fileLayout = new QHBoxLayout(fileBox); + fileLayout->setContentsMargins(8, 1, 8, 1); + auto *name = makeLabel(attachment.name, "meta"); + name->setToolTip(QDir::toNativeSeparators(attachment.path)); + name->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + fileLayout->addWidget(name); + rowLayout->addWidget(fileBox, 1); + rowLayout->addWidget(remove, 0, Qt::AlignVCenter); + attachmentListLayout->addWidget(row); + } + const int visibleRows = std::min( + static_cast(attachmentDrafts.size()), MaximumVisibleAttachments); + attachmentListScroll->setFixedHeight(visibleRows * AttachmentRowHeight + + (visibleRows - 1) * 4); +} + +void ShellWidget::interruptActiveTurn() { + const auto turnId = model.activeTurnId(selectedThreadId); + if (!turnId) + return; + session.interruptTurn(selectedThreadId, *turnId); +} + +void ShellWidget::respondToFirstPending(bool approve) { + const auto &pending = model.pendingRequestPresentations(); + const auto request = + std::find_if(pending.begin(), pending.end(), [this](const auto &entry) { + return entry.second.threadId == selectedThreadId; + }); + if (request == pending.end()) + return; + + if (approve) + reviewPending(request->first); + else + rejectPending(request->first); +} + +void ShellWidget::reviewPending(const std::string &requestKey) { + const auto request = model.pendingRequestPresentations().find(requestKey); + if (request == model.pendingRequestPresentations().end()) + return; + const auto response = PendingRequestDialog::present(request->second, this); + if (!response) + return; + session.respondToServerRequest(nlohmann::json::parse(requestKey), + response->result, response->error); +} + +void ShellWidget::rejectPending(const std::string &requestKey) { + const auto request = model.pendingRequestPresentations().find(requestKey); + if (request == model.pendingRequestPresentations().end()) + return; + PendingRequestResponse response = + PendingRequestDialog::negativeResponse(request->second); + session.respondToServerRequest(nlohmann::json::parse(requestKey), + std::move(response.result), + std::move(response.error)); +} + +} // namespace codexui::codex diff --git a/src/codex/ShellWidget.h b/src/codex/ShellWidget.h new file mode 100644 index 0000000..f111677 --- /dev/null +++ b/src/codex/ShellWidget.h @@ -0,0 +1,279 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_SHELLWIDGET_H +#define CODEXUI_CODEX_SHELLWIDGET_H + +#include "codex/FileSelectionDialog.h" +#include "codex/PresentationModel.h" + +#include + +#include +#include +#include +#include +#include +#include + +class QFrame; +class QAction; +class QEvent; +class QGridLayout; +class QLabel; +class QListWidget; +class QPlainTextEdit; +class QPushButton; +class QScrollArea; +class QSplitter; +class QTabWidget; +class QTimer; +class QToolButton; +class QVariantAnimation; +class QVBoxLayout; + +namespace codexui { +class ExpandingPromptEditor; +} + +namespace codexui::codex { + +class FrontendSession; +class DiffViewer; +class ShellWidgetScrollTest; +class TurnSettingsWidget; + +class ShellWidget final : public QWidget { +public: + explicit ShellWidget(FrontendSession &session, QWidget *parent = nullptr); + +protected: + bool eventFilter(QObject *watched, QEvent *event) override; + +private: + friend class ShellWidgetScrollTest; + + enum class PendingPromptStatus { Awaiting, Acknowledged, Failed }; + enum class ThreadHydrationState { NotHydrated, ReadInFlight, Hydrated }; + + struct PendingPrompt { + std::uint64_t id = 0; + QString prompt; + std::vector attachments; + nlohmann::json turnOptions = nlohmann::json::object(); + PendingPromptStatus status = PendingPromptStatus::Awaiting; + bool dispatched = false; + bool readinessRetryAttempted = false; + qint64 admittedAtMilliseconds = 0; + qint64 acknowledgedAtMilliseconds = 0; + bool completionRefreshScheduled = false; + std::string materializedIdentity; + QString error; + std::unordered_set knownUserMessageIds; + }; + + struct ConversationScrollAnchor { + QString key; + int viewportOffset = 0; + int absoluteValue = 0; + }; + + enum RefreshArea : std::uint32_t { + RefreshNone = 0, + RefreshThreads = 1U << 0U, + RefreshConversation = 1U << 1U, + RefreshInspector = 1U << 2U, + RefreshState = 1U << 3U, + RefreshProtocolStats = 1U << 4U, + RefreshTurnSettings = 1U << 5U, + RefreshStatus = 1U << 6U, + RefreshAll = (1U << 7U) - 1U, + }; + + void handleEvent(const nlohmann::json &event); + void scheduleRefresh(std::uint32_t areas = RefreshAll); + void refresh(); + void refreshThreads(); + void refreshConversation(); + void refreshConversationItems(); + [[nodiscard]] bool refreshConversationItem(const std::string &key, + const std::string &turnId, + const std::string &itemId, + bool &changed); + void refreshInspector(); + void refreshStateInspector(); + void refreshProtocolStats(); + void showProtocolTail(); + void refreshStatus(); + void refreshTurnSettings(); + [[nodiscard]] std::string visiblySelectedThreadId() const; + void addConversationTrailingSpace(); + void updateComposerDockHeight(int height); + void scheduleConversationFollowLatest(); + void scrollConversationToLatest(bool smoothly = true); + void stopConversationScrollAnimation(); + [[nodiscard]] ConversationScrollAnchor + captureConversationScrollAnchor() const; + void restoreConversationScrollAnchor(const ConversationScrollAnchor &anchor); + void scheduleConversationPausedAnchorRestore(); + void settleConversationScroll(bool followLatest, + ConversationScrollAnchor anchor, bool smoothly); + void appendProtocolFrame(const nlohmann::json &frame); + void hydrateHistoricalAgents(); + void showNotice(QString message, bool error = true); + [[nodiscard]] std::uint32_t + refreshAreasForEvent(const nlohmann::json &event) const; + [[nodiscard]] std::string + conversationItemFingerprint(const ItemPresentation &item) const; + [[nodiscard]] QString + pendingPromptAnchorKey(const std::string &threadId, + std::uint64_t submissionId) const; + void scheduleAcknowledgementCompletion(const std::string &threadId, + PendingPrompt &submission); + + void selectThread(std::string threadId); + void beginNewThread(); + void requestThreads(); + void requestModels(); + void readThread(const std::string &threadId); + void ensureThreadHydrated(const std::string &threadId); + [[nodiscard]] bool threadIsHydrated(const std::string &threadId) const; + [[nodiscard]] bool threadRequiresResume(const std::string &threadId) const; + void renameThread(const std::string &threadId); + void forkThread(const std::string &threadId); + void toggleThreadArchive(const std::string &threadId); + void deleteThread(const std::string &threadId); + void submitPrompt(); + void submitPromptToThread(std::string threadId, std::uint64_t submissionId, + std::string prompt, nlohmann::json options, + std::vector attachments); + void dispatchNextPrompt(const std::string &threadId); + void startThreadForPendingPrompts(); + void resetComposer(); + void refreshComposerEnabledState(); + void completePromptSubmission(const std::string &threadId, + std::uint64_t submissionId, + const nlohmann::json &result); + [[nodiscard]] bool attemptPromptThreadRecovery(const std::string &threadId, + std::uint64_t submissionId, + const nlohmann::json &result); + void reconcileAcknowledgedPrompts(const std::string &threadId); + [[nodiscard]] std::unordered_set + materializedUserMessageIds(const std::string &threadId) const; + void chooseAttachments(); + void refreshAttachments(); + void scheduleComposerLayout(); + void refreshComposerLayout(); + void interruptActiveTurn(); + void respondToFirstPending(bool approve); + void reviewPending(const std::string &requestKey); + void rejectPending(const std::string &requestKey); + + FrontendSession &session; + PresentationModel model; + std::string selectedThreadId; + bool localNewThreadIntent = false; + nlohmann::json newThreadDraftOptions = nlohmann::json::object(); + QString newThreadDraftName; + QString newThreadDraftWorkspace; + std::vector attachmentDrafts; + std::uint64_t attachmentRevision = 0; + std::unordered_map> pendingPrompts; + std::deque newThreadPendingPrompts; + std::unordered_map> + materializedPromptItemIds; + std::unordered_map promptAnchorKeys; + std::unordered_map threadHydration; + std::unordered_set operationReadyThreads; + std::uint64_t nextPendingPromptId = 1; + bool newThreadCreationInFlight = false; + + QLabel *controllerLabel = nullptr; + QLabel *workspaceBreadcrumb = nullptr; + QLabel *threadContextStatus = nullptr; + QLabel *agentActivityStatus = nullptr; + QLabel *conversationTitle = nullptr; + QLabel *conversationMeta = nullptr; + QLabel *emptyConversation = nullptr; + QLabel *noticeLabel = nullptr; + QFrame *connectionStatusDot = nullptr; + QFrame *noticeBar = nullptr; + QFrame *sidebar = nullptr; + QFrame *conversationRegion = nullptr; + QFrame *inspector = nullptr; + QSplitter *splitter = nullptr; + QListWidget *threadList = nullptr; + QWidget *conversationContent = nullptr; + QWidget *conversationTrailingSpace = nullptr; + QWidget *composerReserve = nullptr; + QVBoxLayout *conversationLayout = nullptr; + QScrollArea *conversationScroll = nullptr; + QTabWidget *inspectorTabs = nullptr; + QTabWidget *infoTabs = nullptr; + QWidget *planContent = nullptr; + QVBoxLayout *planLayout = nullptr; + QWidget *agentsContent = nullptr; + QVBoxLayout *agentsLayout = nullptr; + DiffViewer *diffViewer = nullptr; + QWidget *requestsContent = nullptr; + QVBoxLayout *requestsLayout = nullptr; + QLabel *protocolStats = nullptr; + QPlainTextEdit *protocolLog = nullptr; + QPlainTextEdit *stateView = nullptr; + codexui::ExpandingPromptEditor *promptEditor = nullptr; + TurnSettingsWidget *turnSettings = nullptr; + QWidget *composerBody = nullptr; + QGridLayout *composerGrid = nullptr; + QPushButton *sendButton = nullptr; + QToolButton *attachmentButton = nullptr; + QFrame *attachmentPanel = nullptr; + QScrollArea *attachmentListScroll = nullptr; + QVBoxLayout *attachmentListLayout = nullptr; + QPushButton *interruptButton = nullptr; + QPushButton *controllerButton = nullptr; + QPushButton *requestButton = nullptr; + QToolButton *connectionButton = nullptr; + QAction *connectAction = nullptr; + QAction *disconnectAction = nullptr; + QAction *reconnectAction = nullptr; + QPushButton *restoreSidebarButton = nullptr; + QPushButton *restoreInspectorButton = nullptr; + QPushButton *approveButton = nullptr; + QPushButton *denyButton = nullptr; + QTimer *refreshTimer = nullptr; + QVariantAnimation *conversationScrollAnimation = nullptr; + std::uint64_t observedPresentationSequence = 0; + std::uint32_t pendingRefreshAreas = RefreshAll; + bool composerExpanded = false; + bool composerActive = false; + bool composerLayoutRefreshPending = false; + bool conversationFollowsLatest = true; + bool conversationScrollRebuilding = false; + bool conversationScrollProgrammatic = false; + bool conversationSpacerAdjusting = false; + bool conversationFollowScrollPending = false; + bool conversationUserScrollPending = false; + bool conversationUserScrollInteraction = false; + bool conversationSmoothFollowRequested = false; + int conversationSmoothScrollFloor = 0; + ConversationScrollAnchor conversationPausedAnchor; + bool conversationPausedAnchorValid = false; + bool conversationPausedAnchorRestorePending = false; + int composerCanonicalHeight = 0; + int conversationTrailingSpaceHeight = 0; + std::uint64_t conversationSpacerRevision = 0; + std::uint64_t conversationScrollSettlementRevision = 0; + std::size_t conversationItemLimit = 80; + bool conversationRebuildPending = true; + std::unordered_map conversationCards; + std::unordered_map conversationCardFingerprints; + std::unordered_map> + commandOutputScrollStates; + std::unordered_map> + dirtyConversationItems; + std::deque protocolLines; + std::unordered_set requestedAgentThreads; +}; + +} // namespace codexui::codex + +#endif // CODEXUI_CODEX_SHELLWIDGET_H diff --git a/src/codex/TurnSettingsWidget.cpp b/src/codex/TurnSettingsWidget.cpp new file mode 100644 index 0000000..446c20c --- /dev/null +++ b/src/codex/TurnSettingsWidget.cpp @@ -0,0 +1,799 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/TurnSettingsWidget.h" + +#include "codex/FileSelectionDialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace codexui::codex { +namespace { + +constexpr auto DefaultValue = "default"; +constexpr int SettingControlHeight = 32; +constexpr int SettingLabelSpacing = 5; + +void drawChevron(QWidget *widget, const QRect &indicator, bool enabled, + bool highlighted) { + if (!indicator.isValid() || indicator.isEmpty()) + return; + const QPointF center = indicator.center(); + QPainterPath chevron; + chevron.moveTo(center.x() - 3.5, center.y() - 1.5); + chevron.lineTo(center.x(), center.y() + 2.0); + chevron.lineTo(center.x() + 3.5, center.y() - 1.5); + + QColor color(QStringLiteral("#667085")); + if (!enabled) + color = QColor(QStringLiteral("#98a2b3")); + else if (highlighted) + color = QColor(QStringLiteral("#1d2633")); + + QPainter painter(widget); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setPen(QPen(color, 1.4, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + painter.setBrush(Qt::NoBrush); + painter.drawPath(chevron); +} + +class CompactComboBox final : public QComboBox { +protected: + void paintEvent(QPaintEvent *event) override { + QComboBox::paintEvent(event); + + QStyleOptionComboBox option; + initStyleOption(&option); + const QRect indicator = style()->subControlRect( + QStyle::CC_ComboBox, &option, QStyle::SC_ComboBoxArrow, this); + drawChevron(this, indicator, option.state & QStyle::State_Enabled, + option.state & + (QStyle::State_MouseOver | QStyle::State_HasFocus)); + } +}; + +class ChevronMenuButton final : public QPushButton { +public: + using QPushButton::QPushButton; + +protected: + void paintEvent(QPaintEvent *event) override { + QPushButton::paintEvent(event); + QStyleOptionButton option; + initStyleOption(&option); + const QRect contents = + style()->subElementRect(QStyle::SE_PushButtonContents, &option, this); + const int indicatorWidth = + style()->pixelMetric(QStyle::PM_MenuButtonIndicator, &option, this); + QRect indicator(contents.right() - std::max(12, indicatorWidth), + contents.top(), std::max(12, indicatorWidth), + contents.height()); + drawChevron(this, indicator, option.state & QStyle::State_Enabled, + option.state & + (QStyle::State_MouseOver | QStyle::State_HasFocus)); + } +}; + +QString text(const std::string &value) { + return QString::fromUtf8(value.data(), static_cast(value.size())); +} + +std::string stringValue(const nlohmann::json &object, const char *key) { + if (!object.is_object()) + return {}; + const auto iterator = object.find(key); + return iterator != object.end() && iterator->is_string() + ? iterator->get() + : std::string{}; +} + +QString friendly(QString value) { + value.replace(QLatin1Char('-'), QLatin1Char(' ')); + value.replace(QLatin1Char('_'), QLatin1Char(' ')); + if (!value.isEmpty()) + value[0] = value[0].toUpper(); + return value; +} + +void addChoice(QComboBox *combo, const QString &label, const QString &value) { + if (combo->findData(value) < 0) + combo->addItem(label, value); +} + +void selectValue(QComboBox *combo, const QString &value, + const QString &fallback = {}) { + int index = combo->findData(value); + if (index < 0) { + combo->addItem(fallback.isEmpty() ? friendly(value) : fallback, value); + index = combo->count() - 1; + } + combo->setCurrentIndex(index); +} + +QComboBox *compactCombo(const char *name) { + auto *combo = new CompactComboBox; + combo->setObjectName(QString::fromLatin1(name)); + combo->setProperty("codexChevron", true); + combo->setFixedHeight(SettingControlHeight); + combo->setMinimumContentsLength(4); + combo->setSizeAdjustPolicy(QComboBox::AdjustToMinimumContentsLengthWithIcon); + return combo; +} + +QWidget *labelled(const QString &caption, QWidget *control, + QWidget *buddy = nullptr) { + auto *surface = new QFrame; + auto *layout = new QVBoxLayout(surface); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(SettingLabelSpacing); + auto *label = new QLabel(caption); + label->setStyleSheet(QStringLiteral("color:#667085;font-weight:600;")); + const int labelHeight = label->fontMetrics().height(); + label->setFixedHeight(labelHeight); + label->setBuddy(buddy ? buddy : control); + control->setAccessibleName(caption); + layout->addWidget(label); + layout->addWidget(control); + surface->setFixedHeight(labelHeight + SettingLabelSpacing + + SettingControlHeight); + surface->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed); + return surface; +} + +nlohmann::json canonicalSandbox(const nlohmann::json &canonical) { + if (!canonical.is_object()) + return nullptr; + if (canonical.contains("sandboxPolicy")) + return canonical["sandboxPolicy"]; + if (canonical.contains("sandbox")) + return canonical["sandbox"]; + return nullptr; +} + +QString sandboxKey(const nlohmann::json &value) { + std::string key; + if (value.is_string()) + key = value.get(); + else + key = stringValue(value, "type"); + if (key == "readOnly") + return QStringLiteral("read-only"); + if (key == "workspaceWrite") + return QStringLiteral("workspace-write"); + if (key == "dangerFullAccess") + return QStringLiteral("danger-full-access"); + if (key == "externalSandbox") + return QStringLiteral("external"); + return key.empty() ? QString::fromLatin1(DefaultValue) : text(key); +} + +bool sandboxNetworkEnabled(const nlohmann::json &value) { + const QString access = sandboxKey(value); + if (access == QStringLiteral("danger-full-access")) + return true; + if (value.is_object()) { + const auto member = value.find("networkAccess"); + if (member != value.end()) { + if (member->is_boolean()) + return member->get(); + if (member->is_string()) + return member->get() == "enabled"; + } + } + return false; +} + +QString optionalString(const nlohmann::json &object, const char *key) { + const std::string value = stringValue(object, key); + return value.empty() ? QString::fromLatin1(DefaultValue) : text(value); +} + +} // namespace + +TurnSettingsWidget::TurnSettingsWidget(QWidget *parent) : QWidget(parent) { + setObjectName(QStringLiteral("codexTurnSettings")); + setStyleSheet(QStringLiteral( + "QWidget#codexTurnSettings{background:#ffffff;border-top:1px solid " + "#d7dee8;}")); + setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + auto *root = new QGridLayout(this); + root->setContentsMargins(10, 8, 10, 8); + root->setHorizontalSpacing(8); + root->setVerticalSpacing(8); + + model = compactCombo("codexModel"); + model->setEditable(true); + effort = compactCombo("codexEffort"); + sandbox = compactCombo("codexSandbox"); + network = compactCombo("codexNetwork"); + cwd = new QLineEdit; + cwd->setObjectName(QStringLiteral("codexWorkspace")); + cwd->setFixedHeight(SettingControlHeight); + cwd->setStyleSheet( + QStringLiteral("QLineEdit#codexWorkspace{min-height:30px;}")); + cwd->setPlaceholderText(QStringLiteral("Codex default workspace")); + auto *workspacePicker = new QWidget; + workspacePicker->setFixedHeight(SettingControlHeight); + auto *workspaceLayout = new QHBoxLayout(workspacePicker); + workspaceLayout->setContentsMargins(0, 0, 0, 0); + workspaceLayout->setSpacing(6); + auto *browseWorkspace = new QToolButton; + browseWorkspace->setIcon(style()->standardIcon(QStyle::SP_DirOpenIcon)); + browseWorkspace->setToolTip(QStringLiteral("Select workspace")); + browseWorkspace->setAccessibleName(QStringLiteral("Select workspace")); + browseWorkspace->setFixedSize(SettingControlHeight, SettingControlHeight); + workspaceLayout->addWidget(cwd, 1); + workspaceLayout->addWidget(browseWorkspace); + approval = compactCombo("codexApproval"); + personality = compactCombo("codexPersonality"); + more = new ChevronMenuButton(QStringLiteral("More")); + more->setProperty("codexChevron", true); + more->setFixedHeight(SettingControlHeight); + + root->addWidget(labelled(QStringLiteral("Model"), model), 0, 0); + root->addWidget(labelled(QStringLiteral("Reasoning"), effort), 0, 1); + root->addWidget(labelled(QStringLiteral("Access"), sandbox), 0, 2); + root->addWidget(labelled(QStringLiteral("Network"), network), 0, 3); + root->addWidget(labelled(QStringLiteral("Workspace"), workspacePicker, cwd), + 1, 0); + root->addWidget(labelled(QStringLiteral("Approval"), approval), 1, 1); + root->addWidget(labelled(QStringLiteral("Style"), personality), 1, 2); + root->addWidget(labelled(QStringLiteral("Additional"), more), 1, 3); + for (int column = 0; column < 4; ++column) + root->setColumnStretch(column, 1); + + moreMenu = new QMenu(this); + auto *moreContents = new QWidget; + moreContents->setMinimumWidth(470); + auto *moreLayout = new QGridLayout(moreContents); + moreLayout->setContentsMargins(14, 12, 14, 12); + moreLayout->setHorizontalSpacing(8); + moreLayout->setVerticalSpacing(8); + permissionProfile = compactCombo("codexPermissionProfile"); + reviewer = compactCombo("codexReviewer"); + serviceTier = compactCombo("codexServiceTier"); + serviceTier->setEditable(true); + summary = compactCombo("codexSummary"); + collaboration = compactCombo("codexCollaboration"); + moreLayout->addWidget( + labelled(QStringLiteral("Permission profile"), permissionProfile), 0, 0); + moreLayout->addWidget(labelled(QStringLiteral("Approval reviewer"), reviewer), + 0, 1); + moreLayout->addWidget(labelled(QStringLiteral("Service tier"), serviceTier), + 1, 0); + moreLayout->addWidget(labelled(QStringLiteral("Reasoning summary"), summary), + 1, 1); + moreLayout->addWidget( + labelled(QStringLiteral("Collaboration mode"), collaboration), 2, 0, 1, + 2); + auto *moreAction = new QWidgetAction(moreMenu); + moreAction->setDefaultWidget(moreContents); + moreMenu->addAction(moreAction); + more->setMenu(moreMenu); + + addChoice(effort, QStringLiteral("Codex default"), DefaultValue); + for (const char *value : + {"minimal", "low", "medium", "high", "xhigh", "ultra"}) + addChoice(effort, friendly(QString::fromLatin1(value)), + QString::fromLatin1(value)); + addChoice(sandbox, QStringLiteral("Codex default"), DefaultValue); + addChoice(sandbox, QStringLiteral("Workspace"), + QStringLiteral("workspace-write")); + addChoice(sandbox, QStringLiteral("Read only"), QStringLiteral("read-only")); + addChoice(sandbox, QStringLiteral("Full access"), + QStringLiteral("danger-full-access")); + addChoice(sandbox, QStringLiteral("External"), QStringLiteral("external")); + addChoice(network, QStringLiteral("Codex default"), DefaultValue); + addChoice(network, QStringLiteral("Restricted"), + QStringLiteral("restricted")); + addChoice(network, QStringLiteral("Enabled"), QStringLiteral("enabled")); + addChoice(approval, QStringLiteral("Codex default"), DefaultValue); + addChoice(approval, QStringLiteral("On request"), + QStringLiteral("on-request")); + addChoice(approval, QStringLiteral("Untrusted"), QStringLiteral("untrusted")); + addChoice(approval, QStringLiteral("Never"), QStringLiteral("never")); + addChoice(personality, QStringLiteral("Codex default"), DefaultValue); + addChoice(personality, QStringLiteral("None"), QStringLiteral("none")); + addChoice(personality, QStringLiteral("Friendly"), + QStringLiteral("friendly")); + addChoice(personality, QStringLiteral("Pragmatic"), + QStringLiteral("pragmatic")); + addChoice(reviewer, QStringLiteral("Codex default"), DefaultValue); + addChoice(reviewer, QStringLiteral("User"), QStringLiteral("user")); + addChoice(reviewer, QStringLiteral("Auto review"), + QStringLiteral("auto_review")); + addChoice(reviewer, QStringLiteral("Guardian"), + QStringLiteral("guardian_subagent")); + addChoice(serviceTier, QStringLiteral("Codex default"), DefaultValue); + addChoice(summary, QStringLiteral("Codex default"), DefaultValue); + addChoice(summary, QStringLiteral("Auto"), QStringLiteral("auto")); + addChoice(summary, QStringLiteral("Concise"), QStringLiteral("concise")); + addChoice(summary, QStringLiteral("Detailed"), QStringLiteral("detailed")); + addChoice(summary, QStringLiteral("None"), QStringLiteral("none")); + addChoice(collaboration, QStringLiteral("Code"), QStringLiteral("default")); + addChoice(collaboration, QStringLiteral("Plan"), QStringLiteral("plan")); + addChoice(permissionProfile, QStringLiteral("Codex default"), DefaultValue); + + const auto connectCombo = [this](QComboBox *combo, Field field) { + connect(combo, &QComboBox::currentIndexChanged, this, + [this, field] { markTouched(field); }); + }; + connectCombo(model, Field::Model); + connect(model->lineEdit(), &QLineEdit::textEdited, this, + [this] { markTouched(Field::Model); }); + connectCombo(effort, Field::Effort); + connectCombo(personality, Field::Personality); + connectCombo(sandbox, Field::Sandbox); + connectCombo(network, Field::Network); + connectCombo(approval, Field::Approval); + connectCombo(reviewer, Field::Reviewer); + connectCombo(permissionProfile, Field::PermissionProfile); + connectCombo(serviceTier, Field::ServiceTier); + connect(serviceTier->lineEdit(), &QLineEdit::textEdited, this, + [this] { markTouched(Field::ServiceTier); }); + connectCombo(summary, Field::Summary); + connectCombo(collaboration, Field::Collaboration); + connect(cwd, &QLineEdit::textEdited, this, + [this] { markTouched(Field::Workspace); }); + connect(browseWorkspace, &QToolButton::clicked, this, [this] { + const QString initialDirectory = + cwd->text().trimmed().isEmpty() + ? QDir::homePath() + : QDir::fromNativeSeparators(cwd->text().trimmed()); + FileSelectionDialog dialog(FileSelectionDialog::Mode::Workspace, + initialDirectory, {}, this); + if (dialog.exec() == QDialog::Accepted) + setWorkspace(dialog.selectedDirectory()); + }); + connect(model, &QComboBox::currentIndexChanged, this, + [this] { refreshModelOptions(); }); + connect(sandbox, &QComboBox::currentIndexChanged, this, + [this] { refreshAccessCompatibility(); }); + connect(permissionProfile, &QComboBox::currentIndexChanged, this, + [this] { refreshAccessCompatibility(); }); +} + +void TurnSettingsWidget::setContext(std::string identity, + const nlohmann::json &canonical, + const nlohmann::json &models, + const nlohmann::json &permissionProfiles) { + const bool changed = contextIdentity != identity; + contextIdentity = std::move(identity); + modelCatalog = models.is_array() ? models : nlohmann::json::array(); + if (changed) + resetFromCanonical(canonical); + refreshModels(modelCatalog); + refreshPermissionProfiles(permissionProfiles); + refreshModelOptions(); + refreshAccessCompatibility(); + refreshMoreIndicator(); +} + +void TurnSettingsWidget::setControlsEnabled(bool enabled) { + setEnabled(enabled); + setToolTip(enabled ? QString{} + : QStringLiteral("Settings apply when starting a turn")); +} + +void TurnSettingsWidget::setWorkspace(QString path) { + cwd->setText(QDir::toNativeSeparators(std::move(path))); + markTouched(Field::Workspace); +} + +std::string TurnSettingsWidget::workspace(const std::string &fallback) const { + const QString selected = cwd->text().trimmed(); + return selected.isEmpty() ? fallback : selected.toStdString(); +} + +nlohmann::json TurnSettingsWidget::threadStartOptions() const { + nlohmann::json result = nlohmann::json::object(); + const auto copyChoice = [this, &result](Field field, const char *name, + const QComboBox *combo) { + if (!touched(field)) + return; + const QString selected = value(combo); + result[name] = selected == DefaultValue + ? nlohmann::json(nullptr) + : nlohmann::json(selected.toStdString()); + }; + copyChoice(Field::Model, "model", model); + copyChoice(Field::Approval, "approvalPolicy", approval); + copyChoice(Field::Reviewer, "approvalsReviewer", reviewer); + copyChoice(Field::Personality, "personality", personality); + copyChoice(Field::ServiceTier, "serviceTier", serviceTier); + if (touched(Field::Workspace)) + result["cwd"] = cwd->text().trimmed().isEmpty() + ? nlohmann::json(nullptr) + : nlohmann::json(cwd->text().trimmed().toStdString()); + const QString selectedProfile = value(permissionProfile); + if (touched(Field::PermissionProfile)) + result["permissions"] = selectedProfile == DefaultValue + ? nlohmann::json(nullptr) + : nlohmann::json(selectedProfile.toStdString()); + if (selectedProfile == DefaultValue && touched(Field::Sandbox)) { + const QString selected = value(sandbox); + result["sandbox"] = selected == DefaultValue || selected == "external" + ? nlohmann::json(nullptr) + : nlohmann::json(selected.toStdString()); + } + return result; +} + +nlohmann::json TurnSettingsWidget::turnStartOptions() const { + nlohmann::json result = nlohmann::json::object(); + const auto copyChoice = [this, &result](Field field, const char *name, + const QComboBox *combo) { + if (!touched(field)) + return; + const QString selected = value(combo); + result[name] = selected == DefaultValue + ? nlohmann::json(nullptr) + : nlohmann::json(selected.toStdString()); + }; + copyChoice(Field::Model, "model", model); + copyChoice(Field::Effort, "effort", effort); + copyChoice(Field::Personality, "personality", personality); + copyChoice(Field::Approval, "approvalPolicy", approval); + copyChoice(Field::Reviewer, "approvalsReviewer", reviewer); + copyChoice(Field::ServiceTier, "serviceTier", serviceTier); + copyChoice(Field::Summary, "summary", summary); + if (touched(Field::Workspace)) + result["cwd"] = cwd->text().trimmed().isEmpty() + ? nlohmann::json(nullptr) + : nlohmann::json(cwd->text().trimmed().toStdString()); + const QString selectedProfile = value(permissionProfile); + if (touched(Field::PermissionProfile)) + result["permissions"] = selectedProfile == DefaultValue + ? nlohmann::json(nullptr) + : nlohmann::json(selectedProfile.toStdString()); + if (selectedProfile == DefaultValue && + (touched(Field::Sandbox) || touched(Field::Network))) { + result["sandboxPolicy"] = sandboxPolicy(); + } + const nlohmann::json mode = collaborationMode(); + if (!mode.is_null()) + result["collaborationMode"] = mode; + return result; +} + +void TurnSettingsWidget::markTouched(Field field) { + touchedFields[static_cast(field)] = true; + refreshMoreIndicator(); +} + +void TurnSettingsWidget::resetFromCanonical(const nlohmann::json &canonical) { + touchedFields.fill(false); + const QSignalBlocker modelBlocker(model); + const QSignalBlocker effortBlocker(effort); + const QSignalBlocker personalityBlocker(personality); + const QSignalBlocker sandboxBlocker(sandbox); + const QSignalBlocker networkBlocker(network); + const QSignalBlocker approvalBlocker(approval); + const QSignalBlocker reviewerBlocker(reviewer); + const QSignalBlocker cwdBlocker(cwd); + const QSignalBlocker permissionBlocker(permissionProfile); + const QSignalBlocker tierBlocker(serviceTier); + const QSignalBlocker summaryBlocker(summary); + const QSignalBlocker collaborationBlocker(collaboration); + + selectValue(model, optionalString(canonical, "model"), + QStringLiteral("Codex default")); + QString canonicalEffort = optionalString(canonical, "reasoningEffort"); + if (canonicalEffort == DefaultValue) + canonicalEffort = optionalString(canonical, "effort"); + selectValue(effort, canonicalEffort, QStringLiteral("Codex default")); + selectValue(personality, optionalString(canonical, "personality"), + QStringLiteral("Codex default")); + const nlohmann::json nativeSandbox = canonicalSandbox(canonical); + selectValue(sandbox, sandboxKey(nativeSandbox), + QStringLiteral("Codex default")); + selectValue(network, sandboxKey(nativeSandbox) == DefaultValue + ? QString::fromLatin1(DefaultValue) + : sandboxNetworkEnabled(nativeSandbox) + ? QStringLiteral("enabled") + : QStringLiteral("restricted")); + std::string nativeApproval = stringValue(canonical, "approvalPolicy"); + selectValue(approval, + nativeApproval.empty() ? QString::fromLatin1(DefaultValue) + : text(nativeApproval), + nativeApproval.empty() ? QStringLiteral("Codex default") + : QStringLiteral("Current policy")); + selectValue(reviewer, optionalString(canonical, "approvalsReviewer"), + QStringLiteral("Codex default")); + cwd->setText(text(stringValue(canonical, "cwd"))); + QString activeProfile = QString::fromLatin1(DefaultValue); + const nlohmann::json profile = + canonical.value("activePermissionProfile", nlohmann::json::object()); + if (profile.is_object() && profile.contains("id") && + profile["id"].is_string()) + activeProfile = text(profile["id"].get()); + selectValue(permissionProfile, activeProfile, + QStringLiteral("Current permission profile")); + selectValue(serviceTier, optionalString(canonical, "serviceTier"), + QStringLiteral("Codex default")); + selectValue(summary, optionalString(canonical, "summary"), + QStringLiteral("Codex default")); + const nlohmann::json mode = + canonical.value("collaborationMode", nlohmann::json::object()); + selectValue(collaboration, mode.is_object() && mode.contains("mode") && + mode["mode"].is_string() + ? text(mode["mode"].get()) + : QStringLiteral("default")); +} + +void TurnSettingsWidget::refreshModels(const nlohmann::json &models) { + const QString selected = model->currentData().toString(); + const QString edited = model->currentText().trimmed(); + const bool custom = + model->isEditable() && + (model->currentIndex() < 0 || + model->currentText() != model->itemText(model->currentIndex())); + const QSignalBlocker blocker(model); + model->clear(); + addChoice(model, QStringLiteral("Codex default"), DefaultValue); + if (models.is_array()) { + for (const auto &entry : models) { + if (!entry.is_object() || entry.value("hidden", false)) + continue; + std::string id = stringValue(entry, "model"); + if (id.empty()) + id = stringValue(entry, "id"); + if (id.empty()) + continue; + const std::string display = stringValue(entry, "displayName"); + addChoice(model, display.empty() ? text(id) : text(display), text(id)); + } + } + selectValue(model, selected.isEmpty() ? QString::fromLatin1(DefaultValue) + : selected); + if (custom && !edited.isEmpty()) + model->setEditText(edited); +} + +void TurnSettingsWidget::refreshModelOptions() { + const QString selected = value(effort); + const QString modelId = model->currentIndex() >= 0 + ? model->currentData().toString() + : model->currentText().trimmed(); + const nlohmann::json *definition = nullptr; + if (modelCatalog.is_array()) { + const auto match = + std::find_if(modelCatalog.begin(), modelCatalog.end(), + [&modelId](const nlohmann::json &entry) { + return text(stringValue(entry, "model")) == modelId || + text(stringValue(entry, "id")) == modelId; + }); + if (match != modelCatalog.end()) + definition = &*match; + } + const QSignalBlocker blocker(effort); + effort->clear(); + QString defaultLabel = QStringLiteral("Codex default"); + if (definition) { + const std::string defaultEffort = + stringValue(*definition, "defaultReasoningEffort"); + if (!defaultEffort.empty()) + defaultLabel = + friendly(text(defaultEffort)) + QStringLiteral(" - default"); + } + addChoice(effort, defaultLabel, DefaultValue); + const nlohmann::json supported = + definition ? definition->value("supportedReasoningEfforts", + nlohmann::json::array()) + : nlohmann::json::array(); + if (supported.is_array() && !supported.empty()) { + for (const auto &option : supported) { + const std::string key = stringValue(option, "reasoningEffort"); + if (!key.empty()) + addChoice(effort, friendly(text(key)), text(key)); + } + } else { + for (const char *key : + {"minimal", "low", "medium", "high", "xhigh", "ultra"}) + addChoice(effort, friendly(QString::fromLatin1(key)), + QString::fromLatin1(key)); + } + const QString requestedEffort = + selected.isEmpty() ? QString::fromLatin1(DefaultValue) : selected; + const bool constrained = supported.is_array() && !supported.empty(); + selectValue(effort, constrained && effort->findData(requestedEffort) < 0 + ? QString::fromLatin1(DefaultValue) + : requestedEffort); + + const QString selectedTier = value(serviceTier); + const QSignalBlocker tierBlocker(serviceTier); + serviceTier->clear(); + QString defaultTierLabel = QStringLiteral("Codex default"); + if (definition) { + const std::string defaultTier = + stringValue(*definition, "defaultServiceTier"); + if (!defaultTier.empty()) + defaultTierLabel += QStringLiteral(" (%1)").arg(text(defaultTier)); + } + addChoice(serviceTier, defaultTierLabel, DefaultValue); + if (definition) { + const nlohmann::json tiers = + definition->value("serviceTiers", nlohmann::json::array()); + if (tiers.is_array()) { + for (const auto &tier : tiers) { + const std::string id = stringValue(tier, "id"); + if (id.empty()) + continue; + const std::string name = stringValue(tier, "name"); + addChoice(serviceTier, name.empty() ? text(id) : text(name), text(id)); + const int index = serviceTier->findData(text(id)); + if (index >= 0) + serviceTier->setItemData( + index, text(stringValue(tier, "description")), Qt::ToolTipRole); + } + } + const nlohmann::json legacyTiers = + definition->value("additionalSpeedTiers", nlohmann::json::array()); + if (legacyTiers.is_array()) { + for (const auto &tier : legacyTiers) { + if (tier.is_string()) + addChoice(serviceTier, friendly(text(tier.get())), + text(tier.get())); + } + } + } + const QString requestedTier = + selectedTier.isEmpty() ? QString::fromLatin1(DefaultValue) : selectedTier; + if (serviceTier->findData(requestedTier) >= 0) + selectValue(serviceTier, requestedTier); + else + serviceTier->setEditText(requestedTier); + + const bool supportsPersonality = + !definition || definition->value("supportsPersonality", true); + personality->setEnabled(supportsPersonality); + personality->setToolTip( + supportsPersonality + ? QString{} + : QStringLiteral( + "The selected model does not support style choices")); + if (!supportsPersonality && value(personality) != DefaultValue) { + const QSignalBlocker personalityBlocker(personality); + selectValue(personality, QString::fromLatin1(DefaultValue)); + } +} + +void TurnSettingsWidget::refreshPermissionProfiles( + const nlohmann::json &profiles) { + const QString selected = value(permissionProfile); + const QSignalBlocker blocker(permissionProfile); + permissionProfile->clear(); + addChoice(permissionProfile, QStringLiteral("Codex default"), DefaultValue); + nlohmann::json entries = profiles; + if (profiles.is_object()) + entries = profiles.value("data", nlohmann::json::array()); + if (entries.is_array()) { + for (const auto &entry : entries) { + const std::string id = stringValue(entry, "id"); + if (id.empty() || !entry.value("allowed", true)) + continue; + addChoice(permissionProfile, text(id), text(id)); + const int index = permissionProfile->findData(text(id)); + if (index >= 0) + permissionProfile->setItemData( + index, text(stringValue(entry, "description")), Qt::ToolTipRole); + } + } + selectValue(permissionProfile, + selected.isEmpty() ? QString::fromLatin1(DefaultValue) : selected, + QStringLiteral("Current permission profile")); +} + +void TurnSettingsWidget::refreshAccessCompatibility() { + const bool namedProfile = value(permissionProfile) != DefaultValue; + sandbox->setEnabled(!namedProfile); + network->setEnabled(!namedProfile && value(sandbox) != "danger-full-access" && + value(sandbox) != DefaultValue); + if (value(sandbox) == "danger-full-access") { + const QSignalBlocker blocker(network); + selectValue(network, QStringLiteral("enabled")); + } else if (value(sandbox) == DefaultValue) { + const QSignalBlocker blocker(network); + selectValue(network, QString::fromLatin1(DefaultValue)); + } + const QString reason = + namedProfile + ? QStringLiteral("The selected permission profile owns access policy") + : QString{}; + sandbox->setToolTip(reason); + network->setToolTip(reason); +} + +void TurnSettingsWidget::refreshMoreIndicator() { + const bool changed = touched(Field::PermissionProfile) || + touched(Field::Reviewer) || + touched(Field::ServiceTier) || touched(Field::Summary) || + touched(Field::Collaboration); + more->setProperty("changed", changed); + more->style()->unpolish(more); + more->style()->polish(more); + more->update(); +} + +bool TurnSettingsWidget::touched(Field field) const noexcept { + return touchedFields[static_cast(field)]; +} + +QString TurnSettingsWidget::value(const QComboBox *combo) const { + if (combo->isEditable()) { + const int index = combo->currentIndex(); + if (index < 0 || combo->currentText() != combo->itemText(index)) + return combo->currentText().trimmed(); + } + return combo->currentData().toString(); +} + +nlohmann::json TurnSettingsWidget::sandboxPolicy() const { + const QString access = value(sandbox); + if (access == DefaultValue) + return nullptr; + if (access == "danger-full-access") + return {{"type", "dangerFullAccess"}}; + if (access == "external") + return {{"type", "externalSandbox"}, + {"networkAccess", + value(network) == "enabled" ? "enabled" : "restricted"}}; + if (access == "read-only") + return {{"type", "readOnly"}, + {"networkAccess", value(network) == "enabled"}}; + return {{"type", "workspaceWrite"}, + {"writableRoots", nlohmann::json::array()}, + {"networkAccess", value(network) == "enabled"}, + {"excludeTmpdirEnvVar", false}, + {"excludeSlashTmp", false}}; +} + +nlohmann::json TurnSettingsWidget::collaborationMode() const { + QString selectedModel = value(model); + if (selectedModel == DefaultValue && modelCatalog.is_array()) { + const auto defaultModel = std::find_if( + modelCatalog.begin(), modelCatalog.end(), + [](const nlohmann::json &entry) { + return entry.is_object() && entry.value("isDefault", false); + }); + if (defaultModel != modelCatalog.end()) { + std::string modelId = stringValue(*defaultModel, "model"); + if (modelId.empty()) + modelId = stringValue(*defaultModel, "id"); + selectedModel = text(modelId); + } + } + if (selectedModel.isEmpty() || selectedModel == DefaultValue) + return nullptr; + + const QString selectedEffort = value(effort); + nlohmann::json settings{{"model", selectedModel.toStdString()}, + {"developer_instructions", nullptr}}; + if (selectedEffort == DefaultValue) + settings["reasoning_effort"] = nullptr; + else if (!selectedEffort.isEmpty()) + settings["reasoning_effort"] = selectedEffort.toStdString(); + return {{"mode", value(collaboration).toStdString()}, + {"settings", std::move(settings)}}; +} + +} // namespace codexui::codex diff --git a/src/codex/TurnSettingsWidget.h b/src/codex/TurnSettingsWidget.h new file mode 100644 index 0000000..b4b65ba --- /dev/null +++ b/src/codex/TurnSettingsWidget.h @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_TURNSETTINGSWIDGET_H +#define CODEXUI_CODEX_TURNSETTINGSWIDGET_H + +#include + +#include + +#include +#include + +class QComboBox; +class QLineEdit; +class QMenu; +class QPushButton; + +namespace codexui::codex { + +class TurnSettingsWidget final : public QWidget { +public: + explicit TurnSettingsWidget(QWidget *parent = nullptr); + + void setContext(std::string identity, const nlohmann::json &canonical, + const nlohmann::json &models, + const nlohmann::json &permissionProfiles); + void setControlsEnabled(bool enabled); + void setWorkspace(QString path); + + [[nodiscard]] std::string workspace(const std::string &fallback) const; + [[nodiscard]] nlohmann::json threadStartOptions() const; + [[nodiscard]] nlohmann::json turnStartOptions() const; + +private: + enum class Field : std::size_t { + Model, + Effort, + Personality, + Sandbox, + Network, + Approval, + Reviewer, + Workspace, + PermissionProfile, + ServiceTier, + Summary, + Collaboration, + Count, + }; + + void markTouched(Field field); + void resetFromCanonical(const nlohmann::json &canonical); + void refreshModels(const nlohmann::json &models); + void refreshModelOptions(); + void refreshPermissionProfiles(const nlohmann::json &profiles); + void refreshAccessCompatibility(); + void refreshMoreIndicator(); + [[nodiscard]] bool touched(Field field) const noexcept; + [[nodiscard]] QString value(const QComboBox *combo) const; + [[nodiscard]] nlohmann::json sandboxPolicy() const; + [[nodiscard]] nlohmann::json collaborationMode() const; + + std::string contextIdentity; + nlohmann::json modelCatalog = nlohmann::json::array(); + std::array(Field::Count)> touchedFields{}; + + QComboBox *model = nullptr; + QComboBox *effort = nullptr; + QComboBox *personality = nullptr; + QComboBox *sandbox = nullptr; + QComboBox *network = nullptr; + QComboBox *approval = nullptr; + QComboBox *reviewer = nullptr; + QLineEdit *cwd = nullptr; + QComboBox *permissionProfile = nullptr; + QComboBox *serviceTier = nullptr; + QComboBox *summary = nullptr; + QComboBox *collaboration = nullptr; + QPushButton *more = nullptr; + QMenu *moreMenu = nullptr; +}; + +} // namespace codexui::codex + +#endif // CODEXUI_CODEX_TURNSETTINGSWIDGET_H diff --git a/src/codex/WorkbenchWidget.cpp b/src/codex/WorkbenchWidget.cpp new file mode 100644 index 0000000..ef02770 --- /dev/null +++ b/src/codex/WorkbenchWidget.cpp @@ -0,0 +1,1001 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/WorkbenchWidget.h" + +#include "codex/FrontendSession.h" +#include "codex/ui/BrandMark.h" +#include "codex/ui/ExpandingPromptEditor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace codexui::codex { +namespace { + +QString text(const std::string &value) { + return QString::fromUtf8(value.data(), static_cast(value.size())); +} + +std::string stringValue(const nlohmann::json &object, const char *key) { + if (!object.is_object()) + return {}; + const auto iterator = object.find(key); + return iterator != object.end() && iterator->is_string() + ? iterator->get() + : std::string{}; +} + +QString displayStatus(const std::string &status) { + if (status == "inProgress" || status == "active") + return QStringLiteral("Running"); + if (status == "completed" || status == "idle") + return QStringLiteral("Completed"); + if (status == "failed" || status == "systemError") + return QStringLiteral("Failed"); + if (status.empty()) + return QStringLiteral("Unknown"); + return text(status); +} + +QLabel *makeLabel(QString value, const char *kind = "body") { + auto *label = new QLabel(std::move(value)); + label->setProperty("kind", kind); + label->setWordWrap(true); + label->setMinimumWidth(0); + label->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + label->setTextInteractionFlags(Qt::TextSelectableByMouse); + return label; +} + +void clearLayout(QLayout *layout) { + while (QLayoutItem *item = layout->takeAt(0)) { + if (QWidget *widget = item->widget()) + widget->deleteLater(); + if (QLayout *child = item->layout()) { + clearLayout(child); + delete child; + } + delete item; + } +} + +QString joinedStrings(const nlohmann::json &value) { + if (!value.is_array()) + return {}; + QStringList result; + for (const auto &item : value) { + if (item.is_string()) + result.push_back(text(item.get())); + } + return result.join(QStringLiteral(", ")); +} + +QString messageText(const nlohmann::json &item) { + const std::string type = stringValue(item, "type"); + if (type == "agentMessage" || type == "plan") + return text(stringValue(item, "text")); + if (type == "userMessage") { + QStringList parts; + const nlohmann::json content = + item.value("content", nlohmann::json::array()); + if (content.is_array()) { + for (const auto &entry : content) { + const std::string value = stringValue(entry, "text"); + if (!value.empty()) + parts.push_back(text(value)); + } + } + return parts.join(QStringLiteral("\n")); + } + return {}; +} + +QFrame *itemFrame(const ItemPresentation &presentation) { + const nlohmann::json &item = presentation.raw; + const std::string typeName = stringValue(item, "type"); + auto *frame = new QFrame; + frame->setProperty("kind", "raised"); + auto *layout = new QVBoxLayout(frame); + layout->setContentsMargins(12, 10, 12, 10); + layout->setSpacing(6); + + QString title; + if (typeName == "userMessage") + title = QStringLiteral("You"); + else if (typeName == "agentMessage") + title = stringValue(item, "phase") == "final_answer" + ? QStringLiteral("Codex") + : QStringLiteral("Codex activity"); + else if (typeName == "commandExecution") + title = QStringLiteral("Command execution"); + else if (typeName == "collabAgentToolCall" || typeName == "subAgentActivity") + title = QStringLiteral("Agent activity"); + else if (typeName == "reasoning") + title = QStringLiteral("Reasoning"); + else if (typeName == "fileChange") + title = QStringLiteral("File changes"); + else + title = text(typeName.empty() ? std::string("Activity") : typeName); + layout->addWidget(makeLabel(title, "title")); + + const QString body = messageText(item); + if (!body.isEmpty()) + layout->addWidget(makeLabel(body)); + + if (typeName == "commandExecution") { + const QString command = text(stringValue(item, "command")); + if (!command.isEmpty()) { + auto *commandView = new QPlainTextEdit(command); + commandView->setReadOnly(true); + commandView->setMaximumHeight(90); + commandView->setLineWrapMode(QPlainTextEdit::WidgetWidth); + commandView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + commandView->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + commandView->setProperty("kind", "command"); + layout->addWidget(commandView); + } + const QString output = text(stringValue(item, "aggregatedOutput")); + if (!output.trimmed().isEmpty()) { + auto *outputView = new QPlainTextEdit(output); + outputView->setReadOnly(true); + outputView->setMaximumHeight(220); + outputView->setLineWrapMode(QPlainTextEdit::WidgetWidth); + outputView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + outputView->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + layout->addWidget(outputView); + } + QStringList metadata; + metadata << displayStatus(stringValue(item, "status")); + if (item.contains("exitCode") && item["exitCode"].is_number_integer()) + metadata << QStringLiteral("exit %1").arg(item["exitCode"].get()); + const QString cwd = text(stringValue(item, "cwd")); + if (!cwd.isEmpty()) + metadata << cwd; + layout->addWidget( + makeLabel(metadata.join(QStringLiteral(" | ")), "meta")); + } else if (typeName == "collabAgentToolCall") { + QStringList metadata; + metadata << text(stringValue(item, "tool")); + metadata << displayStatus(stringValue(item, "status")); + const QString receivers = + joinedStrings(item.value("receiverThreadIds", nlohmann::json::array())); + if (!receivers.isEmpty()) + metadata << receivers; + layout->addWidget( + makeLabel(metadata.join(QStringLiteral(" | ")), "meta")); + const QString prompt = text(stringValue(item, "prompt")); + if (!prompt.isEmpty()) + layout->addWidget(makeLabel(prompt)); + } else if (typeName == "reasoning") { + const QString summaries = + joinedStrings(item.value("summary", nlohmann::json::array())); + if (!summaries.isEmpty()) + layout->addWidget(makeLabel(summaries)); + } else if (body.isEmpty()) { + layout->addWidget(makeLabel(text(item.dump(2)), "meta")); + } + return frame; +} + +QFrame *agentFrame(const AgentPresentation &agent) { + const nlohmann::json &activity = agent.raw; + auto *frame = new QFrame; + frame->setProperty("kind", "raised"); + auto *layout = new QVBoxLayout(frame); + layout->setContentsMargins(12, 10, 12, 10); + layout->setSpacing(6); + + const std::string tool = stringValue(activity, "tool"); + const bool childAgent = !agent.childThreadId.empty(); + const QString title = childAgent ? QStringLiteral("Subagent") + : tool.empty() + ? QStringLiteral("Agent activity") + : QStringLiteral("Agent %1").arg(text(tool)); + layout->addWidget(makeLabel(title, "title")); + + QStringList metadata; + metadata << displayStatus(agent.status); + const QString path = text(stringValue(activity, "agentPath")); + if (!path.isEmpty()) + metadata << path; + if (!tool.empty()) + metadata << text(tool); + const QString model = text(stringValue(activity, "model")); + if (!model.isEmpty()) + metadata << model; + const QString effort = text(stringValue(activity, "reasoningEffort")); + if (!effort.isEmpty()) + metadata << effort; + layout->addWidget(makeLabel(metadata.join(QStringLiteral(" | ")), "meta")); + + const QString prompt = text(stringValue(activity, "prompt")); + if (!prompt.isEmpty()) + layout->addWidget(makeLabel(prompt)); + + const QString result = text(stringValue(activity, "resultText")); + if (!result.isEmpty()) + layout->addWidget(makeLabel(result)); + + QStringList identities; + if (!agent.childThreadId.empty()) + identities << QStringLiteral("thread %1").arg(text(agent.childThreadId)); + const QString sender = text(stringValue(activity, "senderThreadId")); + if (!sender.isEmpty()) + identities << QStringLiteral("sender %1").arg(sender); + const QString receivers = joinedStrings( + activity.value("receiverThreadIds", nlohmann::json::array())); + if (!receivers.isEmpty()) + identities << QStringLiteral("receivers %1").arg(receivers); + if (!identities.isEmpty()) + layout->addWidget( + makeLabel(identities.join(QStringLiteral(" | ")), "meta")); + return frame; +} + +} // namespace + +WorkbenchWidget::WorkbenchWidget(FrontendSession &session, QWidget *parent) + : QWidget(parent), session(session) { + setObjectName(QStringLiteral("workbench")); + auto *root = new QVBoxLayout(this); + root->setContentsMargins(0, 0, 0, 0); + root->setSpacing(0); + + auto *top = new QFrame; + top->setProperty("kind", "panel"); + top->setFixedHeight(64); + auto *topLayout = new QHBoxLayout(top); + topLayout->setContentsMargins(18, 0, 18, 0); + topLayout->addWidget(codexui::BrandMark::createLockup()); + topLayout->addStretch(); + attentionLabel = makeLabel({}, "attentionSection"); + connectionLabel = makeLabel(QStringLiteral("Disconnected"), "meta"); + controllerLabel = makeLabel(QStringLiteral("No role"), "meta"); + controllerButton = new QPushButton(QStringLiteral("Claim control")); + auto *reconnectButton = new QPushButton(QStringLiteral("Reconnect")); + connect(controllerButton, &QPushButton::clicked, this, [this] { + if (model.connection().role == "controller") + this->session.releaseController(); + else + this->session.claimController(); + }); + connect(reconnectButton, &QPushButton::clicked, this, + [this] { this->session.reconnect(); }); + topLayout->addWidget(attentionLabel); + topLayout->addWidget(connectionLabel); + topLayout->addWidget(controllerLabel); + topLayout->addWidget(controllerButton); + topLayout->addWidget(reconnectButton); + root->addWidget(top); + + auto *splitter = new QSplitter; + splitter->setChildrenCollapsible(false); + + auto *sidebar = new QFrame; + sidebar->setProperty("kind", "panel"); + sidebar->setMinimumWidth(230); + sidebar->setMaximumWidth(390); + auto *sidebarLayout = new QVBoxLayout(sidebar); + sidebarLayout->setContentsMargins(10, 10, 10, 10); + auto *sidebarHeader = new QHBoxLayout; + sidebarHeader->addWidget(makeLabel(QStringLiteral("Threads"), "section")); + sidebarHeader->addStretch(); + auto *refreshButton = new QPushButton(QStringLiteral("Refresh")); + auto *newButton = new QPushButton(QStringLiteral("New")); + threadActionsButton = new QToolButton; + threadActionsButton->setText(QStringLiteral("More")); + threadActionsButton->setPopupMode(QToolButton::InstantPopup); + auto *threadActions = new QMenu(threadActionsButton); + threadActions->addAction(QStringLiteral("Reload"), this, + [this] { readSelectedThread(); }); + threadActions->addAction(QStringLiteral("Rename"), this, + [this] { renameSelectedThread(); }); + threadActions->addAction(QStringLiteral("Fork"), this, + [this] { forkSelectedThread(); }); + threadActions->addAction(QStringLiteral("Archive / unarchive"), this, + [this] { toggleSelectedThreadArchive(); }); + threadActions->addSeparator(); + threadActions->addAction(QStringLiteral("Delete"), this, + [this] { deleteSelectedThread(); }); + threadActionsButton->setMenu(threadActions); + sidebarHeader->addWidget(refreshButton); + sidebarHeader->addWidget(newButton); + sidebarHeader->addWidget(threadActionsButton); + sidebarLayout->addLayout(sidebarHeader); + threadList = new QListWidget; + threadList->setSelectionMode(QAbstractItemView::SingleSelection); + threadList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + threadList->setTextElideMode(Qt::ElideRight); + sidebarLayout->addWidget(threadList); + connect(refreshButton, &QPushButton::clicked, this, + [this] { requestThreads(); }); + connect(newButton, &QPushButton::clicked, this, [this] { beginNewThread(); }); + connect(threadList, &QListWidget::itemClicked, this, + [this](QListWidgetItem *item) { + selectThread(item->data(Qt::UserRole).toString().toStdString()); + }); + splitter->addWidget(sidebar); + + auto *center = new QFrame; + center->setProperty("kind", "panel"); + auto *centerLayout = new QVBoxLayout(center); + centerLayout->setContentsMargins(16, 12, 16, 12); + centerLayout->setSpacing(8); + conversationTitle = makeLabel(QStringLiteral("Select a thread"), "heading"); + conversationMeta = makeLabel({}, "meta"); + centerLayout->addWidget(conversationTitle); + centerLayout->addWidget(conversationMeta); + + conversationScroll = new QScrollArea; + conversationScroll->setWidgetResizable(true); + conversationScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + conversationContent = new QWidget; + conversationContent->setMinimumWidth(0); + conversationContent->setSizePolicy(QSizePolicy::Ignored, + QSizePolicy::Preferred); + conversationLayout = new QVBoxLayout(conversationContent); + conversationLayout->setContentsMargins(4, 6, 4, 6); + conversationLayout->setSpacing(8); + emptyConversation = + makeLabel(QStringLiteral("Conversation activity appears here."), "muted"); + conversationLayout->addWidget(emptyConversation); + conversationLayout->addStretch(); + conversationScroll->setWidget(conversationContent); + centerLayout->addWidget(conversationScroll, 1); + + auto *attention = new QFrame; + attention->setProperty("kind", "amberBadge"); + auto *attentionLayout = new QHBoxLayout(attention); + attentionLayout->setContentsMargins(10, 6, 10, 6); + attentionLayout->addWidget(makeLabel( + QStringLiteral("A Codex request needs attention"), "attentionSection")); + attentionLayout->addStretch(); + approveButton = new QPushButton(QStringLiteral("Approve")); + denyButton = new QPushButton(QStringLiteral("Deny")); + attentionLayout->addWidget(denyButton); + attentionLayout->addWidget(approveButton); + connect(approveButton, &QPushButton::clicked, this, + [this] { respondToFirstPending(true); }); + connect(denyButton, &QPushButton::clicked, this, + [this] { respondToFirstPending(false); }); + centerLayout->addWidget(attention); + + auto *composer = new QFrame; + composer->setProperty("kind", "composer"); + auto *composerLayout = new QHBoxLayout(composer); + composerLayout->setContentsMargins(10, 8, 8, 8); + promptEditor = new codexui::ExpandingPromptEditor; + sendButton = new QPushButton(QStringLiteral("Send")); + sendButton->setProperty("kind", "primary"); + interruptButton = new QPushButton(QStringLiteral("Stop")); + interruptButton->setProperty("kind", "stop"); + composerLayout->addWidget(promptEditor, 1); + composerLayout->addWidget(interruptButton); + composerLayout->addWidget(sendButton); + centerLayout->addWidget(composer); + connect(sendButton, &QPushButton::clicked, this, [this] { submitPrompt(); }); + connect(promptEditor, &codexui::ExpandingPromptEditor::submitRequested, this, + [this] { submitPrompt(); }); + connect(interruptButton, &QPushButton::clicked, this, + [this] { interruptActiveTurn(); }); + splitter->addWidget(center); + + inspectorTabs = new QTabWidget; + inspectorTabs->setMinimumWidth(260); + inspectorTabs->setMaximumWidth(430); + planContent = new QWidget; + planLayout = new QVBoxLayout(planContent); + planLayout->setContentsMargins(12, 12, 12, 12); + planLayout->setSpacing(8); + agentsContent = new QWidget; + agentsLayout = new QVBoxLayout(agentsContent); + agentsLayout->setContentsMargins(12, 12, 12, 12); + agentsLayout->setSpacing(8); + requestsContent = new QWidget; + requestsLayout = new QVBoxLayout(requestsContent); + requestsLayout->setContentsMargins(12, 12, 12, 12); + requestsLayout->setSpacing(8); + auto *planScroll = new QScrollArea; + planScroll->setWidgetResizable(true); + planScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + planScroll->setWidget(planContent); + auto *agentsScroll = new QScrollArea; + agentsScroll->setWidgetResizable(true); + agentsScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + agentsScroll->setWidget(agentsContent); + auto *requestsScroll = new QScrollArea; + requestsScroll->setWidgetResizable(true); + requestsScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + requestsScroll->setWidget(requestsContent); + auto *protocolContent = new QWidget; + auto *protocolLayout = new QVBoxLayout(protocolContent); + protocolLayout->setContentsMargins(8, 8, 8, 8); + protocolLayout->setSpacing(6); + protocolStats = makeLabel({}, "meta"); + protocolLog = new QPlainTextEdit; + protocolLog->setProperty("kind", "code"); + protocolLog->setReadOnly(true); + protocolLog->setLineWrapMode(QPlainTextEdit::WidgetWidth); + protocolLog->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + protocolLog->document()->setMaximumBlockCount(2000); + protocolLayout->addWidget(protocolStats); + protocolLayout->addWidget(protocolLog, 1); + auto *stateContent = new QWidget; + auto *stateLayout = new QVBoxLayout(stateContent); + stateLayout->setContentsMargins(8, 8, 8, 8); + stateView = new QPlainTextEdit; + stateView->setProperty("kind", "code"); + stateView->setReadOnly(true); + stateView->setLineWrapMode(QPlainTextEdit::WidgetWidth); + stateView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + stateLayout->addWidget(stateView); + inspectorTabs->addTab(planScroll, QStringLiteral("Plan")); + inspectorTabs->addTab(agentsScroll, QStringLiteral("Agents")); + inspectorTabs->addTab(requestsScroll, QStringLiteral("Requests")); + inspectorTabs->addTab(stateContent, QStringLiteral("State")); + inspectorTabs->addTab(protocolContent, QStringLiteral("Protocol")); + connect(inspectorTabs, &QTabWidget::currentChanged, this, [this](int index) { + if (index == 3) + requestEnvironment(); + }); + splitter->addWidget(inspectorTabs); + splitter->setSizes({270, 900, 320}); + root->addWidget(splitter, 1); + + refreshTimer = new QTimer(this); + refreshTimer->setSingleShot(true); + refreshTimer->setInterval(16); + connect(refreshTimer, &QTimer::timeout, this, [this] { refresh(); }); + + session.setEventHandler( + [this](const nlohmann::json &event) { handleEvent(event); }); + refresh(); +} + +void WorkbenchWidget::handleEvent(const nlohmann::json &event) { + appendProtocolFrame(event); + model.applyEvent(event); + if (event.value("kind", std::string{}) == "event" && + event.value("type", std::string{}) == "connection.bridge" && + event.value("data", nlohmann::json::object()) + .value("state", std::string{}) == "opened") { + environmentRequested = false; + requestThreads(); + if (inspectorTabs->currentIndex() == 3) + requestEnvironment(); + } + + hydrateHistoricalAgents(); + + if (!selectedThreadId.empty() && !model.thread(selectedThreadId)) + selectedThreadId.clear(); + scheduleRefresh(); +} + +void WorkbenchWidget::hydrateHistoricalAgents() { + const ThreadPresentation *thread = model.thread(selectedThreadId); + if (!thread) + return; + for (const std::string &agentId : thread->agentOrder) { + const auto agent = thread->agents.find(agentId); + if (agent == thread->agents.end() || agent->second.childThreadId.empty() || + agent->second.status != "started") + continue; + if (!requestedAgentThreads.insert(agent->second.childThreadId).second) + continue; + session.readThread(agent->second.childThreadId); + } +} + +void WorkbenchWidget::scheduleRefresh() { + if (!refreshTimer->isActive()) + refreshTimer->start(); +} + +void WorkbenchWidget::refresh() { + refreshThreads(); + refreshConversation(); + refreshInspector(); + refreshStateInspector(); + refreshProtocolStats(); + refreshStatus(); +} + +void WorkbenchWidget::refreshProtocolStats() { + std::size_t turns = 0; + std::size_t items = 0; + if (const ThreadPresentation *thread = model.thread(selectedThreadId)) { + turns = thread->turnOrder.size(); + for (const auto &[turnId, turn] : thread->turns) { + static_cast(turnId); + items += turn.itemOrder.size(); + } + } + protocolStats->setText( + QStringLiteral("seq %1 | threads %2 | models %3 | turns %4 | " + "items %5 | pending %6 | telemetry %7") + .arg(static_cast(observedPresentationSequence)) + .arg(static_cast(model.threadOrder().size())) + .arg(static_cast(model.modelCatalog().size())) + .arg(static_cast(turns)) + .arg(static_cast(items)) + .arg(static_cast(model.pendingRequestCount())) + .arg(static_cast(model.telemetry().size()))); +} + +void WorkbenchWidget::appendProtocolFrame(const nlohmann::json &frame) { + if (!protocolLog) + return; + + const std::uint64_t sequence = frame.value("sequence", 0ULL); + if (sequence != 0) { + if (observedPresentationSequence != 0 && + sequence != observedPresentationSequence + 1) { + const QString relation = sequence <= observedPresentationSequence + ? QStringLiteral("NON-MONOTONIC") + : QStringLiteral("SEQUENCE GAP"); + protocolLog->appendPlainText( + QStringLiteral("[%1] %2 expected=%3 received=%4") + .arg(QDateTime::currentDateTime().toString( + QStringLiteral("HH:mm:ss.zzz")), + relation) + .arg(static_cast(observedPresentationSequence + 1)) + .arg(static_cast(sequence))); + } + observedPresentationSequence = + std::max(observedPresentationSequence, sequence); + } + + const std::string kind = stringValue(frame, "kind"); + const std::string subject = kind == "result" ? stringValue(frame, "action") + : stringValue(frame, "type"); + const nlohmann::json scope = frame.value("scope", nlohmann::json::object()); + QStringList parts; + parts << QStringLiteral("[%1]").arg( + QDateTime::currentDateTime().toString(QStringLiteral("HH:mm:ss.zzz"))); + if (sequence != 0) + parts << QStringLiteral("#%1").arg(static_cast(sequence)); + parts << QStringLiteral("g%1").arg( + static_cast(frame.value("generation", 0ULL))); + parts << text(kind); + parts << text(subject); + parts << text(stringValue(frame, "authority")); + if (kind == "result") + parts << (frame.value("ok", false) ? QStringLiteral("ok") + : QStringLiteral("ERROR")); + for (const char *key : + {"threadId", "turnId", "itemId", "requestId", "processId"}) { + const std::string value = stringValue(scope, key); + if (!value.empty()) + parts << QStringLiteral("%1=%2").arg(QString::fromLatin1(key), + text(value)); + } + const std::string correlationId = stringValue(frame, "correlationId"); + if (!correlationId.empty()) + parts << QStringLiteral("correlation=%1").arg(text(correlationId)); + if (kind == "result" && !frame.value("ok", false)) { + const nlohmann::json error = frame.value("error", nlohmann::json::object()); + const std::string message = stringValue(error, "message"); + if (!message.empty()) + parts << text(message); + } + protocolLog->appendPlainText(parts.join(QStringLiteral(" "))); +} + +void WorkbenchWidget::refreshThreads() { + threadList->blockSignals(true); + threadList->clear(); + for (const std::string &threadId : model.threadOrder()) { + const ThreadPresentation *thread = model.thread(threadId); + if (!thread) + continue; + QString title = text(thread->title); + if (title.isEmpty()) + title = text(threadId.substr(0, 12)); + if (model.pendingRequestCount(threadId) != 0) + title.prepend(QStringLiteral("! ")); + auto *item = new QListWidgetItem(title, threadList); + if (model.pendingRequestCount(threadId) != 0) + item->setForeground(QColor(QStringLiteral("#8a5a00"))); + item->setData(Qt::UserRole, text(threadId)); + item->setToolTip(text(thread->cwd)); + if (threadId == selectedThreadId) + threadList->setCurrentItem(item); + } + threadList->blockSignals(false); +} + +void WorkbenchWidget::refreshConversation() { + const int previousMaximum = + conversationScroll->verticalScrollBar()->maximum(); + const bool followLatest = + conversationScroll->verticalScrollBar()->value() >= previousMaximum - 12; + clearLayout(conversationLayout); + + const ThreadPresentation *thread = model.thread(selectedThreadId); + if (!thread) { + conversationTitle->setText(localNewThreadIntent + ? QStringLiteral("New thread") + : QStringLiteral("Select a thread")); + conversationMeta->setText(localNewThreadIntent ? QDir::currentPath() + : QString{}); + emptyConversation = + makeLabel(localNewThreadIntent + ? QStringLiteral("Send a message to create this thread.") + : QStringLiteral("Conversation activity appears here."), + "muted"); + conversationLayout->addWidget(emptyConversation); + conversationLayout->addStretch(); + return; + } + + conversationTitle->setText(text(thread->title)); + conversationMeta->setText(text(thread->cwd) + QStringLiteral(" | ") + + displayStatus(thread->status)); + std::size_t count = 0; + for (const std::string &turnId : thread->turnOrder) { + const auto turn = thread->turns.find(turnId); + if (turn == thread->turns.end()) + continue; + for (const std::string &itemId : turn->second.itemOrder) { + const auto item = turn->second.items.find(itemId); + if (item == turn->second.items.end()) + continue; + conversationLayout->addWidget(itemFrame(item->second)); + ++count; + } + } + if (count == 0) + conversationLayout->addWidget( + makeLabel(QStringLiteral("No materialized activity."), "muted")); + conversationLayout->addStretch(); + if (followLatest) + QTimer::singleShot(0, conversationScroll, [scroll = conversationScroll] { + scroll->verticalScrollBar()->setValue( + scroll->verticalScrollBar()->maximum()); + }); +} + +void WorkbenchWidget::refreshInspector() { + clearLayout(planLayout); + clearLayout(agentsLayout); + clearLayout(requestsLayout); + const ThreadPresentation *thread = model.thread(selectedThreadId); + if (!thread) { + planLayout->addWidget( + makeLabel(QStringLiteral("No selected thread."), "muted")); + planLayout->addStretch(); + agentsLayout->addWidget( + makeLabel(QStringLiteral("No selected thread."), "muted")); + agentsLayout->addStretch(); + requestsLayout->addWidget( + makeLabel(QStringLiteral("No selected thread."), "muted")); + requestsLayout->addStretch(); + return; + } + + const TurnPresentation *planTurn = nullptr; + for (auto turnId = thread->turnOrder.rbegin(); + turnId != thread->turnOrder.rend(); ++turnId) { + const auto turn = thread->turns.find(*turnId); + if (turn != thread->turns.end() && turn->second.plan.is_object() && + turn->second.plan.contains("steps")) { + planTurn = &turn->second; + break; + } + } + if (planTurn) { + const QString explanation = + text(stringValue(planTurn->plan, "explanation")); + if (!explanation.isEmpty()) + planLayout->addWidget(makeLabel(explanation)); + const nlohmann::json steps = + planTurn->plan.value("steps", nlohmann::json::array()); + for (const auto &step : steps) { + auto *row = new QFrame; + row->setProperty("kind", "summary"); + auto *rowLayout = new QVBoxLayout(row); + rowLayout->setContentsMargins(9, 7, 9, 7); + rowLayout->addWidget(makeLabel(text(stringValue(step, "step")))); + rowLayout->addWidget( + makeLabel(displayStatus(stringValue(step, "status")), "meta")); + planLayout->addWidget(row); + } + } else { + planLayout->addWidget( + makeLabel(QStringLiteral("No plan for this thread."), "muted")); + } + planLayout->addStretch(); + + std::size_t agentCount = 0; + for (const std::string &agentId : thread->agentOrder) { + const auto agent = thread->agents.find(agentId); + if (agent == thread->agents.end()) + continue; + agentsLayout->addWidget(agentFrame(agent->second)); + ++agentCount; + } + if (agentCount == 0) + agentsLayout->addWidget(makeLabel( + QStringLiteral("No agent activity for this thread."), "muted")); + agentsLayout->addStretch(); + + std::size_t requestCount = 0; + for (const auto &[id, request] : model.pendingRequestPresentations()) { + if (request.threadId != selectedThreadId) + continue; + auto *frame = new QFrame; + frame->setProperty("kind", "summary"); + auto *layout = new QVBoxLayout(frame); + layout->setContentsMargins(9, 7, 9, 7); + layout->setSpacing(5); + layout->addWidget(makeLabel(text(request.kind), "title")); + layout->addWidget( + makeLabel(QStringLiteral("generation %1 | request %2") + .arg(static_cast(request.generation)) + .arg(text(id)), + "meta")); + layout->addWidget(makeLabel( + QStringLiteral("Review and answer this request using the action bar."), + "meta")); + requestsLayout->addWidget(frame); + ++requestCount; + } + if (requestCount == 0) + requestsLayout->addWidget(makeLabel( + QStringLiteral("No pending requests for this thread."), "muted")); + requestsLayout->addStretch(); +} + +void WorkbenchWidget::refreshStatus() { + const ConnectionPresentation &connection = model.connection(); + connectionLabel->setText(connection.connected + ? QStringLiteral("Connected") + : QStringLiteral("Disconnected")); + controllerLabel->setText(connection.role.empty() ? QStringLiteral("No role") + : text(connection.role)); + controllerButton->setText(connection.role == "controller" + ? QStringLiteral("Release control") + : QStringLiteral("Claim control")); + controllerButton->setEnabled(connection.connected); + const std::size_t pending = model.pendingRequestCount(selectedThreadId); + attentionLabel->setText( + pending == 0 + ? QString{} + : QStringLiteral("%1 pending").arg(static_cast(pending))); + approveButton->parentWidget()->setVisible(pending != 0); + const bool active = model.activeTurnId(selectedThreadId).has_value(); + interruptButton->setVisible(active); + sendButton->setText(active ? QStringLiteral("Steer") + : QStringLiteral("Send")); + sendButton->setEnabled(connection.connected && + connection.role == "controller"); + threadActionsButton->setEnabled(!selectedThreadId.empty() && + connection.connected && + connection.role == "controller"); +} + +void WorkbenchWidget::refreshStateInspector() { + if (!stateView) + return; + nlohmann::json domains = nlohmann::json::object(); + for (const auto &[name, value] : model.globalDomains()) + domains[name] = value; + + nlohmann::json pending = nlohmann::json::object(); + for (const auto &[id, request] : model.pendingRequestPresentations()) { + pending[id] = {{"category", request.kind}, + {"threadId", request.threadId}, + {"generation", request.generation}}; + } + + const nlohmann::json state{{"models", model.modelCatalog()}, + {"pendingRequests", std::move(pending)}, + {"domains", std::move(domains)}}; + stateView->setPlainText(text(state.dump(2))); +} + +void WorkbenchWidget::selectThread(std::string threadId) { + selectedThreadId = std::move(threadId); + localNewThreadIntent = false; + readSelectedThread(); + refresh(); +} + +void WorkbenchWidget::beginNewThread() { + selectedThreadId.clear(); + localNewThreadIntent = true; + threadList->clearSelection(); + promptEditor->setFocus(); + refresh(); +} + +void WorkbenchWidget::requestThreads() { session.listThreads(); } + +void WorkbenchWidget::requestModels() { session.listModels(); } + +void WorkbenchWidget::requestEnvironment() { + if (environmentRequested) + return; + environmentRequested = true; + const std::string cwd = QDir::currentPath().toStdString(); + requestModels(); + session.readModelProviderCapabilities(); + session.readAccount({{"refreshToken", false}}); + session.readAccountRateLimits(); + session.readAccountTokenUsage(); + session.readConfig({{"cwd", cwd}, {"includeLayers", true}}); + session.listPermissionProfiles({{"cwd", cwd}}); + session.listExperimentalFeatures(); + session.listSkills( + {{"cwds", nlohmann::json::array({cwd})}, {"forceReload", false}}); + session.listHooks({{"cwds", nlohmann::json::array({cwd})}}); + session.listPlugins( + {{"cwds", nlohmann::json::array({cwd})}, {"forceRefetch", false}}); + session.listApps(); + session.listMcpServers(); +} + +void WorkbenchWidget::readSelectedThread() { + if (selectedThreadId.empty()) + return; + session.readThread(selectedThreadId); +} + +void WorkbenchWidget::renameSelectedThread() { + const ThreadPresentation *thread = model.thread(selectedThreadId); + if (!thread) + return; + bool accepted = false; + const QString name = + QInputDialog::getText(this, QStringLiteral("Rename thread"), + QStringLiteral("Name"), QLineEdit::Normal, + text(thread->title), &accepted) + .trimmed(); + if (accepted && !name.isEmpty()) + session.renameThread(selectedThreadId, name.toStdString()); +} + +void WorkbenchWidget::forkSelectedThread() { + if (selectedThreadId.empty()) + return; + session.forkThread(selectedThreadId, nlohmann::json::object(), + [this](const nlohmann::json &result) { + if (!result.value("ok", false)) + return; + const std::string threadId = stringValue( + result.value("data", nlohmann::json::object()) + .value("thread", nlohmann::json::object()), + "id"); + if (!threadId.empty()) + selectThread(threadId); + }); +} + +void WorkbenchWidget::toggleSelectedThreadArchive() { + const ThreadPresentation *thread = model.thread(selectedThreadId); + if (!thread) + return; + if (thread->archived) + session.unarchiveThread(selectedThreadId); + else + session.archiveThread(selectedThreadId); +} + +void WorkbenchWidget::deleteSelectedThread() { + if (selectedThreadId.empty()) + return; + if (QMessageBox::question(this, QStringLiteral("Delete thread"), + QStringLiteral("Delete the selected thread?"), + QMessageBox::Yes | QMessageBox::Cancel, + QMessageBox::Cancel) == QMessageBox::Yes) { + session.deleteThread(selectedThreadId); + } +} + +void WorkbenchWidget::submitPrompt() { + const QString promptValue = promptEditor->toPlainText().trimmed(); + if (promptValue.isEmpty()) + return; + const std::string prompt = promptValue.toStdString(); + promptEditor->clear(); + + if (!selectedThreadId.empty()) { + submitPromptToThread(selectedThreadId, prompt); + return; + } + localNewThreadIntent = true; + session.createThread({{"cwd", QDir::currentPath().toStdString()}}, + [this, prompt](const nlohmann::json &result) { + if (!result.value("ok", false)) + return; + const std::string threadId = stringValue( + result.value("data", nlohmann::json::object()) + .value("thread", nlohmann::json::object()), + "id"); + if (threadId.empty()) + return; + selectedThreadId = threadId; + localNewThreadIntent = false; + submitPromptToThread(threadId, prompt); + scheduleRefresh(); + }); +} + +void WorkbenchWidget::submitPromptToThread(std::string threadId, + std::string prompt) { + const nlohmann::json input = + nlohmann::json::array({{{"type", "text"}, + {"text", std::move(prompt)}, + {"text_elements", nlohmann::json::array()}}}); + const auto activeTurn = model.activeTurnId(threadId); + if (activeTurn) { + session.steerTurn(threadId, *activeTurn, input); + } else { + session.startTurn(threadId, input); + } +} + +void WorkbenchWidget::interruptActiveTurn() { + const auto turnId = model.activeTurnId(selectedThreadId); + if (!turnId) + return; + session.interruptTurn(selectedThreadId, *turnId); +} + +void WorkbenchWidget::respondToFirstPending(bool approve) { + const auto &pending = model.pendingRequestPresentations(); + const auto request = + std::find_if(pending.begin(), pending.end(), [this](const auto &entry) { + return entry.second.threadId == selectedThreadId; + }); + if (request == pending.end()) + return; + + nlohmann::json result; + const std::string &type = request->second.kind; + if (type == "command-approval" || type == "file-change-approval") { + result = {{"decision", approve ? "accept" : "decline"}}; + } else if (type == "legacy-patch-approval" || + type == "legacy-command-approval") { + result = {{"decision", + approve ? nlohmann::json("approved") + : nlohmann::json{ + {"denied", {{"rejection", "Denied by user"}}}}}}; + } else if (type == "mcp-elicitation") { + result = {{"action", approve ? "accept" : "decline"}, + {"content", nullptr}, + {"_meta", nullptr}}; + } else { + return; + } + session.respondToServerRequest(nlohmann::json::parse(request->first), + std::move(result)); +} + +} // namespace codexui::codex diff --git a/src/codex/WorkbenchWidget.h b/src/codex/WorkbenchWidget.h new file mode 100644 index 0000000..d6c1e48 --- /dev/null +++ b/src/codex/WorkbenchWidget.h @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_WORKBENCHWIDGET_H +#define CODEXUI_CODEX_WORKBENCHWIDGET_H + +#include "codex/PresentationModel.h" + +#include + +#include +#include +#include + +class QLabel; +class QListWidget; +class QPlainTextEdit; +class QPushButton; +class QScrollArea; +class QTabWidget; +class QTimer; +class QToolButton; +class QVBoxLayout; + +namespace codexui { +class ExpandingPromptEditor; +} + +namespace codexui::codex { + +class FrontendSession; + +class WorkbenchWidget final : public QWidget { +public: + explicit WorkbenchWidget(FrontendSession &session, QWidget *parent = nullptr); + +private: + void handleEvent(const nlohmann::json &event); + void scheduleRefresh(); + void refresh(); + void refreshThreads(); + void refreshConversation(); + void refreshInspector(); + void refreshStateInspector(); + void refreshProtocolStats(); + void refreshStatus(); + void appendProtocolFrame(const nlohmann::json &frame); + void hydrateHistoricalAgents(); + + void selectThread(std::string threadId); + void beginNewThread(); + void requestThreads(); + void requestModels(); + void requestEnvironment(); + void readSelectedThread(); + void renameSelectedThread(); + void forkSelectedThread(); + void toggleSelectedThreadArchive(); + void deleteSelectedThread(); + void submitPrompt(); + void submitPromptToThread(std::string threadId, std::string prompt); + void interruptActiveTurn(); + void respondToFirstPending(bool approve); + + FrontendSession &session; + PresentationModel model; + std::string selectedThreadId; + bool localNewThreadIntent = false; + bool environmentRequested = false; + + QLabel *connectionLabel = nullptr; + QLabel *controllerLabel = nullptr; + QLabel *attentionLabel = nullptr; + QLabel *conversationTitle = nullptr; + QLabel *conversationMeta = nullptr; + QLabel *emptyConversation = nullptr; + QListWidget *threadList = nullptr; + QWidget *conversationContent = nullptr; + QVBoxLayout *conversationLayout = nullptr; + QScrollArea *conversationScroll = nullptr; + QTabWidget *inspectorTabs = nullptr; + QWidget *planContent = nullptr; + QVBoxLayout *planLayout = nullptr; + QWidget *agentsContent = nullptr; + QVBoxLayout *agentsLayout = nullptr; + QWidget *requestsContent = nullptr; + QVBoxLayout *requestsLayout = nullptr; + QLabel *protocolStats = nullptr; + QPlainTextEdit *protocolLog = nullptr; + QPlainTextEdit *stateView = nullptr; + codexui::ExpandingPromptEditor *promptEditor = nullptr; + QPushButton *sendButton = nullptr; + QPushButton *interruptButton = nullptr; + QPushButton *controllerButton = nullptr; + QToolButton *threadActionsButton = nullptr; + QPushButton *approveButton = nullptr; + QPushButton *denyButton = nullptr; + QTimer *refreshTimer = nullptr; + std::uint64_t observedPresentationSequence = 0; + std::unordered_set requestedAgentThreads; +}; + +} // namespace codexui::codex + +#endif // CODEXUI_CODEX_WORKBENCHWIDGET_H diff --git a/src/codex/ipc/QtSocketPairEndpoint.cpp b/src/codex/ipc/QtSocketPairEndpoint.cpp new file mode 100644 index 0000000..16fe6c3 --- /dev/null +++ b/src/codex/ipc/QtSocketPairEndpoint.cpp @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/ipc/QtSocketPairEndpoint.h" + +#include + +#include +#include +#include +#include +#include + +namespace codexui::codex::ipc { + +QtSocketPairEndpoint::QtSocketPairEndpoint(int descriptor, + std::size_t maximumQueuedBytes, + QObject *parent) + : QObject(parent), descriptor(descriptor), + maximumQueuedBytes(maximumQueuedBytes) { + readNotifier = new QSocketNotifier(descriptor, QSocketNotifier::Read, this); + writeNotifier = new QSocketNotifier(descriptor, QSocketNotifier::Write, this); + writeNotifier->setEnabled(false); + connect(readNotifier, &QSocketNotifier::activated, this, + [this] { readReady(); }); + connect(writeNotifier, &QSocketNotifier::activated, this, + [this] { writeReady(); }); +} + +QtSocketPairEndpoint::~QtSocketPairEndpoint() { close(); } + +bool QtSocketPairEndpoint::send(const char *data, std::size_t size) { + if (!isOpen() || size > maximumQueuedBytes || + queuedBytes() > maximumQueuedBytes - size) + return false; + + if (writeOffset != 0 && writeOffset == writeBuffer.size()) { + writeBuffer.clear(); + writeOffset = 0; + } + writeBuffer.append(data, size); + writeReady(); + return isOpen(); +} + +bool QtSocketPairEndpoint::send(const std::string &data) { + return send(data.data(), data.size()); +} + +std::size_t QtSocketPairEndpoint::queuedBytes() const noexcept { + return writeBuffer.size() - writeOffset; +} + +bool QtSocketPairEndpoint::isOpen() const noexcept { + return descriptor >= 0 && !closing; +} + +void QtSocketPairEndpoint::setOnData(DataHandler handler) { + onData = std::move(handler); +} + +void QtSocketPairEndpoint::setOnError(ErrorHandler handler) { + onError = std::move(handler); +} + +void QtSocketPairEndpoint::setOnClosed(ClosedHandler handler) { + onClosed = std::move(handler); +} + +void QtSocketPairEndpoint::close() noexcept { + if (closing) + return; + closing = true; + if (readNotifier) + readNotifier->setEnabled(false); + if (writeNotifier) + writeNotifier->setEnabled(false); + if (descriptor >= 0) { + ::shutdown(descriptor, SHUT_RDWR); + ::close(descriptor); + descriptor = -1; + } + writeBuffer.clear(); + writeOffset = 0; + if (onClosed) + onClosed(); +} + +void QtSocketPairEndpoint::readReady() { + std::array buffer{}; + while (isOpen()) { + const ssize_t received = + ::recv(descriptor, buffer.data(), buffer.size(), 0); + if (received > 0) { + if (onData) + onData(buffer.data(), static_cast(received)); + continue; + } + if (received == 0) { + close(); + return; + } + if (errno == EINTR) + continue; + if (errno == EAGAIN || errno == EWOULDBLOCK) + return; + fail(errno != 0 ? errno : EIO); + return; + } +} + +void QtSocketPairEndpoint::writeReady() { + while (isOpen() && queuedBytes() != 0) { + const ssize_t sent = ::send(descriptor, writeBuffer.data() + writeOffset, + queuedBytes(), MSG_NOSIGNAL); + if (sent > 0) { + writeOffset += static_cast(sent); + continue; + } + if (sent < 0 && errno == EINTR) + continue; + if (sent < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + writeNotifier->setEnabled(true); + return; + } + fail(sent == 0 ? EPIPE : (errno != 0 ? errno : EIO)); + return; + } + + writeBuffer.clear(); + writeOffset = 0; + if (writeNotifier) + writeNotifier->setEnabled(false); +} + +void QtSocketPairEndpoint::fail(int errorNumber) noexcept { + if (onError) + onError(errorNumber); + close(); +} + +} // namespace codexui::codex::ipc diff --git a/src/codex/ipc/QtSocketPairEndpoint.h b/src/codex/ipc/QtSocketPairEndpoint.h new file mode 100644 index 0000000..d877712 --- /dev/null +++ b/src/codex/ipc/QtSocketPairEndpoint.h @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_IPC_QTSOCKETPAIRENDPOINT_H +#define CODEXUI_CODEX_IPC_QTSOCKETPAIRENDPOINT_H + +#include + +#include +#include +#include + +class QSocketNotifier; + +namespace codexui::codex::ipc { + +class QtSocketPairEndpoint final : public QObject { +public: + using DataHandler = std::function; + using ErrorHandler = std::function; + using ClosedHandler = std::function; + + explicit QtSocketPairEndpoint(int descriptor, std::size_t maximumQueuedBytes, + QObject *parent = nullptr); + ~QtSocketPairEndpoint() override; + + QtSocketPairEndpoint(const QtSocketPairEndpoint &) = delete; + QtSocketPairEndpoint &operator=(const QtSocketPairEndpoint &) = delete; + + [[nodiscard]] bool send(const char *data, std::size_t size); + [[nodiscard]] bool send(const std::string &data); + [[nodiscard]] std::size_t queuedBytes() const noexcept; + [[nodiscard]] bool isOpen() const noexcept; + + void setOnData(DataHandler handler); + void setOnError(ErrorHandler handler); + void setOnClosed(ClosedHandler handler); + void close() noexcept; + +private: + void readReady(); + void writeReady(); + void fail(int errorNumber) noexcept; + + int descriptor = -1; + std::size_t maximumQueuedBytes; + std::string writeBuffer; + std::size_t writeOffset = 0; + QSocketNotifier *readNotifier = nullptr; + QSocketNotifier *writeNotifier = nullptr; + DataHandler onData; + ErrorHandler onError; + ClosedHandler onClosed; + bool closing = false; +}; + +} // namespace codexui::codex::ipc + +#endif // CODEXUI_CODEX_IPC_QTSOCKETPAIRENDPOINT_H diff --git a/src/codex/ipc/SNodeSocketPairEndpoint.cpp b/src/codex/ipc/SNodeSocketPairEndpoint.cpp new file mode 100644 index 0000000..d047ad8 --- /dev/null +++ b/src/codex/ipc/SNodeSocketPairEndpoint.cpp @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/ipc/SNodeSocketPairEndpoint.h" + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace codexui::codex::ipc { +namespace { + +constexpr std::size_t MaximumChunkBytes = 16U * 1024U; +constexpr std::size_t MaximumWriteBytesPerEvent = 256U * 1024U; + +logger::LogScope makeLogScope() { + return {logger::LogOrigin::Application, + logger::LogBoundary::Connection, + "codexui.ipc", + "socketpair", + logger::LogRole::Client, + {}}; +} + +} // namespace + +SNodeSocketPairEndpoint * +SNodeSocketPairEndpoint::create(int descriptor, std::size_t maximumQueuedBytes, + std::size_t maximumReadBytesPerEvent) { + if (descriptor < 0 || maximumQueuedBytes == 0 || + maximumReadBytesPerEvent == 0) + return nullptr; + + auto *endpoint = new SNodeSocketPairEndpoint(descriptor, maximumQueuedBytes, + maximumReadBytesPerEvent); + const bool readEnabled = endpoint->ReadEventReceiver::enable(descriptor); + const bool writeEnabled = + readEnabled && endpoint->WriteEventReceiver::enable(descriptor); + if (!readEnabled || !writeEnabled) { + if (readEnabled) + endpoint->ReadEventReceiver::disable(); + if (writeEnabled) + endpoint->WriteEventReceiver::disable(); + endpoint->closeDescriptor(); + delete endpoint; + return nullptr; + } + + endpoint->WriteEventReceiver::suspend(); + endpoint->initializing = false; + return endpoint; +} + +SNodeSocketPairEndpoint::SNodeSocketPairEndpoint( + int descriptor, std::size_t maximumQueuedBytes, + std::size_t maximumReadBytesPerEvent) + : core::eventreceiver::ReadEventReceiver("SocketPairEndpoint", + makeLogScope(), TIMEOUT::DISABLE), + core::eventreceiver::WriteEventReceiver("SocketPairEndpoint", + makeLogScope(), TIMEOUT::DISABLE), + descriptor(descriptor), maximumQueuedBytes(maximumQueuedBytes), + maximumReadBytesPerEvent(maximumReadBytesPerEvent) {} + +SNodeSocketPairEndpoint::~SNodeSocketPairEndpoint() { closeDescriptor(); } + +bool SNodeSocketPairEndpoint::send(const char *data, std::size_t size) { + const std::size_t outstanding = queuedBytes(); + if (closing || !WriteEventReceiver::isEnabled() || + size > maximumQueuedBytes || outstanding > maximumQueuedBytes - size) + return false; + if (size == 0) + return true; + + if (writeOffset != 0 && (writeOffset == writeBuffer.size() || + writeOffset >= writeBuffer.size() / 2)) { + writeBuffer.erase(writeBuffer.begin(), + writeBuffer.begin() + + static_cast(writeOffset)); + writeOffset = 0; + } + writeBuffer.insert(writeBuffer.end(), data, data + size); + if (WriteEventReceiver::isSuspended()) + WriteEventReceiver::resume(); + return true; +} + +bool SNodeSocketPairEndpoint::send(const std::string &data) { + return send(data.data(), data.size()); +} + +std::size_t SNodeSocketPairEndpoint::queuedBytes() const noexcept { + return writeBuffer.size() - writeOffset; +} + +void SNodeSocketPairEndpoint::setOnData(DataHandler handler) { + onData = std::move(handler); +} + +void SNodeSocketPairEndpoint::setOnError(ErrorHandler handler) { + onError = std::move(handler); +} + +void SNodeSocketPairEndpoint::setOnClosed(ClosedHandler handler) { + onClosed = std::move(handler); +} + +void SNodeSocketPairEndpoint::close() { + if (closing) + return; + closing = true; + writeBuffer.clear(); + writeOffset = 0; + if (ReadEventReceiver::isEnabled()) + ReadEventReceiver::disable(); + if (WriteEventReceiver::isEnabled()) + WriteEventReceiver::disable(); +} + +void SNodeSocketPairEndpoint::readEvent() { + std::array chunk{}; + std::size_t totalRead = 0; + while (!closing && totalRead < maximumReadBytesPerEvent) { + const std::size_t requested = + std::min(chunk.size(), maximumReadBytesPerEvent - totalRead); + const ssize_t result = + core::system::recv(descriptor, chunk.data(), requested, 0); + if (result > 0) { + const std::size_t size = static_cast(result); + totalRead += size; + if (onData) + onData(chunk.data(), size); + continue; + } + if (result == 0) { + close(); + return; + } + if (errno == EINTR) + continue; + if (errno == EAGAIN || errno == EWOULDBLOCK) + return; + reportError(errno != 0 ? errno : EIO); + return; + } +} + +void SNodeSocketPairEndpoint::writeEvent() { + std::size_t totalWritten = 0; + while (!closing && queuedBytes() != 0 && + totalWritten < MaximumWriteBytesPerEvent) { + const std::size_t requested = + std::min({queuedBytes(), MaximumChunkBytes, + MaximumWriteBytesPerEvent - totalWritten}); + const ssize_t result = core::system::send( + descriptor, writeBuffer.data() + writeOffset, requested, MSG_NOSIGNAL); + if (result > 0) { + const std::size_t size = static_cast(result); + writeOffset += size; + totalWritten += size; + continue; + } + if (result < 0 && errno == EINTR) + continue; + if (result < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) + return; + reportError(result == 0 ? EPIPE : (errno != 0 ? errno : EIO)); + return; + } + + if (queuedBytes() == 0) { + writeBuffer.clear(); + writeOffset = 0; + if (!closing) + WriteEventReceiver::suspend(); + } +} + +void SNodeSocketPairEndpoint::unobservedEvent() { + if (initializing) + return; + closeDescriptor(); + if (onClosed) + onClosed(); + delete this; +} + +void SNodeSocketPairEndpoint::destruct() { close(); } + +void SNodeSocketPairEndpoint::shutdownEvent( + const core::ShutdownContext &context) { + static_cast(context); + close(); +} + +void SNodeSocketPairEndpoint::closeDescriptor() noexcept { + if (descriptor >= 0) { + ::shutdown(descriptor, SHUT_RDWR); + ::close(descriptor); + descriptor = -1; + } +} + +void SNodeSocketPairEndpoint::reportError(int errorNumber) { + if (onError) + onError(errorNumber); + close(); +} + +} // namespace codexui::codex::ipc diff --git a/src/codex/ipc/SNodeSocketPairEndpoint.h b/src/codex/ipc/SNodeSocketPairEndpoint.h new file mode 100644 index 0000000..59d3419 --- /dev/null +++ b/src/codex/ipc/SNodeSocketPairEndpoint.h @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_IPC_SNODESOCKETPAIRENDPOINT_H +#define CODEXUI_CODEX_IPC_SNODESOCKETPAIRENDPOINT_H + +#include +#include + +#include +#include +#include +#include + +namespace codexui::codex::ipc { + +class SNodeSocketPairEndpoint final + : public core::eventreceiver::ReadEventReceiver, + public core::eventreceiver::WriteEventReceiver { +public: + using DataHandler = std::function; + using ErrorHandler = std::function; + using ClosedHandler = std::function; + + static SNodeSocketPairEndpoint *create(int descriptor, + std::size_t maximumQueuedBytes, + std::size_t maximumReadBytesPerEvent); + + SNodeSocketPairEndpoint(const SNodeSocketPairEndpoint &) = delete; + SNodeSocketPairEndpoint &operator=(const SNodeSocketPairEndpoint &) = delete; + + [[nodiscard]] bool send(const char *data, std::size_t size); + [[nodiscard]] bool send(const std::string &data); + [[nodiscard]] std::size_t queuedBytes() const noexcept; + + void setOnData(DataHandler handler); + void setOnError(ErrorHandler handler); + void setOnClosed(ClosedHandler handler); + void close(); + +private: + SNodeSocketPairEndpoint(int descriptor, std::size_t maximumQueuedBytes, + std::size_t maximumReadBytesPerEvent); + ~SNodeSocketPairEndpoint() override; + + void readEvent() override; + void writeEvent() override; + void unobservedEvent() override; + void destruct() override; + void shutdownEvent(const core::ShutdownContext &context) override; + void closeDescriptor() noexcept; + void reportError(int errorNumber); + + int descriptor; + std::size_t maximumQueuedBytes; + std::size_t maximumReadBytesPerEvent; + std::vector writeBuffer; + std::size_t writeOffset = 0; + DataHandler onData; + ErrorHandler onError; + ClosedHandler onClosed; + bool initializing = true; + bool closing = false; +}; + +} // namespace codexui::codex::ipc + +#endif // CODEXUI_CODEX_IPC_SNODESOCKETPAIRENDPOINT_H diff --git a/src/codex/ipc/SocketPair.cpp b/src/codex/ipc/SocketPair.cpp new file mode 100644 index 0000000..78f74ac --- /dev/null +++ b/src/codex/ipc/SocketPair.cpp @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/ipc/SocketPair.h" + +#include +#include +#include +#include + +namespace codexui::codex::ipc { +namespace { + +void closeDescriptor(int &descriptor) noexcept { + if (descriptor >= 0) { + ::close(descriptor); + descriptor = -1; + } +} + +} // namespace + +SocketPair::SocketPair() noexcept { + int endpoints[2]{-1, -1}; + if (::socketpair(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0, + endpoints) == 0) { + first = endpoints[0]; + second = endpoints[1]; + } else { + creationError = errno != 0 ? errno : EIO; + } +} + +SocketPair::SocketPair(SocketPair &&other) noexcept + : first(std::exchange(other.first, -1)), + second(std::exchange(other.second, -1)), + creationError(std::exchange(other.creationError, 0)) {} + +SocketPair::~SocketPair() { + closeFirstEndpoint(); + closeSecondEndpoint(); +} + +SocketPair &SocketPair::operator=(SocketPair &&other) noexcept { + if (this != &other) { + closeFirstEndpoint(); + closeSecondEndpoint(); + first = std::exchange(other.first, -1); + second = std::exchange(other.second, -1); + creationError = std::exchange(other.creationError, 0); + } + return *this; +} + +bool SocketPair::isValid() const noexcept { + return hasFirstEndpoint() && hasSecondEndpoint(); +} + +bool SocketPair::hasFirstEndpoint() const noexcept { return first >= 0; } + +bool SocketPair::hasSecondEndpoint() const noexcept { return second >= 0; } + +int SocketPair::error() const noexcept { return creationError; } + +int SocketPair::firstEndpoint() const noexcept { return first; } + +int SocketPair::secondEndpoint() const noexcept { return second; } + +int SocketPair::releaseFirstEndpoint() noexcept { + return std::exchange(first, -1); +} + +int SocketPair::releaseSecondEndpoint() noexcept { + return std::exchange(second, -1); +} + +void SocketPair::closeFirstEndpoint() noexcept { closeDescriptor(first); } + +void SocketPair::closeSecondEndpoint() noexcept { closeDescriptor(second); } + +} // namespace codexui::codex::ipc diff --git a/src/codex/ipc/SocketPair.h b/src/codex/ipc/SocketPair.h new file mode 100644 index 0000000..2989d5b --- /dev/null +++ b/src/codex/ipc/SocketPair.h @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_IPC_SOCKETPAIR_H +#define CODEXUI_CODEX_IPC_SOCKETPAIR_H + +namespace codexui::codex::ipc { + +class SocketPair final { +public: + SocketPair() noexcept; + SocketPair(const SocketPair &) = delete; + SocketPair(SocketPair &&other) noexcept; + ~SocketPair(); + + SocketPair &operator=(const SocketPair &) = delete; + SocketPair &operator=(SocketPair &&other) noexcept; + + [[nodiscard]] bool isValid() const noexcept; + [[nodiscard]] bool hasFirstEndpoint() const noexcept; + [[nodiscard]] bool hasSecondEndpoint() const noexcept; + [[nodiscard]] int error() const noexcept; + + [[nodiscard]] int firstEndpoint() const noexcept; + [[nodiscard]] int secondEndpoint() const noexcept; + [[nodiscard]] int releaseFirstEndpoint() noexcept; + [[nodiscard]] int releaseSecondEndpoint() noexcept; + + void closeFirstEndpoint() noexcept; + void closeSecondEndpoint() noexcept; + +private: + int first = -1; + int second = -1; + int creationError = 0; +}; + +} // namespace codexui::codex::ipc + +#endif // CODEXUI_CODEX_IPC_SOCKETPAIR_H diff --git a/src/codex/main.cpp b/src/codex/main.cpp new file mode 100644 index 0000000..f182940 --- /dev/null +++ b/src/codex/main.cpp @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/Configuration.h" +#include "codex/FrontendSession.h" + +#include +#include + +#include "codex/MainWindow.h" + +#include + +#include + +namespace { + +bool isConfigurationOnlyInvocation(int argc, char *argv[]) { + for (int index = 1; index < argc; ++index) { + const std::string_view argument(argv[index]); + if (argument == "-h" || argument.starts_with("--help") || + argument == "-s" || argument == "--show-config" || argument == "-w" || + argument == "--write-config" || argument == "-v" || + argument == "--version" || argument == "-k" || argument == "--kill" || + argument.starts_with("--command-line")) + return true; + } + return false; +} + +} // namespace + +int main(int argc, char *argv[]) { + const bool configurationOnly = isConfigurationOnlyInvocation(argc, argv); + auto *configuration = + utils::Config::configRoot.newSubCommand(); + QApplication application(argc, argv); + QGuiApplication::setDesktopFileName(QStringLiteral("codex-ui")); + QCoreApplication::setApplicationName(QStringLiteral("CodexUI")); + QGuiApplication::setApplicationDisplayName(QStringLiteral("CodexUI")); + core::SNodeC::init(argc, argv); + + codexui::codex::FrontendSession session(*configuration); + if (configurationOnly) { + session.start(false); + session.wait(); + return 0; + } + + codexui::codex::MainWindow window(session); + session.setRuntimeStoppedHandler([&application] { application.quit(); }); + session.start(true); + window.show(); + const int result = application.exec(); + session.shutdown(); + return result; +} diff --git a/src/codex/ui/BrandMark.cpp b/src/codex/ui/BrandMark.cpp new file mode 100644 index 0000000..8869093 --- /dev/null +++ b/src/codex/ui/BrandMark.cpp @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/ui/BrandMark.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace codexui { +namespace { + +constexpr int BrandMarkSize = 36; + +void paintMark(QPainter &painter, const QRectF &bounds) { + const qreal scale = std::min(bounds.width(), bounds.height()) / 36.0; + painter.save(); + painter.translate(bounds.center().x() - 18.0 * scale, + bounds.center().y() - 18.0 * scale); + painter.scale(scale, scale); + + const QRectF surface(1.0, 1.0, 34.0, 34.0); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(QStringLiteral("#2f6feb"))); + painter.drawRoundedRect(surface, 9.0, 9.0); + + painter.setPen(QPen(QColor(QStringLiteral("#ffffff")), 2.7, Qt::SolidLine, + Qt::RoundCap, Qt::RoundJoin)); + painter.setBrush(Qt::NoBrush); + + QPainterPath leftBracket; + leftBracket.moveTo(15.0, 10.5); + leftBracket.lineTo(9.5, 18.0); + leftBracket.lineTo(15.0, 25.5); + painter.drawPath(leftBracket); + + QPainterPath rightBracket; + rightBracket.moveTo(21.0, 10.5); + rightBracket.lineTo(26.5, 18.0); + rightBracket.lineTo(21.0, 25.5); + painter.drawPath(rightBracket); + + painter.setPen(QPen(QColor(QStringLiteral("#63d5a5")), 3.2, Qt::SolidLine, + Qt::RoundCap)); + painter.drawLine(QPointF(15.5, 18.0), QPointF(20.5, 18.0)); + + painter.restore(); +} + +class BrandLockup final : public QWidget { +public: + explicit BrandLockup(QWidget *parent = nullptr) : QWidget(parent) { + mark = new BrandMark(this); + + title = new QLabel(QStringLiteral("CodexUI"), this); + title->setObjectName(QStringLiteral("codexBrandTitle")); + title->setProperty("kind", "applicationTitle"); + title->setWordWrap(false); + QFont titleFont = title->font(); + titleFont.setWeight(QFont::Bold); + int titlePixelSize = BrandMarkSize; + for (int pixelSize = BrandMarkSize; pixelSize > 0; --pixelSize) { + titleFont.setPixelSize(pixelSize); + if (QFontMetrics(titleFont).height() <= BrandMarkSize) { + titlePixelSize = pixelSize; + break; + } + } + title->setStyleSheet( + QStringLiteral("font-size:%1px;font-weight:700;").arg(titlePixelSize)); + + subtitle = new QLabel(QStringLiteral("Codex agent workspace"), this); + subtitle->setObjectName(QStringLiteral("codexBrandSubtitle")); + subtitle->setProperty("kind", "meta"); + subtitle->setWordWrap(false); + + setAccessibleName(QStringLiteral("CodexUI, Codex agent workspace")); + setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + setFixedHeight(BrandMarkSize); + } + + QSize sizeHint() const override { + return {BrandMarkSize + 12 + title->sizeHint().width() + 12 + + subtitle->sizeHint().width(), + BrandMarkSize}; + } + +protected: + void resizeEvent(QResizeEvent *event) override { + QWidget::resizeEvent(event); + layoutChildren(); + } + + void changeEvent(QEvent *event) override { + QWidget::changeEvent(event); + if (event->type() == QEvent::FontChange || + event->type() == QEvent::StyleChange) { + updateGeometry(); + layoutChildren(); + } + } + +private: + void layoutChildren() { + mark->setGeometry(0, 0, BrandMarkSize, BrandMarkSize); + int x = BrandMarkSize + 12; + const int titleWidth = title->sizeHint().width(); + title->setGeometry(x, 0, titleWidth, BrandMarkSize); + x += titleWidth + 12; + + const QFontMetrics titleMetrics(title->font()); + const QFontMetrics subtitleMetrics(subtitle->font()); + const int titleBaseline = + (BrandMarkSize - titleMetrics.height()) / 2 + titleMetrics.ascent(); + const int subtitleY = + std::clamp(titleBaseline - subtitleMetrics.ascent(), 0, + BrandMarkSize - subtitleMetrics.height()); + subtitle->setGeometry(x, subtitleY, subtitle->sizeHint().width(), + subtitleMetrics.height()); + } + + BrandMark *mark = nullptr; + QLabel *title = nullptr; + QLabel *subtitle = nullptr; +}; + +} // namespace + +BrandMark::BrandMark(QWidget *parent) : QWidget(parent) { + setFixedSize(BrandMarkSize, BrandMarkSize); + setAccessibleName(QStringLiteral("CodexUI logo")); + setToolTip(QStringLiteral("CodexUI")); +} + +QIcon BrandMark::icon() { + QIcon icon; + for (const int size : {16, 24, 32, 48, 64, 128}) { + QPixmap pixmap(size, size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + paintMark(painter, QRectF(0.0, 0.0, size, size)); + icon.addPixmap(pixmap); + } + return icon; +} + +QWidget *BrandMark::createLockup(QWidget *parent) { + return new BrandLockup(parent); +} + +void BrandMark::paintEvent(QPaintEvent *event) { + Q_UNUSED(event) + + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing, true); + paintMark(painter, rect()); +} + +} // namespace codexui diff --git a/src/codex/ui/BrandMark.h b/src/codex/ui/BrandMark.h new file mode 100644 index 0000000..8157a50 --- /dev/null +++ b/src/codex/ui/BrandMark.h @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_UI_BRANDMARK_H +#define CODEXUI_UI_BRANDMARK_H + +#include +#include + +namespace codexui { + +class BrandMark final : public QWidget { +public: + explicit BrandMark(QWidget *parent = nullptr); + [[nodiscard]] static QIcon icon(); + [[nodiscard]] static QWidget *createLockup(QWidget *parent = nullptr); + +protected: + void paintEvent(QPaintEvent *event) override; +}; + +} // namespace codexui + +#endif diff --git a/src/codex/ui/ExpandingPromptEditor.cpp b/src/codex/ui/ExpandingPromptEditor.cpp new file mode 100644 index 0000000..8e11eb2 --- /dev/null +++ b/src/codex/ui/ExpandingPromptEditor.cpp @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/ui/ExpandingPromptEditor.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace codexui { + +ExpandingPromptEditor::ExpandingPromptEditor(QWidget *parent) + : QPlainTextEdit(parent) { + setObjectName(QStringLiteral("upcomingPromptEditor")); + setPlaceholderText(QStringLiteral("Message Codex")); + setLineWrapMode(QPlainTextEdit::WidgetWidth); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + maximumEditorHeight = + fontMetrics().lineSpacing() * maximumVisibleLineCount() + 10; + setMinimumHeight(compactHeight()); + setMaximumHeight(maximumEditorHeight); + setFixedHeight(compactHeight()); + setStyleSheet(QStringLiteral( + "QPlainTextEdit{background:transparent;color:#1d2633;border:0;padding:" + "3px 2px;}")); + + connect(this, &QPlainTextEdit::textChanged, this, + &ExpandingPromptEditor::remeasure); +} + +bool ExpandingPromptEditor::requiresExpandedLayout(int widgetWidth) const { + const QString content = toPlainText(); + if (content.isEmpty()) + return false; + if (content.contains(QLatin1Char('\n'))) + return true; + + const int viewportReduction = std::max(0, width() - viewport()->width()); + const qreal lineWidth = std::max(1, widgetWidth - viewportReduction); + QTextLayout layout(content, font()); + layout.setTextOption(document()->defaultTextOption()); + layout.beginLayout(); + QTextLine firstLine = layout.createLine(); + if (firstLine.isValid()) + firstLine.setLineWidth(lineWidth); + const bool wraps = layout.createLine().isValid(); + layout.endLayout(); + return wraps; +} + +void ExpandingPromptEditor::focusInEvent(QFocusEvent *event) { + QPlainTextEdit::focusInEvent(event); + emit focusStateChanged(true); +} + +void ExpandingPromptEditor::focusOutEvent(QFocusEvent *event) { + QPlainTextEdit::focusOutEvent(event); + emit focusStateChanged(false); +} + +void ExpandingPromptEditor::keyPressEvent(QKeyEvent *event) { + if ((event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) && + event->modifiers().testFlag(Qt::ControlModifier) && + !event->isAutoRepeat()) { + emit submitRequested(); + event->accept(); + return; + } + QPlainTextEdit::keyPressEvent(event); +} + +void ExpandingPromptEditor::resizeEvent(QResizeEvent *event) { + QPlainTextEdit::resizeEvent(event); + scheduleRemeasure(); +} + +void ExpandingPromptEditor::scheduleRemeasure() { + if (remeasureScheduled) + return; + remeasureScheduled = true; + QTimer::singleShot(0, this, [this] { + remeasureScheduled = false; + remeasure(); + }); +} + +void ExpandingPromptEditor::remeasure() { + if (viewport()->width() <= 0) + return; + + document()->setTextWidth(viewport()->width()); + qreal laidOutHeight = 0; + QAbstractTextDocumentLayout *documentLayout = document()->documentLayout(); + for (QTextBlock block = document()->begin(); block.isValid(); + block = block.next()) + laidOutHeight += documentLayout->blockBoundingRect(block).height(); + const int documentHeight = static_cast(std::ceil(laidOutHeight)) + 10; + const int wanted = + std::clamp(documentHeight, compactHeight(), maximumEditorHeight); + setVerticalScrollBarPolicy(wanted >= maximumEditorHeight + ? Qt::ScrollBarAsNeeded + : Qt::ScrollBarAlwaysOff); + if (wanted == currentContentHeight) + return; + currentContentHeight = wanted; + setFixedHeight(wanted); + emit editorHeightChanged(wanted); +} + +} // namespace codexui diff --git a/src/ui/ExpandingPromptEditor.h b/src/codex/ui/ExpandingPromptEditor.h similarity index 84% rename from src/ui/ExpandingPromptEditor.h rename to src/codex/ui/ExpandingPromptEditor.h index 44ae769..cc32af2 100644 --- a/src/ui/ExpandingPromptEditor.h +++ b/src/codex/ui/ExpandingPromptEditor.h @@ -20,8 +20,9 @@ class ExpandingPromptEditor final : public QPlainTextEdit public: explicit ExpandingPromptEditor(QWidget* parent = nullptr); - [[nodiscard]] static constexpr int compactHeight() noexcept { return 30; } - [[nodiscard]] static constexpr int maximumContentHeight() noexcept { return 200; } + [[nodiscard]] static constexpr int compactHeight() noexcept { return 32; } + [[nodiscard]] static constexpr int maximumVisibleLineCount() noexcept { return 20; } + [[nodiscard]] bool requiresExpandedLayout(int widgetWidth) const; signals: void submitRequested(); @@ -38,6 +39,7 @@ class ExpandingPromptEditor final : public QPlainTextEdit void scheduleRemeasure(); void remeasure(); + int maximumEditorHeight = compactHeight(); int currentContentHeight = compactHeight(); bool remeasureScheduled = false; }; diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp new file mode 100644 index 0000000..0e27578 --- /dev/null +++ b/src/codex/ui/UiStyle.cpp @@ -0,0 +1,347 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/ui/UiStyle.h" + +#include +#include + +#include + +namespace codexui::UiStyle { + +QString applicationStyleSheet() { + const qreal configuredSize = QFontInfo(QApplication::font()).pointSizeF(); + const qreal baseSize = configuredSize > 0.0 ? configuredSize : 10.0; + const QString compact = + QString::number(std::max(1.0, baseSize - 1.0), 'f', 1); + const QString standard = QString::number(baseSize, 'f', 1); + const QString section = QString::number(baseSize + 1.0, 'f', 1); + const QString heading = QString::number(baseSize + 3.0, 'f', 1); + + return QStringLiteral(R"QSS( + * { + color: #1d2633; + font-size: %1pt; + } + QMainWindow, QWidget#workbench { background: #f6f8fb; } + QLabel { background: transparent; font-weight: 400; } + QLabel[kind="muted"] { color: #667085; font-size: %1pt; } + QLabel[kind="section"] { + color: #667085; + font-size: %3pt; + font-weight: 600; + } + QLabel[kind="attentionSection"] { + color: #a76812; + font-size: %1pt; + font-weight: 600; + } + QLabel[kind="heading"] { font-size: %4pt; font-weight: 600; } + QLabel[kind="applicationTitle"] { font-weight: 700; } + QLabel[kind="brand"] { font-size: %3pt; font-weight: 600; } + QLabel[kind="title"] { font-size: %2pt; font-weight: 600; } + QLabel[kind="body"] { font-size: %2pt; } + QLabel[kind="meta"] { color: #667085; font-size: %1pt; } + QLabel[kind="small"] { color: #667085; font-size: %1pt; } + QPushButton, QToolButton { + background: #ffffff; + border: 1px solid #d7dee8; + border-radius: 7px; + padding: 0 12px; + font-size: %1pt; + font-weight: 600; + } + QPushButton:hover, QToolButton:hover { background: #f1f5fb; border-color: #b9c4d2; } + QPushButton:pressed, QToolButton:pressed { background: #e5eeff; border-color: #bfd3f9; } + QPushButton:focus, QToolButton:focus { border: 2px solid #2f6feb; } + QPushButton:disabled, QToolButton:disabled { color: #98a2b3; background: #f6f8fb; border-color: #d7dee8; } + QPushButton[kind="primary"] { background: #2f6feb; border-color: #2f6feb; color: white; } + QPushButton[kind="primary"]:hover { background: #285fca; border-color: #285fca; } + QPushButton[kind="history"] { background: #e5eeff; border-color: #bfd3f9; color: #285fca; } + QPushButton[kind="history"]:hover { background: #d8e7ff; border-color: #9ebcf3; } + QPushButton[kind="request"] { background: #fff6df; border-color: #e5c77d; color: #8a5a00; } + QPushButton[kind="request"]:hover { background: #ffefc4; border-color: #d5ad50; } + QPushButton[kind="steer"] { background: #ffffff; border-color: #2f6feb; color: #2f6feb; } + QPushButton[kind="steer"]:hover { background: #e5eeff; border-color: #285fca; color: #285fca; } + QPushButton[kind="cancel"] { background: #eef1f5; border-color: #c8d0dc; color: #475467; } + QPushButton[kind="cancel"]:hover { background: #e3e8ef; border-color: #aeb8c6; } + QPushButton[kind="subtle"], QToolButton[kind="subtle"] { + color: #667085; + background: transparent; + border-color: transparent; + } + QToolButton[kind="composerAction"] { + background: #ffffff; + border: 1px solid #d7dee8; + color: #667085; + padding: 0; + } + QToolButton[kind="composerAction"]:hover { background: #f1f5fb; border-color: #b9c4d2; } + QPushButton[kind="agentLink"] { background: #e5eeff; border-color: #bfd3f9; color: #2f6feb; text-align: left; } + QPushButton[kind="stop"] { background: #ffffff; border-color: #b83a3a; color: #b83a3a; } + QPushButton[kind="stop"]:hover { background: #fff1f1; } + QPushButton[codexChevron="true"] { padding-right: 26px; } + QPushButton[codexChevron="true"]::menu-indicator { image: none; width: 0; } + QPushButton[changed="true"] { + background: #e5eeff; + color: #2f6feb; + border-color: #2f6feb; + } + QFrame[kind="panel"] { background: #ffffff; } + QFrame[kind="raised"] { background: #ffffff; border: 1px solid #d7dee8; border-radius: 10px; } + QFrame[messageRole="user"] { background: #eaf2ff; border: 1px solid #bfd3f9; border-radius: 8px; } + QFrame[messageRole="agent"] { background: #ffffff; border: 0; border-radius: 8px; } + QFrame[kind="summary"] { background: #f8fafc; border: 1px solid #d7dee8; border-radius: 7px; } + QFrame[kind="greenBadge"] { background: #e9f7f0; border-radius: 6px; } + QFrame[kind="blueBadge"] { background: #e5eeff; border-radius: 5px; } + QFrame[kind="amberBadge"] { background: #fff6df; border: 1px solid #e5c77d; border-radius: 7px; } + QFrame[kind="composer"] { background: #ffffff; border: 1px solid #d7dee8; border-radius: 10px; } + QFrame[kind="composer"][focused="true"] { border: 2px solid #2f6feb; } + QPlainTextEdit { + background: transparent; + border: 0; + color: #1d2633; + font-size: %2pt; + padding: 0; + selection-background-color: #e5eeff; + selection-color: #1d2633; + } + QPlainTextEdit[empty="true"] { color: #98a2b3; } + QPlainTextEdit[kind="code"], QPlainTextEdit[kind="command"], + QPlainTextEdit[kind="infoViewer"] { + font-family: monospace; + font-size: %1pt; + } + QPlainTextEdit[kind="infoViewer"] { + background: #f8fafc; + border: 1px solid #d7dee8; + border-radius: 7px; + padding: 7px; + } + QPlainTextEdit[kind="dialogEditor"] { + background: #ffffff; + border: 1px solid #d7dee8; + border-radius: 7px; + padding: 8px; + } + QPlainTextEdit[kind="dialogEditor"]:focus { border-color: #2f6feb; } + QLineEdit { + background: #ffffff; + border: 1px solid #d7dee8; + border-radius: 7px; + min-height: 32px; + padding: 0 9px; + selection-background-color: #e5eeff; + selection-color: #1d2633; + } + QLineEdit:focus { border-color: #2f6feb; } + QLineEdit:disabled { color: #98a2b3; background: #f6f8fb; } + QComboBox { + background: #ffffff; + border: 1px solid #d7dee8; + border-radius: 7px; + min-height: 30px; + padding: 0 24px 0 9px; + } + QComboBox:hover { border-color: #b9c4d2; } + QComboBox:focus { border-color: #2f6feb; } + QComboBox:disabled { color: #98a2b3; background: #f6f8fb; } + QComboBox QLineEdit { + background: transparent; + border: 0; + border-radius: 0; + min-height: 0; + padding: 0; + } + QComboBox::drop-down { border: 0; width: 20px; } + QComboBox[codexChevron="true"]::down-arrow { image: none; } + QComboBox QAbstractItemView { + background: #ffffff; + color: #1d2633; + border: 1px solid #d7dee8; + selection-background-color: #e5eeff; + selection-color: #1d2633; + } + QTreeView#codexFileBrowser, QListWidget#codexAttachmentList { + background: #ffffff; + alternate-background-color: #f8fafc; + border: 1px solid #d7dee8; + border-radius: 7px; + outline: 0; + } + QListWidget#codexDiffFiles { + background: #ffffff; + border: 1px solid #d7dee8; + border-radius: 7px; + outline: 0; + } + QListWidget#codexDiffFiles::item { + min-height: 27px; + padding: 3px 7px; + } + QListWidget#codexDiffFiles::item:hover { background: #f1f5fb; } + QListWidget#codexDiffFiles::item:selected { + background: #e5eeff; + color: #1d2633; + } + QPlainTextEdit#codexDiffText { + background: #ffffff; + border: 1px solid #d7dee8; + border-radius: 7px; + padding: 7px; + font-size: %1pt; + } + QTreeView#codexFileBrowser::item, QListWidget#codexAttachmentList::item { + min-height: 28px; + padding: 3px 7px; + } + QTreeView#codexFileBrowser::item:hover, + QListWidget#codexAttachmentList::item:hover { background: #f1f5fb; } + QTreeView#codexFileBrowser::item:selected, + QListWidget#codexAttachmentList::item:selected { + background: #e5eeff; + color: #1d2633; + } + QHeaderView::section { + background: #f8fafc; + color: #667085; + border: 0; + border-bottom: 1px solid #d7dee8; + padding: 7px; + font-size: %1pt; + font-weight: 600; + } + QCheckBox, QRadioButton { spacing: 8px; } + QDialog { background: #ffffff; } + QScrollArea { background: #f6f8fb; border: 0; } + QTabWidget QScrollArea { background: #fbfcfe; } + QDialog QScrollArea { background: #ffffff; } + QScrollArea > QWidget > QWidget { background: transparent; } + QAbstractScrollArea::corner { background: transparent; border: 0; } + QScrollBar:vertical { + background: transparent; + border: 0; + width: 8px; + margin: 2px; + } + QScrollBar::handle:vertical { + background: #b9c4d2; + min-height: 28px; + border-radius: 3px; + } + QScrollBar::handle:vertical:hover { background: #98a2b3; } + QScrollBar::add-line:vertical { + background: transparent; + border: 0; + height: 0; + subcontrol-position: bottom; + subcontrol-origin: margin; + } + QScrollBar::sub-line:vertical { + background: transparent; + border: 0; + height: 0; + subcontrol-position: top; + subcontrol-origin: margin; + } + QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical { + background: none; + border: 0; + width: 0; + height: 0; + } + QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { + background: none; + border: 0; + } + QScrollBar[kind="infoViewer"]:vertical { + background: transparent; + border: 0; + width: 8px; + margin: 2px; + } + QScrollBar[kind="infoViewer"]::handle:vertical { + background: #b9c4d2; + min-height: 28px; + border-radius: 3px; + } + QScrollBar[kind="infoViewer"]::handle:vertical:hover { background: #98a2b3; } + QScrollBar[kind="infoViewer"]::add-line:vertical, + QScrollBar[kind="infoViewer"]::sub-line:vertical { + background: transparent; + border: 0; + height: 0; + } + QScrollBar[kind="infoViewer"]::up-arrow:vertical, + QScrollBar[kind="infoViewer"]::down-arrow:vertical { + background: none; + border: 0; + width: 0; + height: 0; + } + QScrollBar[kind="infoViewer"]::add-page:vertical, + QScrollBar[kind="infoViewer"]::sub-page:vertical { + background: none; + border: 0; + } + QScrollBar:horizontal { + background: transparent; + border: 0; + height: 8px; + margin: 2px; + } + QScrollBar::handle:horizontal { + background: #b9c4d2; + min-width: 28px; + border-radius: 3px; + } + QScrollBar::handle:horizontal:hover { background: #98a2b3; } + QScrollBar::add-line:horizontal { + background: transparent; + border: 0; + width: 0; + subcontrol-position: right; + subcontrol-origin: margin; + } + QScrollBar::sub-line:horizontal { + background: transparent; + border: 0; + width: 0; + subcontrol-position: left; + subcontrol-origin: margin; + } + QScrollBar::left-arrow:horizontal, QScrollBar::right-arrow:horizontal { + background: none; + border: 0; + width: 0; + height: 0; + } + QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { + background: none; + border: 0; + } + QSplitter::handle { background: #d7dee8; } + QSplitter::handle:horizontal { width: 8px; } + QTabBar { background: transparent; } + QTabBar::tab { + background: transparent; + color: #667085; + min-width: 62px; + height: 32px; + border-radius: 7px; + font-size: %1pt; + } + QTabBar::tab:selected { background: #e5eeff; color: #1d2633; font-weight: 600; } + QTabBar::tab:hover:!selected { background: #f1f5fb; color: #1d2633; } + QTabBar::tab:focus { border: 1px solid #2f6feb; } + QMenu { background: #ffffff; color: #1d2633; border: 1px solid #d7dee8; padding: 5px; } + QMenu::item { padding: 7px 28px 7px 10px; border-radius: 4px; } + QMenu::item:selected { background: #e5eeff; color: #1d2633; } + QMenu::item:disabled { color: #98a2b3; } + QMenu::separator { height: 1px; background: #d7dee8; margin: 5px 8px; } + QToolTip { background: #ffffff; color: #1d2633; border: 1px solid #b9c4d2; padding: 5px; } + )QSS") + .arg(compact, standard, section, heading); +} + +} // namespace codexui::UiStyle diff --git a/src/ui/UiStyle.h b/src/codex/ui/UiStyle.h similarity index 100% rename from src/ui/UiStyle.h rename to src/codex/ui/UiStyle.h diff --git a/src/greenfield/codex/ShellWidget.cpp b/src/greenfield/codex/ShellWidget.cpp new file mode 100644 index 0000000..14a2fd9 --- /dev/null +++ b/src/greenfield/codex/ShellWidget.cpp @@ -0,0 +1,1354 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/ShellWidget.h" + +#include "codex/ConnectionDialog.h" +#include "codex/FileSelectionDialog.h" +#include "codex/FrontendSession.h" +#include "codex/NewThreadDialog.h" +#include "codex/PendingRequestDialog.h" +#include "codex/PresentationModel.h" +#include "codex/TurnSettingsWidget.h" +#include "codex/middle/ComposerPane.h" +#include "codex/middle/ConversationProjection.h" +#include "codex/middle/ConversationView.h" +#include "codex/middle/InspectorPane.h" +#include "codex/middle/MiddleRegionWidget.h" +#include "codex/middle/PromptCoordinator.h" +#include "codex/middle/ThreadPane.h" +#include "codex/ui/BrandMark.h" +#include "codex/ui/ExpandingPromptEditor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace codexui::codex { +namespace { + +constexpr auto DraftThreadId = "draft:new-thread"; + +QString text(const std::string &value) { + return QString::fromUtf8(value.data(), static_cast(value.size())); +} + +std::string stringValue(const nlohmann::json &object, const char *key) { + if (!object.is_object()) + return {}; + const auto found = object.find(key); + return found != object.end() && found->is_string() ? found->get() + : std::string{}; +} + +QString displayStatus(const std::string &status) { + if (status == "inProgress" || status == "active") + return QStringLiteral("Running"); + if (status == "completed" || status == "idle") + return QStringLiteral("Completed"); + if (status == "failed" || status == "systemError") + return QStringLiteral("Failed"); + return status.empty() ? QStringLiteral("Unknown") : text(status); +} + +std::string safeMessage(const nlohmann::json &value) { + std::string message = stringValue(value, "message"); + if (message.empty()) + message = stringValue(value, "detail"); + if (!message.empty()) + return message; + const auto error = value.find("error"); + return error != value.end() && error->is_object() + ? stringValue(*error, "message") + : std::string{}; +} + +bool isThreadNotFoundResult(const nlohmann::json &result) { + if (result.value("ok", false)) + return false; + const QString message = + text(safeMessage(result.value("error", nlohmann::json::object()))) + .toLower(); + return message.contains(QStringLiteral("thread")) && + message.contains(QStringLiteral("not found")); +} + +std::optional resultTurnId(const nlohmann::json &result) { + const nlohmann::json scope = result.value("scope", nlohmann::json::object()); + std::string id = stringValue(scope, "turnId"); + if (!id.empty()) + return id; + const nlohmann::json data = result.value("data", nlohmann::json::object()); + id = stringValue(data, "turnId"); + if (!id.empty()) + return id; + const nlohmann::json turn = data.value("turn", nlohmann::json::object()); + id = stringValue(turn, "id"); + return id.empty() ? std::nullopt : std::optional(std::move(id)); +} + +QLabel *makeLabel(QString value, const char *kind = "body") { + auto *label = new QLabel(std::move(value)); + label->setProperty("kind", kind); + label->setTextFormat(Qt::PlainText); + label->setWordWrap(true); + label->setMinimumWidth(0); + label->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + label->setTextInteractionFlags(Qt::TextSelectableByMouse); + return label; +} + +QFrame *statusDot() { + auto *dot = new QFrame; + dot->setFixedSize(8, 8); + dot->setStyleSheet(QStringLiteral("background:#98a2b3;border-radius:4px;")); + return dot; +} + +std::string recoveryKey(const std::string &threadId, + std::uint64_t submissionId) { + return threadId + ':' + std::to_string(submissionId); +} + +} // namespace + +struct ShellWidget::Impl final { + enum class Hydration { NotHydrated, InFlight, Hydrated, Failed }; + struct HistoryWindow { + std::size_t requested = + middle::ConversationProjection::DefaultAuthoritativeItemLimit; + std::size_t effective = + middle::ConversationProjection::DefaultAuthoritativeItemLimit; + std::size_t lastAuthoritativeCount = 0; + }; + + Impl(ShellWidget *owner, FrontendSession &session) + : owner(owner), session(session), alive(std::make_shared(true)) { + buildUi(); + connectUi(); + const auto token = alive; + session.setEventHandler([this, token](const nlohmann::json &event) { + if (*token) + handleEvent(event); + }); + render(); + } + + ~Impl() { + *alive = false; + session.setEventHandler({}); + qApp->removeEventFilter(owner); + } + + void buildUi(); + void connectUi(); + void handleEvent(const nlohmann::json &event); + void render(); + void renderConversation(); + void refreshSettings(); + void refreshStatus(); + void hydrateHistoricalAgents(); + void showNotice(QString message, bool error = true); + + void selectThread(std::string threadId); + void beginNewThread(); + void readThread(const std::string &threadId, bool forced = false); + void ensureThreadHydrated(const std::string &threadId); + [[nodiscard]] bool threadIsHydrated(const std::string &threadId) const; + [[nodiscard]] bool threadRequiresResume(const std::string &threadId) const; + void renameThread(const std::string &threadId); + void forkThread(const std::string &threadId); + void toggleThreadArchive(const std::string &threadId); + void deleteThread(const std::string &threadId); + + [[nodiscard]] bool submitPrompt(QString prompt, + std::vector attachments); + void startThreadForDraft(); + void dispatchNextPrompt(const std::string &threadId); + void dispatchPrompt(middle::PromptDispatch dispatch); + void resumePromptQueue(const std::string &threadId); + void completePrompt(const std::string &threadId, std::uint64_t submissionId, + const nlohmann::json &result); + [[nodiscard]] bool attemptThreadRecovery(const std::string &threadId, + std::uint64_t submissionId, + const nlohmann::json &result); + void scheduleAcceptedTransition(const std::string &threadId, + std::uint64_t submissionId); + + void chooseAttachments(); + void interruptTurn(); + void reviewPending(const std::string &requestKey); + void rejectPending(const std::string &requestKey); + void respondToFirstPending(bool approve); + + ShellWidget *owner = nullptr; + FrontendSession &session; + PresentationModel model; + middle::PromptCoordinator prompts; + std::shared_ptr alive; + + std::string selectedThreadId; + bool newThreadIntent = false; + bool newThreadCreationInFlight = false; + nlohmann::json newThreadOptions = nlohmann::json::object(); + QString newThreadName; + QString newThreadWorkspace; + + std::unordered_map hydration; + std::unordered_map readRevisions; + std::unordered_set staleReadResultCorrelations; + std::uint64_t nextReadRevision = 1; + std::unordered_set operationReadyThreads; + std::unordered_set resumeInFlightThreads; + std::unordered_set dispatchScheduledThreads; + std::unordered_set promptRecoveryAttempted; + std::unordered_map historyWindows; + std::uint64_t observedConnectionGeneration = 0; + QByteArray settingsSnapshot; + QByteArray statusSnapshot; + + middle::MiddleRegionWidget *middleRegion = nullptr; + QPushButton *restoreSidebarButton = nullptr; + QPushButton *restoreInspectorButton = nullptr; + QLabel *workspaceBreadcrumb = nullptr; + QPushButton *requestButton = nullptr; + QFrame *connectionStatusDot = nullptr; + QToolButton *connectionButton = nullptr; + QAction *connectAction = nullptr; + QAction *disconnectAction = nullptr; + QAction *reconnectAction = nullptr; + QPushButton *controllerButton = nullptr; + QLabel *threadContextStatus = nullptr; + QLabel *agentActivityStatus = nullptr; + QLabel *controllerLabel = nullptr; +}; + +void ShellWidget::Impl::buildUi() { + owner->setObjectName(QStringLiteral("workbench")); + auto *root = new QVBoxLayout(owner); + root->setContentsMargins(0, 0, 0, 0); + root->setSpacing(0); + + auto *top = new QFrame; + top->setObjectName(QStringLiteral("topBar")); + top->setStyleSheet(QStringLiteral( + "QFrame#topBar{background:#ffffff;border-bottom:1px solid #d7dee8;}")); + top->setFixedHeight(64); + auto *topLayout = new QHBoxLayout(top); + topLayout->setContentsMargins(18, 0, 18, 0); + topLayout->setSpacing(12); + topLayout->addWidget(codexui::BrandMark::createLockup()); + + restoreSidebarButton = new QPushButton(QStringLiteral("Show threads")); + restoreSidebarButton->setProperty("kind", "subtle"); + restoreSidebarButton->setFixedHeight(32); + restoreSidebarButton->hide(); + topLayout->addSpacing(12); + topLayout->addWidget(restoreSidebarButton); + topLayout->addSpacing(18); + workspaceBreadcrumb = makeLabel(QStringLiteral("No workspace"), "muted"); + workspaceBreadcrumb->setWordWrap(false); + workspaceBreadcrumb->setMaximumWidth(280); + workspaceBreadcrumb->setStyleSheet( + QStringLiteral("color:#667085;font-weight:500;")); + topLayout->addWidget(workspaceBreadcrumb); + topLayout->addStretch(); + + restoreInspectorButton = new QPushButton(QStringLiteral("Show inspector")); + restoreInspectorButton->setProperty("kind", "subtle"); + restoreInspectorButton->setFixedHeight(32); + restoreInspectorButton->hide(); + requestButton = new QPushButton; + requestButton->setProperty("kind", "request"); + requestButton->setFixedHeight(32); + requestButton->hide(); + controllerButton = new QPushButton(QStringLiteral("Claim control")); + controllerButton->setFixedHeight(32); + topLayout->addWidget(restoreInspectorButton); + topLayout->addWidget(requestButton); + topLayout->addWidget(controllerButton); + + connectionStatusDot = statusDot(); + connectionStatusDot->setToolTip(QStringLiteral("Not connected")); + connectionButton = new QToolButton; + connectionButton->setText(QStringLiteral("Connection")); + connectionButton->setProperty("kind", "subtle"); + connectionButton->setPopupMode(QToolButton::InstantPopup); + connectionButton->setFixedHeight(32); + auto *connectionMenu = new QMenu(connectionButton); + connectionMenu->addAction(QStringLiteral("Configure..."), owner, [this] { + if (!model.connection().settings.is_object() || + model.connection().settings.empty()) { + showNotice(QStringLiteral("Connection settings are not available yet.")); + return; + } + ConnectionDialog dialog(model.connection().settings, owner); + if (dialog.exec() != QDialog::Accepted) + return; + const auto token = alive; + session.configureConnection( + dialog.selection(), [this, token](const nlohmann::json &result) { + if (!*token || result.value("ok", false)) + return; + const std::string message = + safeMessage(result.value("error", nlohmann::json::object())); + showNotice(text(message.empty() + ? std::string("Connection configuration failed") + : message)); + }); + }); + connectionMenu->addSeparator(); + connectAction = connectionMenu->addAction( + QStringLiteral("Connect"), owner, [this] { session.connectTransport(); }); + disconnectAction = + connectionMenu->addAction(QStringLiteral("Disconnect"), owner, + [this] { session.disconnectTransport(); }); + reconnectAction = connectionMenu->addAction( + QStringLiteral("Reconnect"), owner, [this] { session.reconnect(); }); + connectionButton->setMenu(connectionMenu); + auto *connectionControl = new QWidget; + auto *connectionLayout = new QHBoxLayout(connectionControl); + connectionLayout->setContentsMargins(0, 0, 0, 0); + connectionLayout->setSpacing(6); + connectionLayout->addWidget(connectionButton); + connectionLayout->addWidget(connectionStatusDot); + topLayout->addWidget(connectionControl); + root->addWidget(top); + + middleRegion = new middle::MiddleRegionWidget; + root->addWidget(middleRegion, 1); + + auto *statusBar = new QFrame; + statusBar->setObjectName(QStringLiteral("customStatusBar")); + statusBar->setStyleSheet(QStringLiteral( + "QFrame#customStatusBar{background:#f8fafc;border-top:1px solid " + "#d7dee8;}")); + statusBar->setFixedHeight(40); + auto *statusLayout = new QHBoxLayout(statusBar); + statusLayout->setContentsMargins(18, 0, 24, 0); + statusLayout->setSpacing(8); + threadContextStatus = makeLabel(QStringLiteral("No thread context"), "meta"); + statusLayout->addWidget(threadContextStatus); + statusLayout->addSpacing(42); + agentActivityStatus = makeLabel(QStringLiteral("No agent activity"), "meta"); + statusLayout->addWidget(agentActivityStatus); + statusLayout->addStretch(); + controllerLabel = makeLabel(QStringLiteral("Observer"), "meta"); + statusLayout->addWidget(controllerLabel); + root->addWidget(statusBar); +} + +void ShellWidget::Impl::connectUi() { + middle::ThreadPane::Actions threadActions; + threadActions.newThread = [this] { beginNewThread(); }; + threadActions.refresh = [this] { session.listThreads(); }; + threadActions.hide = [this] { middleRegion->showSidebar(false); }; + threadActions.select = [this](const std::string &id) { + if (id != selectedThreadId) + selectThread(id); + }; + threadActions.reload = [this](const std::string &id) { + readThread(id, true); + }; + threadActions.rename = [this](const std::string &id) { renameThread(id); }; + threadActions.fork = [this](const std::string &id) { forkThread(id); }; + threadActions.toggleArchive = [this](const std::string &id) { + toggleThreadArchive(id); + }; + threadActions.remove = [this](const std::string &id) { deleteThread(id); }; + middleRegion->threads().setActions(std::move(threadActions)); + + middle::ComposerPane::Actions composerActions; + composerActions.submit = [this](QString prompt, + std::vector attachments) { + return submitPrompt(std::move(prompt), std::move(attachments)); + }; + composerActions.stop = [this] { interruptTurn(); }; + composerActions.attach = [this] { chooseAttachments(); }; + composerActions.review = [this] { respondToFirstPending(true); }; + composerActions.deny = [this] { respondToFirstPending(false); }; + middleRegion->composer().setActions(std::move(composerActions)); + + middleRegion->conversation().setLoadMoreAction([this] { + const std::string key = selectedThreadId.empty() + ? std::string(DraftThreadId) + : selectedThreadId; + HistoryWindow &history = historyWindows[key]; + history.requested += + middle::ConversationProjection::DefaultAuthoritativeItemLimit; + history.effective += + middle::ConversationProjection::DefaultAuthoritativeItemLimit; + renderConversation(); + }); + middleRegion->inspector().setRequestActions( + [this](const std::string &id) { reviewPending(id); }, + [this](const std::string &id) { rejectPending(id); }); + middleRegion->setPaneVisibilityAction( + [this](bool sidebarVisible, bool inspectorVisible) { + restoreSidebarButton->setVisible(!sidebarVisible); + restoreInspectorButton->setVisible(!inspectorVisible); + }); + + connect(restoreSidebarButton, &QPushButton::clicked, owner, + [this] { middleRegion->showSidebar(true); }); + connect(restoreInspectorButton, &QPushButton::clicked, owner, + [this] { middleRegion->showInspector(true); }); + connect(requestButton, &QPushButton::clicked, owner, [this] { + middleRegion->showInspector(true); + middleRegion->inspector().tabs()->setCurrentIndex(3); + }); + connect(controllerButton, &QPushButton::clicked, owner, [this] { + if (model.connection().role == "controller") + session.releaseController(); + else + session.claimController(); + }); + qApp->installEventFilter(owner); +} + +void ShellWidget::Impl::showNotice(QString message, bool error) { + middleRegion->showNotice(std::move(message), error); +} + +void ShellWidget::Impl::handleEvent(const nlohmann::json &event) { + middleRegion->inspector().appendProtocolFrame(event); + + const std::string kind = stringValue(event, "kind"); + const std::string action = stringValue(event, "action"); + const std::string correlationId = stringValue(event, "correlationId"); + const bool staleReadResult = + kind == "result" && action == "thread.read" && !correlationId.empty() && + staleReadResultCorrelations.erase(correlationId) > 0; + if (!staleReadResult) + model.applyEvent(event); + + const ConnectionPresentation &connection = model.connection(); + if (connection.generation != observedConnectionGeneration) { + observedConnectionGeneration = connection.generation; + hydration.clear(); + readRevisions.clear(); + operationReadyThreads.clear(); + dispatchScheduledThreads.clear(); + } + + const std::string type = stringValue(event, "type"); + const nlohmann::json data = event.value("data", nlohmann::json::object()); + const nlohmann::json scope = event.value("scope", nlohmann::json::object()); + const std::string eventThreadId = stringValue(scope, "threadId"); + + if (kind == "result" && !event.value("ok", false) && action != "turn.start" && + action != "turn.steer" && action != "thread.read" && + action != "thread.resume") { + const std::string message = + safeMessage(event.value("error", nlohmann::json::object())); + showNotice(text(message.empty() ? std::string("Codex operation failed") + : message)); + } else if (kind == "event" && type == "notice.added") { + const nlohmann::json notice = + data.value("notice", nlohmann::json::object()); + const std::string message = safeMessage(notice); + if (!message.empty()) + showNotice(text(message), stringValue(data, "severity") == "error"); + } else if (kind == "event" && type == "system.diagnostic") { + const std::string message = safeMessage(data); + if (!message.empty()) + showNotice(QStringLiteral("Protocol diagnostic: %1").arg(text(message))); + } else if (kind == "event" && type == "connection.lifecycle" && + (stringValue(data, "state") == "failure" || + stringValue(data, "state") == "disconnected")) { + const std::string detail = stringValue(data, "detail"); + if (!detail.starts_with("local-")) + showNotice(detail.empty() ? QStringLiteral("Codex bridge disconnected") + : text(detail)); + } + + if (kind == "event" && type == "connection.bridge" && + stringValue(data, "state") == "opened") { + session.listThreads(); + session.listModels(); + ensureThreadHydrated(selectedThreadId); + for (const std::string &threadId : prompts.queuedThreadIds()) { + if (threadId == DraftThreadId) { + if (newThreadIntent) + startThreadForDraft(); + } else { + dispatchNextPrompt(threadId); + } + } + session.listPermissionProfiles( + {{"cwd", QDir::currentPath().toStdString()}}); + } + + if (type == "thread.removed" && !eventThreadId.empty()) { + prompts.clearThread(eventThreadId); + hydration.erase(eventThreadId); + readRevisions.erase(eventThreadId); + operationReadyThreads.erase(eventThreadId); + resumeInFlightThreads.erase(eventThreadId); + dispatchScheduledThreads.erase(eventThreadId); + historyWindows.erase(eventThreadId); + if (selectedThreadId == eventThreadId) { + selectedThreadId.clear(); + middleRegion->composer().clearDraft(); + } + } else if (!eventThreadId.empty()) { + if (const ThreadPresentation *thread = model.thread(eventThreadId)) { + prompts.reconcile(eventThreadId, *thread); + prompts.compactResolved(eventThreadId, + QDateTime::currentMSecsSinceEpoch()); + } + } + + hydrateHistoricalAgents(); + render(); +} + +void ShellWidget::Impl::render() { + middleRegion->threads().refresh(model, selectedThreadId); + renderConversation(); + middleRegion->inspector().refresh(model, selectedThreadId); + refreshSettings(); + refreshStatus(); +} + +void ShellWidget::Impl::renderConversation() { + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + const ThreadPresentation *thread = model.thread(selectedThreadId); + const std::string projectionId = selectedThreadId.empty() && newThreadIntent + ? std::string(DraftThreadId) + : selectedThreadId; + if (thread) + prompts.reconcile(selectedThreadId, *thread); + if (!projectionId.empty()) + prompts.compactResolved(projectionId, now); + const auto submissions = prompts.submissions(projectionId); + std::size_t authoritativeCount = 0; + if (thread) { + for (const std::string &turnId : thread->turnOrder) { + const auto turn = thread->turns.find(turnId); + if (turn != thread->turns.end()) + authoritativeCount += turn->second.itemOrder.size(); + } + } + HistoryWindow &history = historyWindows[projectionId]; + const middle::ConversationView::Mode viewportMode = + middleRegion->conversation().modeForThread(projectionId); + if (viewportMode == middle::ConversationView::Mode::Paused && + authoritativeCount > history.lastAuthoritativeCount) { + // Do not evict the paused visual anchor merely because newer items were + // appended. The hidden prefix stays constant until following resumes. + history.effective += authoritativeCount - history.lastAuthoritativeCount; + } else if (viewportMode == middle::ConversationView::Mode::Following) { + history.effective = history.requested; + } + history.lastAuthoritativeCount = authoritativeCount; + const middle::ConversationSnapshot snapshot = + middle::ConversationProjection::project(projectionId, thread, submissions, + history.effective, now); + if (!thread && newThreadIntent) + middleRegion->conversation().setEmptyMessage( + QStringLiteral("Send a message to create this thread.")); + else if (thread) + middleRegion->conversation().setEmptyMessage( + QStringLiteral("No materialized activity.")); + else + middleRegion->conversation().setEmptyMessage( + QStringLiteral("Conversation activity appears here.")); + middleRegion->conversation().reconcile(snapshot); + + if (thread) { + middleRegion->setThreadHeading(text(thread->title), + text(thread->cwd) + QStringLiteral(" | ") + + displayStatus(thread->status)); + } else if (newThreadIntent) { + middleRegion->setThreadHeading(QStringLiteral("New thread"), + newThreadWorkspace.isEmpty() + ? QDir::currentPath() + : newThreadWorkspace); + } else { + middleRegion->setThreadHeading(QStringLiteral("Select a thread"), {}); + } +} + +void ShellWidget::Impl::refreshSettings() { + nlohmann::json canonical = nlohmann::json::object(); + std::string identity = "no-thread"; + if (const ThreadPresentation *thread = model.thread(selectedThreadId)) { + identity = thread->id; + canonical = thread->raw; + const auto settings = thread->domains.find("thread.settings.changed"); + if (settings != thread->domains.end() && settings->second.is_object()) { + nlohmann::json update = settings->second; + if (update.contains("threadSettings") && + update["threadSettings"].is_object()) + update = update["threadSettings"]; + canonical.merge_patch(update); + } + } else if (newThreadIntent) { + identity = DraftThreadId; + canonical["cwd"] = (newThreadWorkspace.isEmpty() ? QDir::currentPath() + : newThreadWorkspace) + .toStdString(); + } else { + canonical["cwd"] = QDir::currentPath().toStdString(); + } + nlohmann::json profiles = nlohmann::json::array(); + const auto found = + model.globalDomains().find("operation.permission-profiles.list"); + if (found != model.globalDomains().end()) + profiles = found->second; + const std::string serialized = nlohmann::json{ + {"identity", identity}, + {"canonical", canonical}, + {"models", model.modelCatalog()}, + {"profiles", profiles}}.dump(); + const QByteArray next(serialized.data(), + static_cast(serialized.size())); + if (next == settingsSnapshot) + return; + settingsSnapshot = next; + middleRegion->composer().turnSettings()->setContext( + identity, canonical, model.modelCatalog(), profiles); +} + +void ShellWidget::Impl::refreshStatus() { + const ConnectionPresentation &connection = model.connection(); + const ThreadPresentation *thread = model.thread(selectedThreadId); + std::size_t runningAgents = 0; + if (thread) { + for (const auto &[id, agent] : thread->agents) { + static_cast(id); + if (agent.status == "inProgress" || agent.status == "running" || + agent.status == "started") + ++runningAgents; + } + } + const bool active = model.activeTurnId(selectedThreadId).has_value(); + const std::string serialized = nlohmann::json{ + {"connected", connection.connected}, + {"retrying", connection.retrying}, + {"role", connection.role}, + {"settings", connection.settings}, + {"selectedThreadId", selectedThreadId}, + {"newThreadIntent", newThreadIntent}, + {"newThreadWorkspace", newThreadWorkspace.toStdString()}, + {"threadTitle", thread ? thread->title : std::string{}}, + {"threadCwd", thread ? thread->cwd : std::string{}}, + {"threadStatus", thread ? thread->status : std::string{}}, + {"agentCount", thread ? thread->agents.size() : 0U}, + {"runningAgents", runningAgents}, + {"active", active}, + {"selectedPending", model.pendingRequestCount(selectedThreadId)}, + {"totalPending", + model.pendingRequestCount()}}.dump(); + const QByteArray next(serialized.data(), + static_cast(serialized.size())); + if (next == statusSnapshot) + return; + statusSnapshot = next; + QString dotStyle; + QString dotTip; + if (connection.connected) { + dotStyle = QStringLiteral("background:#23845a;border-radius:4px;"); + dotTip = QStringLiteral("Connected"); + } else if (connection.retrying) { + dotStyle = QStringLiteral("background:#d98e1c;border-radius:4px;"); + dotTip = QStringLiteral("Disconnected, retrying"); + } else { + dotStyle = QStringLiteral("background:#b83a3a;border-radius:4px;"); + dotTip = QStringLiteral("Disconnected"); + } + connectionStatusDot->setStyleSheet(dotStyle); + connectionStatusDot->setToolTip(dotTip); + QString selectedTransport; + const std::string selectedKey = stringValue(connection.settings, "selected"); + const nlohmann::json available = + connection.settings.value("available", nlohmann::json::array()); + if (available.is_array()) { + for (const auto &entry : available) { + if (stringValue(entry, "key") == selectedKey) { + selectedTransport = text(stringValue(entry, "label")); + break; + } + } + } + connectionButton->setText(selectedTransport.isEmpty() + ? QStringLiteral("Connection") + : selectedTransport); + connectionButton->setToolTip( + connection.connected ? QStringLiteral("Connected bridge transport") + : QStringLiteral("Disconnected bridge transport")); + connectAction->setEnabled(!connection.connected); + disconnectAction->setEnabled(connection.connected); + reconnectAction->setEnabled(connection.connected); + controllerLabel->setText(connection.role.empty() ? QStringLiteral("No role") + : text(connection.role)); + controllerButton->setText(connection.role == "controller" + ? QStringLiteral("Release control") + : QStringLiteral("Claim control")); + controllerButton->setEnabled(connection.connected); + + const std::size_t selectedPending = + model.pendingRequestCount(selectedThreadId); + const std::size_t totalPending = model.pendingRequestCount(); + requestButton->setText(QStringLiteral("Requests (%1)") + .arg(static_cast(totalPending))); + requestButton->setVisible(totalPending != 0); + middleRegion->composer().setAttentionVisible(selectedPending != 0); + + QString workspace = QStringLiteral("No workspace"); + if (thread) { + workspace = text(thread->cwd); + threadContextStatus->setText( + QStringLiteral("%1 | %2") + .arg(text(thread->title), displayStatus(thread->status))); + agentActivityStatus->setText( + thread->agents.empty() + ? QStringLiteral("No agent activity") + : QStringLiteral("%1 agents | %2 active") + .arg(static_cast(thread->agents.size())) + .arg(static_cast(runningAgents))); + } else { + if (newThreadIntent) + workspace = text(middleRegion->composer().turnSettings()->workspace( + QDir::currentPath().toStdString())); + threadContextStatus->setText(newThreadIntent + ? QStringLiteral("New thread") + : QStringLiteral("No thread context")); + agentActivityStatus->setText(QStringLiteral("No agent activity")); + } + workspaceBreadcrumb->setToolTip(workspace); + workspaceBreadcrumb->setText(workspaceBreadcrumb->fontMetrics().elidedText( + workspace, Qt::ElideMiddle, workspaceBreadcrumb->maximumWidth())); + + const bool canSubmit = + connection.connected && connection.role == "controller"; + middleRegion->composer().setActiveTurn(active); + middleRegion->composer().setCanSubmit(canSubmit); + middleRegion->composer().setSettingsEnabled(canSubmit && !active); +} + +void ShellWidget::Impl::hydrateHistoricalAgents() { + const ThreadPresentation *thread = model.thread(selectedThreadId); + if (!thread) + return; + for (const std::string &id : thread->agentOrder) { + const auto agent = thread->agents.find(id); + if (agent == thread->agents.end() || agent->second.childThreadId.empty() || + agent->second.status != "started") + continue; + // Historical child hydration shares the same monotonic read boundary as + // user-selected threads, so a pre-reconnect result cannot replace newer + // child/agent presentation state. + readThread(agent->second.childThreadId); + } +} + +void ShellWidget::Impl::selectThread(std::string threadId) { + if (threadId.empty()) + return; + if (threadId == selectedThreadId) { + ensureThreadHydrated(threadId); + return; + } + selectedThreadId = std::move(threadId); + newThreadIntent = false; + newThreadOptions = nlohmann::json::object(); + newThreadName.clear(); + newThreadWorkspace.clear(); + middleRegion->composer().clearDraft(); + historyWindows.try_emplace(selectedThreadId); + ensureThreadHydrated(selectedThreadId); + render(); +} + +void ShellWidget::Impl::beginNewThread() { + if (newThreadCreationInFlight) { + showNotice(QStringLiteral("The current new thread is still being created."), + false); + return; + } + const QString initial = + text(middleRegion->composer().turnSettings()->workspace( + QDir::currentPath().toStdString())); + NewThreadDialog dialog(initial, owner); + if (dialog.exec() != QDialog::Accepted) + return; + const NewThreadDraft draft = dialog.draft(); + prompts.clearThread(DraftThreadId); + selectedThreadId.clear(); + newThreadIntent = true; + newThreadName = draft.name; + newThreadWorkspace = draft.workspace; + newThreadOptions = nlohmann::json::object(); + if (!draft.baseInstructions.isEmpty()) + newThreadOptions["baseInstructions"] = draft.baseInstructions.toStdString(); + if (!draft.developerInstructions.isEmpty()) + newThreadOptions["developerInstructions"] = + draft.developerInstructions.toStdString(); + if (draft.ephemeral) + newThreadOptions["ephemeral"] = true; + settingsSnapshot.clear(); + middleRegion->composer().clearDraft(); + middleRegion->composer().turnSettings()->setWorkspace(draft.workspace); + middleRegion->composer().promptEditor()->setFocus(); + render(); +} + +void ShellWidget::Impl::readThread(const std::string &threadId, bool forced) { + if (threadId.empty() || resumeInFlightThreads.contains(threadId)) + return; + if (!forced) { + const auto existing = hydration.find(threadId); + if (existing != hydration.end() && + (existing->second == Hydration::InFlight || + existing->second == Hydration::Hydrated || + existing->second == Hydration::Failed)) + return; + } + hydration[threadId] = Hydration::InFlight; + const auto token = alive; + const std::uint64_t revision = nextReadRevision++; + readRevisions[threadId] = revision; + session.readThread(threadId, [this, token, threadId, + revision](const nlohmann::json &result) { + if (!*token) + return; + const auto current = readRevisions.find(threadId); + if (current == readRevisions.end() || current->second != revision) { + const std::string correlationId = stringValue(result, "correlationId"); + if (!correlationId.empty()) + staleReadResultCorrelations.insert(correlationId); + return; + } + if (result.value("ok", false)) { + hydration[threadId] = Hydration::Hydrated; + QTimer::singleShot(0, owner, + [this, threadId] { dispatchNextPrompt(threadId); }); + return; + } + // A non-forced hydration is attempted once per connection generation. + // Explicit Reload bypasses this terminal state, while a new generation + // clears it together with the other hydration bookkeeping. + hydration[threadId] = Hydration::Failed; + const std::string message = + safeMessage(result.value("error", nlohmann::json::object())); + const QString displayed = + text(message.empty() ? std::string("Thread loading failed") : message); + static_cast(prompts.failQueued(threadId, displayed)); + showNotice(displayed); + render(); + }); +} + +void ShellWidget::Impl::ensureThreadHydrated(const std::string &threadId) { + if (threadId.empty() || threadIsHydrated(threadId) || + !model.connection().connected) + return; + const auto found = hydration.find(threadId); + if (found != hydration.end() && found->second == Hydration::InFlight) + return; + readThread(threadId); +} + +bool ShellWidget::Impl::threadIsHydrated(const std::string &threadId) const { + const auto found = hydration.find(threadId); + return found != hydration.end() && found->second == Hydration::Hydrated; +} + +bool ShellWidget::Impl::threadRequiresResume( + const std::string &threadId) const { + if (operationReadyThreads.contains(threadId)) + return false; + const ThreadPresentation *thread = model.thread(threadId); + return thread && thread->status == "notLoaded"; +} + +void ShellWidget::Impl::renameThread(const std::string &threadId) { + const ThreadPresentation *thread = model.thread(threadId); + if (!thread) + return; + bool accepted = false; + const QString name = + QInputDialog::getText(owner, QStringLiteral("Rename thread"), + QStringLiteral("Name"), QLineEdit::Normal, + text(thread->title), &accepted) + .trimmed(); + if (accepted && !name.isEmpty()) + session.renameThread(threadId, name.toStdString()); +} + +void ShellWidget::Impl::forkThread(const std::string &threadId) { + if (threadId.empty()) + return; + const auto token = alive; + session.forkThread(threadId, nlohmann::json::object(), + [this, token](const nlohmann::json &result) { + if (!*token || !result.value("ok", false)) + return; + const std::string id = stringValue( + result.value("data", nlohmann::json::object()) + .value("thread", nlohmann::json::object()), + "id"); + if (!id.empty()) + selectThread(id); + }); +} + +void ShellWidget::Impl::toggleThreadArchive(const std::string &threadId) { + const ThreadPresentation *thread = model.thread(threadId); + if (!thread) + return; + if (thread->archived) + session.unarchiveThread(threadId); + else + session.archiveThread(threadId); +} + +void ShellWidget::Impl::deleteThread(const std::string &threadId) { + if (threadId.empty()) + return; + if (QMessageBox::question(owner, QStringLiteral("Delete thread"), + QStringLiteral("Delete the selected thread?"), + QMessageBox::Yes | QMessageBox::Cancel, + QMessageBox::Cancel) == QMessageBox::Yes) + session.deleteThread(threadId); +} + +bool ShellWidget::Impl::submitPrompt(QString prompt, + std::vector attachments) { + prompt = prompt.trimmed(); + if (prompt.isEmpty()) + return false; + const std::string visiblySelected = + middleRegion->threads().visiblySelectedThreadId(); + if (!visiblySelected.empty() && visiblySelected != selectedThreadId) { + if (!model.thread(visiblySelected)) { + showNotice(QStringLiteral("The visibly selected thread is no longer " + "available. Your message was not sent.")); + return false; + } + selectThread(visiblySelected); + } + + std::string destination = selectedThreadId; + const ThreadPresentation *thread = model.thread(destination); + if (destination.empty()) { + if (!newThreadIntent) { + showNotice(QStringLiteral("No destination thread is selected. Your " + "message was not sent; select a thread or use " + "New thread.")); + middleRegion->composer().promptEditor()->setFocus(); + return false; + } + destination = DraftThreadId; + thread = nullptr; + } + + if (destination != DraftThreadId) { + const auto state = hydration.find(destination); + if (state != hydration.end() && state->second == Hydration::Failed) { + showNotice(QStringLiteral("Thread loading failed. Reload the thread " + "before sending; your message was not sent.")); + middleRegion->composer().promptEditor()->setFocus(); + return false; + } + } + + const auto activeTurn = destination == DraftThreadId + ? std::optional{} + : model.activeTurnId(destination); + const std::uint64_t submissionId = + prompts.admit(destination, prompt, std::move(attachments), + middleRegion->composer().turnSettings()->turnStartOptions(), + thread, activeTurn, QDateTime::currentMSecsSinceEpoch()); + static_cast(submissionId); + + // Admission is a synchronous UI fact. Transport dispatch is queued below so + // this awaiting projection is committed without forcing paint reentrancy. + middleRegion->conversation().prepareForLocalPromptAdmission(); + renderConversation(); + + if (destination == DraftThreadId) + startThreadForDraft(); + else + dispatchNextPrompt(destination); + return true; +} + +void ShellWidget::Impl::startThreadForDraft() { + if (newThreadCreationInFlight || prompts.submissions(DraftThreadId).empty()) + return; + newThreadCreationInFlight = true; + nlohmann::json options = + middleRegion->composer().turnSettings()->threadStartOptions(); + options.update(newThreadOptions); + options["cwd"] = middleRegion->composer().turnSettings()->workspace( + QDir::currentPath().toStdString()); + const QString requestedName = newThreadName; + const auto token = alive; + session.createThread(std::move(options), [this, token, requestedName]( + const nlohmann::json &result) { + if (!*token) + return; + newThreadCreationInFlight = false; + if (!result.value("ok", false)) { + const std::string message = + safeMessage(result.value("error", nlohmann::json::object())); + const QString error = text( + message.empty() ? std::string("Thread creation failed") : message); + const auto pending = prompts.submissions(DraftThreadId); + std::vector ids; + for (const auto &submission : pending) + ids.push_back(submission.id); + for (const std::uint64_t id : ids) + static_cast(prompts.fail(DraftThreadId, id, error)); + showNotice(error); + render(); + return; + } + const std::string threadId = + stringValue(result.value("data", nlohmann::json::object()) + .value("thread", nlohmann::json::object()), + "id"); + if (threadId.empty()) { + const QString error = + QStringLiteral("Thread creation returned no thread identifier"); + const auto pending = prompts.submissions(DraftThreadId); + std::vector ids; + for (const auto &submission : pending) + ids.push_back(submission.id); + for (const std::uint64_t id : ids) + static_cast(prompts.fail(DraftThreadId, id, error)); + showNotice(error); + render(); + return; + } + + if (!prompts.reassignThread(DraftThreadId, threadId)) { + showNotice(QStringLiteral("Could not attach the draft prompts to " + "the created thread.")); + render(); + return; + } + hydration[threadId] = Hydration::Hydrated; + operationReadyThreads.insert(threadId); + const bool viewingDraft = selectedThreadId.empty() && newThreadIntent; + if (viewingDraft) { + selectedThreadId = threadId; + newThreadIntent = false; + } + newThreadOptions = nlohmann::json::object(); + newThreadName.clear(); + newThreadWorkspace.clear(); + settingsSnapshot.clear(); + if (!requestedName.isEmpty()) + session.renameThread(threadId, requestedName.toStdString()); + render(); + QTimer::singleShot(0, owner, + [this, threadId] { dispatchNextPrompt(threadId); }); + }); +} + +void ShellWidget::Impl::dispatchNextPrompt(const std::string &threadId) { + if (threadId.empty() || !model.connection().connected) + return; + const auto submissions = prompts.submissions(threadId); + if (std::ranges::none_of( + submissions, [](const middle::PromptSubmission &submission) { + return submission.state == middle::PromptState::Queued; + })) + return; + if (resumeInFlightThreads.contains(threadId)) + return; + if (!threadIsHydrated(threadId)) { + ensureThreadHydrated(threadId); + return; + } + if (prompts.hasInFlight(threadId)) + return; + if (threadRequiresResume(threadId)) { + resumePromptQueue(threadId); + return; + } + if (!dispatchScheduledThreads.insert(threadId).second) + return; + + // The admitted card already presents the awaiting state. Queueing transport + // gives Qt one normal paint turn, then samples start-versus-steer at the + // actual send boundary without a forced repaint or reentrant event drain. + const std::uint64_t generation = observedConnectionGeneration; + QTimer::singleShot(0, owner, [this, threadId, generation] { + dispatchScheduledThreads.erase(threadId); + if (observedConnectionGeneration != generation) + return; + if (!model.connection().connected || + resumeInFlightThreads.contains(threadId)) + return; + if (!threadIsHydrated(threadId) || threadRequiresResume(threadId)) { + dispatchNextPrompt(threadId); + return; + } + const auto dispatch = + prompts.beginNext(threadId, model.activeTurnId(threadId)); + if (dispatch) + dispatchPrompt(*dispatch); + }); +} + +void ShellWidget::Impl::dispatchPrompt(middle::PromptDispatch dispatch) { + nlohmann::json input = + nlohmann::json::array({{{"type", "text"}, + {"text", dispatch.prompt.toStdString()}, + {"text_elements", nlohmann::json::array()}}}); + for (const AttachmentDraft &attachment : dispatch.attachments) { + if (attachment.mimeType.startsWith(QStringLiteral("image/"))) + input.push_back( + {{"type", "localImage"}, {"path", attachment.path.toStdString()}}); + else if (attachment.mimeType.startsWith(QStringLiteral("audio/"))) + input.push_back( + {{"type", "localAudio"}, {"path", attachment.path.toStdString()}}); + else + input.push_back({{"type", "mention"}, + {"name", attachment.name.toStdString()}, + {"path", attachment.path.toStdString()}}); + } + + const std::string threadId = dispatch.threadId; + const std::uint64_t submissionId = dispatch.id; + const auto token = alive; + auto completed = [this, token, threadId, + submissionId](const nlohmann::json &result) { + if (*token) + completePrompt(threadId, submissionId, result); + }; + if (dispatch.expectedTurnId) { + session.request("turn.steer", + {{"threadId", dispatch.threadId}, + {"expectedTurnId", *dispatch.expectedTurnId}, + {"clientUserMessageId", dispatch.clientUserMessageId}, + {"input", std::move(input)}}, + std::move(completed)); + } else { + dispatch.turnOptions["clientUserMessageId"] = dispatch.clientUserMessageId; + session.startTurn(dispatch.threadId, std::move(input), + std::move(dispatch.turnOptions), std::move(completed)); + } +} + +void ShellWidget::Impl::resumePromptQueue(const std::string &threadId) { + if (!resumeInFlightThreads.insert(threadId).second) + return; + const auto token = alive; + session.resumeThread( + threadId, nlohmann::json::object(), + [this, token, threadId](const nlohmann::json &result) { + if (!*token) + return; + resumeInFlightThreads.erase(threadId); + if (!result.value("ok", false)) { + const std::string message = + safeMessage(result.value("error", nlohmann::json::object())); + const QString displayed = text( + message.empty() ? std::string("Thread resume failed") : message); + static_cast(prompts.failQueued(threadId, displayed)); + showNotice(displayed); + render(); + return; + } + hydration[threadId] = Hydration::Hydrated; + operationReadyThreads.insert(threadId); + QTimer::singleShot(0, owner, + [this, threadId] { dispatchNextPrompt(threadId); }); + }); +} + +void ShellWidget::Impl::completePrompt(const std::string &threadId, + std::uint64_t submissionId, + const nlohmann::json &result) { + if (attemptThreadRecovery(threadId, submissionId, result)) + return; + promptRecoveryAttempted.erase(recoveryKey(threadId, submissionId)); + if (result.value("ok", false)) { + operationReadyThreads.insert(threadId); + static_cast(prompts.acknowledge(threadId, submissionId, + resultTurnId(result), + QDateTime::currentMSecsSinceEpoch())); + if (const ThreadPresentation *thread = model.thread(threadId)) + prompts.reconcile(threadId, *thread); + scheduleAcceptedTransition(threadId, submissionId); + } else { + const std::string message = + safeMessage(result.value("error", nlohmann::json::object())); + const QString displayed = + text(message.empty() ? std::string("Submission failed") : message); + static_cast(prompts.fail(threadId, submissionId, displayed)); + showNotice(text(message.empty() ? std::string("Turn submission failed") + : message)); + } + render(); + QTimer::singleShot(0, owner, + [this, threadId] { dispatchNextPrompt(threadId); }); +} + +bool ShellWidget::Impl::attemptThreadRecovery(const std::string &threadId, + std::uint64_t submissionId, + const nlohmann::json &result) { + if (!isThreadNotFoundResult(result)) + return false; + const std::string key = recoveryKey(threadId, submissionId); + if (!promptRecoveryAttempted.insert(key).second) + return false; + if (!prompts.requeue(threadId, submissionId)) + return false; + hydration[threadId] = Hydration::NotHydrated; + operationReadyThreads.erase(threadId); + render(); + resumeInFlightThreads.insert(threadId); + const auto token = alive; + session.resumeThread( + threadId, nlohmann::json::object(), + [this, token, threadId](const nlohmann::json &resumeResult) { + if (!*token) + return; + resumeInFlightThreads.erase(threadId); + if (!resumeResult.value("ok", false)) { + const std::string message = safeMessage( + resumeResult.value("error", nlohmann::json::object())); + const QString displayed = + text(message.empty() ? std::string("Thread recovery failed") + : message); + static_cast(prompts.failQueued(threadId, displayed)); + showNotice(displayed); + render(); + return; + } + hydration[threadId] = Hydration::Hydrated; + operationReadyThreads.insert(threadId); + QTimer::singleShot(0, owner, + [this, threadId] { dispatchNextPrompt(threadId); }); + }); + return true; +} + +void ShellWidget::Impl::scheduleAcceptedTransition(const std::string &threadId, + std::uint64_t submissionId) { + const middle::PromptSubmission *submission = + prompts.submission(threadId, submissionId); + if (!submission || submission->state != middle::PromptState::Accepted) + return; + const qint64 elapsed = + QDateTime::currentMSecsSinceEpoch() - submission->acceptedAtMilliseconds; + const int remaining = static_cast(std::max( + 1, middle::AcknowledgementTransitionMilliseconds - elapsed)); + QTimer::singleShot( + remaining, Qt::PreciseTimer, owner, [this, threadId, submissionId] { + const middle::PromptSubmission *current = + prompts.submission(threadId, submissionId); + if (!current || current->state != middle::PromptState::Accepted) + return; + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + if (current->acceptedTransitionActive(now)) { + scheduleAcceptedTransition(threadId, submissionId); + return; + } + if (const ThreadPresentation *thread = model.thread(threadId)) + prompts.reconcile(threadId, *thread); + prompts.compactResolved(threadId, now); + render(); + }); +} + +void ShellWidget::Impl::chooseAttachments() { + const QString initial = + text(middleRegion->composer().turnSettings()->workspace( + QDir::currentPath().toStdString())); + FileSelectionDialog dialog(FileSelectionDialog::Mode::Attachments, initial, + middleRegion->composer().attachments(), owner); + if (dialog.exec() == QDialog::Accepted) + middleRegion->composer().setAttachments(dialog.selectedAttachments()); +} + +void ShellWidget::Impl::interruptTurn() { + const auto turn = model.activeTurnId(selectedThreadId); + if (turn) + session.interruptTurn(selectedThreadId, *turn); +} + +void ShellWidget::Impl::respondToFirstPending(bool approve) { + const auto &pending = model.pendingRequestPresentations(); + const auto request = std::ranges::find_if(pending, [this](const auto &entry) { + return entry.second.threadId == selectedThreadId; + }); + if (request == pending.end()) + return; + if (approve) + reviewPending(request->first); + else + rejectPending(request->first); +} + +void ShellWidget::Impl::reviewPending(const std::string &requestKey) { + const auto request = model.pendingRequestPresentations().find(requestKey); + if (request == model.pendingRequestPresentations().end()) + return; + const auto response = PendingRequestDialog::present(request->second, owner); + if (!response) + return; + session.respondToServerRequest(nlohmann::json::parse(requestKey), + response->result, response->error); +} + +void ShellWidget::Impl::rejectPending(const std::string &requestKey) { + const auto request = model.pendingRequestPresentations().find(requestKey); + if (request == model.pendingRequestPresentations().end()) + return; + PendingRequestResponse response = + PendingRequestDialog::negativeResponse(request->second); + session.respondToServerRequest(nlohmann::json::parse(requestKey), + std::move(response.result), + std::move(response.error)); +} + +ShellWidget::ShellWidget(FrontendSession &session, QWidget *parent) + : QWidget(parent), impl(nullptr) { + // Impl installs this widget as the application event filter. Keep the + // member in a defined null state while Impl builds child widgets: their + // construction can synchronously pass events through that filter. + impl = std::make_unique(this, session); +} + +ShellWidget::~ShellWidget() = default; + +bool ShellWidget::eventFilter(QObject *watched, QEvent *event) { + if (impl && impl->middleRegion->routeScrollEvent(watched, event)) + return true; + return QWidget::eventFilter(watched, event); +} + +} // namespace codexui::codex diff --git a/src/greenfield/codex/ShellWidget.h b/src/greenfield/codex/ShellWidget.h new file mode 100644 index 0000000..b400a45 --- /dev/null +++ b/src/greenfield/codex/ShellWidget.h @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_GREENFIELD_CODEX_SHELLWIDGET_H +#define CODEXUI_GREENFIELD_CODEX_SHELLWIDGET_H + +#include + +#include + +namespace codexui::codex { + +class FrontendSession; + +// Production shell backed by the green-field middle-region implementation. +// The private implementation keeps protocol/application coordination out of +// the visual component interfaces. +class ShellWidget final : public QWidget { +public: + explicit ShellWidget(FrontendSession &session, QWidget *parent = nullptr); + ~ShellWidget() override; + + ShellWidget(const ShellWidget &) = delete; + ShellWidget &operator=(const ShellWidget &) = delete; + +protected: + bool eventFilter(QObject *watched, QEvent *event) override; + +private: + struct Impl; + std::unique_ptr impl; +}; + +} // namespace codexui::codex + +#endif diff --git a/src/greenfield/codex/middle/ComposerPane.cpp b/src/greenfield/codex/middle/ComposerPane.cpp new file mode 100644 index 0000000..8afcda6 --- /dev/null +++ b/src/greenfield/codex/middle/ComposerPane.cpp @@ -0,0 +1,420 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/middle/ComposerPane.h" + +#include "codex/TurnSettingsWidget.h" +#include "codex/ui/ExpandingPromptEditor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace codexui::codex::middle { +namespace { + +constexpr int ControlHeight = 32; +constexpr int HorizontalInset = 24; +constexpr int BottomInset = 12; +constexpr int AttachmentRowHeight = 28; +constexpr int MaximumVisibleAttachments = 4; + +QLabel *makeLabel(QString value, const char *kind) { + auto *label = new QLabel(std::move(value)); + label->setProperty("kind", kind); + label->setTextFormat(Qt::PlainText); + label->setWordWrap(true); + label->setMinimumWidth(0); + label->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + return label; +} + +void clearLayout(QLayout *layout) { + while (QLayoutItem *item = layout->takeAt(0)) { + delete item->widget(); + delete item; + } +} + +void repolish(QWidget *widget) { + widget->style()->unpolish(widget); + widget->style()->polish(widget); + widget->update(); +} + +} // namespace + +ComposerPane::ComposerPane(QWidget *anchor) + : QWidget(anchor), anchor_(anchor), reserve_(new QWidget(anchor)) { + Q_ASSERT(anchor_); + setObjectName(QStringLiteral("composerOverlay")); + setAttribute(Qt::WA_StyledBackground, false); + setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + anchor_->installEventFilter(this); + + reserve_->setObjectName(QStringLiteral("composerCanonicalReserve")); + reserve_->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + reserve_->setFixedHeight(0); + + auto *root = new QVBoxLayout(this); + root->setContentsMargins(0, 8, 0, 0); + root->setSpacing(0); + + attention_ = new QFrame(this); + attention_->setProperty("kind", "amberBadge"); + auto *attentionLayout = new QHBoxLayout(attention_); + attentionLayout->setContentsMargins(10, 6, 10, 6); + attentionLayout->addWidget(makeLabel( + QStringLiteral("A Codex request needs attention"), "attentionSection")); + attentionLayout->addStretch(); + auto *deny = new QPushButton(QStringLiteral("Deny"), attention_); + auto *review = new QPushButton(QStringLiteral("Review"), attention_); + attentionLayout->addWidget(deny); + attentionLayout->addWidget(review); + connect(deny, &QPushButton::clicked, this, [this] { + if (actions_.deny) + actions_.deny(); + }); + connect(review, &QPushButton::clicked, this, [this] { + if (actions_.review) + actions_.review(); + }); + attention_->hide(); + root->addWidget(attention_); + + turnSettings_ = new TurnSettingsWidget(this); + root->addWidget(turnSettings_); + + composer_ = new QFrame(this); + composer_->setProperty("kind", "composer"); + auto *composerLayout = new QVBoxLayout(composer_); + composerLayout->setContentsMargins(10, 8, 8, 8); + composerLayout->setSpacing(6); + + attachmentPanel_ = new QFrame(composer_); + attachmentPanel_->setProperty("kind", "summary"); + auto *attachmentPanelLayout = new QVBoxLayout(attachmentPanel_); + attachmentPanelLayout->setContentsMargins(6, 6, 6, 6); + attachmentPanelLayout->setSpacing(4); + attachmentListScroll_ = new QScrollArea(attachmentPanel_); + attachmentListScroll_->setWidgetResizable(true); + attachmentListScroll_->setFrameShape(QFrame::NoFrame); + attachmentListScroll_->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + auto *attachmentContent = new QWidget; + attachmentListLayout_ = new QVBoxLayout(attachmentContent); + attachmentListLayout_->setContentsMargins(0, 0, 0, 0); + attachmentListLayout_->setSpacing(4); + attachmentListScroll_->setWidget(attachmentContent); + attachmentPanelLayout->addWidget(attachmentListScroll_); + attachmentPanel_->hide(); + composerLayout->addWidget(attachmentPanel_); + + composerBody_ = new QWidget(composer_); + composerBody_->installEventFilter(this); + composerGrid_ = new QGridLayout(composerBody_); + composerGrid_->setContentsMargins(0, 0, 0, 0); + composerGrid_->setHorizontalSpacing(8); + composerGrid_->setVerticalSpacing(6); + composerGrid_->setColumnStretch(1, 1); + + attachmentButton_ = new QToolButton(composerBody_); + attachmentButton_->setProperty("kind", "composerAction"); + attachmentButton_->setIcon( + QIcon::fromTheme(QIcon::ThemeIcon::MailAttachment)); + attachmentButton_->setIconSize(QSize(16, 16)); + attachmentButton_->setToolTip(QStringLiteral("Attach files")); + attachmentButton_->setAccessibleName(QStringLiteral("Attach files")); + attachmentButton_->setFixedSize(ControlHeight, ControlHeight); + + promptEditor_ = new codexui::ExpandingPromptEditor(composerBody_); + sendButton_ = new QPushButton(QStringLiteral("Send"), composerBody_); + sendButton_->setProperty("kind", "primary"); + sendButton_->setFixedSize(62, ControlHeight); + stopButton_ = new QPushButton(QStringLiteral("Stop"), composerBody_); + stopButton_->setProperty("kind", "stop"); + stopButton_->setFixedSize(54, ControlHeight); + stopButton_->hide(); + + composerGrid_->addWidget(attachmentButton_, 0, 0); + composerGrid_->addWidget(promptEditor_, 0, 1); + composerGrid_->addWidget(sendButton_, 0, 2); + composerLayout->addWidget(composerBody_); + root->addWidget(composer_); + + connect(sendButton_, &QPushButton::clicked, this, [this] { submitDraft(); }); + connect(promptEditor_, &codexui::ExpandingPromptEditor::submitRequested, this, + [this] { submitDraft(); }); + connect(promptEditor_, &QPlainTextEdit::textChanged, this, [this] { + refreshAdaptiveLayout(); + synchronizeGeometry(); + }); + connect(promptEditor_, &codexui::ExpandingPromptEditor::editorHeightChanged, + this, [this](int) { + refreshAdaptiveLayout(); + synchronizeGeometry(); + }); + connect(stopButton_, &QPushButton::clicked, this, [this] { + if (actions_.stop) + actions_.stop(); + }); + connect(attachmentButton_, &QToolButton::clicked, this, [this] { + if (actions_.attach) + actions_.attach(); + }); + + refreshAttachments(); + synchronizeGeometry(); + QTimer::singleShot(0, this, [this] { + // The compact reserve is measured only after the splitter has assigned + // the center pane its real width. Until then the overlay may be placed, + // but its construction-time size must not become canonical. + canonicalCaptureEnabled_ = true; + synchronizeGeometry(); + }); + raise(); +} + +void ComposerPane::setActions(Actions actions) { + actions_ = std::move(actions); +} + +void ComposerPane::setExtraOverlayHeightAction( + std::function action) { + extraOverlayHeightAction_ = std::move(action); + if (extraOverlayHeightAction_) + extraOverlayHeightAction_(extraHeight_); +} + +void ComposerPane::setAttachments(std::vector attachments) { + attachments_ = std::move(attachments); + refreshAttachments(); + synchronizeGeometry(); +} + +const std::vector &ComposerPane::attachments() const noexcept { + return attachments_; +} + +void ComposerPane::setAttentionVisible(bool visible) { + if (attention_->isVisible() == visible) + return; + attention_->setVisible(visible); + synchronizeGeometry(); +} + +void ComposerPane::setActiveTurn(bool active) { + if (activeTurn_ == active) + return; + activeTurn_ = active; + stopButton_->setVisible(active); + sendButton_->setText(active ? QStringLiteral("Steer") + : QStringLiteral("Send")); + refreshActionStyle(); + refreshAdaptiveLayout(); + synchronizeGeometry(); +} + +void ComposerPane::setCanSubmit(bool canSubmit) { + // Admission never locks or greys the editor; independent prompts may be + // entered while earlier submissions await their real app-server callback. + promptEditor_->setEnabled(true); + sendButton_->setEnabled(canSubmit); + attachmentButton_->setEnabled(canSubmit); + for (QPushButton *button : attachmentPanel_->findChildren()) + button->setEnabled(true); +} + +void ComposerPane::setSettingsEnabled(bool enabled) { + turnSettings_->setControlsEnabled(enabled); +} + +void ComposerPane::clearDraft() { + promptEditor_->clear(); + attachments_.clear(); + refreshAttachments(); + synchronizeGeometry(); +} + +void ComposerPane::synchronizeGeometry() { + if (synchronizing_ || !anchor_ || !layout()) + return; + synchronizing_ = true; + + const int width = std::max(0, anchor_->width() - 2 * HorizontalInset); + if (this->width() != width) + resize(width, std::max(0, height())); + layout()->activate(); + refreshAdaptiveLayout(); + layout()->activate(); + + const int availableHeight = std::max(0, anchor_->height() - BottomInset); + const int naturalHeight = sizeHint().height(); + const int wantedHeight = std::min(naturalHeight, availableHeight); + if (canonicalCaptureEnabled_ && canonicalHeight_ == 0 && naturalHeight > 0) { + // The reserve describes the compact surface, not the amount which happened + // to fit into an unlaid-out parent during construction. + canonicalHeight_ = naturalHeight; + reserve_->setFixedHeight(canonicalHeight_); + } + const int wantedExtra = canonicalCaptureEnabled_ && canonicalHeight_ > 0 + ? std::max(0, wantedHeight - canonicalHeight_) + : 0; + setGeometry(HorizontalInset, availableHeight - wantedHeight, width, + wantedHeight); + raise(); + + if (wantedExtra != extraHeight_) { + extraHeight_ = wantedExtra; + if (extraOverlayHeightAction_) + extraOverlayHeightAction_(extraHeight_); + } + synchronizing_ = false; +} + +bool ComposerPane::event(QEvent *event) { + const bool result = QWidget::event(event); + if ((event->type() == QEvent::LayoutRequest || + event->type() == QEvent::Show) && + !synchronizing_) + synchronizeGeometry(); + return result; +} + +bool ComposerPane::eventFilter(QObject *watched, QEvent *event) { + if (watched == anchor_ && + (event->type() == QEvent::Resize || event->type() == QEvent::Show || + event->type() == QEvent::LayoutRequest)) + synchronizeGeometry(); + if (watched == composerBody_ && + (event->type() == QEvent::Resize || event->type() == QEvent::Show || + event->type() == QEvent::LayoutRequest)) { + refreshAdaptiveLayout(); + synchronizeGeometry(); + } + return QWidget::eventFilter(watched, event); +} + +void ComposerPane::submitDraft() { + const QString prompt = promptEditor_->toPlainText().trimmed(); + if (prompt.isEmpty() || !sendButton_->isEnabled() || !actions_.submit) + return; + std::vector attachments = attachments_; + if (actions_.submit(prompt, std::move(attachments))) + clearDraft(); +} + +void ComposerPane::refreshAttachments() { + clearLayout(attachmentListLayout_); + const bool hasAttachments = !attachments_.empty(); + attachmentPanel_->setVisible(hasAttachments); + if (!hasAttachments) { + attachmentListScroll_->setFixedHeight(0); + return; + } + + for (std::size_t index = 0; index < attachments_.size(); ++index) { + const AttachmentDraft &attachment = attachments_[index]; + auto *row = new QWidget; + row->setFixedHeight(AttachmentRowHeight); + auto *rowLayout = new QHBoxLayout(row); + rowLayout->setContentsMargins(0, 2, 0, 2); + rowLayout->setSpacing(5); + + auto *remove = new QPushButton(QStringLiteral("X"), row); + remove->setAccessibleName(QStringLiteral("Remove %1").arg(attachment.name)); + remove->setToolTip(QStringLiteral("Remove attachment")); + remove->setFixedSize(18, 18); + remove->setStyleSheet( + QStringLiteral("QPushButton{background:#b83a3a;color:#ffffff;border:0;" + "border-radius:4px;padding:0;font-weight:700;}" + "QPushButton:hover{background:#9f2f2f;}" + "QPushButton:pressed{background:#842626;}")); + connect(remove, &QPushButton::clicked, this, [this, index] { + if (index >= attachments_.size()) + return; + attachments_.erase(attachments_.begin() + + static_cast(index)); + refreshAttachments(); + synchronizeGeometry(); + }); + + auto *fileBox = new QFrame(row); + fileBox->setObjectName(QStringLiteral("attachmentFileBox")); + fileBox->setStyleSheet( + QStringLiteral("QFrame#attachmentFileBox{background:#ffffff;" + "border:1px solid #d7dee8;border-radius:6px;}")); + auto *fileLayout = new QHBoxLayout(fileBox); + fileLayout->setContentsMargins(8, 1, 8, 1); + auto *name = makeLabel(attachment.name, "meta"); + name->setToolTip(QDir::toNativeSeparators(attachment.path)); + fileLayout->addWidget(name); + rowLayout->addWidget(fileBox, 1); + rowLayout->addWidget(remove, 0, Qt::AlignVCenter); + attachmentListLayout_->addWidget(row); + } + + const int visibleRows = std::min(static_cast(attachments_.size()), + MaximumVisibleAttachments); + attachmentListScroll_->setFixedHeight(visibleRows * AttachmentRowHeight + + (visibleRows - 1) * 4); +} + +void ComposerPane::refreshAdaptiveLayout() { + if (!composerBody_ || composerBody_->width() <= 0) + return; + const int visibleControls = activeTurn_ ? 3 : 2; + const int controlsWidth = attachmentButton_->width() + sendButton_->width() + + (activeTurn_ ? stopButton_->width() : 0); + const int compactEditorWidth = + composerBody_->contentsRect().width() - controlsWidth - + visibleControls * composerGrid_->horizontalSpacing(); + const bool shouldExpand = + promptEditor_->requiresExpandedLayout(compactEditorWidth); + if (shouldExpand == expanded_ && + composerGrid_->indexOf(stopButton_) == (activeTurn_ ? 3 : -1)) + return; + + expanded_ = shouldExpand; + composerGrid_->removeWidget(attachmentButton_); + composerGrid_->removeWidget(promptEditor_); + composerGrid_->removeWidget(sendButton_); + composerGrid_->removeWidget(stopButton_); + if (expanded_) { + composerGrid_->addWidget(promptEditor_, 0, 0, 1, 4); + composerGrid_->addWidget(attachmentButton_, 1, 0); + composerGrid_->addWidget(sendButton_, 1, 2); + if (activeTurn_) + composerGrid_->addWidget(stopButton_, 1, 3); + } else { + composerGrid_->addWidget(attachmentButton_, 0, 0); + composerGrid_->addWidget(promptEditor_, 0, 1); + composerGrid_->addWidget(sendButton_, 0, 2); + if (activeTurn_) + composerGrid_->addWidget(stopButton_, 0, 3); + } + composerGrid_->invalidate(); + composerGrid_->activate(); +} + +void ComposerPane::refreshActionStyle() { + const QString kind = + activeTurn_ ? QStringLiteral("steer") : QStringLiteral("primary"); + if (sendButton_->property("kind").toString() == kind) + return; + sendButton_->setProperty("kind", kind); + repolish(sendButton_); +} + +} // namespace codexui::codex::middle diff --git a/src/greenfield/codex/middle/ComposerPane.h b/src/greenfield/codex/middle/ComposerPane.h new file mode 100644 index 0000000..6f31952 --- /dev/null +++ b/src/greenfield/codex/middle/ComposerPane.h @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_GREENFIELD_CODEX_MIDDLE_COMPOSERPANE_H +#define CODEXUI_GREENFIELD_CODEX_MIDDLE_COMPOSERPANE_H + +#include "codex/FileSelectionDialog.h" + +#include + +#include +#include +#include + +class QEvent; +class QFrame; +class QGridLayout; +class QPushButton; +class QScrollArea; +class QToolButton; +class QVBoxLayout; + +namespace codexui { +class ExpandingPromptEditor; +} + +namespace codexui::codex { +class TurnSettingsWidget; + +namespace middle { + +// A bottom-aligned overlay. Only canonicalReserve() participates in the +// center layout; all height above that reserve is reported as trailing +// conversation space. +class ComposerPane final : public QWidget { +public: + struct Actions { + std::function)> submit; + std::function stop; + std::function attach; + std::function review; + std::function deny; + }; + + explicit ComposerPane(QWidget *anchor); + + void setActions(Actions actions); + void setExtraOverlayHeightAction(std::function action); + void setAttachments(std::vector attachments); + [[nodiscard]] const std::vector & + attachments() const noexcept; + void setAttentionVisible(bool visible); + void setActiveTurn(bool active); + void setCanSubmit(bool canSubmit); + void setSettingsEnabled(bool enabled); + void clearDraft(); + void synchronizeGeometry(); + + [[nodiscard]] QWidget *canonicalReserve() const noexcept { return reserve_; } + [[nodiscard]] int canonicalReserveHeight() const noexcept { + return canonicalHeight_; + } + [[nodiscard]] int extraOverlayHeight() const noexcept { return extraHeight_; } + [[nodiscard]] codexui::ExpandingPromptEditor *promptEditor() const noexcept { + return promptEditor_; + } + [[nodiscard]] TurnSettingsWidget *turnSettings() const noexcept { + return turnSettings_; + } + +protected: + bool event(QEvent *event) override; + bool eventFilter(QObject *watched, QEvent *event) override; + +private: + void submitDraft(); + void refreshAttachments(); + void refreshAdaptiveLayout(); + void refreshActionStyle(); + + QWidget *anchor_ = nullptr; + QWidget *reserve_ = nullptr; + QFrame *attention_ = nullptr; + TurnSettingsWidget *turnSettings_ = nullptr; + QFrame *composer_ = nullptr; + QFrame *attachmentPanel_ = nullptr; + QScrollArea *attachmentListScroll_ = nullptr; + QVBoxLayout *attachmentListLayout_ = nullptr; + QWidget *composerBody_ = nullptr; + QGridLayout *composerGrid_ = nullptr; + QToolButton *attachmentButton_ = nullptr; + codexui::ExpandingPromptEditor *promptEditor_ = nullptr; + QPushButton *sendButton_ = nullptr; + QPushButton *stopButton_ = nullptr; + + Actions actions_; + std::function extraOverlayHeightAction_; + std::vector attachments_; + int canonicalHeight_ = 0; + int extraHeight_ = 0; + bool activeTurn_ = false; + bool expanded_ = false; + bool synchronizing_ = false; + bool canonicalCaptureEnabled_ = false; +}; + +} // namespace middle +} // namespace codexui::codex + +#endif // CODEXUI_GREENFIELD_CODEX_MIDDLE_COMPOSERPANE_H diff --git a/src/greenfield/codex/middle/ConversationCards.cpp b/src/greenfield/codex/middle/ConversationCards.cpp new file mode 100644 index 0000000..37351e8 --- /dev/null +++ b/src/greenfield/codex/middle/ConversationCards.cpp @@ -0,0 +1,676 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/middle/ConversationCards.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace codexui::codex::middle { +namespace { + +constexpr int MaximumCommandOutputHeight = 220; +constexpr int PendingAnimationIntervalMilliseconds = 32; +constexpr qint64 PendingHalfCycleMilliseconds = 850; + +QLabel *makeLabel(const QString &value, const char *kind = "body", + QWidget *parent = nullptr) { + auto *label = new QLabel(value, parent); + label->setProperty("kind", kind); + label->setTextFormat(Qt::PlainText); + label->setWordWrap(true); + label->setMinimumWidth(0); + label->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + label->setTextInteractionFlags(Qt::TextSelectableByMouse); + return label; +} + +QString markdownHtml(const QString &markdown) { + QTextDocument document; + document.setMarkdown(markdown, QTextDocument::MarkdownNoHTML); + return document.toHtml(); +} + +QLabel *makeMarkdownLabel(const QString &value, QWidget *parent = nullptr) { + auto *label = new QLabel(parent); + label->setProperty("kind", "body"); + label->setTextFormat(Qt::RichText); + label->setWordWrap(true); + label->setMinimumWidth(0); + label->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + label->setOpenExternalLinks(true); + label->setTextInteractionFlags(Qt::TextSelectableByMouse | + Qt::LinksAccessibleByMouse); + label->setProperty("markdownSource", value); + label->setText(markdownHtml(value)); + return label; +} + +bool setVisibleText(QLabel *label, const QString &text) { + const bool visible = !text.isEmpty(); + const bool explicitlyVisible = !label->isHidden(); + const bool changed = label->text() != text || explicitlyVisible != visible; + if (label->text() != text) + label->setText(text); + if (explicitlyVisible != visible) + label->setVisible(visible); + return changed; +} + +bool setVisibleMarkdown(QLabel *label, const QString &markdown) { + const bool visible = !markdown.isEmpty(); + const bool contentChanged = + label->property("markdownSource").toString() != markdown; + const bool explicitlyVisible = !label->isHidden(); + const bool changed = contentChanged || explicitlyVisible != visible; + if (contentChanged) { + label->setProperty("markdownSource", markdown); + label->setText(markdownHtml(markdown)); + } + if (explicitlyVisible != visible) + label->setVisible(visible); + return changed; +} + +QString displayStatus(const QString &status) { + if (status == QStringLiteral("inProgress") || + status == QStringLiteral("active")) + return QStringLiteral("Running"); + if (status == QStringLiteral("completed") || status == QStringLiteral("idle")) + return QStringLiteral("Completed"); + if (status == QStringLiteral("failed") || + status == QStringLiteral("systemError")) + return QStringLiteral("Failed"); + if (status.isEmpty()) + return QStringLiteral("Unknown"); + return status; +} + +QString commandMetadata(const CommandExecutionData &command) { + QStringList metadata{displayStatus(command.status)}; + if (command.exitCode) + metadata << QStringLiteral("exit %1").arg(*command.exitCode); + if (!command.cwd.isEmpty()) + metadata << command.cwd; + return metadata.join(QStringLiteral(" | ")); +} + +QString agentMetadata(const AgentActivityData &activity) { + QStringList metadata; + if (!activity.tool.isEmpty()) + metadata << activity.tool; + metadata << displayStatus(activity.status.isEmpty() ? activity.kind + : activity.status); + if (!activity.receivers.isEmpty()) + metadata << activity.receivers.join(QStringLiteral(", ")); + return metadata.join(QStringLiteral(" | ")); +} + +bool acceptedTransitionActive(const LocalPromptData &prompt, qint64 now) { + return prompt.acceptedTransitionActive(now); +} + +bool presentationEquals(const VisibleCardData &left, + const VisibleCardData &right) { + if (left.kind != right.kind || left.payload.index() != right.payload.index()) + return false; + switch (left.kind) { + case CardKind::UserMessage: + return std::get(left.payload) == + std::get(right.payload); + case CardKind::AgentMessage: + return std::get(left.payload) == + std::get(right.payload); + case CardKind::CommandExecution: + return std::get(left.payload) == + std::get(right.payload); + case CardKind::AgentActivity: + return std::get(left.payload) == + std::get(right.payload); + case CardKind::Reasoning: + return std::get(left.payload) == + std::get(right.payload); + case CardKind::FileChanges: { + const auto &first = std::get(left.payload); + const auto &second = std::get(right.payload); + // The card presents the aggregate status and path count. The detailed + // change JSON belongs to the Changes inspector and is deliberately not a + // conversation-card invalidation source. + return first.status == second.status && first.pathCount == second.pathCount; + } + case CardKind::Plan: + return std::get(left.payload) == + std::get(right.payload); + case CardKind::GenericActivity: + return std::get(left.payload) == + std::get(right.payload); + case CardKind::LocalPrompt: { + const auto &first = std::get(left.payload); + const auto &second = std::get(right.payload); + return first.prompt == second.prompt && + first.attachmentCount == second.attachmentCount && + first.state == second.state && + first.acceptedAtMilliseconds == second.acceptedAtMilliseconds && + first.error == second.error; + } + } + return false; +} + +} // namespace + +CommandOutputView::CommandOutputView(const QString &output, QWidget *parent) + : QPlainTextEdit(parent) { + setReadOnly(true); + setMinimumHeight(0); + setMaximumHeight(MaximumCommandOutputHeight); + setLineWrapMode(QPlainTextEdit::WidgetWidth); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + setProperty("kind", "code"); + setObjectName(QStringLiteral("commandOutputView")); + setStyleSheet(QStringLiteral( + "background:#111827;color:#e5e7eb;border-radius:6px;padding:7px;" + "font-family:monospace;")); + + connect(verticalScrollBar(), &QScrollBar::valueChanged, this, + [this](int value) { + if (programmaticScroll_) + return; + preservedScrollValue_ = value; + followsLatest_ = isAtBottom(); + }); + connect(verticalScrollBar(), &QScrollBar::rangeChanged, this, + [this](int, int) { settleScroll(); }); + + setOutput(output); + measureAtCurrentWidth(false); + settleScroll(); +} + +CommandOutputView::ScrollState CommandOutputView::scrollState() const { + return {followsLatest_, preservedScrollValue_}; +} + +bool CommandOutputView::followsLatest() const noexcept { + return followsLatest_; +} + +bool CommandOutputView::setOutput(const QString &output) { + if (toPlainText() == output) + return false; + + const bool retainedFollow = followsLatest_; + const int retainedValue = preservedScrollValue_; + programmaticScroll_ = true; + setPlainText(output); + followsLatest_ = retainedFollow; + preservedScrollValue_ = retainedValue; + programmaticScroll_ = false; + // Asking the document layout for its size here completes wrapping at the + // already assigned viewport width. The enclosing conversation can then + // account for the final card height in the same reconciliation transaction. + measureAtCurrentWidth(true); + settleScroll(); + return true; +} + +void CommandOutputView::restoreScrollState(const ScrollState &state) { + followsLatest_ = state.followsLatest; + preservedScrollValue_ = std::max(0, state.value); + settleScroll(); +} + +QSize CommandOutputView::sizeHint() const { + QSize result = QPlainTextEdit::sizeHint(); + result.setHeight(preferredHeight_); + return result; +} + +QSize CommandOutputView::minimumSizeHint() const { + QSize result = QPlainTextEdit::minimumSizeHint(); + result.setHeight(0); + return result; +} + +void CommandOutputView::resizeEvent(QResizeEvent *event) { + QPlainTextEdit::resizeEvent(event); + // A parent layout is already assigning this width. Refresh the preferred + // height synchronously without scheduling a second outer layout pass. + measureAtCurrentWidth(false); + settleScroll(); +} + +void CommandOutputView::wheelEvent(QWheelEvent *event) { + QScrollBar *bar = verticalScrollBar(); + const int delta = !event->pixelDelta().isNull() ? event->pixelDelta().y() + : event->angleDelta().y(); + if (bar->maximum() <= bar->minimum() || + (delta > 0 && bar->value() <= bar->minimum()) || + (delta < 0 && bar->value() >= bar->maximum())) { + event->ignore(); + return; + } + + if (delta > 0) + followsLatest_ = false; + QPlainTextEdit::wheelEvent(event); + preservedScrollValue_ = bar->value(); + followsLatest_ = isAtBottom(); +} + +void CommandOutputView::measureAtCurrentWidth(bool notifyParent) { + const int contentHeight = static_cast( + std::ceil(document()->documentLayout()->documentSize().height())); + const int wantedHeight = std::clamp(contentHeight + 2 * frameWidth() + 14, 0, + MaximumCommandOutputHeight); + if (wantedHeight != preferredHeight_) { + preferredHeight_ = wantedHeight; + if (notifyParent) + updateGeometry(); + } +} + +void CommandOutputView::settleScroll() { + if (settlingScroll_) + return; + settlingScroll_ = true; + QScrollBar *bar = verticalScrollBar(); + const int target = + followsLatest_ + ? bar->maximum() + : std::clamp(preservedScrollValue_, bar->minimum(), bar->maximum()); + const bool wasProgrammatic = programmaticScroll_; + programmaticScroll_ = true; + bar->setValue(target); + preservedScrollValue_ = target; + programmaticScroll_ = wasProgrammatic; + settlingScroll_ = false; +} + +bool CommandOutputView::isAtBottom() const { + return verticalScrollBar()->value() >= verticalScrollBar()->maximum() - 1; +} + +class ConversationCard::Impl final { +public: + Impl(ConversationCard *owner, const VisibleCardData &initial) + : owner(owner), current(initial) { + owner->setObjectName(QStringLiteral("conversationCard")); + owner->setProperty("conversationCardKey", + QString::fromStdString(stableKey(initial.key))); + owner->setProperty("conversationCardKind", static_cast(initial.kind)); + + layout = new QVBoxLayout(owner); + layout->setContentsMargins(12, 10, 12, 10); + layout->setSpacing(6); + createChildren(initial.kind); + applyPayload(initial); + } + + bool apply(const VisibleCardData &next) { + if (current.key != next.key || current.kind != next.kind) { + Q_ASSERT_X(false, "ConversationCard::apply", + "a persistent conversation card cannot change key or kind"); + return false; + } + const bool presentationChanged = !presentationEquals(current, next); + current = next; + if (!presentationChanged) + return false; + applyPayload(next); + owner->updateGeometry(); + owner->update(); + return true; + } + + void createChildren(CardKind kind) { + owner->setProperty("kind", "raised"); + switch (kind) { + case CardKind::UserMessage: + owner->setProperty("messageRole", "user"); + title = makeLabel(QStringLiteral("You"), "title", owner); + body = makeLabel({}, "body", owner); + layout->addWidget(title); + layout->addWidget(body); + break; + case CardKind::AgentMessage: + owner->setProperty("messageRole", "agent"); + title = makeLabel({}, "title", owner); + body = makeMarkdownLabel({}, owner); + layout->addWidget(title); + layout->addWidget(body); + break; + case CardKind::CommandExecution: + title = makeLabel(QStringLiteral("Command execution"), "title", owner); + command = new QPlainTextEdit(owner); + command->setReadOnly(true); + command->setMaximumHeight(90); + command->setLineWrapMode(QPlainTextEdit::WidgetWidth); + command->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + command->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + command->setProperty("kind", "command"); + command->setObjectName(QStringLiteral("commandTextView")); + command->setStyleSheet(QStringLiteral( + "background:#f8fafc;border:1px solid #d7dee8;border-radius:6px;" + "padding:7px;font-family:monospace;")); + output = new CommandOutputView({}, owner); + output->hide(); + metadata = makeLabel({}, "meta", owner); + metadata->setObjectName(QStringLiteral("commandMetadata")); + layout->addWidget(title); + layout->addWidget(command); + layout->addWidget(output); + layout->addWidget(metadata); + break; + case CardKind::AgentActivity: + title = makeLabel(QStringLiteral("Agent activity"), "title", owner); + metadata = makeLabel({}, "meta", owner); + body = makeLabel({}, "body", owner); + detail = makeMarkdownLabel({}, owner); + layout->addWidget(title); + layout->addWidget(metadata); + layout->addWidget(body); + layout->addWidget(detail); + break; + case CardKind::Reasoning: + title = makeLabel(QStringLiteral("Reasoning"), "title", owner); + body = makeMarkdownLabel({}, owner); + layout->addWidget(title); + layout->addWidget(body); + break; + case CardKind::FileChanges: + title = makeLabel(QStringLiteral("File changes"), "title", owner); + metadata = makeLabel({}, "meta", owner); + layout->addWidget(title); + layout->addWidget(metadata); + break; + case CardKind::Plan: + title = makeLabel(QStringLiteral("plan"), "title", owner); + body = makeMarkdownLabel({}, owner); + layout->addWidget(title); + layout->addWidget(body); + break; + case CardKind::GenericActivity: + title = makeLabel({}, "title", owner); + metadata = makeLabel({}, "meta", owner); + layout->addWidget(title); + layout->addWidget(metadata); + break; + case CardKind::LocalPrompt: + owner->setObjectName(QStringLiteral("pendingPromptCard")); + owner->setStyleSheet(QStringLiteral( + "QFrame#pendingPromptCard{background:transparent;border:0;}")); + title = makeLabel(QStringLiteral("You"), "title", owner); + body = makeLabel({}, "body", owner); + metadata = makeLabel({}, "meta", owner); + layout->addWidget(title); + layout->addWidget(body); + layout->addWidget(metadata); + animationTimer = new QTimer(owner); + animationTimer->setInterval(PendingAnimationIntervalMilliseconds); + QObject::connect(animationTimer, &QTimer::timeout, owner, [this] { + if (refreshPendingPresentation()) + owner->updateGeometry(); + owner->update(); + }); + break; + } + } + + void applyPayload(const VisibleCardData &data) { + switch (data.kind) { + case CardKind::UserMessage: { + const auto &message = std::get(data.payload); + setVisibleText(body, message.text); + break; + } + case CardKind::AgentMessage: { + const auto &message = std::get(data.payload); + title->setText(message.finalAnswer ? QStringLiteral("Codex") + : QStringLiteral("Codex activity")); + setVisibleMarkdown(body, message.text); + break; + } + case CardKind::CommandExecution: { + const auto &execution = std::get(data.payload); + if (command->toPlainText() != execution.command) + command->setPlainText(execution.command); + command->setVisible(!execution.command.isEmpty()); + const bool visibleOutput = terminalOutputHasVisibleText(execution.output); + if (visibleOutput) { + output->setOutput(execution.output); + output->show(); + } else { + output->hide(); + output->setOutput({}); + // Once the surface ceases to exist there is no user-owned paused + // position to retain. If visible output appears later it starts in + // the documented follow-latest state. + output->restoreScrollState({true, 0}); + } + metadata->setText(commandMetadata(execution)); + metadata->show(); + break; + } + case CardKind::AgentActivity: { + const auto &activity = std::get(data.payload); + metadata->setText(agentMetadata(activity)); + metadata->show(); + setVisibleText(body, activity.prompt); + setVisibleMarkdown(detail, activity.resultText); + break; + } + case CardKind::Reasoning: { + const auto &reasoning = std::get(data.payload); + setVisibleMarkdown(body, reasoning.summary); + break; + } + case CardKind::FileChanges: { + const auto &changes = std::get(data.payload); + QStringList values{displayStatus(changes.status)}; + values << QStringLiteral("%1 paths").arg(changes.pathCount); + metadata->setText(values.join(QStringLiteral(" | "))); + metadata->show(); + break; + } + case CardKind::Plan: { + const auto &plan = std::get(data.payload); + setVisibleMarkdown(body, plan.text); + break; + } + case CardKind::GenericActivity: { + const auto &activity = std::get(data.payload); + title->setText(activity.type.isEmpty() ? QStringLiteral("Activity") + : activity.type); + metadata->setText(QString::fromStdString(activity.raw.dump(2))); + metadata->show(); + break; + } + case CardKind::LocalPrompt: { + const auto &prompt = std::get(data.payload); + body->setText(prompt.prompt); + body->show(); + refreshPendingPresentation(); + break; + } + } + } + + bool refreshPendingPresentation() { + const auto *prompt = std::get_if(¤t.payload); + if (!prompt) + return false; + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + const bool transitioning = acceptedTransitionActive(*prompt, now); + const bool waiting = prompt->state == PromptState::Queued || + prompt->state == PromptState::InFlight; + const bool failed = prompt->state == PromptState::Failed; + const QString foreground = waiting || transitioning + ? QStringLiteral("#536b8f") + : failed ? QStringLiteral("#9b2c2c") + : QStringLiteral("#1d2633"); + const QString style = + QStringLiteral("background:transparent;color:%1;").arg(foreground); + bool changed = false; + for (QLabel *label : {title, body, metadata}) { + if (label->styleSheet() != style) { + label->setStyleSheet(style); + changed = true; + } + } + + QString status; + if (prompt->state == PromptState::Queued || + prompt->state == PromptState::InFlight) + status = QStringLiteral("Waiting for app-server acknowledgment"); + else if (transitioning) + status = QStringLiteral("Accepted by app-server"); + else if (failed) + status = prompt->error.isEmpty() + ? QStringLiteral("Not sent") + : QStringLiteral("Not sent: %1").arg(prompt->error); + + if (prompt->attachmentCount > 0) { + const QString attachments = + QStringLiteral("%1 attachment%2") + .arg(prompt->attachmentCount) + .arg(prompt->attachmentCount == 1 ? QString{} + : QStringLiteral("s")); + status = status.isEmpty() + ? attachments + : status + QStringLiteral(" | ") + attachments; + } + changed = setVisibleText(metadata, status) || changed; + + if (waiting || transitioning) { + if (!animationTimer->isActive()) + animationTimer->start(); + } else { + animationTimer->stop(); + } + return changed; + } + + ConversationCard *owner = nullptr; + VisibleCardData current; + QVBoxLayout *layout = nullptr; + QLabel *title = nullptr; + QLabel *body = nullptr; + QLabel *metadata = nullptr; + QLabel *detail = nullptr; + QPlainTextEdit *command = nullptr; + CommandOutputView *output = nullptr; + QTimer *animationTimer = nullptr; +}; + +ConversationCard::ConversationCard(const VisibleCardData &data, QWidget *parent) + : QFrame(parent), impl_(std::make_unique(this, data)) {} + +ConversationCard::~ConversationCard() = default; + +CardKind ConversationCard::cardKind() const noexcept { + return impl_->current.kind; +} + +const VisibleCardData &ConversationCard::data() const noexcept { + return impl_->current; +} + +std::optional +ConversationCard::commandOutputScrollState() const { + if (!impl_->output) + return std::nullopt; + return impl_->output->scrollState(); +} + +void ConversationCard::restoreCommandOutputScrollState( + const CommandOutputView::ScrollState &state) { + if (impl_->output) + impl_->output->restoreScrollState(state); +} + +bool ConversationCard::apply(const VisibleCardData &data) { + return impl_->apply(data); +} + +void ConversationCard::paintEvent(QPaintEvent *event) { + QFrame::paintEvent(event); + if (impl_->current.kind != CardKind::LocalPrompt) + return; + const auto *prompt = std::get_if(&impl_->current.payload); + if (!prompt) + return; + + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + const QRectF bounds = QRectF(rect()).adjusted(1.5, 1.5, -1.5, -1.5); + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + const bool waiting = prompt->state == PromptState::Queued || + prompt->state == PromptState::InFlight; + const bool transitioning = acceptedTransitionActive(*prompt, now); + const bool failed = prompt->state == PromptState::Failed; + const QColor background = waiting || transitioning + ? QColor(QStringLiteral("#dbe7f8")) + : failed ? QColor(QStringLiteral("#fff1f1")) + : QColor(QStringLiteral("#eaf2ff")); + const QColor border = waiting || transitioning + ? QColor(QStringLiteral("#9eb9df")) + : failed ? QColor(QStringLiteral("#e5a3a3")) + : QColor(QStringLiteral("#bfd3f9")); + painter.setBrush(background); + painter.setPen(QPen(border, 1.0)); + painter.drawRoundedRect(bounds, 8.0, 8.0); + + if (!waiting && !transitioning) + return; + + const qint64 phase = now % (2 * PendingHalfCycleMilliseconds); + const qreal position = + waiting ? phase <= PendingHalfCycleMilliseconds + ? qreal(phase) / PendingHalfCycleMilliseconds + : qreal(2 * PendingHalfCycleMilliseconds - phase) / + PendingHalfCycleMilliseconds + : std::clamp(qreal(now - prompt->acceptedAtMilliseconds) / + AcknowledgementTransitionMilliseconds, + 0.0, 1.0); + const qreal center = bounds.left() + position * bounds.width(); + const qreal radius = std::max(28.0, bounds.width() * 0.24); + QLinearGradient sweep(center - radius, 0.0, center + radius, 0.0); + sweep.setColorAt(0.0, QColor(47, 111, 235, 0)); + sweep.setColorAt(0.5, QColor(117, 160, 239, 105)); + sweep.setColorAt(1.0, QColor(47, 111, 235, 0)); + QPainterPath clip; + clip.addRoundedRect(bounds, 8.0, 8.0); + painter.save(); + painter.setClipPath(clip); + painter.fillRect(bounds, sweep); + painter.restore(); + + painter.setBrush(Qt::NoBrush); + painter.setPen(QPen(QColor(QStringLiteral("#79a0d7")), 1.5)); + painter.drawRoundedRect(bounds, 8.0, 8.0); +} + +ConversationCard *createConversationCard(const VisibleCardData &data, + QWidget *parent) { + return new ConversationCard(data, parent); +} + +} // namespace codexui::codex::middle diff --git a/src/greenfield/codex/middle/ConversationCards.h b/src/greenfield/codex/middle/ConversationCards.h new file mode 100644 index 0000000..769b648 --- /dev/null +++ b/src/greenfield/codex/middle/ConversationCards.h @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_GREENFIELD_CODEX_MIDDLE_CONVERSATIONCARDS_H +#define CODEXUI_GREENFIELD_CODEX_MIDDLE_CONVERSATIONCARDS_H + +#include "codex/middle/MiddleTypes.h" + +#include +#include + +#include +#include + +class QLabel; +class QPaintEvent; +class QResizeEvent; +class QTimer; +class QVBoxLayout; +class QWheelEvent; + +namespace codexui::codex::middle { + +class CommandOutputView final : public QPlainTextEdit { +public: + struct ScrollState { + bool followsLatest = true; + int value = 0; + + friend bool operator==(const ScrollState &, const ScrollState &) = default; + }; + + explicit CommandOutputView(const QString &output, QWidget *parent = nullptr); + + [[nodiscard]] ScrollState scrollState() const; + [[nodiscard]] bool followsLatest() const noexcept; + + // Returns false for a true no-op. Programmatic document/range changes do + // not alter the user's follow/paused choice. + bool setOutput(const QString &output); + void restoreScrollState(const ScrollState &state); + + QSize sizeHint() const override; + QSize minimumSizeHint() const override; + +protected: + void resizeEvent(QResizeEvent *event) override; + void wheelEvent(QWheelEvent *event) override; + +private: + void measureAtCurrentWidth(bool notifyParent); + void settleScroll(); + [[nodiscard]] bool isAtBottom() const; + + bool followsLatest_ = true; + bool programmaticScroll_ = false; + bool settlingScroll_ = false; + int preservedScrollValue_ = 0; + int preferredHeight_ = 0; +}; + +class ConversationCard : public QFrame { +public: + explicit ConversationCard(const VisibleCardData &data, + QWidget *parent = nullptr); + ~ConversationCard() override; + + [[nodiscard]] CardKind cardKind() const noexcept; + [[nodiscard]] const VisibleCardData &data() const noexcept; + [[nodiscard]] std::optional + commandOutputScrollState() const; + void + restoreCommandOutputScrollState(const CommandOutputView::ScrollState &state); + + // A key and kind identify the persistent widget. apply() updates all card + // kinds in place and returns false when neither content nor presentation + // changed. Passing a different key or kind is a programming error. + bool apply(const VisibleCardData &data); + +protected: + void paintEvent(QPaintEvent *event) override; + +private: + class Impl; + std::unique_ptr impl_; +}; + +[[nodiscard]] ConversationCard * +createConversationCard(const VisibleCardData &data, QWidget *parent = nullptr); + +} // namespace codexui::codex::middle + +#endif // CODEXUI_GREENFIELD_CODEX_MIDDLE_CONVERSATIONCARDS_H diff --git a/src/greenfield/codex/middle/ConversationProjection.cpp b/src/greenfield/codex/middle/ConversationProjection.cpp new file mode 100644 index 0000000..d5757cb --- /dev/null +++ b/src/greenfield/codex/middle/ConversationProjection.cpp @@ -0,0 +1,310 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/middle/ConversationProjection.h" + +#include +#include +#include +#include +#include +#include + +namespace codexui::codex::middle { +namespace { + +QString text(const std::string &value) { + return QString::fromUtf8(value.data(), static_cast(value.size())); +} + +std::string stringValue(const nlohmann::json &object, const char *key) { + if (!object.is_object()) + return {}; + const auto value = object.find(key); + return value != object.end() && value->is_string() ? value->get() + : std::string{}; +} + +QString messageText(const nlohmann::json &item) { + const std::string type = stringValue(item, "type"); + if (type == "agentMessage" || type == "plan") + return text(stringValue(item, "text")); + if (type != "userMessage") + return {}; + + QStringList parts; + const auto content = item.find("content"); + if (content != item.end() && content->is_array()) { + for (const nlohmann::json &entry : *content) { + const std::string value = stringValue(entry, "text"); + if (!value.empty()) + parts.push_back(text(value)); + } + } + if (parts.empty()) { + const std::string fallback = stringValue(item, "text"); + if (!fallback.empty()) + parts.push_back(text(fallback)); + } + return parts.join(QStringLiteral("\n")); +} + +QString joinedStrings(const nlohmann::json &value) { + if (!value.is_array()) + return {}; + QStringList result; + for (const nlohmann::json &entry : value) + if (entry.is_string()) + result.push_back(text(entry.get())); + return result.join(QStringLiteral(", ")); +} + +QStringList stringList(const nlohmann::json &value) { + QStringList result; + if (!value.is_array()) + return result; + for (const nlohmann::json &entry : value) + if (entry.is_string()) + result.push_back(text(entry.get())); + return result; +} + +std::string sectionComponent(std::string_view prefix, std::string_view threadId, + std::string_view suffix) { + std::string result(prefix); + result += std::to_string(threadId.size()); + result.push_back(':'); + result.append(threadId); + result += std::to_string(suffix.size()); + result.push_back(':'); + result.append(suffix); + return result; +} + +VisibleCardData authoritativeCard(const AuthoritativeItemKey &identity, + const ItemPresentation &presentation, + CardKey visualKey) { + const nlohmann::json &item = presentation.raw; + const std::string type = stringValue(item, "type"); + VisibleCardData result{ + std::move(visualKey), CardKind::GenericActivity, + identity.threadId, identity.turnId, + identity.itemId, GenericActivityData{text(type), item}}; + + if (type == "userMessage") { + result.kind = CardKind::UserMessage; + result.payload = UserMessageData{messageText(item)}; + } else if (type == "agentMessage") { + result.kind = CardKind::AgentMessage; + result.payload = AgentMessageData{ + messageText(item), stringValue(item, "phase") == "final_answer"}; + } else if (type == "commandExecution") { + result.kind = CardKind::CommandExecution; + QString output = text(stringValue(item, "aggregatedOutput")); + if (output.isEmpty()) + output = text(stringValue(item, "output")); + if (!terminalOutputHasVisibleText(output)) + output.clear(); + std::optional exitCode; + const auto rawExitCode = item.find("exitCode"); + if (rawExitCode != item.end() && rawExitCode->is_number_integer()) + exitCode = rawExitCode->get(); + result.payload = + CommandExecutionData{text(stringValue(item, "command")), output, + text(stringValue(item, "status")), + text(stringValue(item, "cwd")), exitCode}; + } else if (type == "collabAgentToolCall" || type == "subAgentActivity") { + result.kind = CardKind::AgentActivity; + result.payload = AgentActivityData{ + text(stringValue(item, "tool")), + text(stringValue(item, "status")), + text(stringValue(item, "kind")), + text(stringValue(item, "prompt")), + text(stringValue(item, "resultText")), + stringList(item.value("receiverThreadIds", nlohmann::json::array()))}; + } else if (type == "reasoning") { + result.kind = CardKind::Reasoning; + result.payload = ReasoningData{ + joinedStrings(item.value("summary", nlohmann::json::array()))}; + } else if (type == "fileChange") { + result.kind = CardKind::FileChanges; + const nlohmann::json changes = + item.value("changes", nlohmann::json::array()); + result.payload = FileChangesData{ + text(stringValue(item, "status")), + changes.is_array() ? static_cast(changes.size()) : 0, changes}; + } else if (type == "plan") { + const QString plan = messageText(item); + if (!plan.isEmpty()) { + result.kind = CardKind::Plan; + result.payload = PlanData{plan}; + } + } + return result; +} + +struct OrderedItem { + AuthoritativeItemKey key; + const ItemPresentation *presentation = nullptr; +}; + +std::vector orderedItems(const std::string &threadId, + const ThreadPresentation *thread) { + std::vector result; + if (!thread) + return result; + for (const std::string &turnId : thread->turnOrder) { + const auto turn = thread->turns.find(turnId); + if (turn == thread->turns.end()) + continue; + for (const std::string &itemId : turn->second.itemOrder) { + const auto item = turn->second.items.find(itemId); + if (item == turn->second.items.end()) + continue; + result.push_back( + {AuthoritativeItemKey{threadId, turnId, itemId}, &item->second}); + } + } + return result; +} + +struct ProjectedNode { + std::size_t position = 0; + std::uint64_t tieBreaker = 0; + std::string sectionKey; + std::string turnId; + VisibleCardData card; +}; + +std::size_t submissionPosition( + const PromptSubmission &submission, + const std::vector &authoritativeItems, + std::optional materializedIndex = std::nullopt) { + if (submission.admissionAnchor) { + const auto anchor = std::ranges::find( + authoritativeItems, *submission.admissionAnchor, &OrderedItem::key); + if (anchor != authoritativeItems.end()) + return static_cast( + std::distance(authoritativeItems.begin(), anchor) + 1) * + 2; + } + if (materializedIndex) + return *materializedIndex * 2 + 1; + // No authoritative tail was known at admission. Until reconcile establishes + // one, the prompt is a tail item rather than a synthetic history prefix. + return authoritativeItems.size() * 2 + 2; +} + +} // namespace + +ConversationSnapshot ConversationProjection::project( + const std::string &threadId, const ThreadPresentation *authoritativeThread, + std::span localSubmissions, + std::size_t authoritativeItemLimit, qint64 nowMilliseconds) { + ConversationSnapshot result; + result.threadId = threadId; + + const std::vector authoritativeItems = + orderedItems(threadId, authoritativeThread); + result.hiddenAuthoritativeItemCount = + authoritativeItems.size() > authoritativeItemLimit + ? authoritativeItems.size() - authoritativeItemLimit + : 0; + result.hasMore = result.hiddenAuthoritativeItemCount > 0; + const std::size_t firstVisible = result.hiddenAuthoritativeItemCount; + + std::map bindings; + for (const PromptSubmission &submission : localSubmissions) + if (submission.materializedItem) + bindings.emplace(*submission.materializedItem, &submission); + + std::vector nodes; + nodes.reserve(authoritativeItems.size() - firstVisible + + localSubmissions.size()); + for (std::size_t index = firstVisible; index < authoritativeItems.size(); + ++index) { + const OrderedItem &item = authoritativeItems[index]; + const auto binding = bindings.find(item.key); + if (binding != bindings.end() && + binding->second->localCardVisible(nowMilliseconds)) + continue; + CardKey visualKey = item.key; + if (binding != bindings.end()) + visualKey = LocalPromptKey{binding->second->id}; + const std::size_t position = + binding == bindings.end() + ? index * 2 + 1 + : submissionPosition(*binding->second, authoritativeItems, index); + const std::uint64_t tieBreaker = + binding == bindings.end() ? 0 : binding->second->admissionOrdinal; + nodes.push_back({position, tieBreaker, + sectionComponent("turn:", threadId, item.key.turnId), + item.key.turnId, + authoritativeCard(item.key, *item.presentation, + std::move(visualKey))}); + } + + for (const PromptSubmission &submission : localSubmissions) { + if (!submission.localCardVisible(nowMilliseconds)) + continue; + std::optional materializedIndex; + if (submission.materializedItem) { + const auto materialized = std::ranges::find( + authoritativeItems, *submission.materializedItem, &OrderedItem::key); + if (materialized != authoritativeItems.end()) + materializedIndex = static_cast( + std::distance(authoritativeItems.begin(), materialized)); + } + const std::size_t position = + submissionPosition(submission, authoritativeItems, materializedIndex); + + bool knownTurn = false; + if (authoritativeThread && submission.expectedTurnId) { + const auto turn = + authoritativeThread->turns.find(*submission.expectedTurnId); + knownTurn = turn != authoritativeThread->turns.end(); + } + const std::string turnId = + submission.expectedTurnId.value_or(std::string{}); + const std::string sectionKey = + knownTurn ? sectionComponent("turn:", threadId, turnId) + : "pending:" + std::to_string(submission.id); + VisibleCardData card{ + LocalPromptKey{submission.id}, + CardKind::LocalPrompt, + threadId, + turnId, + {}, + LocalPromptData{submission.id, submission.prompt, + static_cast(submission.attachments.size()), + submission.state == PromptState::Queued + ? PromptState::InFlight + : submission.state, + submission.acceptedAtMilliseconds, submission.error}}; + nodes.push_back({position, submission.admissionOrdinal, sectionKey, turnId, + std::move(card)}); + } + + std::ranges::sort(nodes, + [](const ProjectedNode &left, const ProjectedNode &right) { + if (left.position != right.position) + return left.position < right.position; + return left.tieBreaker < right.tieBreaker; + }); + + // Aggregate by section key rather than merely grouping adjacent nodes. This + // guarantees one structural section for each represented turn. + std::map sectionIndexes; + for (ProjectedNode &node : nodes) { + auto section = sectionIndexes.find(node.sectionKey); + if (section == sectionIndexes.end()) { + const std::size_t index = result.sections.size(); + sectionIndexes.emplace(node.sectionKey, index); + result.sections.push_back({node.sectionKey, node.turnId, {}}); + section = sectionIndexes.find(node.sectionKey); + } + result.sections[section->second].cards.push_back(std::move(node.card)); + } + return result; +} + +} // namespace codexui::codex::middle diff --git a/src/greenfield/codex/middle/ConversationProjection.h b/src/greenfield/codex/middle/ConversationProjection.h new file mode 100644 index 0000000..9cfe024 --- /dev/null +++ b/src/greenfield/codex/middle/ConversationProjection.h @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_GREENFIELD_CODEX_MIDDLE_CONVERSATIONPROJECTION_H +#define CODEXUI_GREENFIELD_CODEX_MIDDLE_CONVERSATIONPROJECTION_H + +#include "codex/PresentationModel.h" +#include "codex/middle/MiddleTypes.h" +#include "codex/middle/PromptCoordinator.h" + +#include + +#include +#include +#include + +namespace codexui::codex::middle { + +// Pure canonical projection. Initial rendering is simply reconciliation from +// an empty snapshot; no separate full-rebuild ordering exists. +class ConversationProjection final { +public: + static constexpr std::size_t DefaultAuthoritativeItemLimit = + AuthoritativeHistoryPageSize; + + [[nodiscard]] static ConversationSnapshot + project(const std::string &threadId, + const ThreadPresentation *authoritativeThread, + std::span localSubmissions, + std::size_t authoritativeItemLimit, qint64 nowMilliseconds); + + [[nodiscard]] static ConversationSnapshot + project(const ThreadPresentation &authoritativeThread, + std::span localSubmissions, + std::size_t authoritativeItemLimit, qint64 nowMilliseconds) { + return project(authoritativeThread.id, &authoritativeThread, + localSubmissions, authoritativeItemLimit, nowMilliseconds); + } +}; + +} // namespace codexui::codex::middle + +#endif // CODEXUI_GREENFIELD_CODEX_MIDDLE_CONVERSATIONPROJECTION_H diff --git a/src/greenfield/codex/middle/ConversationView.cpp b/src/greenfield/codex/middle/ConversationView.cpp new file mode 100644 index 0000000..824b5c1 --- /dev/null +++ b/src/greenfield/codex/middle/ConversationView.cpp @@ -0,0 +1,666 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/middle/ConversationView.h" + +#include "codex/middle/ConversationCards.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace codexui::codex::middle { +namespace { + +constexpr int BottomMargin = 16; +constexpr int CardSpacing = 8; +constexpr int NativeScrollLineStep = 20; + +QLabel *makeEmptyLabel() { + auto *label = + new QLabel(QStringLiteral("Conversation activity appears here.")); + label->setProperty("kind", "muted"); + label->setWordWrap(true); + label->setMinimumWidth(0); + label->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + return label; +} + +} // namespace + +class ConversationView::TurnSectionWidget final : public QWidget { +public: + explicit TurnSectionWidget(QWidget *parent = nullptr) : QWidget(parent) { + setAttribute(Qt::WA_StyledBackground, false); + setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + cards = new QVBoxLayout(this); + cards->setContentsMargins(0, 0, 0, 0); + cards->setSpacing(CardSpacing); + } + + QVBoxLayout *cards = nullptr; +}; + +ConversationView::ConversationView(QWidget *parent) + : QAbstractScrollArea(parent) { + setObjectName(QStringLiteral("conversationScroll")); + setFrameShape(QFrame::NoFrame); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn); + setSizeAdjustPolicy(QAbstractScrollArea::AdjustIgnored); + verticalScrollBar()->setSingleStep(NativeScrollLineStep); + viewport()->setAutoFillBackground(false); + + content_ = new QWidget(viewport()); + content_->setObjectName(QStringLiteral("conversationContent")); + content_->setAttribute(Qt::WA_StyledBackground, false); + content_->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + content_->installEventFilter(this); + + contentLayout_ = new QVBoxLayout(content_); + contentLayout_->setContentsMargins(0, 0, 0, BottomMargin); + contentLayout_->setSpacing(CardSpacing); + + loadMore_ = new QPushButton(QStringLiteral("Load more activities"), content_); + loadMore_->setProperty("kind", "history"); + loadMore_->setFixedHeight(32); + loadMore_->hide(); + connect(loadMore_, &QPushButton::clicked, this, [this] { + if (loadMoreAction_) + loadMoreAction_(); + }); + contentLayout_->addWidget(loadMore_, 0, Qt::AlignHCenter); + + empty_ = makeEmptyLabel(); + emptyMessage_ = empty_->text(); + empty_->setParent(content_); + contentLayout_->addWidget(empty_); + + trailingSpace_ = new QWidget(content_); + trailingSpace_->setObjectName(QStringLiteral("conversationTrailingSpace")); + trailingSpace_->setFixedHeight(0); + trailingSpace_->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + contentLayout_->addWidget(trailingSpace_); + + auto *tailStretch = new QWidget(content_); + tailStretch->setMinimumHeight(0); + tailStretch->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); + contentLayout_->addWidget(tailStretch, 1); + + followAnimation_ = new QVariantAnimation(this); + followAnimation_->setEasingCurve(QEasingCurve::OutCubic); + connect(followAnimation_, &QVariantAnimation::valueChanged, this, + [this](const QVariant &value) { + if (mode_ != Mode::Following || applying_) { + followAnimation_->stop(); + return; + } + // Never let a retargeted animation move an already-following view + // backwards. + setScrollValue( + std::max(verticalScrollBar()->value(), value.toInt())); + }); + connect(followAnimation_, &QVariantAnimation::finished, this, [this] { + if (mode_ == Mode::Following) + setScrollValue(verticalScrollBar()->maximum()); + }); + + connect(verticalScrollBar(), &QScrollBar::sliderPressed, this, [this] { + sliderDown_ = true; + pausedByComposerGrowth_ = false; + stopFollowingAnimation(); + }); + connect(verticalScrollBar(), &QScrollBar::sliderReleased, this, [this] { + sliderDown_ = false; + handleUserScrollValue(verticalScrollBar()->value()); + }); + connect(verticalScrollBar(), &QScrollBar::actionTriggered, this, + [this](int action) { + userActionPending_ = true; + pausedByComposerGrowth_ = false; + stopFollowingAnimation(); + if (action == QAbstractSlider::SliderSingleStepSub || + action == QAbstractSlider::SliderPageStepSub || + action == QAbstractSlider::SliderToMinimum) { + mode_ = Mode::Paused; + } + }); + connect(verticalScrollBar(), &QScrollBar::valueChanged, this, + [this](int value) { + positionContent(); + if (programmaticScroll_ || applying_) + return; + if (sliderDown_ || userActionPending_) { + handleUserScrollValue(value); + } + userActionPending_ = false; + }); + + recomputeGeometry(); +} + +void ConversationView::setLoadMoreAction(std::function action) { + loadMoreAction_ = std::move(action); +} + +void ConversationView::setEmptyMessage(QString message) { + if (message == emptyMessage_) + return; + const Anchor anchor = captureAnchor(); + const bool follow = mode_ == Mode::Following; + applying_ = true; + viewport()->setUpdatesEnabled(false); + const QSignalBlocker scrollSignals(verticalScrollBar()); + emptyMessage_ = std::move(message); + empty_->setText(emptyMessage_); + recomputeGeometry(); + if (follow) + setScrollValue(verticalScrollBar()->maximum()); + else + restoreAnchor(anchor); + applying_ = false; + viewport()->setUpdatesEnabled(true); + viewport()->update(); +} + +void ConversationView::storeCurrentThreadState() { + if (threadId_.empty()) + return; + threadStates_[threadId_] = {mode_, captureAnchor(), pausedByComposerGrowth_}; +} + +void ConversationView::setThread(const std::string &threadId) { + if (threadId == threadId_) + return; + storeCurrentThreadState(); + stopFollowingAnimation(); + threadId_ = threadId; + const auto saved = threadStates_.find(threadId_); + mode_ = saved == threadStates_.end() ? Mode::Following : saved->second.mode; + pausedByComposerGrowth_ = + saved != threadStates_.end() && saved->second.pausedByComposerGrowth; +} + +bool ConversationView::reconcile(const ConversationSnapshot &snapshot) { + if (snapshot == snapshot_ && snapshot.threadId == threadId_) + return false; + + const bool switchedThread = snapshot.threadId != threadId_; + if (switchedThread) + setThread(snapshot.threadId); + + Anchor anchor = captureAnchor(); + if (switchedThread) { + const auto saved = threadStates_.find(threadId_); + if (saved != threadStates_.end()) { + mode_ = saved->second.mode; + anchor = saved->second.anchor; + } else { + mode_ = Mode::Following; + pausedByComposerGrowth_ = false; + anchor = {}; + } + } + const bool follow = mode_ == Mode::Following; + + stopFollowingAnimation(); + applying_ = true; + viewport()->setUpdatesEnabled(false); + content_->setUpdatesEnabled(false); + const QSignalBlocker scrollSignals(verticalScrollBar()); + + bool visualChange = switchedThread; + const bool showLoadMore = snapshot.hasMore; + if (loadMore_->isVisible() != showLoadMore) { + loadMore_->setVisible(showLoadMore); + visualChange = true; + } + if (showLoadMore) { + const std::size_t page = std::min(AuthoritativeHistoryPageSize, + snapshot.hiddenAuthoritativeItemCount); + const QString label = QStringLiteral("Load %1 more activities") + .arg(static_cast(page)); + if (loadMore_->text() != label) { + loadMore_->setText(label); + visualChange = true; + } + loadMore_->setToolTip(QStringLiteral("%1 earlier activities are retained") + .arg(static_cast( + snapshot.hiddenAuthoritativeItemCount))); + } + + std::unordered_set wantedSections; + std::unordered_set wantedCards; + std::vector displayedKeys; + std::vector> + commandOutputRestorations; + const auto retainCommandOutputState = [this](const std::string &key, + ConversationCard *card) { + const auto state = card ? card->commandOutputScrollState() : std::nullopt; + if (state && !state->followsLatest) + commandOutputStates_[key] = *state; + else + commandOutputStates_.erase(key); + }; + int sectionIndex = 1; // load-more owns index zero (also while hidden). + + for (const TurnSection §ionData : snapshot.sections) { + wantedSections.insert(sectionData.key); + TurnSectionWidget *section = nullptr; + const auto existingSection = sections_.find(sectionData.key); + if (existingSection == sections_.end()) { + section = new TurnSectionWidget(content_); + section->setProperty("turnSectionKey", + QString::fromStdString(sectionData.key)); + sections_.emplace(sectionData.key, section); + visualChange = true; + } else { + section = existingSection->second; + } + section->setProperty("turnId", QString::fromStdString(sectionData.turnId)); + + if (contentLayout_->indexOf(section) != sectionIndex) { + contentLayout_->removeWidget(section); + contentLayout_->insertWidget(sectionIndex, section); + visualChange = true; + } + ++sectionIndex; + + int cardIndex = 0; + for (const VisibleCardData &cardData : sectionData.cards) { + const std::string key = stableKey(cardData.key); + wantedCards.insert(key); + displayedKeys.push_back(key); + + ConversationCard *card = nullptr; + const auto existingCard = cards_.find(key); + if (existingCard != cards_.end() && + existingCard->second->cardKind() == cardData.kind) { + card = existingCard->second; + visualChange = card->apply(cardData) || visualChange; + } else { + if (existingCard != cards_.end()) { + retainCommandOutputState(key, existingCard->second); + delete existingCard->second; + cards_.erase(existingCard); + } + card = createConversationCard(cardData, section); + card->setProperty("conversationAnchorKey", QString::fromStdString(key)); + if (const auto saved = commandOutputStates_.find(key); + saved != commandOutputStates_.end()) { + commandOutputRestorations.emplace_back(card, saved->second); + commandOutputStates_.erase(saved); + } + cards_.emplace(key, card); + visualChange = true; + } + + if (card->parentWidget() != section) { + if (QWidget *oldParent = card->parentWidget(); + oldParent && oldParent->layout()) + oldParent->layout()->removeWidget(card); + card->setParent(section); + visualChange = true; + } + if (section->cards->indexOf(card) != cardIndex) { + section->cards->removeWidget(card); + section->cards->insertWidget(cardIndex, card); + visualChange = true; + } + ++cardIndex; + } + section->setVisible(!sectionData.cards.empty()); + } + + for (auto iterator = cards_.begin(); iterator != cards_.end();) { + if (wantedCards.contains(iterator->first)) { + ++iterator; + continue; + } + retainCommandOutputState(iterator->first, iterator->second); + delete iterator->second; + iterator = cards_.erase(iterator); + visualChange = true; + } + for (auto iterator = sections_.begin(); iterator != sections_.end();) { + if (wantedSections.contains(iterator->first)) { + ++iterator; + continue; + } + delete iterator->second; + iterator = sections_.erase(iterator); + visualChange = true; + } + + const bool empty = displayedKeys.empty(); + if (empty_->isVisible() != empty) { + empty_->setVisible(empty); + visualChange = true; + } + displayedCardKeys_ = std::move(displayedKeys); + snapshot_ = snapshot; + + recomputeGeometry(); + for (const auto &[card, state] : commandOutputRestorations) + card->restoreCommandOutputScrollState(state); + if (follow) { + if (switchedThread) { + setScrollValue(verticalScrollBar()->maximum()); + } else { + // Reflow above the viewport must preserve the same painted card/pixel + // first. Smooth following starts only after that stable transaction. + restoreAnchor(anchor); + } + } else { + restoreAnchor(anchor); + } + + applying_ = false; + content_->setUpdatesEnabled(true); + viewport()->setUpdatesEnabled(true); + viewport()->update(); + + if (follow && !switchedThread) { + const int stableValue = verticalScrollBar()->value(); + if (verticalScrollBar()->maximum() > stableValue + 3) + animateToBottom(stableValue); + else + setScrollValue(verticalScrollBar()->maximum()); + } + storeCurrentThreadState(); + return visualChange; +} + +void ConversationView::setTrailingSpaceHeight(int height) { + height = std::max(0, height); + if (height == trailingSpaceHeight_) + return; + + const bool grew = height > trailingSpaceHeight_; + const Anchor anchor = captureAnchor(); + const int previousValue = verticalScrollBar()->value(); + stopFollowingAnimation(); + + applying_ = true; + viewport()->setUpdatesEnabled(false); + content_->setUpdatesEnabled(false); + const QSignalBlocker scrollSignals(verticalScrollBar()); + + if (grew) { + pausedByComposerGrowth_ = + pausedByComposerGrowth_ || mode_ == Mode::Following; + mode_ = Mode::Paused; + } + trailingSpaceHeight_ = height; + trailingSpace_->setFixedHeight(height); + recomputeGeometry(); + if (mode_ == Mode::Following) + setScrollValue(verticalScrollBar()->maximum()); + else if (grew && anchor.stableKey.empty()) + setScrollValue(std::min(previousValue, verticalScrollBar()->maximum())); + else + restoreAnchor(anchor); + if (!grew && isAtBottom()) { + mode_ = Mode::Following; + pausedByComposerGrowth_ = false; + } + + applying_ = false; + content_->setUpdatesEnabled(true); + viewport()->setUpdatesEnabled(true); + viewport()->update(); + storeCurrentThreadState(); +} + +void ConversationView::prepareForLocalPromptAdmission() { + if (mode_ != Mode::Paused || !pausedByComposerGrowth_) + return; + mode_ = Mode::Following; + pausedByComposerGrowth_ = false; + storeCurrentThreadState(); +} + +bool ConversationView::forwardWheelEvent(QWheelEvent *event) { + return event && applyWheel(event); +} + +bool ConversationView::isAtBottom() const noexcept { + return verticalScrollBar()->value() >= verticalScrollBar()->maximum() - 1; +} + +ConversationView::Mode +ConversationView::modeForThread(const std::string &threadId) const noexcept { + if (threadId == threadId_) + return mode_; + const auto saved = threadStates_.find(threadId); + return saved == threadStates_.end() ? Mode::Following : saved->second.mode; +} + +bool ConversationView::eventFilter(QObject *watched, QEvent *event) { + if (watched == content_ && event->type() == QEvent::LayoutRequest && + !applying_) { + Anchor anchor = captureAnchor(); + if (mode_ == Mode::Paused) { + const auto retained = threadStates_.find(threadId_); + if (retained != threadStates_.end() && + !retained->second.anchor.stableKey.empty()) + anchor = retained->second.anchor; + } + const bool follow = mode_ == Mode::Following; + stopFollowingAnimation(); + applying_ = true; + viewport()->setUpdatesEnabled(false); + const QSignalBlocker scrollSignals(verticalScrollBar()); + recomputeGeometry(); + restoreAnchor(anchor); + applying_ = false; + viewport()->setUpdatesEnabled(true); + viewport()->update(); + const int stableValue = verticalScrollBar()->value(); + if (follow && verticalScrollBar()->maximum() > stableValue + 3) + animateToBottom(stableValue); + else if (follow) + setScrollValue(verticalScrollBar()->maximum()); + storeCurrentThreadState(); + return true; + } + return QAbstractScrollArea::eventFilter(watched, event); +} + +void ConversationView::resizeEvent(QResizeEvent *event) { + const Anchor anchor = captureAnchor(); + const bool follow = mode_ == Mode::Following; + stopFollowingAnimation(); + applying_ = true; + viewport()->setUpdatesEnabled(false); + const QSignalBlocker scrollSignals(verticalScrollBar()); + QAbstractScrollArea::resizeEvent(event); + recomputeGeometry(); + if (follow) + setScrollValue(verticalScrollBar()->maximum()); + else + restoreAnchor(anchor); + applying_ = false; + viewport()->setUpdatesEnabled(true); + viewport()->update(); + storeCurrentThreadState(); +} + +void ConversationView::wheelEvent(QWheelEvent *event) { + if (!applyWheel(event)) + QAbstractScrollArea::wheelEvent(event); +} + +ConversationView::Anchor ConversationView::captureAnchor() const { + Anchor anchor; + anchor.absoluteValue = verticalScrollBar()->value(); + for (const std::string &key : displayedCardKeys_) { + ConversationCard *card = cardForStableKey(key); + if (!card || !card->isVisible()) + continue; + const int viewportTop = card->mapTo(viewport(), QPoint(0, 0)).y(); + if (viewportTop + card->height() < 0) + continue; + anchor.stableKey = key; + // The contract is visual stability. Capture the actual painted offset + // instead of deriving it from content coordinates while a layout/range + // transaction may temporarily be between those coordinate systems. + anchor.pixelOffset = viewportTop; + break; + } + return anchor; +} + +void ConversationView::restoreAnchor(const Anchor &anchor) { + int value = anchor.absoluteValue; + if (!anchor.stableKey.empty()) { + if (ConversationCard *card = cardForStableKey(anchor.stableKey)) { + const int top = card->mapTo(content_, QPoint(0, 0)).y(); + value = top - anchor.pixelOffset; + } + } + setScrollValue(std::clamp(value, verticalScrollBar()->minimum(), + verticalScrollBar()->maximum())); +} + +void ConversationView::setScrollValue(int value) { + value = std::clamp(value, verticalScrollBar()->minimum(), + verticalScrollBar()->maximum()); + programmaticScroll_ = true; + verticalScrollBar()->setValue(value); + programmaticScroll_ = false; + positionContent(); +} + +void ConversationView::stopFollowingAnimation() { + if (followAnimation_->state() != QAbstractAnimation::Stopped) + followAnimation_->stop(); +} + +void ConversationView::animateToBottom(int previousValue) { + if (mode_ != Mode::Following) + return; + const int destination = verticalScrollBar()->maximum(); + const int start = + std::clamp(std::max(verticalScrollBar()->value(), previousValue), + verticalScrollBar()->minimum(), destination); + const int distance = destination - start; + stopFollowingAnimation(); + if (distance <= 3) { + setScrollValue(destination); + return; + } + setScrollValue(start); + followAnimation_->setDuration(std::clamp(110 + distance / 3, 130, 260)); + followAnimation_->setStartValue(start); + followAnimation_->setEndValue(destination); + followAnimation_->start(); +} + +void ConversationView::recomputeGeometry() { + if (!content_ || !viewport()) + return; + const int width = std::max(0, viewport()->width()); + + // Give every nested layout its final width before asking for height. This + // makes wrapped labels and command output contribute to the same range + // transaction as their insertion/update. + content_->resize(width, std::max(viewport()->height(), contentHeight_)); + contentLayout_->setGeometry(content_->rect()); + for (const auto &[key, section] : sections_) { + static_cast(key); + section->layout()->activate(); + } + contentLayout_->activate(); + + int wanted = contentLayout_->hasHeightForWidth() + ? contentLayout_->heightForWidth(width) + : contentLayout_->sizeHint().height(); + wanted = std::max(wanted, contentLayout_->minimumSize().height()); + contentHeight_ = std::max(viewport()->height(), wanted); + content_->resize(width, contentHeight_); + contentLayout_->setGeometry(QRect(0, 0, width, contentHeight_)); + for (const auto &[key, section] : sections_) { + static_cast(key); + section->layout()->activate(); + } + contentLayout_->activate(); + + verticalScrollBar()->setPageStep(viewport()->height()); + verticalScrollBar()->setRange( + 0, std::max(0, contentHeight_ - viewport()->height())); + positionContent(); +} + +void ConversationView::positionContent() { + if (content_) + content_->move(0, -verticalScrollBar()->value()); +} + +void ConversationView::handleUserScrollValue(int value) { + stopFollowingAnimation(); + pausedByComposerGrowth_ = false; + mode_ = value >= verticalScrollBar()->maximum() - 1 ? Mode::Following + : Mode::Paused; + storeCurrentThreadState(); +} + +bool ConversationView::applyWheel(QWheelEvent *event) { + if (!event) + return false; + const int intent = !event->pixelDelta().isNull() ? event->pixelDelta().y() + : event->angleDelta().y(); + if (intent == 0) + return false; + + pausedByComposerGrowth_ = false; + const int oldValue = verticalScrollBar()->value(); + if (intent > 0) { + // An upward wheel/touchpad gesture pauses before any subsequent layout or + // incoming frame can move the viewport. + stopFollowingAnimation(); + mode_ = Mode::Paused; + } + // Keep Qt's native wheel/touchpad interpretation, but deliver it directly + // to the scrollbar. Calling QAbstractScrollArea::wheelEvent() here would + // redispatch through ShellWidget's application event filter, which routes + // the same gesture back into this method recursively. + QScrollBar *bar = verticalScrollBar(); + const QPointF local = + bar->mapFromGlobal(event->globalPosition().toPoint()); + QWheelEvent forwarded(local, event->globalPosition(), event->pixelDelta(), + event->angleDelta(), event->buttons(), + event->modifiers(), event->phase(), event->inverted()); + const QScopedValueRollback nativeDispatch(dispatchingNativeWheel_, true); + QApplication::sendEvent(bar, &forwarded); + positionContent(); + if (verticalScrollBar()->value() < oldValue) + mode_ = Mode::Paused; + if (verticalScrollBar()->value() >= verticalScrollBar()->maximum() - 1) + mode_ = Mode::Following; + storeCurrentThreadState(); + event->accept(); + return true; +} + +ConversationCard * +ConversationView::cardForStableKey(const std::string &key) const { + const auto card = cards_.find(key); + return card == cards_.end() ? nullptr : card->second; +} + +} // namespace codexui::codex::middle diff --git a/src/greenfield/codex/middle/ConversationView.h b/src/greenfield/codex/middle/ConversationView.h new file mode 100644 index 0000000..8c01ad5 --- /dev/null +++ b/src/greenfield/codex/middle/ConversationView.h @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_GREENFIELD_CODEX_MIDDLE_CONVERSATIONVIEW_H +#define CODEXUI_GREENFIELD_CODEX_MIDDLE_CONVERSATIONVIEW_H + +#include "codex/middle/ConversationCards.h" + +#include + +#include +#include +#include +#include +#include + +class QLabel; +class QEvent; +class QPushButton; +class QVariantAnimation; +class QVBoxLayout; +class QWheelEvent; + +namespace codexui::codex::middle { + +// The conversation has one projection path and one geometry owner. Its +// content is positioned directly in QAbstractScrollArea's viewport, so every +// reconciliation can update the layout, range, and stable anchor in one +// synchronous transaction. +class ConversationView final : public QAbstractScrollArea { +public: + enum class Mode { Following, Paused }; + + explicit ConversationView(QWidget *parent = nullptr); + + void setLoadMoreAction(std::function action); + void setEmptyMessage(QString message); + + // Returns false for a typed projection no-op. Existing cards are mutated by + // key; first render and later updates use this same reconciliation path. + bool reconcile(const ConversationSnapshot &snapshot); + + // Extra composer height is represented after the final card, while the + // viewport itself keeps its canonical geometry. + void setTrailingSpaceHeight(int height); + + // A local admission may resume a pause caused solely by composer growth. + // Explicit user-owned scrolling remains paused. + void prepareForLocalPromptAdmission(); + + // Used by the middle-region chrome and adjacent splitter handles. Nested + // scrollable controls should consume their own event before this is called. + bool forwardWheelEvent(QWheelEvent *event); + + [[nodiscard]] Mode mode() const noexcept { return mode_; } + [[nodiscard]] Mode modeForThread(const std::string &threadId) const noexcept; + [[nodiscard]] bool isAtBottom() const noexcept; + [[nodiscard]] bool dispatchingNativeWheel() const noexcept { + return dispatchingNativeWheel_; + } + [[nodiscard]] int trailingSpaceHeight() const noexcept { + return trailingSpaceHeight_; + } + +protected: + bool eventFilter(QObject *watched, QEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + void wheelEvent(QWheelEvent *event) override; + +private: + struct Anchor { + std::string stableKey; + int pixelOffset = 0; + int absoluteValue = 0; + }; + + struct ThreadScrollState { + Mode mode = Mode::Following; + Anchor anchor; + bool pausedByComposerGrowth = false; + }; + + class TurnSectionWidget; + + void setThread(const std::string &threadId); + [[nodiscard]] Anchor captureAnchor() const; + void restoreAnchor(const Anchor &anchor); + void storeCurrentThreadState(); + void setScrollValue(int value); + void stopFollowingAnimation(); + void animateToBottom(int previousValue); + void recomputeGeometry(); + void positionContent(); + void handleUserScrollValue(int value); + [[nodiscard]] bool applyWheel(QWheelEvent *event); + [[nodiscard]] ConversationCard * + cardForStableKey(const std::string &stableKey) const; + + QWidget *content_ = nullptr; + QVBoxLayout *contentLayout_ = nullptr; + QPushButton *loadMore_ = nullptr; + QLabel *empty_ = nullptr; + QWidget *trailingSpace_ = nullptr; + QVariantAnimation *followAnimation_ = nullptr; + std::function loadMoreAction_; + + ConversationSnapshot snapshot_; + std::string threadId_; + std::unordered_map sections_; + std::unordered_map cards_; + std::vector displayedCardKeys_; + std::unordered_map threadStates_; + std::unordered_map + commandOutputStates_; + + Mode mode_ = Mode::Following; + int trailingSpaceHeight_ = 0; + int contentHeight_ = 0; + QString emptyMessage_; + bool applying_ = false; + bool programmaticScroll_ = false; + bool sliderDown_ = false; + bool userActionPending_ = false; + bool pausedByComposerGrowth_ = false; + bool dispatchingNativeWheel_ = false; +}; + +} // namespace codexui::codex::middle + +#endif // CODEXUI_GREENFIELD_CODEX_MIDDLE_CONVERSATIONVIEW_H diff --git a/src/greenfield/codex/middle/InspectorPane.cpp b/src/greenfield/codex/middle/InspectorPane.cpp new file mode 100644 index 0000000..d22ece8 --- /dev/null +++ b/src/greenfield/codex/middle/InspectorPane.cpp @@ -0,0 +1,729 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/middle/InspectorPane.h" + +#include "codex/DiffViewer.h" +#include "codex/PresentationModel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace codexui::codex::middle { +namespace { + +constexpr int MaximumProtocolLines = 2000; + +QString text(const std::string &value) { + return QString::fromUtf8(value.data(), static_cast(value.size())); +} + +QString joinedStrings(const nlohmann::json &value) { + if (!value.is_array()) + return {}; + QStringList result; + for (const auto &item : value) { + if (item.is_string()) + result.push_back(text(item.get())); + } + return result.join(QStringLiteral(", ")); +} + +std::string stringValue(const nlohmann::json &object, const char *key) { + if (!object.is_object()) + return {}; + const auto found = object.find(key); + return found != object.end() && found->is_string() ? found->get() + : std::string{}; +} + +QString displayStatus(const std::string &status) { + if (status == "inProgress" || status == "active") + return QStringLiteral("Running"); + if (status == "completed" || status == "idle") + return QStringLiteral("Completed"); + if (status == "failed" || status == "systemError") + return QStringLiteral("Failed"); + return status.empty() ? QStringLiteral("Unknown") : text(status); +} + +QLabel *makeLabel(QString value, const char *kind = "body") { + auto *label = new QLabel(std::move(value)); + label->setProperty("kind", kind); + label->setTextFormat(Qt::PlainText); + label->setWordWrap(true); + label->setMinimumWidth(0); + label->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + label->setTextInteractionFlags(Qt::TextSelectableByMouse); + return label; +} + +QLabel *makeMarkdownLabel(const QString &value) { + QTextDocument document; + document.setMarkdown(value, QTextDocument::MarkdownNoHTML); + auto *label = new QLabel(document.toHtml()); + label->setProperty("kind", "body"); + label->setTextFormat(Qt::RichText); + label->setWordWrap(true); + label->setMinimumWidth(0); + label->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + label->setOpenExternalLinks(true); + label->setTextInteractionFlags(Qt::TextSelectableByMouse | + Qt::LinksAccessibleByMouse); + return label; +} + +void clearLayout(QLayout *layout) { + while (QLayoutItem *item = layout->takeAt(0)) { + if (QWidget *widget = item->widget()) + delete widget; + if (QLayout *child = item->layout()) { + clearLayout(child); + delete child; + } + delete item; + } +} + +QByteArray bytes(const nlohmann::json &value) { + const std::string serialized = value.dump(); + return QByteArray(serialized.data(), + static_cast(serialized.size())); +} + +QFrame *agentFrame(const AgentPresentation &agent) { + auto *frame = new QFrame; + frame->setProperty("kind", "raised"); + auto *layout = new QVBoxLayout(frame); + layout->setContentsMargins(12, 10, 12, 10); + layout->setSpacing(6); + const std::string tool = stringValue(agent.raw, "tool"); + const QString title = + !agent.childThreadId.empty() ? QStringLiteral("Subagent") + : tool.empty() ? QStringLiteral("Agent activity") + : QStringLiteral("Agent %1").arg(text(tool)); + layout->addWidget(makeLabel(title, "title")); + QStringList metadata{displayStatus(agent.status)}; + for (const char *key : {"agentPath", "tool", "model", "reasoningEffort"}) { + const QString value = text(stringValue(agent.raw, key)); + if (!value.isEmpty()) + metadata << value; + } + layout->addWidget(makeLabel(metadata.join(QStringLiteral(" | ")), "meta")); + const QString prompt = text(stringValue(agent.raw, "prompt")); + if (!prompt.isEmpty()) + layout->addWidget(makeLabel(prompt)); + const QString result = text(stringValue(agent.raw, "resultText")); + if (!result.isEmpty()) + layout->addWidget(makeMarkdownLabel(result)); + QStringList identities; + if (!agent.childThreadId.empty()) + identities << QStringLiteral("thread %1").arg(text(agent.childThreadId)); + const QString sender = text(stringValue(agent.raw, "senderThreadId")); + if (!sender.isEmpty()) + identities << QStringLiteral("sender %1").arg(sender); + const QString receivers = joinedStrings( + agent.raw.value("receiverThreadIds", nlohmann::json::array())); + if (!receivers.isEmpty()) + identities << QStringLiteral("receivers %1").arg(receivers); + if (!identities.isEmpty()) + layout->addWidget( + makeLabel(identities.join(QStringLiteral(" | ")), "meta")); + return frame; +} + +struct ScrollPosition { + bool followsTail = true; + int value = 0; +}; + +void restoreScrollPosition(QPlainTextEdit *view, + const ScrollPosition &position) { + QScrollBar *scrollBar = view->verticalScrollBar(); + if (position.followsTail) { + scrollBar->setValue(scrollBar->maximum()); + return; + } + scrollBar->setValue( + std::clamp(position.value, scrollBar->minimum(), scrollBar->maximum())); +} + +} // namespace + +InspectorPane::InspectorPane(QWidget *parent) : QFrame(parent) { + setObjectName(QStringLiteral("inspector")); + setStyleSheet(QStringLiteral("QFrame#inspector{background:#fbfcfe;}")); + setMinimumWidth(300); + setMaximumWidth(520); + + auto *outer = new QVBoxLayout(this); + outer->setContentsMargins(18, 14, 20, 0); + outer->setSpacing(0); + auto *heading = new QHBoxLayout; + heading->addWidget(makeLabel(QStringLiteral("INSPECTOR"), "section")); + heading->addStretch(); + auto *hide = new QPushButton(QStringLiteral("Hide")); + hide->setProperty("kind", "subtle"); + hide->setFixedSize(58, 24); + connect(hide, &QPushButton::clicked, this, [this] { + if (hideAction) + hideAction(); + }); + heading->addWidget(hide); + outer->addLayout(heading); + outer->addSpacing(7); + + inspectorTabs = new QTabWidget; + inspectorTabs->setDocumentMode(true); + planContent = new QWidget; + planLayout = new QVBoxLayout(planContent); + planLayout->setContentsMargins(12, 12, 12, 12); + planLayout->setSpacing(8); + agentsContent = new QWidget; + agentsLayout = new QVBoxLayout(agentsContent); + agentsLayout->setContentsMargins(12, 12, 12, 12); + agentsLayout->setSpacing(8); + requestsContent = new QWidget; + requestsLayout = new QVBoxLayout(requestsContent); + requestsLayout->setContentsMargins(12, 12, 12, 12); + requestsLayout->setSpacing(8); + diffViewer = new DiffViewer; + + const auto makeScroll = [](QWidget *content) { + auto *scroll = new QScrollArea; + scroll->setWidgetResizable(true); + scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scroll->setWidget(content); + return scroll; + }; + + auto *stateContent = new QWidget; + auto *stateLayout = new QVBoxLayout(stateContent); + stateLayout->setContentsMargins(8, 8, 8, 8); + stateView = new QPlainTextEdit; + stateView->setObjectName(QStringLiteral("stateInfoView")); + stateView->setProperty("kind", "infoViewer"); + stateView->setReadOnly(true); + stateView->setLineWrapMode(QPlainTextEdit::WidgetWidth); + stateView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + stateView->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + stateView->verticalScrollBar()->setProperty("kind", "infoViewer"); + stateLayout->addWidget(stateView); + + auto *protocolContent = new QWidget; + auto *protocolLayout = new QVBoxLayout(protocolContent); + protocolLayout->setContentsMargins(8, 8, 8, 8); + protocolLayout->setSpacing(6); + protocolLog = new QPlainTextEdit; + protocolLog->setObjectName(QStringLiteral("protocolInfoLog")); + protocolLog->setProperty("kind", "infoViewer"); + protocolLog->setReadOnly(true); + protocolLog->setLineWrapMode(QPlainTextEdit::WidgetWidth); + protocolLog->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + protocolLog->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + protocolLog->verticalScrollBar()->setProperty("kind", "infoViewer"); + protocolLog->document()->setMaximumBlockCount(MaximumProtocolLines); + connect(protocolLog->verticalScrollBar(), &QScrollBar::valueChanged, this, + [this](int value) { + if (mutatingProtocolLog) + return; + QScrollBar *scrollBar = protocolLog->verticalScrollBar(); + protocolFollowsTail = value >= scrollBar->maximum() - 1; + if (!protocolFollowsTail) + protocolPausedScrollValue = value; + }); + protocolStats = makeLabel({}, "meta"); + protocolStats->setObjectName(QStringLiteral("protocolInfoStats")); + protocolLayout->addWidget(protocolLog, 1); + protocolLayout->addWidget(protocolStats); + + infoTabs = new QTabWidget; + infoTabs->setObjectName(QStringLiteral("infoTabs")); + infoTabs->setDocumentMode(true); + infoTabs->addTab(stateContent, QStringLiteral("State")); + infoTabs->addTab(protocolContent, QStringLiteral("Protocol")); + + inspectorTabs->addTab(makeScroll(planContent), QStringLiteral("Plan")); + inspectorTabs->addTab(makeScroll(agentsContent), QStringLiteral("Agents")); + inspectorTabs->addTab(diffViewer, QStringLiteral("Changes")); + inspectorTabs->addTab(makeScroll(requestsContent), + QStringLiteral("Requests")); + inspectorTabs->addTab(infoTabs, QStringLiteral("Info")); + outer->addWidget(inspectorTabs, 1); + + connect(inspectorTabs, &QTabWidget::currentChanged, this, + [this](int) { refreshCurrentTab(); }); + connect(infoTabs, &QTabWidget::currentChanged, this, [this](int index) { + if (index == 1) + showProtocolTail(); + refreshCurrentTab(); + }); +} + +void InspectorPane::setHideAction(std::function hide) { + hideAction = std::move(hide); +} + +void InspectorPane::setRequestActions(RequestAction review, + RequestAction reject) { + reviewRequest = std::move(review); + rejectRequest = std::move(reject); +} + +void InspectorPane::refresh(const PresentationModel &model, + const std::string &selectedThreadId) { + currentModel = &model; + currentThreadId = selectedThreadId; + refreshCurrentTab(); +} + +void InspectorPane::refreshCurrentTab() { + if (!currentModel) + return; + switch (inspectorTabs->currentIndex()) { + case 0: + refreshPlan(); + break; + case 1: + refreshAgents(); + break; + case 2: + refreshChanges(); + break; + case 3: + refreshRequests(); + break; + case 4: + if (infoTabs->currentIndex() == 0) + refreshState(); + else { + showProtocolTail(); + refreshProtocolStats(); + } + break; + default: + break; + } +} + +void InspectorPane::refreshPlan() { + const ThreadPresentation *thread = currentModel->thread(currentThreadId); + nlohmann::json snapshot{{"threadId", currentThreadId}}; + const TurnPresentation *planTurn = nullptr; + const ItemPresentation *planItem = nullptr; + if (thread) { + for (auto id = thread->turnOrder.rbegin(); id != thread->turnOrder.rend(); + ++id) { + const auto turn = thread->turns.find(*id); + if (turn == thread->turns.end()) + continue; + if (turn->second.plan.is_object() && + turn->second.plan.contains("steps")) { + planTurn = &turn->second; + snapshot["plan"]["explanation"] = + stringValue(planTurn->plan, "explanation"); + snapshot["plan"]["steps"] = nlohmann::json::array(); + for (const auto &step : + planTurn->plan.value("steps", nlohmann::json::array())) + snapshot["plan"]["steps"].push_back( + {{"step", stringValue(step, "step")}, + {"status", stringValue(step, "status")}}); + break; + } + for (auto itemId = turn->second.itemOrder.rbegin(); + itemId != turn->second.itemOrder.rend(); ++itemId) { + const auto item = turn->second.items.find(*itemId); + if (item != turn->second.items.end() && + stringValue(item->second.raw, "type") == "plan") { + planItem = &item->second; + snapshot["planItem"] = stringValue(planItem->raw, "text"); + break; + } + } + if (planItem) + break; + } + } + const QByteArray next = bytes(snapshot); + if (next == planSnapshot) + return; + planSnapshot = next; + setUpdatesEnabled(false); + clearLayout(planLayout); + if (!thread) { + planLayout->addWidget( + makeLabel(QStringLiteral("No selected thread."), "muted")); + } else if (planTurn) { + const QString explanation = + text(stringValue(planTurn->plan, "explanation")); + if (!explanation.isEmpty()) + planLayout->addWidget(makeMarkdownLabel(explanation)); + for (const auto &step : + planTurn->plan.value("steps", nlohmann::json::array())) { + auto *row = new QFrame; + row->setProperty("kind", "summary"); + auto *layout = new QVBoxLayout(row); + layout->setContentsMargins(9, 7, 9, 7); + layout->addWidget(makeLabel(text(stringValue(step, "step")))); + layout->addWidget( + makeLabel(displayStatus(stringValue(step, "status")), "meta")); + planLayout->addWidget(row); + } + } else if (planItem) { + const QString value = text(stringValue(planItem->raw, "text")); + planLayout->addWidget( + value.isEmpty() + ? makeLabel(QStringLiteral("Plan is being prepared."), "muted") + : makeMarkdownLabel(value)); + } else { + planLayout->addWidget( + makeLabel(QStringLiteral("No plan for this thread."), "muted")); + } + planLayout->addStretch(); + setUpdatesEnabled(true); +} + +void InspectorPane::refreshAgents() { + const ThreadPresentation *thread = currentModel->thread(currentThreadId); + nlohmann::json snapshot = nlohmann::json::array(); + if (thread) { + for (const std::string &id : thread->agentOrder) { + const auto agent = thread->agents.find(id); + if (agent != thread->agents.end()) + snapshot.push_back( + {{"id", id}, + {"status", agent->second.status}, + {"childThreadId", agent->second.childThreadId}, + {"agentPath", stringValue(agent->second.raw, "agentPath")}, + {"tool", stringValue(agent->second.raw, "tool")}, + {"model", stringValue(agent->second.raw, "model")}, + {"reasoningEffort", + stringValue(agent->second.raw, "reasoningEffort")}, + {"prompt", stringValue(agent->second.raw, "prompt")}, + {"resultText", stringValue(agent->second.raw, "resultText")}, + {"senderThreadId", + stringValue(agent->second.raw, "senderThreadId")}, + {"receiverThreadIds", + agent->second.raw.value("receiverThreadIds", + nlohmann::json::array())}}); + } + } + const QByteArray next = + bytes({{"threadId", currentThreadId}, {"agents", snapshot}}); + if (next == agentsSnapshot) + return; + agentsSnapshot = next; + setUpdatesEnabled(false); + clearLayout(agentsLayout); + if (!thread) + agentsLayout->addWidget( + makeLabel(QStringLiteral("No selected thread."), "muted")); + else if (snapshot.empty()) + agentsLayout->addWidget(makeLabel( + QStringLiteral("No agent activity for this thread."), "muted")); + else + for (const std::string &id : thread->agentOrder) { + const auto agent = thread->agents.find(id); + if (agent != thread->agents.end()) + agentsLayout->addWidget(agentFrame(agent->second)); + } + agentsLayout->addStretch(); + setUpdatesEnabled(true); +} + +void InspectorPane::refreshChanges() { + const ThreadPresentation *thread = currentModel->thread(currentThreadId); + QString liveDiff; + std::vector retained; + if (thread) { + for (auto id = thread->turnOrder.rbegin(); + id != thread->turnOrder.rend() && liveDiff.isEmpty(); ++id) { + const auto turn = thread->turns.find(*id); + if (turn == thread->turns.end()) + continue; + const auto domain = turn->second.domains.find("turn.diff.changed"); + if (domain != turn->second.domains.end()) + liveDiff = text(stringValue(domain->second, "diff")); + } + if (liveDiff.isEmpty()) { + for (auto id = thread->turnOrder.rbegin(); + id != thread->turnOrder.rend() && retained.empty(); ++id) { + const auto turn = thread->turns.find(*id); + if (turn == thread->turns.end()) + continue; + for (auto itemId = turn->second.itemOrder.rbegin(); + itemId != turn->second.itemOrder.rend(); ++itemId) { + const auto item = turn->second.items.find(*itemId); + if (item == turn->second.items.end() || + stringValue(item->second.raw, "type") != "fileChange") + continue; + for (const auto &change : + item->second.raw.value("changes", nlohmann::json::array())) { + QString kind = text(stringValue(change, "kind")); + if (kind.isEmpty() && change.contains("kind") && + change["kind"].is_object()) + kind = text(stringValue(change["kind"], "type")); + retained.push_back({text(stringValue(change, "path")), + std::move(kind), + text(stringValue(change, "diff"))}); + } + if (!retained.empty()) + break; + } + } + } + } + nlohmann::json signature{{"threadId", currentThreadId}, + {"live", liveDiff.toStdString()}}; + for (const auto &change : retained) + signature["retained"].push_back({change.path.toStdString(), + change.kind.toStdString(), + change.diff.toStdString()}); + const QByteArray next = bytes(signature); + if (next == changesSnapshot) + return; + changesSnapshot = next; + diffViewer->setChanges(std::move(liveDiff), std::move(retained)); +} + +void InspectorPane::refreshRequests() { + nlohmann::json snapshot = nlohmann::json::array(); + for (const auto &[id, request] : + currentModel->pendingRequestPresentations()) { + const nlohmann::json questions = + request.raw.value("questions", nlohmann::json::array()); + snapshot.push_back( + {{"id", id}, + {"kind", request.kind}, + {"threadId", request.threadId}, + {"generation", request.generation}, + {"command", stringValue(request.raw, "command")}, + {"reason", stringValue(request.raw, "reason")}, + {"message", stringValue(request.raw, "message")}, + {"questionCount", questions.is_array() ? questions.size() : 0U}}); + } + const QByteArray next = bytes(snapshot); + if (next == requestsSnapshot) + return; + requestsSnapshot = next; + setUpdatesEnabled(false); + clearLayout(requestsLayout); + for (const auto &[id, request] : + currentModel->pendingRequestPresentations()) { + auto *frame = new QFrame; + frame->setProperty("kind", "summary"); + auto *layout = new QVBoxLayout(frame); + layout->setContentsMargins(9, 7, 9, 7); + layout->setSpacing(5); + layout->addWidget(makeLabel(text(request.kind), "title")); + QString threadContext = text(request.threadId); + if (const ThreadPresentation *thread = + currentModel->thread(request.threadId); + thread && !thread->title.empty()) + threadContext = text(thread->title); + layout->addWidget( + makeLabel(QStringLiteral("thread %1 | generation %2 | request %3") + .arg(threadContext) + .arg(static_cast(request.generation)) + .arg(text(id)), + "meta")); + for (const auto &[key, prefix] : + std::array, 3>{ + {{"command", "Command: "}, + {"reason", "Reason: "}, + {"message", ""}}}) { + const QString value = text(stringValue(request.raw, key)); + if (!value.isEmpty()) + layout->addWidget( + makeLabel(QString::fromLatin1(prefix) + value, "meta")); + } + const auto questions = request.raw.find("questions"); + if (questions != request.raw.end() && questions->is_array()) + layout->addWidget( + makeLabel(QStringLiteral("%1 questions") + .arg(static_cast(questions->size())), + "meta")); + auto *actions = new QHBoxLayout; + actions->setContentsMargins(0, 2, 0, 0); + auto *deny = new QPushButton(QStringLiteral("Deny")); + auto *review = new QPushButton(QStringLiteral("Review")); + review->setProperty("kind", "primary"); + deny->setFixedHeight(28); + review->setFixedHeight(28); + connect(deny, &QPushButton::clicked, this, [this, id] { + if (rejectRequest) + rejectRequest(id); + }); + connect(review, &QPushButton::clicked, this, [this, id] { + if (reviewRequest) + reviewRequest(id); + }); + actions->addStretch(); + actions->addWidget(deny); + actions->addWidget(review); + layout->addLayout(actions); + requestsLayout->addWidget(frame); + } + if (snapshot.empty()) + requestsLayout->addWidget( + makeLabel(QStringLiteral("No pending requests."), "muted")); + requestsLayout->addStretch(); + setUpdatesEnabled(true); +} + +void InspectorPane::refreshState() { + nlohmann::json domains = nlohmann::json::object(); + for (const auto &[name, value] : currentModel->globalDomains()) + domains[name] = value; + nlohmann::json pending = nlohmann::json::object(); + for (const auto &[id, request] : currentModel->pendingRequestPresentations()) + pending[id] = {{"category", request.kind}, + {"threadId", request.threadId}, + {"generation", request.generation}}; + nlohmann::json state{{"models", currentModel->modelCatalog()}, + {"pendingRequests", std::move(pending)}, + {"domains", std::move(domains)}}; + std::string rendered = state.dump(2); + constexpr std::size_t MaximumBytes = 32U * 1024U; + if (rendered.size() > MaximumBytes) { + const std::size_t total = rendered.size(); + rendered.resize(MaximumBytes); + rendered += "\n\n[State display truncated at 32 KiB; retained bytes: " + + std::to_string(total) + "]"; + } + const QByteArray next(rendered.data(), + static_cast(rendered.size())); + if (next == stateSnapshot) + return; + stateSnapshot = next; + stateView->setPlainText(text(rendered)); +} + +void InspectorPane::refreshProtocolStats() { + std::size_t turns = 0; + std::size_t items = 0; + if (const ThreadPresentation *thread = + currentModel->thread(currentThreadId)) { + turns = thread->turnOrder.size(); + for (const auto &[id, turn] : thread->turns) { + static_cast(id); + items += turn.itemOrder.size(); + } + } + const QString value = + QStringLiteral("seq %1 | threads %2 | models %3 | turns %4 | " + "items %5 | pending %6 | telemetry %7") + .arg(static_cast(observedSequence)) + .arg(static_cast(currentModel->threadOrder().size())) + .arg(static_cast(currentModel->modelCatalog().size())) + .arg(static_cast(turns)) + .arg(static_cast(items)) + .arg(static_cast(currentModel->pendingRequestCount())) + .arg(static_cast(currentModel->telemetry().size())); + if (value.toUtf8() == protocolStatsSnapshot) + return; + protocolStatsSnapshot = value.toUtf8(); + protocolStats->setText(value); +} + +void InspectorPane::showProtocolTail() { + QStringList lines; + lines.reserve(static_cast(protocolLines.size())); + for (const QString &line : protocolLines) + lines << line; + const QString value = lines.join(QLatin1Char('\n')); + if (protocolLog->toPlainText() == value) + return; + const ScrollPosition position{protocolFollowsTail, protocolPausedScrollValue}; + mutatingProtocolLog = true; + protocolLog->setPlainText(value); + restoreProtocolScroll(position.followsTail, position.value); +} + +void InspectorPane::restoreProtocolScroll(bool followsTail, int pausedValue) { + const ScrollPosition position{followsTail, pausedValue}; + restoreScrollPosition(protocolLog, position); + const std::uint64_t revision = ++protocolScrollRevision; + QTimer::singleShot(0, this, [this, position, revision] { + if (revision != protocolScrollRevision) + return; + restoreScrollPosition(protocolLog, position); + protocolFollowsTail = position.followsTail; + if (!position.followsTail) + protocolPausedScrollValue = protocolLog->verticalScrollBar()->value(); + mutatingProtocolLog = false; + }); +} + +void InspectorPane::appendProtocolFrame(const nlohmann::json &frame) { + const auto record = [this](QString line) { + if (protocolLines.size() >= MaximumProtocolLines) + protocolLines.pop_front(); + protocolLines.push_back(line); + const ScrollPosition position{protocolFollowsTail, + protocolPausedScrollValue}; + mutatingProtocolLog = true; + protocolLog->appendPlainText(line); + restoreProtocolScroll(position.followsTail, position.value); + }; + const std::uint64_t sequence = frame.value("sequence", 0ULL); + if (sequence != 0) { + if (observedSequence != 0 && sequence != observedSequence + 1) { + record(QStringLiteral("[%1] %2 expected=%3 received=%4") + .arg(QDateTime::currentDateTime().toString( + QStringLiteral("HH:mm:ss.zzz")), + sequence <= observedSequence + ? QStringLiteral("NON-MONOTONIC") + : QStringLiteral("SEQUENCE GAP")) + .arg(static_cast(observedSequence + 1)) + .arg(static_cast(sequence))); + } + observedSequence = std::max(observedSequence, sequence); + } + const std::string kind = stringValue(frame, "kind"); + const std::string subject = kind == "result" ? stringValue(frame, "action") + : stringValue(frame, "type"); + const nlohmann::json scope = frame.value("scope", nlohmann::json::object()); + QStringList parts{QStringLiteral("[%1]").arg( + QDateTime::currentDateTime().toString(QStringLiteral("HH:mm:ss.zzz")))}; + if (sequence != 0) + parts << QStringLiteral("#%1").arg(static_cast(sequence)); + parts << QStringLiteral("g%1").arg( + static_cast(frame.value("generation", 0ULL))) + << text(kind) << text(subject) << text(stringValue(frame, "authority")); + if (kind == "result") + parts << (frame.value("ok", false) ? QStringLiteral("ok") + : QStringLiteral("ERROR")); + for (const char *key : + {"threadId", "turnId", "itemId", "requestId", "processId"}) { + const std::string value = stringValue(scope, key); + if (!value.empty()) + parts << QStringLiteral("%1=%2").arg(QString::fromLatin1(key), + text(value)); + } + const std::string correlation = stringValue(frame, "correlationId"); + if (!correlation.empty()) + parts << QStringLiteral("correlation=%1").arg(text(correlation)); + if (kind == "result" && !frame.value("ok", false)) { + const std::string message = + stringValue(frame.value("error", nlohmann::json::object()), "message"); + if (!message.empty()) + parts << text(message); + } + record(parts.join(QStringLiteral(" "))); +} + +} // namespace codexui::codex::middle diff --git a/src/greenfield/codex/middle/InspectorPane.h b/src/greenfield/codex/middle/InspectorPane.h new file mode 100644 index 0000000..b50dc7a --- /dev/null +++ b/src/greenfield/codex/middle/InspectorPane.h @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_GREENFIELD_CODEX_MIDDLE_INSPECTORPANE_H +#define CODEXUI_GREENFIELD_CODEX_MIDDLE_INSPECTORPANE_H + +#include +#include +#include + +#include + +#include +#include +#include +#include + +class QLabel; +class QPlainTextEdit; +class QTabWidget; +class QVBoxLayout; + +namespace codexui::codex { + +class DiffViewer; +class PresentationModel; + +namespace middle { + +// The inspector owns only presentation snapshots. It never clears a visible +// tab in response to an unrelated frame and never participates in app-server +// state ownership. +class InspectorPane final : public QFrame { +public: + using RequestAction = std::function; + + explicit InspectorPane(QWidget *parent = nullptr); + + void setHideAction(std::function hide); + void setRequestActions(RequestAction review, RequestAction reject); + void refresh(const PresentationModel &model, + const std::string &selectedThreadId); + void appendProtocolFrame(const nlohmann::json &frame); + + [[nodiscard]] QTabWidget *tabs() const noexcept { return inspectorTabs; } + +private: + void refreshCurrentTab(); + void refreshPlan(); + void refreshAgents(); + void refreshChanges(); + void refreshRequests(); + void refreshState(); + void refreshProtocolStats(); + void showProtocolTail(); + void restoreProtocolScroll(bool followsTail, int pausedValue); + + const PresentationModel *currentModel = nullptr; + std::string currentThreadId; + RequestAction reviewRequest; + RequestAction rejectRequest; + std::function hideAction; + + QTabWidget *inspectorTabs = nullptr; + QTabWidget *infoTabs = nullptr; + QWidget *planContent = nullptr; + QVBoxLayout *planLayout = nullptr; + QWidget *agentsContent = nullptr; + QVBoxLayout *agentsLayout = nullptr; + QWidget *requestsContent = nullptr; + QVBoxLayout *requestsLayout = nullptr; + DiffViewer *diffViewer = nullptr; + QPlainTextEdit *stateView = nullptr; + QPlainTextEdit *protocolLog = nullptr; + QLabel *protocolStats = nullptr; + + QByteArray planSnapshot; + QByteArray agentsSnapshot; + QByteArray changesSnapshot; + QByteArray requestsSnapshot; + QByteArray stateSnapshot; + QByteArray protocolStatsSnapshot; + std::deque protocolLines; + std::uint64_t observedSequence = 0; + bool protocolFollowsTail = true; + bool mutatingProtocolLog = false; + int protocolPausedScrollValue = 0; + std::uint64_t protocolScrollRevision = 0; +}; + +} // namespace middle +} // namespace codexui::codex + +#endif diff --git a/src/greenfield/codex/middle/MiddleRegionWidget.cpp b/src/greenfield/codex/middle/MiddleRegionWidget.cpp new file mode 100644 index 0000000..9292d10 --- /dev/null +++ b/src/greenfield/codex/middle/MiddleRegionWidget.cpp @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/middle/MiddleRegionWidget.h" + +#include "codex/middle/ComposerPane.h" +#include "codex/middle/ConversationView.h" +#include "codex/middle/InspectorPane.h" +#include "codex/middle/ThreadPane.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace codexui::codex::middle { +namespace { + +QLabel *makeLabel(QString value, const char *kind = "body") { + auto *label = new QLabel(std::move(value)); + label->setProperty("kind", kind); + label->setTextFormat(Qt::PlainText); + label->setWordWrap(true); + label->setMinimumWidth(0); + label->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + label->setTextInteractionFlags(Qt::TextSelectableByMouse); + return label; +} + +QFrame *divider() { + auto *line = new QFrame; + line->setFixedHeight(1); + line->setStyleSheet(QStringLiteral("background:#d7dee8;")); + return line; +} + +int verticalIntent(const QWheelEvent *event) { + if (!event->pixelDelta().isNull()) + return event->pixelDelta().y(); + return event->angleDelta().y(); +} + +bool canConsume(const QAbstractScrollArea *area, int delta) { + if (!area) + return false; + const QScrollBar *bar = area->verticalScrollBar(); + if (!bar || bar->maximum() <= bar->minimum()) + return false; + if (delta > 0) + return bar->value() > bar->minimum(); + if (delta < 0) + return bar->value() < bar->maximum(); + return false; +} + +} // namespace + +MiddleRegionWidget::MiddleRegionWidget(QWidget *parent) : QWidget(parent) { + auto *root = new QVBoxLayout(this); + root->setContentsMargins(0, 0, 0, 0); + root->setSpacing(0); + + splitter = new QSplitter(Qt::Horizontal); + splitter->setChildrenCollapsible(false); + splitter->setHandleWidth(8); + + threadPane = new ThreadPane; + splitter->addWidget(threadPane); + + conversationRegion = new QFrame; + conversationRegion->setObjectName(QStringLiteral("conversation")); + conversationRegion->setStyleSheet( + QStringLiteral("QFrame#conversation{background:#f6f8fb;}")); + conversationRegion->setMinimumWidth(480); + auto *center = new QVBoxLayout(conversationRegion); + center->setContentsMargins(24, 14, 24, 12); + center->setSpacing(0); + auto *context = new QHBoxLayout; + auto *badge = makeLabel(QStringLiteral("THREAD"), "small"); + badge->setAlignment(Qt::AlignCenter); + badge->setFixedSize(58, 20); + badge->setStyleSheet(QStringLiteral( + "background:#e5eeff;color:#2f6feb;border-radius:5px;font-weight:600;")); + context->addWidget(badge); + context->addStretch(); + center->addLayout(context); + center->addSpacing(2); + conversationTitle = + makeLabel(QStringLiteral("No synchronized thread"), "heading"); + conversationMetadata = makeLabel({}, "meta"); + center->addWidget(conversationTitle); + center->addSpacing(2); + center->addWidget(conversationMetadata); + center->addSpacing(7); + center->addWidget(divider()); + center->addSpacing(7); + + noticeBar = new QFrame; + noticeBar->setStyleSheet(QStringLiteral( + "background:#fff4f2;border:1px solid #efc2bc;border-radius:6px;")); + auto *noticeLayout = new QHBoxLayout(noticeBar); + noticeLayout->setContentsMargins(10, 6, 8, 6); + noticeLabel = makeLabel({}, "meta"); + noticeLabel->setStyleSheet(QStringLiteral("color:#9d2e2e;")); + auto *dismiss = new QPushButton(QStringLiteral("Dismiss")); + dismiss->setProperty("kind", "subtle"); + dismiss->setFixedHeight(28); + noticeLayout->addWidget(noticeLabel, 1); + noticeLayout->addWidget(dismiss); + noticeBar->hide(); + connect(dismiss, &QPushButton::clicked, noticeBar, &QWidget::hide); + center->addWidget(noticeBar); + + conversationView = new ConversationView; + center->addWidget(conversationView, 1); + composerPane = new ComposerPane(conversationRegion); + composerPane->setExtraOverlayHeightAction( + [this](int height) { conversationView->setTrailingSpaceHeight(height); }); + center->addWidget(composerPane->canonicalReserve()); + splitter->addWidget(conversationRegion); + + inspectorPane = new InspectorPane; + splitter->addWidget(inspectorPane); + splitter->setStretchFactor(0, 0); + splitter->setStretchFactor(1, 1); + splitter->setStretchFactor(2, 0); + splitter->setSizes({282, 834, 404}); + root->addWidget(splitter); + + inspectorPane->setHideAction([this] { showInspector(false); }); +} + +ThreadPane &MiddleRegionWidget::threads() const noexcept { return *threadPane; } + +ConversationView &MiddleRegionWidget::conversation() const noexcept { + return *conversationView; +} + +ComposerPane &MiddleRegionWidget::composer() const noexcept { + return *composerPane; +} + +InspectorPane &MiddleRegionWidget::inspector() const noexcept { + return *inspectorPane; +} + +QSplitter *MiddleRegionWidget::splitterWidget() const noexcept { + return splitter; +} + +void MiddleRegionWidget::setThreadHeading(QString title, QString metadata) { + if (conversationTitle->text() != title) + conversationTitle->setText(std::move(title)); + if (conversationMetadata->text() != metadata) + conversationMetadata->setText(std::move(metadata)); +} + +void MiddleRegionWidget::showNotice(QString message, bool error) { + if (message.trimmed().isEmpty()) + return; + noticeLabel->setText(std::move(message)); + noticeBar->setStyleSheet( + error ? QStringLiteral("background:#fff4f2;border:1px solid #efc2bc;" + "border-radius:6px;") + : QStringLiteral("background:#fff8e8;border:1px solid #e5c77d;" + "border-radius:6px;")); + noticeLabel->setStyleSheet(error ? QStringLiteral("color:#9d2e2e;") + : QStringLiteral("color:#8a5a00;")); + noticeBar->show(); +} + +void MiddleRegionWidget::showSidebar(bool visible) { + if (threadPane->isVisible() == visible) + return; + threadPane->setVisible(visible); + if (paneVisibilityAction) + paneVisibilityAction(sidebarVisible(), inspectorVisible()); +} + +void MiddleRegionWidget::showInspector(bool visible) { + if (inspectorPane->isVisible() == visible) + return; + inspectorPane->setVisible(visible); + if (paneVisibilityAction) + paneVisibilityAction(sidebarVisible(), inspectorVisible()); +} + +bool MiddleRegionWidget::sidebarVisible() const noexcept { + return threadPane->isVisible(); +} + +bool MiddleRegionWidget::inspectorVisible() const noexcept { + return inspectorPane->isVisible(); +} + +void MiddleRegionWidget::setPaneVisibilityAction( + std::function action) { + paneVisibilityAction = std::move(action); +} + +bool MiddleRegionWidget::routeScrollEvent(QObject *watched, QEvent *event) { + if (!event || event->type() != QEvent::Wheel) + return false; + auto *target = qobject_cast(watched); + if (!target) + return false; + // Native delivery from ConversationView to its scrollbar must finish at + // that scrollbar. Re-routing it would recursively re-enter applyWheel(). + if (conversationView->dispatchingNativeWheel()) + return false; + const bool inCenter = + target == conversationRegion || conversationRegion->isAncestorOf(target); + const bool onHandle = + target == splitter->handle(1) || target == splitter->handle(2); + if (!inCenter && !onHandle) + return false; + + auto *wheel = static_cast(event); + if (inCenter) { + if (target == conversationView || target == conversationView->viewport() || + conversationView->isAncestorOf(target)) { + // Cards and the outer viewport naturally route to ConversationView; + // nested editors below are handled by the edge test. + for (QWidget *ancestor = target; ancestor && ancestor != conversationView; + ancestor = ancestor->parentWidget()) { + if (auto *nested = qobject_cast(ancestor); + nested && nested != conversationView) { + if (canConsume(nested, verticalIntent(wheel))) + return false; + break; + } + } + if (target == conversationView || target == conversationView->viewport()) + return false; + } else { + for (QWidget *ancestor = target; + ancestor && ancestor != conversationRegion; + ancestor = ancestor->parentWidget()) { + if (auto *nested = qobject_cast(ancestor)) { + if (canConsume(nested, verticalIntent(wheel))) + return false; + break; + } + } + } + } + const bool consumed = conversationView->forwardWheelEvent(wheel); + if (consumed) + event->accept(); + return consumed; +} + +} // namespace codexui::codex::middle diff --git a/src/greenfield/codex/middle/MiddleRegionWidget.h b/src/greenfield/codex/middle/MiddleRegionWidget.h new file mode 100644 index 0000000..391ea7f --- /dev/null +++ b/src/greenfield/codex/middle/MiddleRegionWidget.h @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_GREENFIELD_CODEX_MIDDLE_MIDDLEREGIONWIDGET_H +#define CODEXUI_GREENFIELD_CODEX_MIDDLE_MIDDLEREGIONWIDGET_H + +#include + +#include + +class QEvent; +class QFrame; +class QLabel; +class QSplitter; + +namespace codexui::codex::middle { + +class ComposerPane; +class ConversationView; +class InspectorPane; +class ThreadPane; + +// The sole geometry owner for the three-pane workspace. Protocol and domain +// decisions remain in ShellWidget; this class owns only visible layout and +// wheel routing across the complete center strip. +class MiddleRegionWidget final : public QWidget { +public: + explicit MiddleRegionWidget(QWidget *parent = nullptr); + + [[nodiscard]] ThreadPane &threads() const noexcept; + [[nodiscard]] ConversationView &conversation() const noexcept; + [[nodiscard]] ComposerPane &composer() const noexcept; + [[nodiscard]] InspectorPane &inspector() const noexcept; + [[nodiscard]] QSplitter *splitterWidget() const noexcept; + + void setThreadHeading(QString title, QString metadata); + void showNotice(QString message, bool error = true); + void showSidebar(bool visible); + void showInspector(bool visible); + [[nodiscard]] bool sidebarVisible() const noexcept; + [[nodiscard]] bool inspectorVisible() const noexcept; + void setPaneVisibilityAction( + std::function action); + + // Called by ShellWidget's application event filter. Returns true only when + // a wheel/touchpad event was consumed by the conversation. + bool routeScrollEvent(QObject *watched, QEvent *event); + +private: + QSplitter *splitter = nullptr; + ThreadPane *threadPane = nullptr; + QFrame *conversationRegion = nullptr; + QLabel *conversationTitle = nullptr; + QLabel *conversationMetadata = nullptr; + QFrame *noticeBar = nullptr; + QLabel *noticeLabel = nullptr; + ConversationView *conversationView = nullptr; + ComposerPane *composerPane = nullptr; + InspectorPane *inspectorPane = nullptr; + std::function paneVisibilityAction; +}; + +} // namespace codexui::codex::middle + +#endif diff --git a/src/greenfield/codex/middle/MiddleTypes.cpp b/src/greenfield/codex/middle/MiddleTypes.cpp new file mode 100644 index 0000000..614e719 --- /dev/null +++ b/src/greenfield/codex/middle/MiddleTypes.cpp @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/middle/MiddleTypes.h" + +#include + +namespace codexui::codex::middle { +namespace { + +void appendComponent(std::string &result, std::string_view value) { + result += std::to_string(value.size()); + result.push_back(':'); + result.append(value); +} + +} // namespace + +std::string stableKey(const CardKey &key) { + if (const auto *authoritative = std::get_if(&key)) { + std::string result = "item:"; + appendComponent(result, authoritative->threadId); + appendComponent(result, authoritative->turnId); + appendComponent(result, authoritative->itemId); + return result; + } + return "prompt:" + std::to_string(std::get(key).submissionId); +} + +bool terminalOutputHasVisibleText(QStringView output) { + for (qsizetype index = 0; index < output.size(); ++index) { + const ushort code = output[index].unicode(); + if (code == 0x9b) { + while (++index < output.size()) { + const ushort candidate = output[index].unicode(); + if (candidate >= 0x40 && candidate <= 0x7e) + break; + } + continue; + } + if (code == 0x90 || code == 0x98 || code == 0x9d || code == 0x9e || + code == 0x9f) { + while (++index < output.size()) { + const ushort candidate = output[index].unicode(); + if (candidate == 0x07 || candidate == 0x9c) + break; + if (candidate == 0x1b && index + 1 < output.size() && + output[index + 1].unicode() == '\\') { + ++index; + break; + } + } + continue; + } + if (code == 0x1b) { + if (++index >= output.size()) + break; + const ushort introducer = output[index].unicode(); + if (introducer == '[') { + while (++index < output.size()) { + const ushort candidate = output[index].unicode(); + if (candidate >= 0x40 && candidate <= 0x7e) + break; + } + continue; + } + if (introducer == ']' || introducer == 'P' || introducer == '^' || + introducer == '_' || introducer == 'X') { + while (++index < output.size()) { + if (output[index].unicode() == 0x07 || + output[index].unicode() == 0x9c) + break; + if (output[index].unicode() == 0x1b && index + 1 < output.size() && + output[index + 1].unicode() == '\\') { + ++index; + break; + } + } + continue; + } + if (introducer >= 0x20 && introducer <= 0x2f) { + while (++index < output.size()) { + const ushort candidate = output[index].unicode(); + if (candidate >= 0x30 && candidate <= 0x7e) + break; + } + } + continue; + } + if (output[index].isPrint() && !output[index].isSpace()) + return true; + } + return false; +} + +std::vector ConversationSnapshot::cardKeys() const { + std::vector result; + for (const TurnSection §ion : sections) + for (const VisibleCardData &card : section.cards) + result.push_back(card.key); + return result; +} + +const VisibleCardData * +ConversationSnapshot::find(const CardKey &key) const noexcept { + for (const TurnSection §ion : sections) + for (const VisibleCardData &card : section.cards) + if (card.key == key) + return &card; + return nullptr; +} + +} // namespace codexui::codex::middle diff --git a/src/greenfield/codex/middle/MiddleTypes.h b/src/greenfield/codex/middle/MiddleTypes.h new file mode 100644 index 0000000..220dee0 --- /dev/null +++ b/src/greenfield/codex/middle/MiddleTypes.h @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_GREENFIELD_CODEX_MIDDLE_MIDDLETYPES_H +#define CODEXUI_GREENFIELD_CODEX_MIDDLE_MIDDLETYPES_H + +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace codexui::codex::middle { + +inline constexpr qint64 AcknowledgementTransitionMilliseconds = 500; +inline constexpr std::size_t AuthoritativeHistoryPageSize = 80; + +struct AuthoritativeItemKey { + std::string threadId; + std::string turnId; + std::string itemId; + + auto operator<=>(const AuthoritativeItemKey &) const = default; +}; + +// Submission identifiers are process-wide and deliberately independent of a +// thread identifier. A locally admitted prompt therefore keeps its visual key +// when a new-thread draft receives its authoritative server thread id. +struct LocalPromptKey { + std::uint64_t submissionId = 0; + + auto operator<=>(const LocalPromptKey &) const = default; +}; + +using CardKey = std::variant; + +[[nodiscard]] std::string stableKey(const CardKey &key); +[[nodiscard]] bool terminalOutputHasVisibleText(QStringView output); + +enum class PromptState { Queued, InFlight, Accepted, Failed }; + +enum class CardKind { + UserMessage, + AgentMessage, + CommandExecution, + AgentActivity, + Reasoning, + FileChanges, + Plan, + GenericActivity, + LocalPrompt, +}; + +struct UserMessageData { + QString text; + + bool operator==(const UserMessageData &) const = default; +}; + +struct AgentMessageData { + QString text; + bool finalAnswer = false; + + bool operator==(const AgentMessageData &) const = default; +}; + +struct CommandExecutionData { + QString command; + QString output; + QString status; + QString cwd; + std::optional exitCode; + + bool operator==(const CommandExecutionData &) const = default; +}; + +struct AgentActivityData { + QString tool; + QString status; + QString kind; + QString prompt; + QString resultText; + QStringList receivers; + + bool operator==(const AgentActivityData &) const = default; +}; + +struct ReasoningData { + QString summary; + + bool operator==(const ReasoningData &) const = default; +}; + +struct FileChangesData { + QString status; + int pathCount = 0; + nlohmann::json changes = nlohmann::json::array(); + + // The conversation card shows only status and path count. Diff contents are + // owned by the Changes inspector and must not turn a visually identical + // conversation projection into a layout mutation. + bool operator==(const FileChangesData &other) const { + return status == other.status && pathCount == other.pathCount; + } +}; + +struct PlanData { + QString text; + + bool operator==(const PlanData &) const = default; +}; + +struct GenericActivityData { + QString type; + nlohmann::json raw = nlohmann::json::object(); + + bool operator==(const GenericActivityData &) const = default; +}; + +struct LocalPromptData { + std::uint64_t submissionId = 0; + QString prompt; + int attachmentCount = 0; + PromptState state = PromptState::Queued; + qint64 acceptedAtMilliseconds = 0; + QString error; + + [[nodiscard]] bool + acceptedTransitionActive(qint64 nowMilliseconds) const noexcept { + return state == PromptState::Accepted && acceptedAtMilliseconds > 0 && + nowMilliseconds >= acceptedAtMilliseconds && + nowMilliseconds - acceptedAtMilliseconds < + AcknowledgementTransitionMilliseconds; + } + + bool operator==(const LocalPromptData &) const = default; +}; + +using CardPayload = + std::variant; + +struct VisibleCardData { + CardKey key; + CardKind kind = CardKind::GenericActivity; + std::string threadId; + std::string turnId; + std::string itemId; + CardPayload payload = GenericActivityData{}; + + bool operator==(const VisibleCardData &) const = default; +}; + +// A section is a structural, visually transparent turn container. It contains +// only data that can affect the conversation presentation; turn lifecycle +// metadata belongs to the authoritative model and inspector. +struct TurnSection { + std::string key; + std::string turnId; + std::vector cards; + + bool operator==(const TurnSection &) const = default; +}; + +struct ConversationSnapshot { + std::string threadId; + std::vector sections; + std::size_t hiddenAuthoritativeItemCount = 0; + bool hasMore = false; + + [[nodiscard]] std::vector cardKeys() const; + [[nodiscard]] const VisibleCardData *find(const CardKey &key) const noexcept; + + bool operator==(const ConversationSnapshot &) const = default; +}; + +} // namespace codexui::codex::middle + +#endif // CODEXUI_GREENFIELD_CODEX_MIDDLE_MIDDLETYPES_H diff --git a/src/greenfield/codex/middle/PromptCoordinator.cpp b/src/greenfield/codex/middle/PromptCoordinator.cpp new file mode 100644 index 0000000..bcfc684 --- /dev/null +++ b/src/greenfield/codex/middle/PromptCoordinator.cpp @@ -0,0 +1,368 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/middle/PromptCoordinator.h" + +#include +#include +#include + +namespace codexui::codex::middle { +namespace { + +QString text(const std::string &value) { + return QString::fromUtf8(value.data(), static_cast(value.size())); +} + +std::string stringValue(const nlohmann::json &object, const char *key) { + if (!object.is_object()) + return {}; + const auto value = object.find(key); + return value != object.end() && value->is_string() ? value->get() + : std::string{}; +} + +QString userMessageText(const nlohmann::json &item) { + QStringList parts; + const auto content = item.find("content"); + if (content != item.end() && content->is_array()) { + for (const nlohmann::json &entry : *content) { + const std::string value = stringValue(entry, "text"); + if (!value.empty()) + parts.push_back(text(value)); + } + } + if (parts.empty()) { + const std::string value = stringValue(item, "text"); + if (!value.empty()) + parts.push_back(text(value)); + } + return parts.join(QStringLiteral("\n")); +} + +std::vector> +orderedItems(const std::string &threadId, const ThreadPresentation &thread) { + std::vector> result; + for (const std::string &turnId : thread.turnOrder) { + const auto turn = thread.turns.find(turnId); + if (turn == thread.turns.end()) + continue; + for (const std::string &itemId : turn->second.itemOrder) { + const auto item = turn->second.items.find(itemId); + if (item == turn->second.items.end()) + continue; + result.emplace_back(AuthoritativeItemKey{threadId, turnId, itemId}, + &item->second); + } + } + return result; +} + +} // namespace + +bool PromptSubmission::acceptedTransitionActive( + qint64 nowMilliseconds) const noexcept { + return state == PromptState::Accepted && acceptedAtMilliseconds > 0 && + nowMilliseconds >= acceptedAtMilliseconds && + nowMilliseconds - acceptedAtMilliseconds < + AcknowledgementTransitionMilliseconds; +} + +bool PromptSubmission::localCardVisible(qint64 nowMilliseconds) const noexcept { + return state == PromptState::Queued || state == PromptState::InFlight || + state == PromptState::Failed || !materializedItem || + acceptedTransitionActive(nowMilliseconds); +} + +std::uint64_t PromptCoordinator::admit( + std::string threadId, QString prompt, + std::vector attachments, nlohmann::json turnOptions, + const ThreadPresentation *authoritativeThread, + std::optional activeTurnId, qint64 nowMilliseconds) { + PromptSubmission submission; + submission.id = nextSubmissionId++; + submission.admissionOrdinal = nextAdmissionOrdinal++; + submission.threadId = std::move(threadId); + submission.clientUserMessageId = "codexui-" + + std::to_string(nowMilliseconds) + '-' + + std::to_string(submission.id); + submission.prompt = std::move(prompt); + submission.attachments = std::move(attachments); + submission.turnOptions = std::move(turnOptions); + submission.expectedTurnId = std::move(activeTurnId); + + if (authoritativeThread) { + const auto items = orderedItems(submission.threadId, *authoritativeThread); + if (!items.empty()) + submission.admissionAnchor = items.back().first; + } + + const std::uint64_t id = submission.id; + byThread[submission.threadId].push_back(std::move(submission)); + return id; +} + +std::optional +PromptCoordinator::beginNext(const std::string &threadId, + std::optional activeTurnId) { + auto found = byThread.find(threadId); + if (found == byThread.end()) + return std::nullopt; + if (std::any_of(found->second.begin(), found->second.end(), + [](const PromptSubmission &submission) { + return submission.state == PromptState::InFlight; + })) + return std::nullopt; + auto next = std::find_if(found->second.begin(), found->second.end(), + [](const PromptSubmission &submission) { + return submission.state == PromptState::Queued; + }); + if (next == found->second.end()) + return std::nullopt; + next->state = PromptState::InFlight; + // Start versus steer is an operation-time fact. A turn which was active + // when the prompt entered the local queue may have completed meanwhile. + next->expectedTurnId = std::move(activeTurnId); + return PromptDispatch{next->id, + next->threadId, + next->clientUserMessageId, + next->prompt, + next->attachments, + next->turnOptions, + next->expectedTurnId}; +} + +bool PromptCoordinator::acknowledge( + const std::string &threadId, std::uint64_t submissionId, + std::optional authoritativeTurnId, qint64 nowMilliseconds) { + PromptSubmission *pending = find(threadId, submissionId); + if (!pending || pending->state != PromptState::InFlight) + return false; + pending->state = PromptState::Accepted; + pending->acceptedAtMilliseconds = nowMilliseconds; + pending->error.clear(); + if (authoritativeTurnId) + pending->expectedTurnId = std::move(authoritativeTurnId); + return true; +} + +bool PromptCoordinator::fail(const std::string &threadId, + std::uint64_t submissionId, QString error) { + PromptSubmission *pending = find(threadId, submissionId); + if (!pending || (pending->state != PromptState::InFlight && + pending->state != PromptState::Queued)) + return false; + pending->state = PromptState::Failed; + pending->error = std::move(error); + return true; +} + +bool PromptCoordinator::requeue(const std::string &threadId, + std::uint64_t submissionId) { + PromptSubmission *pending = find(threadId, submissionId); + if (!pending || pending->state != PromptState::InFlight) + return false; + pending->state = PromptState::Queued; + return true; +} + +std::size_t PromptCoordinator::failQueued(const std::string &threadId, + const QString &error) { + auto found = byThread.find(threadId); + if (found == byThread.end()) + return 0; + std::size_t count = 0; + for (PromptSubmission &submission : found->second) { + if (submission.state != PromptState::Queued) + continue; + submission.state = PromptState::Failed; + submission.error = error; + ++count; + } + return count; +} + +bool PromptCoordinator::reassignThread(const std::string &fromThreadId, + const std::string &toThreadId) { + if (fromThreadId == toThreadId) + return true; + auto source = byThread.find(fromThreadId); + if (source == byThread.end()) + return true; + auto destination = byThread.find(toThreadId); + const bool sourceInFlight = + std::any_of(source->second.begin(), source->second.end(), + [](const PromptSubmission &submission) { + return submission.state == PromptState::InFlight; + }); + const bool destinationInFlight = + destination != byThread.end() && + std::any_of(destination->second.begin(), destination->second.end(), + [](const PromptSubmission &submission) { + return submission.state == PromptState::InFlight; + }); + if (sourceInFlight && destinationInFlight) + return false; + + std::vector moved = std::move(source->second); + byThread.erase(source); + for (PromptSubmission &submission : moved) { + submission.threadId = toThreadId; + if (submission.admissionAnchor) + submission.admissionAnchor->threadId = toThreadId; + if (submission.materializedItem) + submission.materializedItem->threadId = toThreadId; + } + auto &target = byThread[toThreadId]; + target.insert(target.end(), std::make_move_iterator(moved.begin()), + std::make_move_iterator(moved.end())); + std::ranges::sort(target, {}, &PromptSubmission::admissionOrdinal); + return true; +} + +void PromptCoordinator::reconcile( + const std::string &threadId, + const ThreadPresentation &authoritativeThread) { + auto found = byThread.find(threadId); + if (found == byThread.end()) + return; + const auto items = orderedItems(threadId, authoritativeThread); + std::set claimed; + for (const PromptSubmission &submission : found->second) + if (submission.materializedItem) + claimed.insert(*submission.materializedItem); + + for (PromptSubmission &submission : found->second) { + if (submission.materializedItem) + continue; + + // A prompt admitted before thread hydration is anchored exactly once when + // the authoritative tail first becomes available. Later output therefore + // cannot move the local card through the history. + if (!submission.admissionAnchor && + submission.state == PromptState::Queued && !items.empty()) + submission.admissionAnchor = items.back().first; + + const auto exact = std::ranges::find_if(items, [&](const auto &entry) { + const auto &[key, item] = entry; + return !claimed.contains(key) && + stringValue(item->raw, "type") == "userMessage" && + stringValue(item->raw, "clientId") == + submission.clientUserMessageId; + }); + if (exact != items.end()) { + submission.materializedItem = exact->first; + submission.expectedTurnId = exact->first.turnId; + claimed.insert(exact->first); + continue; + } + + // Semantic acknowledgement remains callback-only. Without protocol + // client-id support, do not guess from text before that callback arrives. + if (submission.state != PromptState::Accepted) + continue; + + std::size_t firstCandidate = 0; + if (submission.admissionAnchor) { + const auto anchor = std::ranges::find( + items, *submission.admissionAnchor, + [](const auto &entry) -> const AuthoritativeItemKey & { + return entry.first; + }); + if (anchor != items.end()) + firstCandidate = + static_cast(std::distance(items.begin(), anchor)) + 1; + } + + for (std::size_t index = firstCandidate; index < items.size(); ++index) { + const auto &[key, item] = items[index]; + if (submission.expectedTurnId && key.turnId != *submission.expectedTurnId) + continue; + if (claimed.contains(key) || + stringValue(item->raw, "type") != "userMessage" || + userMessageText(item->raw).trimmed() != submission.prompt.trimmed()) + continue; + submission.materializedItem = key; + if (!submission.expectedTurnId) + submission.expectedTurnId = key.turnId; + claimed.insert(key); + break; + } + } +} + +void PromptCoordinator::compactResolved(const std::string &threadId, + qint64 nowMilliseconds) { + auto found = byThread.find(threadId); + if (found == byThread.end()) + return; + for (PromptSubmission &submission : found->second) { + if (submission.state != PromptState::Accepted || + !submission.materializedItem || + submission.acceptedTransitionActive(nowMilliseconds) || + submission.clientUserMessageId.empty()) + continue; + std::string{}.swap(submission.clientUserMessageId); + QString{}.swap(submission.prompt); + std::vector{}.swap(submission.attachments); + submission.turnOptions = nlohmann::json::object(); + submission.error.clear(); + submission.error.squeeze(); + submission.expectedTurnId.reset(); + submission.acceptedAtMilliseconds = 0; + } +} + +std::span +PromptCoordinator::submissions(const std::string &threadId) const noexcept { + const auto found = byThread.find(threadId); + if (found == byThread.end()) + return {}; + return found->second; +} + +const PromptSubmission * +PromptCoordinator::submission(const std::string &threadId, + std::uint64_t submissionId) const noexcept { + const auto found = byThread.find(threadId); + if (found == byThread.end()) + return nullptr; + const auto candidate = + std::ranges::find(found->second, submissionId, &PromptSubmission::id); + return candidate == found->second.end() ? nullptr : &*candidate; +} + +bool PromptCoordinator::hasInFlight( + const std::string &threadId) const noexcept { + const auto pending = submissions(threadId); + return std::ranges::any_of(pending, [](const PromptSubmission &submission) { + return submission.state == PromptState::InFlight; + }); +} + +std::vector PromptCoordinator::queuedThreadIds() const { + std::vector result; + for (const auto &[threadId, submissions] : byThread) { + if (std::ranges::any_of(submissions, + [](const PromptSubmission &submission) { + return submission.state == PromptState::Queued; + })) + result.push_back(threadId); + } + return result; +} + +void PromptCoordinator::clearThread(const std::string &threadId) { + byThread.erase(threadId); +} + +PromptSubmission *PromptCoordinator::find(const std::string &threadId, + std::uint64_t submissionId) noexcept { + auto found = byThread.find(threadId); + if (found == byThread.end()) + return nullptr; + auto candidate = + std::ranges::find(found->second, submissionId, &PromptSubmission::id); + return candidate == found->second.end() ? nullptr : &*candidate; +} + +} // namespace codexui::codex::middle diff --git a/src/greenfield/codex/middle/PromptCoordinator.h b/src/greenfield/codex/middle/PromptCoordinator.h new file mode 100644 index 0000000..3c52802 --- /dev/null +++ b/src/greenfield/codex/middle/PromptCoordinator.h @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_GREENFIELD_CODEX_MIDDLE_PROMPTCOORDINATOR_H +#define CODEXUI_GREENFIELD_CODEX_MIDDLE_PROMPTCOORDINATOR_H + +#include "codex/FileSelectionDialog.h" +#include "codex/PresentationModel.h" +#include "codex/middle/MiddleTypes.h" + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace codexui::codex::middle { + +struct PromptSubmission { + std::uint64_t id = 0; + std::uint64_t admissionOrdinal = 0; + std::string threadId; + std::string clientUserMessageId; + QString prompt; + std::vector attachments; + nlohmann::json turnOptions = nlohmann::json::object(); + PromptState state = PromptState::Queued; + qint64 acceptedAtMilliseconds = 0; + QString error; + std::optional admissionAnchor; + std::optional expectedTurnId; + std::optional materializedItem; + + [[nodiscard]] bool + acceptedTransitionActive(qint64 nowMilliseconds) const noexcept; + [[nodiscard]] bool localCardVisible(qint64 nowMilliseconds) const noexcept; +}; + +struct PromptDispatch { + std::uint64_t id = 0; + std::string threadId; + std::string clientUserMessageId; + QString prompt; + std::vector attachments; + nlohmann::json turnOptions = nlohmann::json::object(); + std::optional expectedTurnId; +}; + +// Owns only local submission state. It does not schedule timers and cannot +// infer acknowledgement from presentation events: acknowledge() is intended +// to be called exclusively by the matching turn.start/turn.steer completion. +class PromptCoordinator final { +public: + [[nodiscard]] std::uint64_t + admit(std::string threadId, QString prompt, + std::vector attachments, nlohmann::json turnOptions, + const ThreadPresentation *authoritativeThread, + std::optional activeTurnId, qint64 nowMilliseconds); + + // Starts at most one queued submission for a thread. The active turn is + // sampled at dispatch time because earlier queued submissions may have + // created a turn since admission. + [[nodiscard]] std::optional + beginNext(const std::string &threadId, + std::optional activeTurnId = std::nullopt); + + [[nodiscard]] bool acknowledge(const std::string &threadId, + std::uint64_t submissionId, + std::optional authoritativeTurnId, + qint64 nowMilliseconds); + [[nodiscard]] bool fail(const std::string &threadId, + std::uint64_t submissionId, QString error); + [[nodiscard]] bool requeue(const std::string &threadId, + std::uint64_t submissionId); + std::size_t failQueued(const std::string &threadId, const QString &error); + + // Used when the app-server assigns an id to an explicit New Thread draft. + // LocalPromptKey is unaffected by this move. + [[nodiscard]] bool reassignThread(const std::string &fromThreadId, + const std::string &toThreadId); + + // Correlates prompts with authoritative userMessage items. Exact client ids + // may bind before acknowledgement so the awaiting card is never duplicated; + // the content fallback is used only after the real operation callback. + void reconcile(const std::string &threadId, + const ThreadPresentation &authoritativeThread); + + // Resolved submissions remain as lightweight authoritative-item aliases so + // their visual keys stay stable. Dispatch-only payload is released after the + // accepted transition and materialization are both complete. + void compactResolved(const std::string &threadId, qint64 nowMilliseconds); + + [[nodiscard]] std::span + submissions(const std::string &threadId) const noexcept; + [[nodiscard]] const PromptSubmission * + submission(const std::string &threadId, + std::uint64_t submissionId) const noexcept; + [[nodiscard]] bool hasInFlight(const std::string &threadId) const noexcept; + [[nodiscard]] std::vector queuedThreadIds() const; + + void clearThread(const std::string &threadId); + +private: + [[nodiscard]] PromptSubmission *find(const std::string &threadId, + std::uint64_t submissionId) noexcept; + + std::map> byThread; + std::uint64_t nextSubmissionId = 1; + std::uint64_t nextAdmissionOrdinal = 1; +}; + +} // namespace codexui::codex::middle + +#endif // CODEXUI_GREENFIELD_CODEX_MIDDLE_PROMPTCOORDINATOR_H diff --git a/src/greenfield/codex/middle/ThreadPane.cpp b/src/greenfield/codex/middle/ThreadPane.cpp new file mode 100644 index 0000000..7d89773 --- /dev/null +++ b/src/greenfield/codex/middle/ThreadPane.cpp @@ -0,0 +1,319 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/middle/ThreadPane.h" + +#include "codex/PresentationModel.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace codexui::codex::middle { +namespace { + +QString text(const std::string &value) { + return QString::fromUtf8(value.data(), static_cast(value.size())); +} + +QString displayStatus(const std::string &status) { + if (status == "inProgress" || status == "active") + return QStringLiteral("Running"); + if (status == "completed" || status == "idle") + return QStringLiteral("Completed"); + if (status == "failed" || status == "systemError") + return QStringLiteral("Failed"); + return status.empty() ? QStringLiteral("Unknown") : text(status); +} + +QLabel *makeLabel(QString value, const char *kind = "body") { + auto *label = new QLabel(std::move(value)); + label->setProperty("kind", kind); + label->setTextFormat(Qt::PlainText); + label->setWordWrap(true); + label->setMinimumWidth(0); + label->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + label->setTextInteractionFlags(Qt::TextSelectableByMouse); + return label; +} + +QFrame *statusDot() { + auto *dot = new QFrame; + dot->setObjectName(QStringLiteral("threadStatusDot")); + dot->setFixedSize(8, 8); + return dot; +} + +void updateRow(QWidget *row, const ThreadPresentation &thread, + std::size_t requestCount) { + auto *title = row->findChild(QStringLiteral("threadTitle")); + auto *status = row->findChild(QStringLiteral("threadStatus")); + auto *dot = row->findChild(QStringLiteral("threadStatusDot")); + QString titleText = text(thread.title); + if (titleText.isEmpty()) + titleText = text(thread.id.substr(0, 12)); + if (requestCount != 0) + titleText.prepend(QStringLiteral("! ")); + title->setText(titleText); + status->setText(displayStatus(thread.status)); + QString color = QStringLiteral("#98a2b3"); + if (requestCount != 0) + color = QStringLiteral("#a76812"); + else if (thread.status == "active" || thread.status == "inProgress") + color = QStringLiteral("#2f6feb"); + else if (thread.status == "failed" || thread.status == "systemError") + color = QStringLiteral("#b83a3a"); + dot->setStyleSheet( + QStringLiteral("background:%1;border-radius:4px;").arg(color)); +} + +QWidget *createRow() { + auto *row = new QWidget; + row->setAttribute(Qt::WA_TransparentForMouseEvents); + row->setStyleSheet(QStringLiteral("background:transparent;")); + auto *layout = new QHBoxLayout(row); + layout->setContentsMargins(5, 2, 5, 2); + layout->setSpacing(8); + layout->addWidget(statusDot()); + auto *copy = new QVBoxLayout; + copy->setContentsMargins(0, 0, 0, 0); + copy->setSpacing(1); + auto *title = makeLabel({}, "title"); + title->setObjectName(QStringLiteral("threadTitle")); + title->setStyleSheet(QStringLiteral("font-weight:500;")); + auto *status = makeLabel({}, "meta"); + status->setObjectName(QStringLiteral("threadStatus")); + copy->addWidget(title); + copy->addWidget(status); + layout->addLayout(copy, 1); + return row; +} + +} // namespace + +ThreadPane::ThreadPane(QWidget *parent) : QFrame(parent) { + setObjectName(QStringLiteral("sidebar")); + setStyleSheet(QStringLiteral("QFrame#sidebar{background:#f8fafc;}")); + setMinimumWidth(220); + setMaximumWidth(440); + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(10, 14, 10, 17); + layout->setSpacing(0); + auto *header = new QHBoxLayout; + header->setContentsMargins(8, 0, 6, 8); + header->addWidget(makeLabel(QStringLiteral("WORK"), "section")); + header->addStretch(); + auto *hide = new QPushButton(QStringLiteral("Hide")); + hide->setProperty("kind", "subtle"); + hide->setFixedSize(52, 24); + connect(hide, &QPushButton::clicked, this, [this] { + if (actions.hide) + actions.hide(); + }); + header->addWidget(hide); + layout->addLayout(header); + + auto *create = new QPushButton(QStringLiteral("+ New thread")); + create->setFixedHeight(36); + create->setStyleSheet(QStringLiteral( + "QPushButton{background:#ffffff;color:#2f6feb;border:1px solid #bfd3f9;" + "border-radius:8px;text-align:left;padding-left:14px;font-weight:600;}" + "QPushButton:hover{background:#e5eeff;border-color:#2f6feb;}" + "QPushButton:disabled{background:#f6f8fb;color:#98a2b3;" + "border-color:#d7dee8;}")); + connect(create, &QPushButton::clicked, this, [this] { + if (actions.newThread) + actions.newThread(); + }); + layout->addWidget(create); + layout->addSpacing(8); + + auto *toolbar = new QHBoxLayout; + toolbar->setContentsMargins(4, 0, 4, 6); + auto *refresh = new QPushButton(QStringLiteral("Refresh")); + refresh->setProperty("kind", "subtle"); + refresh->setFixedHeight(28); + connect(refresh, &QPushButton::clicked, this, [this] { + if (actions.refresh) + actions.refresh(); + }); + toolbar->addWidget(refresh); + toolbar->addStretch(); + layout->addLayout(toolbar); + + list = new QListWidget; + list->setObjectName(QStringLiteral("threadList")); + list->setSelectionMode(QAbstractItemView::SingleSelection); + list->setContextMenuPolicy(Qt::CustomContextMenu); + list->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + list->setTextElideMode(Qt::ElideRight); + list->setStyleSheet(QStringLiteral( + "QListWidget#threadList{background:transparent;border:0;outline:0;}" + "QListWidget#threadList::item{min-height:30px;border:0;border-radius:5px;" + "padding:2px 8px;color:#344054;}" + "QListWidget#threadList::item:hover{background:#eef3fa;}" + "QListWidget#threadList::item:selected{background:#e5eeff;" + "color:#1d2633;font-weight:600;}")); + connect(list, &QListWidget::itemSelectionChanged, this, [this] { + if (actions.select) { + const std::string id = visiblySelectedThreadId(); + if (!id.empty()) + actions.select(id); + } + }); + connect(list, &QListWidget::customContextMenuRequested, this, + [this](const QPoint &position) { showContextMenu(position); }); + layout->addWidget(list); +} + +void ThreadPane::setActions(Actions next) { actions = std::move(next); } + +void ThreadPane::refresh(const PresentationModel &model, + const std::string &selectedThreadId) { + currentModel = &model; + const std::vector &authoritativeOrder = model.threadOrder(); + std::erase_if(retainedVisibleThreads, [&](const std::string &id) { + return !model.thread(id) || + std::find(authoritativeOrder.begin(), authoritativeOrder.end(), + id) != authoritativeOrder.end(); + }); + if (!selectedThreadId.empty() && model.thread(selectedThreadId) && + std::find(authoritativeOrder.begin(), authoritativeOrder.end(), + selectedThreadId) == authoritativeOrder.end() && + std::find(retainedVisibleThreads.begin(), retainedVisibleThreads.end(), + selectedThreadId) == retainedVisibleThreads.end()) { + retainedVisibleThreads.insert(retainedVisibleThreads.begin(), + selectedThreadId); + } + std::vector visibleOrder = retainedVisibleThreads; + visibleOrder.insert(visibleOrder.end(), authoritativeOrder.begin(), + authoritativeOrder.end()); + nlohmann::json visible = nlohmann::json::array(); + for (const std::string &id : visibleOrder) { + const ThreadPresentation *thread = model.thread(id); + if (!thread) + continue; + visible.push_back({{"id", id}, + {"title", thread->title}, + {"cwd", thread->cwd}, + {"status", thread->status}, + {"pending", model.pendingRequestCount(id)}}); + } + const std::string serialized = + nlohmann::json{{"selected", selectedThreadId}, {"rows", visible}}.dump(); + const QByteArray next(serialized.data(), + static_cast(serialized.size())); + if (next == visibleSnapshot) + return; + visibleSnapshot = next; + list->blockSignals(true); + list->setUpdatesEnabled(false); + // Selection is a projection of selectedThreadId, never retained widget + // state. This also makes an explicit New Thread draft visibly select no + // existing row. + list->clearSelection(); + list->setCurrentRow(-1); + std::unordered_set retained; + int wantedIndex = 0; + for (const std::string &id : visibleOrder) { + const ThreadPresentation *thread = model.thread(id); + if (!thread) + continue; + retained.insert(id); + QListWidgetItem *item = nullptr; + const auto found = rows.find(id); + if (found == rows.end()) { + item = new QListWidgetItem; + item->setSizeHint(QSize(0, 48)); + item->setData(Qt::UserRole, text(id)); + list->insertItem(wantedIndex, item); + list->setItemWidget(item, createRow()); + rows[id] = item; + } else { + item = found->second; + const int currentIndex = list->row(item); + if (currentIndex != wantedIndex) { + // Removing an index widget transfers it into Qt's deferred-deletion + // path. It must never be attached again after moving the item. + list->removeItemWidget(item); + item = list->takeItem(currentIndex); + list->insertItem(wantedIndex, item); + list->setItemWidget(item, createRow()); + rows[id] = item; + } + } + item->setToolTip(text(thread->cwd)); + updateRow(list->itemWidget(item), *thread, model.pendingRequestCount(id)); + if (id == selectedThreadId) + list->setCurrentItem(item); + ++wantedIndex; + } + for (auto it = rows.begin(); it != rows.end();) { + if (retained.contains(it->first)) { + ++it; + continue; + } + delete list->takeItem(list->row(it->second)); + it = rows.erase(it); + } + list->setUpdatesEnabled(true); + list->blockSignals(false); +} + +std::string ThreadPane::visiblySelectedThreadId() const { + const QList selected = list->selectedItems(); + return selected.size() == 1 && selected.front() + ? selected.front()->data(Qt::UserRole).toString().toStdString() + : std::string{}; +} + +void ThreadPane::showContextMenu(const QPoint &position) { + QListWidgetItem *item = list->itemAt(position); + if (!item || !currentModel) + return; + const std::string id = item->data(Qt::UserRole).toString().toStdString(); + const ThreadPresentation *thread = currentModel->thread(id); + if (!thread) + return; + QMenu menu(list); + menu.addAction(QStringLiteral("Reload"), this, [this, id] { + if (actions.reload) + actions.reload(id); + }); + const bool canControl = currentModel->connection().connected && + currentModel->connection().role == "controller"; + QAction *rename = menu.addAction(QStringLiteral("Rename"), this, [this, id] { + if (actions.rename) + actions.rename(id); + }); + QAction *fork = menu.addAction(QStringLiteral("Fork"), this, [this, id] { + if (actions.fork) + actions.fork(id); + }); + QAction *archive = + menu.addAction(thread->archived ? QStringLiteral("Unarchive") + : QStringLiteral("Archive"), + this, [this, id] { + if (actions.toggleArchive) + actions.toggleArchive(id); + }); + menu.addSeparator(); + QAction *remove = menu.addAction(QStringLiteral("Delete"), this, [this, id] { + if (actions.remove) + actions.remove(id); + }); + rename->setEnabled(canControl); + fork->setEnabled(canControl); + archive->setEnabled(canControl); + remove->setEnabled(canControl); + menu.exec(list->viewport()->mapToGlobal(position)); +} + +} // namespace codexui::codex::middle diff --git a/src/greenfield/codex/middle/ThreadPane.h b/src/greenfield/codex/middle/ThreadPane.h new file mode 100644 index 0000000..2f7ba18 --- /dev/null +++ b/src/greenfield/codex/middle/ThreadPane.h @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_GREENFIELD_CODEX_MIDDLE_THREADPANE_H +#define CODEXUI_GREENFIELD_CODEX_MIDDLE_THREADPANE_H + +#include +#include + +#include +#include +#include +#include + +class QListWidget; +class QListWidgetItem; + +namespace codexui::codex { +class PresentationModel; + +namespace middle { + +class ThreadPane final : public QFrame { +public: + struct Actions { + std::function newThread; + std::function refresh; + std::function hide; + std::function select; + std::function reload; + std::function rename; + std::function fork; + std::function toggleArchive; + std::function remove; + }; + + explicit ThreadPane(QWidget *parent = nullptr); + + void setActions(Actions actions); + void refresh(const PresentationModel &model, + const std::string &selectedThreadId); + [[nodiscard]] std::string visiblySelectedThreadId() const; + +private: + void showContextMenu(const QPoint &position); + + const PresentationModel *currentModel = nullptr; + Actions actions; + QListWidget *list = nullptr; + std::unordered_map rows; + std::vector retainedVisibleThreads; + QByteArray visibleSnapshot; +}; + +} // namespace middle +} // namespace codexui::codex + +#endif diff --git a/src/main.cpp b/src/main.cpp deleted file mode 100644 index b69f36a..0000000 --- a/src/main.cpp +++ /dev/null @@ -1,16 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "app/Application.h" - -#include - -int main(int argc, char* argv[]) -{ - QApplication qtApplication(argc, argv); - qtApplication.setApplicationName("CodexUI"); - qtApplication.setOrganizationName("SNodeC"); - - codexui::Application application; - application.show(); - return qtApplication.exec(); -} diff --git a/src/ui/AnchoredTurnSurface.cpp b/src/ui/AnchoredTurnSurface.cpp deleted file mode 100644 index ad5ca34..0000000 --- a/src/ui/AnchoredTurnSurface.cpp +++ /dev/null @@ -1,82 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "ui/AnchoredTurnSurface.h" - -#include "ui/UpcomingTurnDock.h" - -#include -#include - -#include - -namespace codexui { - -AnchoredTurnSurface::AnchoredTurnSurface(QWidget* parent) - : QWidget(parent) -{ - setObjectName(QStringLiteral("anchoredTurnSurface")); - setAttribute(Qt::WA_StyledBackground, true); - setStyleSheet(QStringLiteral("#anchoredTurnSurface{background:#f6f8fb;}")); -} - -void AnchoredTurnSurface::setConversationWidget(QWidget* widget) -{ - if (conversation == widget) - return; - if (conversation) - conversation->removeEventFilter(this); - conversation = widget; - if (conversation) { - conversation->setParent(this); - conversation->installEventFilter(this); - conversation->show(); - conversation->lower(); - } - relayout(); -} - -void AnchoredTurnSurface::setUpcomingTurnDock(UpcomingTurnDock* widget) -{ - if (dock == widget) - return; - if (dock) - dock->removeEventFilter(this); - dock = widget; - if (dock) { - dock->setParent(this); - dock->installEventFilter(this); - dock->show(); - dock->raise(); - connect(dock, &UpcomingTurnDock::dockHeightChanged, this, [this] { relayout(); }); - } - relayout(); -} - -bool AnchoredTurnSurface::eventFilter(QObject* watched, QEvent* event) -{ - if ((watched == dock || watched == conversation) - && (event->type() == QEvent::LayoutRequest || event->type() == QEvent::Resize - || event->type() == QEvent::Show)) - relayout(); - return QWidget::eventFilter(watched, event); -} - -void AnchoredTurnSurface::resizeEvent(QResizeEvent* event) -{ - QWidget::resizeEvent(event); - relayout(); -} - -void AnchoredTurnSurface::relayout() -{ - const int base = dock ? dock->baseHeight() : 0; - if (conversation) - conversation->setGeometry(0, 0, width(), std::max(0, height() - base)); - if (dock) { - const int dockHeight = std::min(height(), std::max(base, dock->height())); - dock->setGeometry(0, height() - dockHeight, width(), dockHeight); - dock->raise(); - } -} - -} // namespace codexui diff --git a/src/ui/AnchoredTurnSurface.h b/src/ui/AnchoredTurnSurface.h deleted file mode 100644 index b738239..0000000 --- a/src/ui/AnchoredTurnSurface.h +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#ifndef CODEXUI_UI_ANCHOREDTURNSURFACE_H -#define CODEXUI_UI_ANCHOREDTURNSURFACE_H - -#include - -class QResizeEvent; - -namespace codexui { - -class UpcomingTurnDock; - -// Keeps the conversation viewport at a fixed geometry and anchors the dock to -// the bottom edge. When the composer grows, only the dock's top edge moves and -// the additional area overlays the conversation. -class AnchoredTurnSurface final : public QWidget -{ - Q_OBJECT - -public: - explicit AnchoredTurnSurface(QWidget* parent = nullptr); - void setConversationWidget(QWidget* widget); - void setUpcomingTurnDock(UpcomingTurnDock* widget); - -protected: - bool eventFilter(QObject* watched, QEvent* event) override; - void resizeEvent(QResizeEvent* event) override; - -private: - void relayout(); - - QWidget* conversation = nullptr; - UpcomingTurnDock* dock = nullptr; -}; - -} // namespace codexui - -#endif // CODEXUI_UI_ANCHOREDTURNSURFACE_H diff --git a/src/ui/ConversationWidget.cpp b/src/ui/ConversationWidget.cpp deleted file mode 100644 index 0237920..0000000 --- a/src/ui/ConversationWidget.cpp +++ /dev/null @@ -1,4132 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "ui/ConversationWidget.h" - -#include "ui/AnchoredTurnSurface.h" -#include "ui/UpcomingTurnDock.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -namespace codexui -{ -namespace -{ -namespace sdk = ai::openai::codex::frontend::client; -namespace frontend = ai::openai::codex::frontend; - -// Incomplete replacement proofs stay bounded even though a fully loaded -// thread materializes all retained history. A partial publication that would -// require more work simply keeps the existing presentation until recovery. -constexpr qsizetype recoveryInspectionItemBudget = 256; -constexpr qsizetype recoveryInspectionTurnBudget = 32; -constexpr std::size_t maximumActivityItemsPerSegment = 16; -constexpr qsizetype largeMessageEditorThreshold = 64 * 1024; -constexpr int largeMessageEditorHeight = 240; - -struct ActivityPresentation -{ - QString title; - QString detail; - QString status; - QString tail; - bool truncated = false; - std::optional detailChannel; - struct DeferredItemText - { - sdk::State state; - ai::openai::codex::typed::ItemId itemId; - ai::openai::codex::typed::ThreadId threadId; - ai::openai::codex::typed::TurnId turnId; - sdk::ItemContentChannel channel = sdk::ItemContentChannel::AgentText; - std::uint64_t contentRevision = 0; - std::uint64_t utf8Bytes = 0; - }; - std::optional deferredDetail; - std::optional deferredOutput; -}; - -QString fromUtf8(std::string_view value) -{ - return QString::fromUtf8(value.data(), static_cast(value.size())); -} - -QString fromUtf8(const std::string& value) -{ - return QString::fromStdString(value); -} - -std::string_view itemContent(const sdk::ItemState& item, - sdk::ItemContentChannel channel) noexcept -{ - const std::optional* content = nullptr; - switch (channel) - { - case sdk::ItemContentChannel::AgentText: - content = &item.agentText; - break; - case sdk::ItemContentChannel::ReasoningText: - content = &item.reasoningText; - break; - case sdk::ItemContentChannel::ReasoningSummary: - content = &item.reasoningSummary; - break; - case sdk::ItemContentChannel::CommandOutput: - content = &item.commandOutput; - break; - } - return content && *content ? std::string_view(**content) : std::string_view{}; -} - -std::optional deferredItemText( - const sdk::State& state, - const ai::openai::codex::typed::ThreadId& threadId, - const ai::openai::codex::typed::TurnId& turnId, - const ai::openai::codex::typed::ItemId& itemId, - sdk::ItemContentChannel channel) -{ - const auto descriptor = state.itemContentDescriptor( - threadId, turnId, itemId, channel); - if (!descriptor || !descriptor->present - || descriptor->retainedUtf8Bytes == 0) - return std::nullopt; - return ActivityPresentation::DeferredItemText{ - state, - itemId, - threadId, - turnId, - channel, - descriptor->contentRevision, - descriptor->retainedUtf8Bytes}; -} - -std::optional deferredItemText( - const sdk::State& state, - const sdk::ItemState& item, - sdk::ItemContentChannel channel) -{ - const std::string_view content = itemContent(item, channel); - if (content.empty() || !item.threadId || !item.turnId) - return std::nullopt; - auto source = deferredItemText( - state, *item.threadId, *item.turnId, item.id, channel); - if (!source - || source->utf8Bytes != static_cast(content.size())) - return std::nullopt; - return source; -} - -bool sameDeferredItemText( - const ActivityPresentation::DeferredItemText& left, - const ActivityPresentation::DeferredItemText& right) noexcept -{ - return left.contentRevision == right.contentRevision - && left.itemId == right.itemId - && left.threadId == right.threadId - && left.turnId == right.turnId - && left.channel == right.channel - && left.utf8Bytes == right.utf8Bytes; -} - -QString materializeDeferredItemText( - const ActivityPresentation::DeferredItemText& source) -{ - const sdk::ItemState* item = source.state.item( - source.threadId, source.turnId, source.itemId); - return item ? fromUtf8(itemContent(*item, source.channel)) : QString{}; -} - -QString humanize(QString value) -{ - value.replace(QLatin1Char('_'), QLatin1Char(' ')); - value.replace(QLatin1Char('-'), QLatin1Char(' ')); - for (qsizetype index = 1; index < value.size(); ++index) - { - if (value.at(index).isUpper() && value.at(index - 1).isLower()) - { - value.insert(index, QLatin1Char(' ')); - ++index; - } - } - if (!value.isEmpty()) value[0] = value.at(0).toUpper(); - return value; -} - -QString compact(const QString& value, qsizetype maximum = 500) -{ - if (value.size() <= maximum) return value; - return value.left(maximum).trimmed() + QStringLiteral("…"); -} - -QString plainTooltip(const QString& value) -{ - return Qt::convertFromPlainText(value, Qt::WhiteSpaceNormal); -} - -QString singleLinePreview(QString value, qsizetype maximum = 180) -{ - value.replace(QLatin1Char('\n'), QLatin1Char(' ')); - value.replace(QLatin1Char('\r'), QLatin1Char(' ')); - return compact(value.simplified(), maximum); -} - -QString compactId(const std::string& id) -{ - const QString value = fromUtf8(id); - return value.size() > 12 ? value.left(6) + QChar(0x2026) + value.right(5) : value; -} - -QLabel* textLabel(const QString& text, const char* kind = nullptr) -{ - auto* result = new QLabel(text); - result->setTextFormat(Qt::PlainText); - if (kind) result->setProperty("kind", kind); - return result; -} - -class WrappingLabel final : public QLabel -{ -public: - explicit WrappingLabel(const QString& text, bool markdown = false) - : markdown(markdown) - { - setTextFormat(markdown ? Qt::RichText : Qt::PlainText); - setWordWrap(true); - QSizePolicy policy(QSizePolicy::Ignored, QSizePolicy::Preferred); - policy.setHeightForWidth(true); - setSizePolicy(policy); - if (markdown) { - setTextInteractionFlags(Qt::TextBrowserInteraction); - setOpenExternalLinks(false); - connect(this, &QLabel::linkActivated, this, [](const QString& target) { - const QUrl url = QUrl::fromUserInput(target); - if (url.scheme() == QStringLiteral("https") - || url.scheme() == QStringLiteral("http")) - (void)QDesktopServices::openUrl(url); - }); - } - setContent(text); - } - - bool setContent(const QString& text) - { - if (text == sourceText) - return false; - sourceText = text; - setProperty("sourceText", sourceText); - if (!markdown) { - const int previousHeight = preferredHeight(); - heightCache.clear(); - QLabel::setText(text); - updateGeometry(); - return previousHeight != preferredHeight(); - } - - return renderMarkdownNow(); - } - - [[nodiscard]] const QString& content() const noexcept { return sourceText; } - - int heightForWidth(int width) const override - { - const auto found = heightCache.constFind(width); - if (found != heightCache.cend()) - return *found; - const int height = QLabel::heightForWidth(width); - heightCache.insert(width, height); - return height; - } - -protected: - void changeEvent(QEvent* event) override - { - if (event->type() == QEvent::FontChange || event->type() == QEvent::StyleChange) - heightCache.clear(); - if (markdown && event->type() == QEvent::FontChange) - renderMarkdownNow(); - QLabel::changeEvent(event); - } - -private: - [[nodiscard]] int preferredHeight() const - { - const int availableWidth = width(); - return availableWidth > 0 ? heightForWidth(availableWidth) : sizeHint().height(); - } - - bool renderMarkdownNow() - { - const int previousHeight = preferredHeight(); - heightCache.clear(); - setTextFormat(Qt::RichText); - QLabel::setText(safeMarkdownHtml(sourceText, font())); - updateGeometry(); - setProperty("markdownRenderMode", QStringLiteral("markdown")); - return previousHeight != preferredHeight(); - } - - static QString safeMarkdownHtml(const QString& markdownText, const QFont& renderFont) - { - QTextDocument document; - document.setDefaultFont(renderFont); - document.setDefaultStyleSheet(QStringLiteral( - "a{color:#2f6feb;} code{font-family:monospace;background:#eef1f5;}" - "pre{font-family:monospace;background:#eef1f5;white-space:pre-wrap;}")); - document.setMarkdown( - markdownText, - QTextDocument::MarkdownFeatures(QTextDocument::MarkdownDialectGitHub) - | QTextDocument::MarkdownNoHTML); - - struct ImageRange { int start; int length; QString alt; }; - std::vector images; - for (QTextBlock block = document.begin(); block.isValid(); block = block.next()) { - for (auto iterator = block.begin(); !iterator.atEnd(); ++iterator) { - const QTextFragment fragment = iterator.fragment(); - if (!fragment.isValid() || !fragment.charFormat().isImageFormat()) - continue; - const QTextImageFormat format = fragment.charFormat().toImageFormat(); - images.push_back(ImageRange{ - fragment.position(), - fragment.length(), - format.property(QTextFormat::ImageAltText).toString()}); - } - } - for (auto iterator = images.rbegin(); iterator != images.rend(); ++iterator) { - QTextCursor cursor(&document); - cursor.setPosition(iterator->start); - cursor.setPosition(iterator->start + iterator->length, QTextCursor::KeepAnchor); - cursor.insertText(iterator->alt.isEmpty() - ? QStringLiteral("[Image]") - : QStringLiteral("[Image: %1]").arg(iterator->alt)); - } - return document.toHtml(); - } - - bool markdown = false; - QString sourceText; - mutable QHash heightCache; -}; - -class StreamingMessageView final : public QTextEdit -{ -public: - explicit StreamingMessageView(const QString& text) - : sourceText(text) - , sourceUtf8Bytes(static_cast(text.toUtf8().size())) - { - measurementDocument = new QTextDocument(this); - QSizePolicy policy(QSizePolicy::Ignored, QSizePolicy::Preferred); - policy.setHeightForWidth(true); - setSizePolicy(policy); - setReadOnly(true); - setUndoRedoEnabled(false); - setAcceptRichText(false); - setFrameStyle(QFrame::NoFrame); - setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - setStyleSheet(QStringLiteral("QTextEdit{background:transparent;border:0;padding:0;}")); - viewport()->setAutoFillBackground(false); - document()->setDocumentMargin(0.0); - document()->setDefaultFont(font()); - document()->setPlainText(text); - measurementDocument->setDocumentMargin(0.0); - measurementDocument->setDefaultFont(font()); - measurementDocument->setPlainText(text); - setProperty("sourceUtf8Bytes", static_cast(sourceUtf8Bytes)); - setProperty("streamAppendCount", 0); - setProperty("fullReplacementCount", 0); - setProperty("geometryInvalidationCount", 0); - setProperty("sourceMaterializationCount", 0); - setProperty("markdownRenderMode", QStringLiteral("streaming-plain")); - } - - [[nodiscard]] const QString& content() const noexcept { return sourceText; } - [[nodiscard]] std::uint64_t utf8Bytes() const noexcept { return sourceUtf8Bytes; } - - bool replaceContent(const QString& text) - { - if (text == sourceText) - return false; - if (text.startsWith(sourceText)) - { - const auto applied = applyAppend( - sourceUtf8Bytes, 0, text.mid(sourceText.size())); - return applied.value_or(false); - } - const int previousHeight = preferredHeight(); - sourceText = text; - sourceUtf8Bytes = static_cast(text.toUtf8().size()); - document()->setPlainText(text); - measurementDocument->setPlainText(text); - heightCache.clear(); - const bool geometryChanged = previousHeight != preferredHeight(); - setProperty("sourceUtf8Bytes", static_cast(sourceUtf8Bytes)); - setProperty("fullReplacementCount", property("fullReplacementCount").toULongLong() + 1); - if (geometryChanged) - invalidateGeometry(); - return geometryChanged; - } - - std::optional applyAppend(std::uint64_t baseContentBytes, - std::uint64_t discardPrefixBytes, - const QString& delta) - { - if (baseContentBytes != sourceUtf8Bytes || discardPrefixBytes > sourceUtf8Bytes) - return std::nullopt; - - const int previousHeight = preferredHeight(); - const QByteArray deltaUtf8 = delta.toUtf8(); - if (discardPrefixBytes == 0) - { - sourceText.append(delta); - QTextCursor cursor(document()); - cursor.movePosition(QTextCursor::End); - cursor.insertText(delta); - QTextCursor measurementCursor(measurementDocument); - measurementCursor.movePosition(QTextCursor::End); - measurementCursor.insertText(delta); - } - else - { - const QByteArray previousUtf8 = sourceText.toUtf8(); - const QByteArray nextUtf8 = previousUtf8.mid( - static_cast(discardPrefixBytes)) + deltaUtf8; - sourceText = QString::fromUtf8(nextUtf8); - document()->setPlainText(sourceText); - measurementDocument->setPlainText(sourceText); - } - sourceUtf8Bytes = baseContentBytes - discardPrefixBytes - + static_cast(deltaUtf8.size()); - heightCache.clear(); - const bool geometryChanged = previousHeight != preferredHeight(); - setProperty("sourceUtf8Bytes", static_cast(sourceUtf8Bytes)); - setProperty("streamAppendCount", property("streamAppendCount").toULongLong() + 1); - if (geometryChanged) - invalidateGeometry(); - return geometryChanged; - } - - bool hasHeightForWidth() const override { return true; } - - int heightForWidth(int width) const override - { - const auto found = heightCache.constFind(width); - if (found != heightCache.cend()) - return *found; - const qreal textWidth = qMax(1, width); - if (measurementDocument->textWidth() != textWidth) - measurementDocument->setTextWidth(textWidth); - const int height = qCeil(measurementDocument->size().height()); - heightCache.insert(width, height); - return height; - } - - QSize sizeHint() const override - { - const int preferredWidth = width() > 0 ? width() : 480; - return QSize(preferredWidth, heightForWidth(preferredWidth)); - } - -protected: - void wheelEvent(QWheelEvent* event) override - { - // This view grows with its document; the enclosing conversation owns - // vertical navigation. - event->ignore(); - } - - void resizeEvent(QResizeEvent* event) override - { - QTextEdit::resizeEvent(event); - const qreal textWidth = qMax(1, viewport()->width()); - if (document()->textWidth() != textWidth) - document()->setTextWidth(textWidth); - } - - void changeEvent(QEvent* event) override - { - if (event->type() == QEvent::FontChange || event->type() == QEvent::StyleChange) - { - document()->setDefaultFont(font()); - if (measurementDocument) - measurementDocument->setDefaultFont(font()); - heightCache.clear(); - invalidateGeometry(); - } - QTextEdit::changeEvent(event); - } - -private: - void invalidateGeometry() - { - setProperty( - "geometryInvalidationCount", - property("geometryInvalidationCount").toULongLong() + 1); - updateGeometry(); - } - - [[nodiscard]] int preferredHeight() const - { - const int availableWidth = width(); - return availableWidth > 0 ? heightForWidth(availableWidth) : sizeHint().height(); - } - - QString sourceText; - std::uint64_t sourceUtf8Bytes = 0; - QTextDocument* measurementDocument = nullptr; - mutable QHash heightCache; -}; - -QLabel* wrappingLabel(const QString& text, const char* kind = nullptr) -{ - auto* result = new WrappingLabel(text); - if (kind) result->setProperty("kind", kind); - return result; -} - -QWidget* messageContentWidget(const QString& text, bool streaming) -{ - if (text.size() <= largeMessageEditorThreshold && streaming) - { - auto* result = new StreamingMessageView(text); - result->setProperty("kind", "body"); - return result; - } - if (text.size() <= largeMessageEditorThreshold) - { - auto* result = new WrappingLabel(text, true); - result->setProperty("kind", "body"); - result->setProperty("sourceUtf8Bytes", static_cast(text.toUtf8().size())); - result->setProperty("sourceMaterializationCount", 0); - return result; - } - - auto* result = new QPlainTextEdit; - result->setProperty("kind", "body"); - result->setReadOnly(true); - result->setUndoRedoEnabled(false); - result->setLineWrapMode(QPlainTextEdit::WidgetWidth); - result->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - result->setFixedHeight(largeMessageEditorHeight); - result->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - result->setPlainText(text); - result->setProperty("sourceUtf8Bytes", static_cast(text.toUtf8().size())); - result->setProperty("streamAppendCount", 0); - result->setProperty("fullReplacementCount", 0); - result->setProperty("sourceMaterializationCount", 0); - result->setProperty("markdownRenderMode", QStringLiteral("large-plain")); - return result; -} - -QString messageContentText(QWidget* content) -{ - content->setProperty( - "sourceMaterializationCount", - content->property("sourceMaterializationCount").toULongLong() + 1); - if (const auto* label = dynamic_cast(content)) - return label->content(); - if (const auto* streaming = dynamic_cast(content)) - return streaming->content(); - if (const auto* editor = qobject_cast(content)) - return editor->toPlainText(); - return {}; -} - -qsizetype messageContentSize(const QWidget* content) -{ - if (const auto* label = dynamic_cast(content)) - return label->content().size(); - if (const auto* streaming = dynamic_cast(content)) - return streaming->content().size(); - if (const auto* editor = qobject_cast(content)) - return qMax(0, editor->document()->characterCount() - 1); - return 0; -} - -std::uint64_t messageContentUtf8Bytes(const QWidget* content) -{ - if (const auto* streaming = dynamic_cast(content)) - return streaming->utf8Bytes(); - return content->property("sourceUtf8Bytes").toULongLong(); -} - -bool setMessageContentText(QWidget* content, - const QString& text) -{ - if (auto* label = dynamic_cast(content)) - { - const bool geometryChanged = label->setContent(text); - label->setProperty("sourceUtf8Bytes", static_cast(text.toUtf8().size())); - return geometryChanged; - } - if (auto* streamingView = dynamic_cast(content)) - return streamingView->replaceContent(text); - else if (auto* editor = qobject_cast(content); editor && editor->toPlainText() != text) - { - editor->setPlainText(text); - editor->setProperty("sourceUtf8Bytes", static_cast(text.toUtf8().size())); - editor->setProperty("fullReplacementCount", editor->property("fullReplacementCount").toULongLong() + 1); - return false; - } - return false; -} - -std::optional appendMessageContent(QWidget* content, - std::uint64_t baseContentBytes, - std::uint64_t discardPrefixBytes, - const QString& delta) -{ - if (auto* streamingView = dynamic_cast(content)) - return streamingView->applyAppend(baseContentBytes, discardPrefixBytes, delta); - - auto* editor = qobject_cast(content); - if (!editor) - return std::nullopt; - const std::uint64_t currentBytes = editor->property("sourceUtf8Bytes").toULongLong(); - if (currentBytes != baseContentBytes || discardPrefixBytes > currentBytes) - return std::nullopt; - - const QByteArray deltaUtf8 = delta.toUtf8(); - - if (discardPrefixBytes == 0) - { - QTextCursor cursor = editor->textCursor(); - cursor.movePosition(QTextCursor::End); - cursor.insertText(delta); - } - else - { - const QByteArray previousUtf8 = editor->toPlainText().toUtf8(); - editor->setPlainText( - QString::fromUtf8(previousUtf8.mid(static_cast(discardPrefixBytes)) - + deltaUtf8)); - } - const std::uint64_t nextBytes = baseContentBytes - discardPrefixBytes - + static_cast(deltaUtf8.size()); - editor->setProperty("sourceUtf8Bytes", static_cast(nextBytes)); - editor->setProperty("streamAppendCount", editor->property("streamAppendCount").toULongLong() + 1); - return false; -} - -bool messageContentWidgetMatches(const QWidget* content, - qsizetype textSize, - bool streaming) -{ - const bool needsEditor = textSize > largeMessageEditorThreshold; - const bool hasEditor = qobject_cast(content) != nullptr; - const bool needsStreamingView = !needsEditor && streaming; - const bool hasStreamingView = dynamic_cast(content) != nullptr; - const bool hasMarkdownView = dynamic_cast(content) != nullptr; - return (needsEditor && hasEditor) - || (needsStreamingView && hasStreamingView) - || (!needsEditor && !needsStreamingView && hasMarkdownView); -} - -QWidget* ensureMessageContentWidget(QVBoxLayout* layout, - QWidget* content, - const QString& text, - bool streaming) -{ - if (messageContentWidgetMatches(content, text.size(), streaming)) - return content; - - QWidget* replacement = messageContentWidget(text, streaming); - replacement->setObjectName(QStringLiteral("conversationMessageContent")); - replacement->setProperty( - "sourceMaterializationCount", - content->property("sourceMaterializationCount")); - delete layout->replaceWidget(content, replacement); - content->hide(); - content->deleteLater(); - return replacement; -} - -QFrame* divider() -{ - auto* line = new QFrame; - line->setFixedHeight(1); - line->setStyleSheet(QStringLiteral("background:#d7dee8;")); - return line; -} - -QWidget* badge(const QString& text, const QString& background, const QString& foreground, int width = 0, - int height = 22) -{ - auto* frame = new QFrame; - frame->setFixedSize(width > 0 ? width : qMax(58, text.size() * 7 + 18), height); - frame->setStyleSheet(QStringLiteral("background:%1;border-radius:%2px;").arg(background).arg(height > 20 ? 6 : 5)); - auto* layout = new QHBoxLayout(frame); - layout->setContentsMargins(0, 0, 0, 0); - auto* copy = textLabel(text); - copy->setAlignment(Qt::AlignCenter); - copy->setStyleSheet(QStringLiteral("color:%1;font-size:9px;font-weight:600;").arg(foreground)); - layout->addWidget(copy); - return frame; -} - -void clearLayout(QLayout* layout) -{ - while (QLayoutItem* item = layout->takeAt(0)) - { - if (QLayout* child = item->layout()) - clearLayout(child); - else - delete item->widget(); - delete item; - } -} - -QString statusColor(const QString& status) -{ - const QString normalized = status.toLower(); - if (normalized.contains(QStringLiteral("fail")) || normalized.contains(QStringLiteral("error"))) - return QStringLiteral("#b83a3a"); - if (normalized.contains(QStringLiteral("complete")) || normalized.contains(QStringLiteral("success")) || - normalized == QStringLiteral("done")) - return QStringLiteral("#23845a"); - if (normalized.contains(QStringLiteral("progress")) || normalized.contains(QStringLiteral("running")) || - normalized.contains(QStringLiteral("active")) || normalized.contains(QStringLiteral("stream"))) - return QStringLiteral("#2f6feb"); - if (normalized.contains(QStringLiteral("interrupt")) || normalized.contains(QStringLiteral("cancel"))) - return QStringLiteral("#a76812"); - return QStringLiteral("#667085"); -} - -QString statusGlyph(const QString& status) -{ - const QString normalized = status.toLower(); - if (normalized.contains(QStringLiteral("fail")) || normalized.contains(QStringLiteral("error"))) - return QStringLiteral("×"); - if (normalized.contains(QStringLiteral("complete")) || normalized.contains(QStringLiteral("success")) || - normalized == QStringLiteral("done")) - return QStringLiteral("✓"); - if (normalized.contains(QStringLiteral("progress")) || normalized.contains(QStringLiteral("running")) || - normalized.contains(QStringLiteral("active")) || normalized.contains(QStringLiteral("stream"))) - return QStringLiteral("●"); - return QStringLiteral("•"); -} - -QString knownKindTitle(frontend::ThreadItemKind kind) -{ - switch (kind) - { - case frontend::ThreadItemKind::AgentMessage: - return QStringLiteral("Agent message"); - case frontend::ThreadItemKind::CollabAgentToolCall: - return QStringLiteral("Agent collaboration"); - case frontend::ThreadItemKind::CommandExecution: - return QStringLiteral("Command execution"); - case frontend::ThreadItemKind::ContextCompaction: - return QStringLiteral("Context compaction"); - case frontend::ThreadItemKind::DynamicToolCall: - return QStringLiteral("Tool call"); - case frontend::ThreadItemKind::EnteredReviewMode: - return QStringLiteral("Entered review mode"); - case frontend::ThreadItemKind::ExitedReviewMode: - return QStringLiteral("Exited review mode"); - case frontend::ThreadItemKind::FileChange: - return QStringLiteral("File changes"); - case frontend::ThreadItemKind::HookPrompt: - return QStringLiteral("Hook prompt"); - case frontend::ThreadItemKind::ImageGeneration: - return QStringLiteral("Image generation"); - case frontend::ThreadItemKind::ImageView: - return QStringLiteral("Image viewed"); - case frontend::ThreadItemKind::McpToolCall: - return QStringLiteral("MCP tool call"); - case frontend::ThreadItemKind::Plan: - return QStringLiteral("Plan"); - case frontend::ThreadItemKind::Reasoning: - return QStringLiteral("Reasoning"); - case frontend::ThreadItemKind::Sleep: - return QStringLiteral("Wait"); - case frontend::ThreadItemKind::SubAgentActivity: - return QStringLiteral("Subagent activity"); - case frontend::ThreadItemKind::UserMessage: - return QStringLiteral("User message"); - case frontend::ThreadItemKind::WebSearch: - return QStringLiteral("Web search"); - } - return QStringLiteral("Item"); -} - -QString itemStatus(const sdk::ItemState& item) -{ - return item.status && !item.status->empty() ? humanize(fromUtf8(*item.status)) : QStringLiteral("Recorded"); -} - -QString truncationText(const sdk::ItemState& item) -{ - if (!item.contentTruncated && !item.truncated && item.omittedFields.empty()) return {}; - if (item.droppedContentBytes && *item.droppedContentBytes > 0) - return QStringLiteral("Content truncated · %1 bytes omitted").arg(*item.droppedContentBytes); - return QStringLiteral("Content truncated or omitted by the synchronized state"); -} - -QString userMessageTruncationText(const sdk::UserMessageSemanticView& message) -{ - // Non-text user-input details are intentionally outside this text-only - // presentation. Their omission must not label complete retained prompt - // text as truncated. - return message.textTruncated ? QStringLiteral("Retained text is truncated") : QString{}; -} - -struct MessagePresentation -{ - QString status; - QString statusColor; - QString content; - QString truncation; - bool missing = false; - bool streaming = false; -}; - -bool streamingMessageStatus(const QString& status) -{ - const QString normalized = status.toLower(); - return normalized == QStringLiteral("started") - || normalized == QStringLiteral("unknown") - || normalized.contains(QStringLiteral("progress")) - || normalized.contains(QStringLiteral("running")) - || normalized.contains(QStringLiteral("active")) - || normalized.contains(QStringLiteral("stream")); -} - -bool turnStreamsMessages(const sdk::TurnState& turn) noexcept -{ - return !turn.terminal && (turn.active || turn.connectionInvalidated); -} - -MessagePresentation messagePresentationMetadata(const sdk::ItemState& item, - bool user, - bool turnStreaming) -{ - MessagePresentation result; - const QString itemStatusText = itemStatus(item); - result.status = itemStatusText; - result.statusColor = statusColor(itemStatusText); - result.streaming = !user - && (turnStreaming || streamingMessageStatus(itemStatusText)); - - if (!user) - { - const auto semantic = sdk::itemSemanticView(item); - const auto* agent = semantic ? std::get_if(&semantic->details) : nullptr; - if (agent && agent->phase) - result.status += QStringLiteral(" · ") + humanize(fromUtf8(*agent->phase)); - } - - const auto userMessage = user ? sdk::userMessageSemanticView(item) : std::nullopt; - result.truncation = userMessage ? userMessageTruncationText(*userMessage) - : truncationText(item); - return result; -} - -MessagePresentation messagePresentation(const sdk::ItemState& item, - bool user, - bool turnStreaming) -{ - MessagePresentation result = messagePresentationMetadata( - item, user, turnStreaming); - const auto userMessage = user ? sdk::userMessageSemanticView(item) : std::nullopt; - if (user) - { - if (!userMessage) - { - result.content = QStringLiteral("User message is unavailable"); - result.missing = true; - } - else if (userMessage->text.empty()) - { - result.content = QStringLiteral("User message contains no retained text"); - result.missing = true; - } - else - { - result.content = fromUtf8(userMessage->text); - } - } - else - { - result.content = item.agentText && !item.agentText->empty() - ? fromUtf8(*item.agentText) - : (item.summary ? fromUtf8(*item.summary) : QString{}); - if (result.content.isEmpty()) - { - result.content = QStringLiteral("No retained message content"); - result.missing = true; - } - } - - return result; -} - -bool applyMessageMetadata(QLabel* status, - QWidget* content, - QLabel* truncation, - const MessagePresentation& presentation) -{ - if (status->text() != presentation.status) - status->setText(presentation.status); - const QString statusStyle = - QStringLiteral("color:%1;font-size:9px;").arg(presentation.statusColor); - if (status->styleSheet() != statusStyle) - status->setStyleSheet(statusStyle); - - const QString kind = presentation.missing ? QStringLiteral("meta") : QStringLiteral("body"); - if (content->property("kind").toString() != kind) - { - content->setProperty("kind", kind); - content->style()->unpolish(content); - content->style()->polish(content); - } - - bool geometryChanged = false; - if (truncation->text() != presentation.truncation) - { - truncation->setText(presentation.truncation); - geometryChanged = truncation->isVisible(); - } - const bool truncationVisible = !presentation.truncation.isEmpty(); - geometryChanged = geometryChanged || truncation->isVisible() != truncationVisible; - truncation->setVisible(truncationVisible); - return geometryChanged; -} - -bool applyMessagePresentation(QLabel* status, - QWidget* content, - QLabel* truncation, - const MessagePresentation& presentation) -{ - const bool metadataGeometryChanged = applyMessageMetadata( - status, content, truncation, presentation); - return setMessageContentText(content, presentation.content) - || metadataGeometryChanged; -} - -QString pendingRequestDetail(const sdk::State& state, const sdk::ItemState& item) -{ - for (const auto& request : state.pendingRequests()) - { - if (!request.threadId || !request.turnId || !request.itemId || !item.threadId || !item.turnId - || *request.threadId != *item.threadId || *request.turnId != *item.turnId - || *request.itemId != item.id) - continue; - const auto view = sdk::pendingRequestPresentation(request); - QString result = QStringLiteral("Awaiting %1").arg(humanize(fromUtf8(frontend::toString(view.kind)))); - if (view.fileChangeCount) - result += QStringLiteral(" · %1 file changes").arg(*view.fileChangeCount); - else if (view.parsedCommandCount) - result += QStringLiteral(" · %1 commands").arg(*view.parsedCommandCount); - if (view.truncated) result += QStringLiteral(" · details omitted"); - return result; - } - return {}; -} - -ActivityPresentation activityPresentation(const sdk::State& state, - const sdk::ItemState& item, - bool includeOutput = true, - bool includeReasoningContent = true) -{ - ActivityPresentation result; - result.title = item.kind.known ? knownKindTitle(*item.kind.known) : QStringLiteral("Unknown item"); - result.status = itemStatus(item); - result.truncated = item.truncated || item.contentTruncated || !item.omittedFields.empty(); - if (!item.kind.is(frontend::ThreadItemKind::Reasoning) - && item.summary && !item.summary->empty()) - result.detail = fromUtf8(*item.summary); - - const auto semantic = sdk::itemSemanticView(item); - if (semantic) - { - if (const auto* command = std::get_if(&semantic->details)) - { - if (command->command) - { - const QString fullCommand = fromUtf8(*command->command); - result.title = singleLinePreview(fullCommand, 240); - result.detail = QStringLiteral("Command:\n%1").arg(fullCommand); - } - if (command->cwd) - { - const QString cwd = QStringLiteral("Working directory:\n%1") - .arg(fromUtf8(command->cwd->value)); - result.detail = result.detail.isEmpty() ? cwd - : result.detail + QStringLiteral("\n\n") + cwd; - } - if (command->status) result.status = humanize(fromUtf8(*command->status)); - QStringList tail; - if (command->durationMs) tail.append(QStringLiteral("%1 ms").arg(*command->durationMs)); - if (command->exitCode) tail.append(QStringLiteral("exit %1").arg(*command->exitCode)); - result.tail = tail.join(QStringLiteral(" · ")); - } - else if (const auto* changes = std::get_if(&semantic->details)) - { - if (changes->changeCount) - result.title = QStringLiteral("Changed %1 file entries").arg(*changes->changeCount); - if (changes->status) result.status = humanize(fromUtf8(*changes->status)); - qsizetype redacted = 0; - qsizetype omitted = 0; - for (const auto& change : changes->changes) - { - redacted += change.pathRedacted ? 1 : 0; - omitted += change.diffOmitted ? 1 : 0; - } - QStringList detail; - if (redacted) detail.append(QStringLiteral("%1 paths redacted").arg(redacted)); - if (omitted) detail.append(QStringLiteral("%1 diffs omitted").arg(omitted)); - if (changes->changesTruncated) detail.append(QStringLiteral("change list truncated")); - if (!detail.isEmpty()) result.detail = detail.join(QStringLiteral(" · ")); - } - else if (const auto* tool = std::get_if(&semantic->details)) - { - QStringList identity; - if (tool->server) identity.append(fromUtf8(*tool->server)); - if (tool->nameSpace) identity.append(fromUtf8(*tool->nameSpace)); - if (tool->tool) identity.append(fromUtf8(*tool->tool)); - if (!identity.isEmpty()) result.title = identity.join(QStringLiteral(" · ")); - if (tool->status) result.status = humanize(fromUtf8(*tool->status)); - if (tool->hasResult) - { - const QString retained = - *tool->hasResult ? QStringLiteral("Result retained") : QStringLiteral("No retained result"); - result.detail = result.detail.isEmpty() ? retained : result.detail + QStringLiteral(" · ") + retained; - } - } - else if (const auto* search = std::get_if(&semantic->details)) - { - if (search->query) result.detail = fromUtf8(*search->query); - } - else if (const auto* collab = std::get_if(&semantic->details)) - { - if (collab->tool) result.title = QStringLiteral("Agent · %1").arg(fromUtf8(*collab->tool)); - if (collab->status) result.status = humanize(fromUtf8(*collab->status)); - QStringList detail; - if (collab->receiverCount) detail.append(QStringLiteral("%1 receivers").arg(*collab->receiverCount)); - if (collab->agentStateCount) detail.append(QStringLiteral("%1 agent states").arg(*collab->agentStateCount)); - if (collab->senderThreadId) - detail.append(QStringLiteral("from %1").arg(compactId(collab->senderThreadId->value))); - if (!detail.isEmpty()) result.detail = detail.join(QStringLiteral(" · ")); - } - else if (const auto* plan = std::get_if(&semantic->details)) - { - if (plan->text) result.detail = fromUtf8(*plan->text); - result.truncated = result.truncated || plan->textTruncated; - } - else if (const auto* agent = std::get_if(&semantic->details)) - { - if (agent->agentPath) result.title = fromUtf8(*agent->agentPath); - QStringList detail; - if (agent->kind) detail.append(humanize(fromUtf8(*agent->kind))); - if (agent->agentThreadId) - detail.append(QStringLiteral("thread %1").arg(compactId(agent->agentThreadId->value))); - result.detail = detail.join(QStringLiteral(" · ")); - } - result.truncated = result.truncated || semantic->truncated || !semantic->omittedFields.empty(); - } - - // Command execution is the common source, but file-change and future typed - // activities may also carry the canonical command-output channel. - if (includeOutput) - result.deferredOutput = deferredItemText( - state, item, sdk::ItemContentChannel::CommandOutput); - - if (includeReasoningContent && item.kind.is(frontend::ThreadItemKind::Reasoning)) - { - if (item.reasoningSummary && !item.reasoningSummary->empty()) - { - result.detailChannel = sdk::ItemContentChannel::ReasoningSummary; - result.deferredDetail = deferredItemText( - state, item, *result.detailChannel); - } - else if (item.reasoningText && !item.reasoningText->empty()) - { - result.detailChannel = sdk::ItemContentChannel::ReasoningText; - result.deferredDetail = deferredItemText( - state, item, *result.detailChannel); - } - } - - if (!item.kind.known) - { - result.detail = humanize(fromUtf8(item.kind.identity)) + - (result.detail.isEmpty() ? QString{} : QStringLiteral(" · ") + result.detail); - } - - const QString pending = pendingRequestDetail(state, item); - if (!pending.isEmpty()) - { - result.detail = result.detail.isEmpty() ? pending : result.detail + QStringLiteral(" · ") + pending; - result.status = QStringLiteral("Awaiting input"); - } - return result; -} - -QToolButton* disclosureButton(bool expanded, - const QString& accessibleName, - bool activityRow = false) -{ - auto* button = new QToolButton; - button->setObjectName(QStringLiteral("activityDisclosure")); - button->setProperty("activityRow", activityRow); - button->setAutoRaise(true); - button->setCheckable(true); - button->setArrowType(Qt::NoArrow); - button->setIcon(button->style()->standardIcon(expanded ? QStyle::SP_ArrowDown - : QStyle::SP_ArrowRight)); - button->setIconSize(QSize(12, 12)); - button->setChecked(expanded); - button->setToolTip(expanded ? QStringLiteral("Collapse") : QStringLiteral("Expand")); - button->setAccessibleName(accessibleName); - button->setFixedSize(22, 22); - button->setStyleSheet(QStringLiteral( - "QToolButton#activityDisclosure{background:transparent;border:1px solid transparent;" - "border-radius:5px;padding:0;}" - "QToolButton#activityDisclosure[activityRow=\"true\"]{padding-left:4px;padding-right:0;" - "padding-top:0;padding-bottom:0;}" - "QToolButton#activityDisclosure:hover{background:#f1f5fb;border-color:#d7dee8;}" - "QToolButton#activityDisclosure:pressed{background:#e9eff7;border-color:#d7dee8;}" - "QToolButton#activityDisclosure:focus{border:1px solid #2f6feb;}")); - return button; -} - -void setDisclosureState(QToolButton* disclosure, QWidget* details, bool expanded) -{ - disclosure->setChecked(expanded); - disclosure->setIcon(disclosure->style()->standardIcon(expanded ? QStyle::SP_ArrowDown - : QStyle::SP_ArrowRight)); - disclosure->setToolTip(expanded ? QStringLiteral("Collapse") : QStringLiteral("Expand")); - details->setVisible(expanded); -} - -QPlainTextEdit* activityOutputWidget(const QString& text) -{ - auto* output = new QPlainTextEdit; - output->setObjectName(QStringLiteral("conversationActivityOutput")); - output->setReadOnly(true); - output->setUndoRedoEnabled(false); - output->setLineWrapMode(QPlainTextEdit::NoWrap); - output->setMinimumHeight(96); - output->setMaximumHeight(240); - output->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); - output->setStyleSheet(QStringLiteral( - "QPlainTextEdit#conversationActivityOutput{font-family:monospace;font-size:11px;" - "background:#fbfcfe;border:1px solid #e1e7ef;border-radius:6px;padding:6px;}" - "QAbstractScrollArea::corner{background:transparent;}" - "QScrollBar:vertical{background:transparent;border:0;width:8px;margin:2px;}" - "QScrollBar::handle:vertical{background:#b9c4d2;min-height:28px;border-radius:3px;}" - "QScrollBar::handle:vertical:hover{background:#98a2b3;}" - "QScrollBar::add-line:vertical,QScrollBar::sub-line:vertical{background:transparent;border:0;height:0;}" - "QScrollBar::add-page:vertical,QScrollBar::sub-page:vertical{background:transparent;}" - "QScrollBar:horizontal{background:transparent;border:0;height:8px;margin:2px;}" - "QScrollBar::handle:horizontal{background:#b9c4d2;min-width:28px;border-radius:3px;}" - "QScrollBar::handle:horizontal:hover{background:#98a2b3;}" - "QScrollBar::add-line:horizontal,QScrollBar::sub-line:horizontal{background:transparent;border:0;width:0;}" - "QScrollBar::add-page:horizontal,QScrollBar::sub-page:horizontal{background:transparent;}")); - output->setPlainText(text); - output->setProperty("sourceUtf8Bytes", static_cast(text.toUtf8().size())); - output->setProperty("streamAppendCount", 0); - output->setProperty("fullReplacementCount", 0); - return output; -} - -class ActivityDetails final : public QWidget -{ -public: - bool replaceDeferredDetail( - std::optional source) - { - const bool previouslyAvailable = hasDetail(); - const bool contentChanged = source.has_value() != deferredDetail.has_value() - || (source && deferredDetail - && !sameDeferredItemText( - *source, *deferredDetail)); - deferredDetail = std::move(source); - deferredDetailDirty = deferredDetail.has_value() - && (deferredDetailDirty || contentChanged); - detailBytes = deferredDetail ? deferredDetail->utf8Bytes : 0; - if (deferredDetail) - detailChannel = deferredDetail->channel; - else - detailChannel.reset(); - setProperty( - "deferredDetailBytes", - static_cast(detailBytes)); - return previouslyAvailable != hasDetail(); - } - - void clearDeferredDetail() - { - deferredDetail.reset(); - deferredDetailDirty = false; - detailBytes = 0; - detailChannel.reset(); - setProperty("deferredDetailBytes", 0ULL); - } - - bool ensureDeferredDetail(QVBoxLayout* layout) - { - if (!layout || !deferredDetail || !deferredDetailDirty) - return false; - - const QString text = materializeDeferredItemText(*deferredDetail); - auto* detail = findChild( - QStringLiteral("conversationActivityDetail")); - auto* streaming = dynamic_cast(detail); - const bool compatible = streaming - && detail->property("activityContentChannel").toInt() - == static_cast(deferredDetail->channel); - bool geometryChanged = false; - if (!compatible) - { - auto* replacement = new StreamingMessageView(text); - replacement->setObjectName( - QStringLiteral("conversationActivityDetail")); - replacement->setProperty("kind", "meta"); - replacement->setProperty( - "activityContentChannel", - static_cast(deferredDetail->channel)); - replacement->style()->unpolish(replacement); - replacement->style()->polish(replacement); - if (detail) - { - delete layout->replaceWidget(detail, replacement); - detail->hide(); - detail->deleteLater(); - } - else - { - layout->insertWidget(0, replacement); - } - geometryChanged = true; - } - else - { - geometryChanged = streaming->replaceContent(text); - } - if (auto* current = findChild( - QStringLiteral("conversationActivityDetail")); - current && current->isHidden()) - { - current->show(); - geometryChanged = true; - } - deferredDetailDirty = false; - ++detailMaterializationCount; - setProperty( - "detailMaterializationCount", - static_cast(detailMaterializationCount)); - return geometryChanged; - } - - [[nodiscard]] bool acceptsDetailAppend( - sdk::ItemContentChannel channel, - std::uint64_t baseContentBytes, - std::uint64_t discardPrefixBytes) const noexcept - { - return baseContentBytes == detailBytes - && discardPrefixBytes <= detailBytes - && (!detailChannel || *detailChannel == channel); - } - - void recordMaterializedDetailSource( - ActivityPresentation::DeferredItemText source) - { - detailBytes = source.utf8Bytes; - detailChannel = source.channel; - deferredDetail = std::move(source); - deferredDetailDirty = false; - setProperty( - "deferredDetailBytes", - static_cast(detailBytes)); - } - - bool replaceOutput( - std::optional source) - { - const bool previouslyAvailable = hasOutput(); - const bool contentChanged = source.has_value() != deferredOutput.has_value() - || (source && deferredOutput - && !sameDeferredItemText( - *source, *deferredOutput)); - deferredOutput = std::move(source); - outputBytes = deferredOutput ? deferredOutput->utf8Bytes : 0; - deferredOutputDirty = deferredOutput.has_value() - && (deferredOutputDirty || contentChanged); - setProperty("deferredOutputBytes", static_cast(outputBytes)); - if (!deferredOutput && outputEditor) - { - outputEditor->hide(); - if (outputHeading) - outputHeading->hide(); - } - return previouslyAvailable != hasOutput(); - } - - bool materializeOutput(QVBoxLayout* layout) - { - if (!layout || !deferredOutput || !deferredOutputDirty) - return false; - const QString text = materializeDeferredItemText(*deferredOutput); - bool geometryChanged = false; - if (!outputEditor) - { - if (!outputHeading) - { - outputHeading = textLabel(QStringLiteral("Output"), "small"); - outputHeading->setObjectName( - QStringLiteral("conversationActivityOutputHeading")); - outputHeading->setStyleSheet( - QStringLiteral("font-size:10px;font-weight:600;color:#475467;")); - const auto* incomplete = findChild( - QStringLiteral("conversationActivityIncomplete")); - const int headingPosition = incomplete - ? layout->indexOf(incomplete) - : layout->count(); - layout->insertWidget(headingPosition, outputHeading); - } - outputEditor = activityOutputWidget(text); - const auto* incomplete = findChild( - QStringLiteral("conversationActivityIncomplete")); - const int position = incomplete - ? layout->indexOf(incomplete) - : layout->count(); - layout->insertWidget(position, outputEditor); - geometryChanged = true; - } - else - { - const bool followsEnd = outputEditor->verticalScrollBar()->maximum() - - outputEditor->verticalScrollBar()->value() <= 2; - const int previousScroll = outputEditor->verticalScrollBar()->value(); - geometryChanged = setMessageContentText(outputEditor, text); - outputEditor->verticalScrollBar()->setValue( - followsEnd ? outputEditor->verticalScrollBar()->maximum() - : qMin(previousScroll, - outputEditor->verticalScrollBar()->maximum())); - } - outputEditor->show(); - if (outputHeading) - outputHeading->show(); - deferredOutputDirty = false; - ++outputMaterializationCount; - setProperty( - "outputMaterializationCount", - static_cast(outputMaterializationCount)); - return geometryChanged; - } - - void recordMaterializedOutputSource( - ActivityPresentation::DeferredItemText source) - { - outputBytes = source.utf8Bytes; - deferredOutput = std::move(source); - deferredOutputDirty = false; - setProperty( - "deferredOutputBytes", - static_cast(outputBytes)); - } - - std::optional applyOutputAppend(std::uint64_t baseContentBytes, - std::uint64_t discardPrefixBytes, - const QString& delta) - { - if (!outputEditor || deferredOutputDirty - || baseContentBytes != outputBytes - || discardPrefixBytes > outputBytes) - return std::nullopt; - - const QByteArray deltaUtf8 = delta.toUtf8(); - const bool followsEnd = outputEditor->verticalScrollBar()->maximum() - - outputEditor->verticalScrollBar()->value() <= 2; - const int previousScroll = outputEditor->verticalScrollBar()->value(); - const auto applied = appendMessageContent( - outputEditor, baseContentBytes, discardPrefixBytes, delta); - if (!applied) - return std::nullopt; - outputBytes = baseContentBytes - discardPrefixBytes - + static_cast(deltaUtf8.size()); - outputEditor->verticalScrollBar()->setValue( - followsEnd ? outputEditor->verticalScrollBar()->maximum() - : qMin(previousScroll, outputEditor->verticalScrollBar()->maximum())); - return *applied; - } - - QPlainTextEdit* ensureOutput(QVBoxLayout* layout) - { - if (!layout || !hasOutput()) - return outputEditor; - static_cast(materializeOutput(layout)); - return outputEditor; - } - - [[nodiscard]] bool hasDetail() const noexcept - { - if (detailBytes != 0) - return true; - const auto* detail = findChild( - QStringLiteral("conversationActivityDetail")); - return detail && !detail->isHidden(); - } - [[nodiscard]] bool hasOutput() const noexcept { return outputBytes != 0; } - [[nodiscard]] std::uint64_t retainedDetailBytes() const noexcept - { - return detailBytes; - } - [[nodiscard]] std::uint64_t retainedOutputBytes() const noexcept { return outputBytes; } - [[nodiscard]] QPlainTextEdit* output() const noexcept { return outputEditor; } - [[nodiscard]] QLabel* heading() const noexcept { return outputHeading; } - -private: - std::optional deferredDetail; - std::optional deferredOutput; - std::optional detailChannel; - std::uint64_t detailBytes = 0; - std::uint64_t outputBytes = 0; - std::uint64_t detailMaterializationCount = 0; - std::uint64_t outputMaterializationCount = 0; - bool deferredDetailDirty = false; - bool deferredOutputDirty = false; - QPlainTextEdit* outputEditor = nullptr; - QLabel* outputHeading = nullptr; -}; - -QWidget* activityDetailWidget(const ActivityPresentation& presentation) -{ - QWidget* detail = nullptr; - if (presentation.detailChannel) - { - auto* streaming = new StreamingMessageView(presentation.detail); - streaming->setProperty( - "activityContentChannel", - static_cast(*presentation.detailChannel)); - detail = streaming; - } - else - { - detail = wrappingLabel(presentation.detail, "meta"); - } - detail->setObjectName(QStringLiteral("conversationActivityDetail")); - detail->setProperty("kind", "meta"); - detail->style()->unpolish(detail); - detail->style()->polish(detail); - return detail; -} - -bool updateActivityDetail(ActivityDetails* details, - QVBoxLayout* layout, - const ActivityPresentation& presentation) -{ - if (!details || !layout) - return false; - if (presentation.deferredDetail) - { - bool changed = details->replaceDeferredDetail( - presentation.deferredDetail); - if (details->isVisible()) - changed = details->ensureDeferredDetail(layout) || changed; - return changed; - } - details->clearDeferredDetail(); - QWidget* detail = details->findChild( - QStringLiteral("conversationActivityDetail")); - if (presentation.detail.isEmpty()) - { - const bool changed = detail && !detail->isHidden(); - if (detail) - detail->hide(); - return changed; - } - - const auto expectedChannel = presentation.detailChannel; - const auto* streaming = dynamic_cast(detail); - const bool compatible = expectedChannel - ? streaming - && detail->property("activityContentChannel").toInt() - == static_cast(*expectedChannel) - : detail && !streaming; - bool geometryChanged = false; - if (!compatible) - { - QWidget* replacement = activityDetailWidget(presentation); - if (detail) - { - delete layout->replaceWidget(detail, replacement); - detail->hide(); - detail->deleteLater(); - } - else - { - layout->insertWidget(0, replacement); - } - detail = replacement; - geometryChanged = true; - } - else if (auto* streamingDetail = dynamic_cast(detail)) - { - geometryChanged = streamingDetail->replaceContent(presentation.detail); - } - else if (auto* label = dynamic_cast(detail)) - { - geometryChanged = label->setContent(presentation.detail); - } - if (detail->isHidden()) - { - detail->show(); - geometryChanged = true; - } - return geometryChanged; -} - -bool updateActivityDisclosureAvailability(QWidget* row) -{ - if (!row) - return false; - auto* details = dynamic_cast( - row->findChild(QStringLiteral("conversationActivityDetails"))); - auto* disclosure = row->findChild(QStringLiteral("activityDisclosure")); - if (!details || !disclosure) - return false; - const auto* incomplete = details->findChild( - QStringLiteral("conversationActivityIncomplete")); - const bool hasDetails = details->hasDetail() || details->hasOutput() - || (incomplete && !incomplete->isHidden()); - bool changed = disclosure->isVisible() != hasDetails; - if (auto* prefix = row->findChild( - QStringLiteral("conversationActivityPrefix"))) - { - changed = changed || prefix->width() != (hasDetails ? 31 : 14); - prefix->setFixedWidth(hasDetails ? 31 : 14); - } - if (auto* leadingLayout = row->findChild( - QStringLiteral("conversationActivityLeadingLayout"))) - { - changed = changed || leadingLayout->spacing() != (hasDetails ? 0 : 6); - leadingLayout->setSpacing(hasDetails ? 0 : 6); - } - disclosure->setEnabled(hasDetails); - disclosure->setVisible(hasDetails); - if (!hasDetails) - setDisclosureState(disclosure, details, false); - return changed; -} - -void addActivityRow(QVBoxLayout* rows, - const QString& itemId, - const ActivityPresentation& item, - bool expanded, - const std::function& layoutChanged = {}) -{ - auto* line = new QWidget; - line->setObjectName(QStringLiteral("conversationActivityRow")); - line->setProperty("itemId", itemId); - line->setMinimumHeight(38); - auto* lineLayout = new QVBoxLayout(line); - lineLayout->setContentsMargins(2, 5, 4, 5); - lineLayout->setSpacing(5); - - auto* summary = new QWidget; - summary->setObjectName(QStringLiteral("conversationActivitySummary")); - auto* layout = new QHBoxLayout(summary); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(6); - - const bool hasDetails = !item.detail.isEmpty() - || item.deferredDetail.has_value() - || item.deferredOutput.has_value() - || item.truncated; - const QString color = statusColor(item.status); - auto* prefix = new QWidget; - prefix->setObjectName(QStringLiteral("conversationActivityPrefix")); - prefix->setFixedSize(hasDetails ? 31 : 14, 22); - - auto* symbol = textLabel(statusGlyph(item.status)); - symbol->setParent(prefix); - symbol->setObjectName(QStringLiteral("conversationActivitySymbol")); - symbol->setFixedSize(14, 22); - symbol->move(0, 0); - symbol->setAlignment(Qt::AlignCenter); - symbol->setAttribute(Qt::WA_TransparentForMouseEvents); - symbol->setStyleSheet(QStringLiteral("color:%1;font-size:12px;font-weight:600;").arg(color)); - - auto* disclosure = disclosureButton( - expanded, - QStringLiteral("Activity details: %1").arg(item.title), - true); - disclosure->setParent(prefix); - disclosure->move(9, 0); - disclosure->setEnabled(hasDetails); - disclosure->setVisible(hasDetails); - symbol->raise(); - - auto* title = wrappingLabel(item.title); - title->setObjectName(QStringLiteral("conversationActivityTitle")); - title->setToolTip(plainTooltip(item.title)); - title->setStyleSheet(QStringLiteral("font-size:12px;font-weight:500;")); - // Align the first wrapped text line with the 22 px status/disclosure - // controls. A top content inset keeps later lines flowing downward - // instead of vertically centering the complete multiline label. - const int titleTopInset = qMax(0, (disclosure->height() - title->fontMetrics().height()) / 2); - title->setContentsMargins(0, titleTopInset, 0, 0); - title->setAlignment(Qt::AlignLeft | Qt::AlignTop); - title->setMinimumHeight(disclosure->height()); - - // Keep a native 22 px hit target without letting that hit target define - // the visible columns. Its right-pointing chevron begins at the same x as - // titles on rows without details, and the following title retains the - // header's measured seven-pixel visible gap. - auto* leading = new QWidget; - auto* leadingLayout = new QHBoxLayout(leading); - leadingLayout->setObjectName(QStringLiteral("conversationActivityLeadingLayout")); - leadingLayout->setContentsMargins(0, 0, 0, 0); - leadingLayout->setSpacing(hasDetails ? 0 : 6); - leadingLayout->addWidget(prefix, 0, Qt::AlignTop); - leadingLayout->addWidget(title, 1); - layout->addWidget(leading, 1); - - auto* tail = textLabel(item.tail, "meta"); - tail->setObjectName(QStringLiteral("conversationActivityTail")); - tail->setFixedHeight(disclosure->height()); - tail->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); - layout->addWidget(tail, 0, Qt::AlignTop); - tail->setVisible(!item.tail.isEmpty()); - auto* state = textLabel(item.status); - state->setObjectName(QStringLiteral("conversationActivityStatus")); - state->setStyleSheet(QStringLiteral("color:%1;font-size:9px;font-weight:600;").arg(color)); - state->setFixedHeight(disclosure->height()); - state->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); - layout->addWidget(state, 0, Qt::AlignTop); - lineLayout->addWidget(summary); - - auto* details = new ActivityDetails; - details->setObjectName(QStringLiteral("conversationActivityDetails")); - auto* detailsLayout = new QVBoxLayout(details); - detailsLayout->setContentsMargins(42, 0, 4, 4); - detailsLayout->setSpacing(6); - if (item.deferredDetail) - { - details->replaceDeferredDetail(item.deferredDetail); - if (expanded) - details->ensureDeferredDetail(detailsLayout); - } - else if (!item.detail.isEmpty()) - { - detailsLayout->addWidget(activityDetailWidget(item)); - } - if (item.deferredOutput) - { - details->replaceOutput(item.deferredOutput); - if (expanded) - details->ensureOutput(detailsLayout); - } - if (item.truncated) - { - auto* omitted = textLabel(QStringLiteral("Canonical activity detail is incomplete"), "small"); - omitted->setObjectName(QStringLiteral("conversationActivityIncomplete")); - omitted->setStyleSheet(QStringLiteral("color:#a76812;font-size:9px;")); - detailsLayout->addWidget(omitted); - } - lineLayout->addWidget(details); - setDisclosureState(disclosure, details, expanded && hasDetails); - QObject::connect(disclosure, &QToolButton::clicked, line, - [disclosure, details, detailsLayout, layoutChanged](bool) - { - const bool next = !details->isVisible(); - if (next) - { - details->ensureDeferredDetail(detailsLayout); - details->ensureOutput(detailsLayout); - } - setDisclosureState(disclosure, details, next); - if (layoutChanged) - layoutChanged(); - }); - rows->addWidget(line); -} - -bool updateActivityRowMetadata(QWidget* row, - const ActivityPresentation& item, - bool contentGeometryChanged, - bool* geometryChanged) -{ - if (!row) - return false; - auto* title = dynamic_cast( - row->findChild(QStringLiteral("conversationActivityTitle"))); - auto* symbol = row->findChild(QStringLiteral("conversationActivitySymbol")); - auto* tail = row->findChild(QStringLiteral("conversationActivityTail")); - auto* status = row->findChild(QStringLiteral("conversationActivityStatus")); - auto* details = dynamic_cast( - row->findChild(QStringLiteral("conversationActivityDetails"))); - auto* disclosure = row->findChild(QStringLiteral("activityDisclosure")); - if (!title || !symbol || !tail || !status || !details || !disclosure) - return false; - auto* detailsLayout = qobject_cast(details->layout()); - if (!detailsLayout) - return false; - - bool changed = contentGeometryChanged || title->setContent(item.title); - title->setToolTip(plainTooltip(item.title)); - disclosure->setAccessibleName(QStringLiteral("Activity details: %1").arg(item.title)); - symbol->setText(statusGlyph(item.status)); - symbol->setStyleSheet( - QStringLiteral("color:%1;font-size:12px;font-weight:600;").arg(statusColor(item.status))); - if (tail->text() != item.tail) - tail->setText(item.tail); - const bool tailVisible = !item.tail.isEmpty(); - changed = changed || tail->isVisible() != tailVisible; - tail->setVisible(tailVisible); - status->setText(item.status); - status->setStyleSheet( - QStringLiteral("color:%1;font-size:9px;font-weight:600;").arg(statusColor(item.status))); - - auto* incomplete = details->findChild(QStringLiteral("conversationActivityIncomplete")); - if (!incomplete && item.truncated) - { - incomplete = textLabel(QStringLiteral("Canonical activity detail is incomplete"), "small"); - incomplete->setObjectName(QStringLiteral("conversationActivityIncomplete")); - incomplete->setStyleSheet(QStringLiteral("color:#a76812;font-size:9px;")); - detailsLayout->addWidget(incomplete); - changed = true; - } - if (incomplete) - { - changed = changed || incomplete->isVisible() != item.truncated; - incomplete->setVisible(item.truncated); - } - - changed = updateActivityDisclosureAvailability(row) || changed; - - if (changed) - { - detailsLayout->invalidate(); - details->updateGeometry(); - if (QLayout* rowLayout = row->layout()) - rowLayout->invalidate(); - row->updateGeometry(); - } - if (geometryChanged) - *geometryChanged = changed; - return true; -} - -bool updateActivityRow(QWidget* row, - const ActivityPresentation& item, - bool* geometryChanged) -{ - auto* details = row - ? dynamic_cast(row->findChild( - QStringLiteral("conversationActivityDetails"))) - : nullptr; - auto* detailsLayout = details ? qobject_cast(details->layout()) : nullptr; - if (!details || !detailsLayout) - return false; - - bool contentGeometryChanged = updateActivityDetail(details, detailsLayout, item); - contentGeometryChanged = details->replaceOutput(item.deferredOutput) - || contentGeometryChanged; - if (details->isVisible()) - { - contentGeometryChanged = details->ensureDeferredDetail(detailsLayout) - || contentGeometryChanged; - if (details->hasOutput()) - { - contentGeometryChanged = details->materializeOutput(detailsLayout) - || contentGeometryChanged; - } - } - return updateActivityRowMetadata( - row, item, contentGeometryChanged, geometryChanged); -} - -QFrame* activityCard(const sdk::State& state, - const std::vector& items, - bool typedPlanAvailable, - bool expanded, - const QSet& expandedItems, - const std::function& layoutChanged) -{ - auto* card = new QFrame; - card->setObjectName(QStringLiteral("conversationActivityCard")); - card->setProperty("kind", "panel"); - card->setStyleSheet(QStringLiteral( - "QFrame#conversationActivityCard{background:#ffffff;border:1px solid #d7dee8;border-radius:10px;}")); - auto* layout = new QVBoxLayout(card); - layout->setContentsMargins(16, 16, 16, 12); - layout->setSpacing(0); - - auto* header = new QHBoxLayout; - layout->addLayout(header); - auto* disclosure = disclosureButton(expanded, QStringLiteral("Activity group")); - header->addWidget(disclosure); - auto* title = textLabel(QStringLiteral("Activity")); - title->setStyleSheet(QStringLiteral("font-size:12px;font-weight:600;")); - header->addWidget(title); - header->addStretch(); - const bool legacyPlanAvailable = std::ranges::any_of(items, [](const sdk::ItemState* item) { - return item && item->kind.is(frontend::ThreadItemKind::Plan); - }); - auto* planAvailable = textLabel(QStringLiteral("Plan available"), "small"); - planAvailable->setObjectName(QStringLiteral("conversationActivityPlanAvailable")); - header->addWidget(planAvailable); - planAvailable->setVisible(typedPlanAvailable || legacyPlanAvailable); - header->addSpacing(8); - auto* count = textLabel( - QStringLiteral("%1 activit%2").arg(items.size()).arg(items.size() == 1 ? "y" : "ies"), - "small"); - count->setObjectName(QStringLiteral("conversationActivityCount")); - header->addWidget(count); - auto* body = new QWidget; - body->setObjectName(QStringLiteral("conversationActivityBody")); - auto* bodyLayout = new QVBoxLayout(body); - bodyLayout->setContentsMargins(0, 9, 0, 0); - bodyLayout->setSpacing(3); - bodyLayout->addWidget(divider()); - auto* rows = new QVBoxLayout; - rows->setObjectName(QStringLiteral("conversationActivityRows")); - rows->setContentsMargins(0, 0, 0, 0); - rows->setSpacing(4); - for (const auto* item : items) - { - const QString itemId = fromUtf8(item->id.value); - addActivityRow(rows, - itemId, - activityPresentation(state, *item), - expandedItems.contains(itemId), - layoutChanged); - } - bodyLayout->addLayout(rows); - layout->addWidget(body); - setDisclosureState(disclosure, body, expanded); - QObject::connect(disclosure, &QToolButton::clicked, card, - [disclosure, body, layoutChanged](bool) - { - const bool next = !body->isVisible(); - if (next) - { - for (auto* candidate : body->findChildren( - QStringLiteral("conversationActivityDetails"))) - { - auto* details = dynamic_cast(candidate); - if (!details) - continue; - if (details->isHidden()) - continue; - auto* detailsLayout = qobject_cast( - details->layout()); - details->ensureDeferredDetail(detailsLayout); - details->ensureOutput(detailsLayout); - } - } - setDisclosureState(disclosure, body, next); - if (layoutChanged) - layoutChanged(); - }); - return card; -} - -void addMessage(QVBoxLayout* timeline, - const sdk::ItemState& item, - bool user, - bool turnStreaming) -{ - const MessagePresentation presentation = messagePresentation( - item, user, turnStreaming); - auto* header = new QHBoxLayout; - header->addWidget(textLabel(user ? QStringLiteral("YOU") : QStringLiteral("CODEX"), "section")); - header->addStretch(); - auto* status = textLabel({}, "small"); - status->setObjectName(QStringLiteral("conversationMessageStatus")); - header->addWidget(status); - timeline->addLayout(header); - timeline->addSpacing(3); - - QWidget* container = nullptr; - QVBoxLayout* layout = nullptr; - if (user) - { - auto* card = new QFrame; - card->setObjectName(QStringLiteral("conversationUserMessageCard")); - card->setProperty("kind", "raised"); - card->setStyleSheet(QStringLiteral( - "QFrame#conversationUserMessageCard{background:#f8fafc;" - "border:1px solid #d7dee8;border-radius:10px;}")); - layout = new QVBoxLayout(card); - layout->setContentsMargins(16, 12, 16, 12); - layout->setSpacing(5); - container = card; - } - else - { - container = new QWidget; - layout = new QVBoxLayout(container); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(5); - } - auto* copy = messageContentWidget(presentation.content, presentation.streaming); - copy->setObjectName(QStringLiteral("conversationMessageContent")); - layout->addWidget(copy); - auto* marker = textLabel({}, "small"); - marker->setObjectName(QStringLiteral("conversationMessageTruncation")); - marker->setStyleSheet(QStringLiteral("color:#a76812;font-size:9px;")); - layout->addWidget(marker); - applyMessagePresentation(status, copy, marker, presentation); - timeline->addWidget(container); - timeline->addSpacing(16); -} - -QString tokenCounts(const QString& name, const std::optional& counts) -{ - if (!counts) return {}; - QStringList values; - if (counts->totalTokens) values.append(QStringLiteral("%1 total").arg(*counts->totalTokens)); - if (counts->inputTokens) values.append(QStringLiteral("%1 in").arg(*counts->inputTokens)); - if (counts->outputTokens) values.append(QStringLiteral("%1 out").arg(*counts->outputTokens)); - if (counts->cachedInputTokens) values.append(QStringLiteral("%1 cached").arg(*counts->cachedInputTokens)); - if (counts->reasoningOutputTokens) - values.append(QStringLiteral("%1 reasoning").arg(*counts->reasoningOutputTokens)); - return values.isEmpty() ? QString{} : name + QStringLiteral(" ") + values.join(QStringLiteral(" / ")); -} - -QString tokenUsageText(const sdk::TurnState& turn) -{ - const auto usage = sdk::tokenUsageView(turn); - if (!usage) return {}; - QStringList parts; - const QString last = tokenCounts(QStringLiteral("last"), usage->last); - const QString total = tokenCounts(QStringLiteral("total"), usage->total); - if (!last.isEmpty()) parts.append(last); - if (!total.isEmpty()) parts.append(total); - if (usage->modelContextWindowPresent) - parts.append(usage->modelContextWindow ? QStringLiteral("context %1").arg(*usage->modelContextWindow) - : QStringLiteral("context unavailable")); - if (usage->truncated) parts.append(QStringLiteral("usage details omitted")); - return parts.join(QStringLiteral(" · ")); -} - -QString failureText(const sdk::TurnState& turn) -{ - const auto failure = sdk::failureView(turn); - if (!failure) return {}; - QStringList details; - if (failure->message) details.append(fromUtf8(*failure->message)); - if (failure->additionalDetails) details.append(fromUtf8(*failure->additionalDetails)); - if (failure->codexErrorCategory) - details.append(humanize(fromUtf8(*failure->codexErrorCategory))); - else if (failure->unknownErrorDiscriminator) - details.append(QStringLiteral("Backend error: %1").arg(fromUtf8(*failure->unknownErrorDiscriminator))); - if (failure->httpStatusCode) details.append(QStringLiteral("HTTP %1").arg(*failure->httpStatusCode)); - if (failure->nonSteerableTurnKind) - details.append(QStringLiteral("turn kind %1").arg(fromUtf8(*failure->nonSteerableTurnKind))); - if (failure->redacted) details.append(QStringLiteral("sensitive detail redacted")); - if (failure->decodingOmitted) details.append(QStringLiteral("additional detail omitted")); - return details.isEmpty() ? QStringLiteral("Turn failed") - : QStringLiteral("Turn failed · ") + details.join(QStringLiteral(" · ")); -} - -void addPresentationValue(QCryptographicHash& hash, const QByteArray& value) -{ - hash.addData(QByteArray::number(value.size())); - hash.addData(QByteArrayLiteral(":")); - hash.addData(value); -} - -void addPresentationValue(QCryptographicHash& hash, const QString& value) -{ - addPresentationValue(hash, value.toUtf8()); -} - -void addPresentationValue(QCryptographicHash& hash, std::string_view value) -{ - hash.addData(QByteArray::number(value.size())); - hash.addData(QByteArrayLiteral(":")); - hash.addData(QByteArrayView(value.data(), static_cast(value.size()))); -} - -void addPresentationValue(QCryptographicHash& hash, bool value) -{ - addPresentationValue(hash, value ? QByteArrayLiteral("1") : QByteArrayLiteral("0")); -} - -bool addItemContentIdentity(QCryptographicHash& hash, - const sdk::State& state, - const sdk::ItemState& item, - sdk::ItemContentChannel channel) -{ - if (!item.threadId || !item.turnId) - { - addPresentationValue(hash, false); - return false; - } - const auto descriptor = state.itemContentDescriptor( - *item.threadId, *item.turnId, item.id, channel); - addPresentationValue(hash, descriptor.has_value()); - if (!descriptor) - return false; - addPresentationValue(hash, descriptor->present); - addPresentationValue( - hash, - QByteArray::number(static_cast(descriptor->retainedUtf8Bytes))); - addPresentationValue( - hash, - QByteArray::number(static_cast(descriptor->contentRevision))); - return true; -} - -void addEmptyState(QVBoxLayout* timeline, const QString& title, const QString& detail) -{ - auto* empty = new QFrame; - empty->setObjectName(QStringLiteral("conversationEmptyState")); - empty->setProperty("kind", "panel"); - empty->setStyleSheet(QStringLiteral( - "QFrame#conversationEmptyState{background:#ffffff;border:1px solid #d7dee8;border-radius:10px;}")); - auto* layout = new QVBoxLayout(empty); - layout->setContentsMargins(20, 24, 20, 24); - layout->setSpacing(5); - auto* heading = textLabel(title); - heading->setStyleSheet(QStringLiteral("font-size:13px;font-weight:600;")); - layout->addWidget(heading); - auto* copy = wrappingLabel(detail, "meta"); - layout->addWidget(copy); - timeline->addWidget(empty); - timeline->addSpacing(16); -} - -struct TimelineSegment -{ - QString id; - std::vector items; - bool missing = false; -}; - -struct ActivityExpansionState -{ - bool groupExpanded = true; - QSet expandedItems; -}; - -ActivityExpansionState activityExpansionState(const QWidget* segment) -{ - ActivityExpansionState result; - if (!segment) - return result; - if (const auto* body = segment->findChild(QStringLiteral("conversationActivityBody"))) - result.groupExpanded = !body->isHidden(); - for (const auto* row : segment->findChildren(QStringLiteral("conversationActivityRow"))) - { - const auto* details = row->findChild(QStringLiteral("conversationActivityDetails")); - if (details && !details->isHidden()) - result.expandedItems.insert(row->property("itemId").toString()); - } - return result; -} - -struct TimelineEntry -{ - const sdk::TurnState* turn = nullptr; - qsizetype turnNumber = 0; - TimelineSegment segment; -}; - -struct TimelineTurnSlice -{ - const sdk::TurnState* turn = nullptr; - qsizetype turnNumber = 0; - qsizetype firstItem = 0; -}; - -struct TimelineWindow -{ - std::vector turns; - qsizetype renderedItems = 0; - qsizetype totalItems = 0; -}; - -QString segmentStorageKey(const QString& turnId, const QString& segmentId) -{ - return turnId + QChar(0x1f) + segmentId; -} - -std::vector timelineSegments(const sdk::State& state, - const sdk::ThreadState& thread, - const sdk::TurnState& turn, - qsizetype firstItem, - qsizetype endItem) -{ - std::vector result; - if (turn.orderedItems.empty()) - { - result.push_back({QStringLiteral("empty"), {}, false}); - return result; - } - - std::vector activities; - QString activityId; - const auto flushActivities = [&] - { - if (!activities.empty()) - result.push_back({QStringLiteral("activities:") + activityId, activities, false}); - activities.clear(); - activityId.clear(); - }; - - const qsizetype activityWidth = static_cast(maximumActivityItemsPerSegment); - // Stable ordinal buckets keep activity-card identities from shifting on - // every append while inspecting at most one partial bucket before the window. - const qsizetype scanStart = firstItem - firstItem % activityWidth; - const qsizetype scanEnd = qMin( - qMax(scanStart, endItem), - static_cast(turn.orderedItems.size())); - qsizetype activityBucket = -1; - for (qsizetype index = scanStart; index < scanEnd; ++index) - { - const auto& itemId = turn.orderedItems.at(index); - const qsizetype itemBucket = index / activityWidth; - if (itemBucket != activityBucket) - { - flushActivities(); - activityBucket = itemBucket; - } - const auto* item = state.item(thread.id, turn.id, itemId); - if (!item) - { - flushActivities(); - if (index >= firstItem) - result.push_back({QStringLiteral("missing:") + fromUtf8(itemId.value), {}, true}); - continue; - } - const bool message = item->kind.is(frontend::ThreadItemKind::UserMessage) - || item->kind.is(frontend::ThreadItemKind::AgentMessage); - if (message) - { - flushActivities(); - if (index >= firstItem) - result.push_back({QStringLiteral("message:") + fromUtf8(item->id.value), {item}, false}); - } - else - { - if (activityId.isEmpty()) - activityId = fromUtf8(item->id.value); - if (index >= firstItem) - activities.push_back(item); - } - } - flushActivities(); - return result; -} - -std::vector timelineSegments(const sdk::State& state, - const sdk::ThreadState& thread, - const sdk::TurnState& turn, - qsizetype firstItem) -{ - return timelineSegments( - state, - thread, - turn, - firstItem, - static_cast(turn.orderedItems.size())); -} - -qsizetype timelineItemCount(const TimelineSegment& segment) -{ - return qMax(1, static_cast(segment.items.size())); -} - -TimelineWindow retainedTimelineWindow(const sdk::State& state, const sdk::ThreadState& thread) -{ - TimelineWindow result; - result.turns.reserve(thread.orderedTurns.size()); - for (qsizetype index = 0; - index < static_cast(thread.orderedTurns.size()); - ++index) - { - const auto* turn = state.turn(thread.id, thread.orderedTurns.at(index)); - if (!turn) - continue; - const qsizetype itemCount = qMax(1, static_cast(turn->orderedItems.size())); - result.turns.push_back({turn, index + 1, 0}); - result.renderedItems += itemCount; - } - result.totalItems = result.renderedItems; - return result; -} - -bool incompleteStateContainsRenderedTimeline( - const sdk::State& state, - const sdk::ThreadState& thread, - const QStringList& renderedTurnIds, - const QHash>& renderedTurnItemRanges, - const QHash& renderedSegmentIds, - const QHash& renderedSegmentItemIds, - qsizetype& inspectedItems) -{ - inspectedItems = 0; - constexpr qsizetype maximumRecoveryInspectedItems = - recoveryInspectionItemBudget - + (static_cast(maximumActivityItemsPerSegment) - 1) - * recoveryInspectionTurnBudget; - for (const QString& renderedTurnId : renderedTurnIds) - { - const sdk::TurnState* retainedTurn = state.turn( - thread.id, - ai::openai::codex::typed::TurnId{ - renderedTurnId.toStdString()}); - if (!retainedTurn) - return false; - - const auto range = renderedTurnItemRanges.constFind(renderedTurnId); - if (range == renderedTurnItemRanges.cend() - || range->first < 0 || range->second < range->first - || range->second - > static_cast(retainedTurn->orderedItems.size())) - return false; - const qsizetype activityWidth = - static_cast(maximumActivityItemsPerSegment); - const qsizetype scanStart = - range->first - range->first % activityWidth; - const qsizetype inspectedRange = range->second - scanStart; - if (inspectedRange < 0 - || inspectedRange - > maximumRecoveryInspectedItems - inspectedItems) - return false; - inspectedItems += inspectedRange; - - const std::vector retainedSegments = - timelineSegments( - state, - thread, - *retainedTurn, - range->first, - range->second); - QHash> retainedItemsBySegment; - retainedItemsBySegment.reserve( - static_cast(retainedSegments.size())); - for (const TimelineSegment& segment : retainedSegments) - { - QSet retainedItemIds; - retainedItemIds.reserve( - static_cast(segment.items.size())); - for (const sdk::ItemState* item : segment.items) - { - if (item) - retainedItemIds.insert(fromUtf8(item->id.value)); - } - retainedItemsBySegment.insert( - segment.id, std::move(retainedItemIds)); - } - for (const QString& renderedSegmentId : - renderedSegmentIds.value(renderedTurnId)) - { - const auto retained = - retainedItemsBySegment.constFind(renderedSegmentId); - if (retained == retainedItemsBySegment.cend()) - return false; - const QString storage = segmentStorageKey( - renderedTurnId, renderedSegmentId); - for (const QString& renderedItemId : - renderedSegmentItemIds.value(storage)) - { - if (!retained->contains(renderedItemId)) - return false; - } - } - } - return true; -} - -QByteArray segmentPresentationKey(const sdk::State& state, - const TimelineSegment& segment, - bool typedPlanAvailable, - bool turnStreaming, - bool threadFullyLoaded) -{ - QCryptographicHash hash(QCryptographicHash::Sha256); - addPresentationValue(hash, segment.id); - addPresentationValue(hash, segment.missing); - addPresentationValue(hash, typedPlanAvailable); - addPresentationValue(hash, turnStreaming); - // A bounded backend snapshot can preserve an empty turn shell while - // omitting its descendants. Completeness affects only that empty-state - // presentation; populated segments should keep their stable identity. - if (segment.items.empty()) - addPresentationValue(hash, threadFullyLoaded); - for (const auto* item : segment.items) - { - addPresentationValue(hash, item != nullptr); - if (!item) - continue; - addPresentationValue(hash, item->id.value); - addPresentationValue(hash, item->kind.identity); - addPresentationValue(hash, itemStatus(*item)); - addPresentationValue(hash, truncationText(*item)); - if (item->kind.is(frontend::ThreadItemKind::UserMessage)) - { - const auto message = sdk::userMessageSemanticView(*item); - addPresentationValue(hash, message.has_value()); - if (message) - { - // User text has no append channel and is normally immutable; - // hash it directly so an equal-length authoritative repair is - // never mistaken for unchanged content. - addPresentationValue(hash, message->text); - addPresentationValue(hash, userMessageTruncationText(*message)); - } - } - else if (item->kind.is(frontend::ThreadItemKind::AgentMessage)) - { - const std::string_view content = item->agentText && !item->agentText->empty() - ? std::string_view(*item->agentText) - : (item->summary - ? std::string_view(*item->summary) - : std::string_view{}); - if (!item->agentText || item->agentText->empty() - || !addItemContentIdentity( - hash, state, *item, sdk::ItemContentChannel::AgentText)) - addPresentationValue(hash, content); - const auto semantic = sdk::itemSemanticView(*item); - const auto* agent = semantic ? std::get_if(&semantic->details) : nullptr; - addPresentationValue(hash, - agent && agent->phase ? std::string_view(*agent->phase) : std::string_view{}); - } - else - { - // A command-output channel may be several MiB. Do not convert and - // hash its complete text merely to discover that an immutable item - // revision changed; exact content updates explicitly bypass an - // equal key during reconciliation below. - const bool reasoning = item->kind.is(frontend::ThreadItemKind::Reasoning); - const ActivityPresentation presentation = activityPresentation( - state, *item, false, !reasoning); - addPresentationValue(hash, presentation.title); - addPresentationValue(hash, presentation.detail); - if (!addItemContentIdentity( - hash, state, *item, sdk::ItemContentChannel::CommandOutput) - && item->commandOutput) - addPresentationValue(hash, std::string_view(*item->commandOutput)); - if (reasoning) - { - if (!addItemContentIdentity( - hash, state, *item, sdk::ItemContentChannel::ReasoningText) - && item->reasoningText) - addPresentationValue(hash, std::string_view(*item->reasoningText)); - if (!addItemContentIdentity( - hash, state, *item, sdk::ItemContentChannel::ReasoningSummary) - && item->reasoningSummary) - addPresentationValue(hash, std::string_view(*item->reasoningSummary)); - } - addPresentationValue(hash, presentation.status); - addPresentationValue(hash, presentation.tail); - addPresentationValue(hash, presentation.truncated); - } - } - return hash.result(); -} - -QWidget* timelineSegmentWidget(const sdk::State& state, - const TimelineSegment& segment, - bool typedPlanAvailable, - bool turnStreaming, - bool threadFullyLoaded, - const ActivityExpansionState& activityExpansion, - const std::function& layoutChanged) -{ - auto* host = new QWidget; - host->setObjectName(QStringLiteral("conversationSegment")); - host->setProperty("segmentId", segment.id); - host->setProperty("timelineItemCount", timelineItemCount(segment)); - host->setStyleSheet(QStringLiteral("background:transparent;")); - auto* layout = new QVBoxLayout(host); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); - - if (segment.missing) - { - ActivityPresentation omitted; - omitted.title = QStringLiteral("Unavailable item"); - omitted.detail = QStringLiteral( - "The ordered item shell is not retained in current State"); - omitted.status = QStringLiteral("Omitted"); - omitted.truncated = true; - auto* card = new QFrame; - card->setProperty("kind", "panel"); - auto* rows = new QVBoxLayout(card); - rows->setContentsMargins(16, 8, 16, 8); - addActivityRow(rows, QString{}, omitted, false, layoutChanged); - layout->addWidget(card); - layout->addSpacing(16); - } - else if (segment.items.empty()) - { - addEmptyState( - layout, - threadFullyLoaded ? QStringLiteral("No items in this turn") - : QStringLiteral("Conversation history incomplete"), - threadFullyLoaded - ? QStringLiteral("The synchronized turn currently has no retained items.") - : QStringLiteral( - "Some turns or items are unavailable in the current synchronized view.")); - } - else if (segment.items.size() == 1 - && (segment.items.front()->kind.is(frontend::ThreadItemKind::UserMessage) - || segment.items.front()->kind.is(frontend::ThreadItemKind::AgentMessage))) - { - const auto* item = segment.items.front(); - const bool user = item->kind.is(frontend::ThreadItemKind::UserMessage); - host->setProperty("messageUser", user); - addMessage(layout, *item, user, turnStreaming); - } - else - { - layout->addWidget(activityCard(state, - segment.items, - typedPlanAvailable, - activityExpansion.groupExpanded, - activityExpansion.expandedItems, - layoutChanged)); - layout->addSpacing(16); - } - return host; -} - -std::optional exactAppendResultBytes( - const ConversationContentAppend& append) noexcept -{ - if (append.discardPrefixBytes > append.baseContentBytes - || append.baseContentBytes - append.discardPrefixBytes - > std::numeric_limits::max() - - append.deltaUtf8Bytes) - return std::nullopt; - return append.baseContentBytes - append.discardPrefixBytes - + append.deltaUtf8Bytes; -} - -bool applyExactActivityAppend(const sdk::State& state, - const ai::openai::codex::typed::ThreadId& threadId, - QWidget* row, - const ConversationContentUpdate& update, - bool* geometryChanged, - bool* mayShrink) -{ - if (!row || !update.append) - return false; - const ConversationContentAppend& append = *update.append; - auto* details = dynamic_cast( - row->findChild(QStringLiteral("conversationActivityDetails"))); - auto* detailsLayout = details ? qobject_cast(details->layout()) : nullptr; - if (!details || !detailsLayout) - return false; - - const auto expectedBytes = exactAppendResultBytes(append); - if (!expectedBytes) - return false; - auto source = deferredItemText( - state, - threadId, - ai::openai::codex::typed::TurnId{update.turnId.toStdString()}, - ai::openai::codex::typed::ItemId{update.itemId.toStdString()}, - update.channel); - if (!source || source->utf8Bytes != *expectedBytes) - return false; - - bool contentGeometryChanged = false; - if (update.channel == sdk::ItemContentChannel::CommandOutput) - { - if (!details->isVisible()) - { - if (details->retainedOutputBytes() != append.baseContentBytes - || append.discardPrefixBytes > append.baseContentBytes) - return false; - contentGeometryChanged = details->replaceOutput(source); - } - else - { - const auto applied = details->applyOutputAppend( - append.baseContentBytes, append.discardPrefixBytes, append.delta); - if (!applied) - return false; - contentGeometryChanged = *applied; - details->recordMaterializedOutputSource(std::move(*source)); - } - } - else if ((update.channel == sdk::ItemContentChannel::ReasoningText - || update.channel == sdk::ItemContentChannel::ReasoningSummary)) - { - if (!details->acceptsDetailAppend( - update.channel, - append.baseContentBytes, - append.discardPrefixBytes)) - return false; - if (!details->isVisible()) - { - contentGeometryChanged = details->replaceDeferredDetail(source); - } - else - { - auto* detail = dynamic_cast( - details->findChild(QStringLiteral("conversationActivityDetail"))); - if (!detail && append.baseContentBytes == 0 - && append.discardPrefixBytes == 0) - { - contentGeometryChanged = details->replaceDeferredDetail(source); - contentGeometryChanged = details->ensureDeferredDetail(detailsLayout) - || contentGeometryChanged; - } - else - { - if (!detail - || detail->property("activityContentChannel").toInt() - != static_cast(update.channel)) - return false; - const auto applied = detail->applyAppend( - append.baseContentBytes, append.discardPrefixBytes, append.delta); - if (!applied) - return false; - contentGeometryChanged = *applied; - details->recordMaterializedDetailSource(std::move(*source)); - } - } - } - else - { - return false; - } - - contentGeometryChanged = updateActivityDisclosureAvailability(row) - || contentGeometryChanged; - if (contentGeometryChanged) - { - detailsLayout->invalidate(); - details->updateGeometry(); - if (QLayout* rowLayout = row->layout()) - rowLayout->invalidate(); - row->updateGeometry(); - } - if (geometryChanged) - *geometryChanged = contentGeometryChanged; - if (mayShrink) - *mayShrink = append.discardPrefixBytes > append.deltaUtf8Bytes; - return true; -} - -bool updateTimelineActivitySegment(QWidget* host, - const sdk::State& state, - const TimelineSegment& segment, - bool typedPlanAvailable, - const ConversationContentUpdates* exactContentChanges, - bool* geometryChanged, - bool* mayShrink, - const std::function& layoutChanged) -{ - if (!host || segment.missing || segment.items.empty()) - return false; - if (segment.items.size() == 1 - && (segment.items.front()->kind.is(frontend::ThreadItemKind::UserMessage) - || segment.items.front()->kind.is(frontend::ThreadItemKind::AgentMessage))) - return false; - - auto* rowsLayout = host->findChild(QStringLiteral("conversationActivityRows")); - auto* count = host->findChild(QStringLiteral("conversationActivityCount")); - auto* planAvailable = host->findChild(QStringLiteral("conversationActivityPlanAvailable")); - if (!rowsLayout || !count || !planAvailable - || static_cast(rowsLayout->count()) > segment.items.size()) - return false; - std::vector rows; - rows.reserve(static_cast(rowsLayout->count())); - for (int index = 0; index < rowsLayout->count(); ++index) - { - QWidget* row = rowsLayout->itemAt(index)->widget(); - if (!row || row->objectName() != QStringLiteral("conversationActivityRow")) - return false; - rows.push_back(row); - } - for (std::size_t index = 0; index < rows.size(); ++index) - { - const auto* item = segment.items.at(index); - if (!item || rows.at(index)->property("itemId").toString() != fromUtf8(item->id.value)) - return false; - } - bool anyGeometryChanged = false; - bool anyMayShrink = false; - for (std::size_t index = 0; index < rows.size(); ++index) - { - const auto* item = segment.items.at(index); - bool rowGeometryChanged = false; - bool rowMayShrink = false; - bool handledExactly = false; - if (exactContentChanges) - { - for (const ConversationContentUpdate& update : *exactContentChanges) - { - if (update.itemId != fromUtf8(item->id.value)) - continue; - if (item->threadId) - handledExactly = applyExactActivityAppend( - state, *item->threadId, rows.at(index), update, - &rowGeometryChanged, &rowMayShrink); - if (!handledExactly) - break; - } - if (std::none_of( - exactContentChanges->cbegin(), exactContentChanges->cend(), - [item](const ConversationContentUpdate& update) - { return update.itemId == fromUtf8(item->id.value); })) - continue; - } - if (!handledExactly) - { - if (!updateActivityRow( - rows.at(index), activityPresentation(state, *item), - &rowGeometryChanged)) - return false; - // A full authoritative replacement may shorten any wrapping - // detail or hide output/truncation UI. - rowMayShrink = rowGeometryChanged; - } - anyGeometryChanged = anyGeometryChanged || rowGeometryChanged; - anyMayShrink = anyMayShrink || rowMayShrink; - } - for (std::size_t index = rows.size(); index < segment.items.size(); ++index) - { - const auto* item = segment.items.at(index); - if (!item) - return false; - addActivityRow(rowsLayout, - fromUtf8(item->id.value), - activityPresentation(state, *item), - false, - layoutChanged); - anyGeometryChanged = true; - } - const QString countText = QStringLiteral("%1 activit%2") - .arg(segment.items.size()) - .arg(segment.items.size() == 1 ? "y" : "ies"); - anyGeometryChanged = anyGeometryChanged || count->text() != countText; - count->setText(countText); - const bool nextPlanVisible = typedPlanAvailable - || std::ranges::any_of( - segment.items, [](const sdk::ItemState* item) { - return item && item->kind.is(frontend::ThreadItemKind::Plan); - }); - anyGeometryChanged = anyGeometryChanged - || planAvailable->isVisible() != nextPlanVisible; - planAvailable->setVisible(nextPlanVisible); - if (anyGeometryChanged) - { - rowsLayout->invalidate(); - if (QLayout* hostLayout = host->layout()) - hostLayout->invalidate(); - host->updateGeometry(); - } - if (geometryChanged) - *geometryChanged = anyGeometryChanged; - if (mayShrink) - *mayShrink = anyMayShrink; - return true; -} - -bool updateTimelineMessageSegment(QWidget* host, - const TimelineSegment& segment, - bool turnStreaming, - bool* geometryChanged, - bool* mayShrink) -{ - if (!host || segment.missing || segment.items.size() != 1) - return false; - const auto* item = segment.items.front(); - if (!item) - return false; - const bool user = item->kind.is(frontend::ThreadItemKind::UserMessage); - if (!user && !item->kind.is(frontend::ThreadItemKind::AgentMessage)) - return false; - if (!host->property("messageUser").isValid() - || host->property("messageUser").toBool() != user) - return false; - - auto* status = host->findChild(QStringLiteral("conversationMessageStatus")); - auto* contentWidget = host->findChild(QStringLiteral("conversationMessageContent")); - auto* truncation = host->findChild(QStringLiteral("conversationMessageTruncation")); - if (!status || !contentWidget || !truncation) - return false; - - const QString previousContent = messageContentText(contentWidget); - const QString previousTruncation = truncation->text(); - const bool previousTruncationVisible = truncation->isVisible(); - const QString previousKind = contentWidget->property("kind").toString(); - const MessagePresentation presentation = messagePresentation( - *item, user, turnStreaming); - if (mayShrink) - { - const QString nextKind = presentation.missing ? QStringLiteral("meta") - : QStringLiteral("body"); - *mayShrink = !presentation.content.startsWith(previousContent) - || previousKind != nextKind - || (previousTruncationVisible - && !presentation.truncation.startsWith(previousTruncation)); - } - auto* contentLayout = qobject_cast(contentWidget->parentWidget()->layout()); - if (!contentLayout) - return false; - QWidget* previousContentWidget = contentWidget; - contentWidget = ensureMessageContentWidget( - contentLayout, contentWidget, presentation.content, presentation.streaming); - const bool rendererChanged = previousContentWidget != contentWidget; - if (mayShrink) - *mayShrink = *mayShrink || rendererChanged; - const bool presentationGeometryChanged = applyMessagePresentation( - status, contentWidget, truncation, presentation); - if (geometryChanged) - *geometryChanged = rendererChanged || presentationGeometryChanged; - return true; -} - -QWidget* timelineTurnWidget(const sdk::TurnState& turn, - qsizetype visibleTurn, - QVBoxLayout*& itemLayout, - QLabel*& turnLabel, - QLabel*& statusLabel, - std::function detailsRequested) -{ - auto* host = new QWidget; - host->setObjectName(QStringLiteral("conversationTurn")); - host->setProperty("turnId", fromUtf8(turn.id.value)); - host->setStyleSheet(QStringLiteral("background:transparent;")); - auto* layout = new QVBoxLayout(host); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); - if (visibleTurn > 1) - { - layout->addSpacing(7); - layout->addWidget(divider()); - layout->addSpacing(12); - } - - auto* turnHeader = new QHBoxLayout; - turnLabel = textLabel(QStringLiteral("TURN %1").arg(visibleTurn), "section"); - turnLabel->setToolTip(fromUtf8(turn.id.value)); - turnHeader->addWidget(turnLabel); - turnHeader->addStretch(); - auto* details = new QPushButton(QStringLiteral("Details")); - details->setObjectName(QStringLiteral("conversationTurnDetails")); - details->setProperty("kind", "subtle"); - details->setFixedSize(58, 24); - QObject::connect(details, &QPushButton::clicked, details, - [detailsRequested = std::move(detailsRequested)] { detailsRequested(); }); - turnHeader->addWidget(details); - const QString status = humanize(fromUtf8(turn.status.value)); - statusLabel = textLabel(status, "small"); - statusLabel->setStyleSheet( - QStringLiteral("color:%1;font-size:9px;font-weight:600;").arg(statusColor(status))); - turnHeader->addWidget(statusLabel); - layout->addLayout(turnHeader); - layout->addSpacing(10); - - auto* itemHost = new QWidget; - itemHost->setStyleSheet(QStringLiteral("background:transparent;")); - itemLayout = new QVBoxLayout(itemHost); - itemLayout->setContentsMargins(0, 0, 0, 0); - itemLayout->setSpacing(0); - layout->addWidget(itemHost); - return host; -} - -QByteArray turnSummaryPresentationKey(const sdk::TurnState* turn, - qsizetype index) -{ - QCryptographicHash hash(QCryptographicHash::Sha256); - addPresentationValue(hash, turn != nullptr); - if (!turn) - return hash.result(); - addPresentationValue(hash, turn->id.value); - addPresentationValue(hash, QByteArray::number(index)); - addPresentationValue(hash, turn->status.value); - addPresentationValue(hash, failureText(*turn)); - return hash.result(); -} - -} // namespace - -ConversationWidget::ConversationWidget(QWidget* parent) : QWidget(parent) -{ - setObjectName(QStringLiteral("conversation")); - setStyleSheet(QStringLiteral("QWidget#conversation{background:#f6f8fb;}")); - setMinimumWidth(480); - - auto* root = new QVBoxLayout(this); - root->setContentsMargins(24, 14, 24, 0); - root->setSpacing(0); - - auto* context = new QHBoxLayout; - context->setSpacing(10); - context->addWidget(badge(QStringLiteral("THREAD"), QStringLiteral("#e5eeff"), QStringLiteral("#2f6feb"), 54, 18)); - contextPath = textLabel(QStringLiteral("No thread selected"), "small"); - contextPath->setStyleSheet(QStringLiteral("color:#667085;font-size:9px;font-weight:500;")); - context->addWidget(contextPath); - contextPath->hide(); - context->addStretch(); - root->addLayout(context); - root->addSpacing(2); - threadTitle = textLabel(QStringLiteral("No synchronized thread"), "heading"); - root->addWidget(threadTitle); - root->addSpacing(2); - threadDetail = textLabel(QStringLiteral("Select a synchronized thread to view its conversation"), "meta"); - root->addWidget(threadDetail); - root->addSpacing(7); - root->addWidget(divider()); - root->addSpacing(7); - - turnFailure = textLabel({}, "meta"); - turnFailure->setWordWrap(true); - turnFailure->setStyleSheet(QStringLiteral( - "background:#fff1f1;color:#b83a3a;border:1px solid #efc4c4;border-radius:6px;" - "padding:7px 10px;font-size:10px;")); - turnFailure->hide(); - root->addSpacing(3); - root->addWidget(turnFailure); - root->addSpacing(3); - - scrollArea = new QScrollArea; - scrollArea->setWidgetResizable(true); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - auto* conversationScroll = scrollArea->verticalScrollBar(); - layoutSettleTimer = new QTimer(this); - layoutSettleTimer->setSingleShot(true); - layoutSettleTimer->setInterval(16); - connect(layoutSettleTimer, &QTimer::timeout, this, &ConversationWidget::settleTimelineLayout); - connect(conversationScroll, &QScrollBar::rangeChanged, this, - [this, conversationScroll](int, int maximum) - { - if (pinLatestDuringLayout || followingLatest) - conversationScroll->setValue(maximum); - }); - connect(conversationScroll, &QScrollBar::actionTriggered, this, - [this, conversationScroll](int) - { - followingLatest = false; - pendingFollowLatest = false; - pendingPreviousScroll = conversationScroll->value(); - pendingViewportAnchor.clear(); - }); - connect(conversationScroll, &QScrollBar::sliderPressed, this, - [this, conversationScroll] - { - followingLatest = false; - pendingFollowLatest = false; - pendingPreviousScroll = conversationScroll->value(); - pendingViewportAnchor.clear(); - }); - connect(conversationScroll, &QScrollBar::valueChanged, this, - [this, conversationScroll] - { - if (conversationScroll->maximum() - conversationScroll->value() > 72) - followingLatest = false; - requestDeferredPresentationAtTail(); - }); - auto* content = new QWidget; - content->setStyleSheet(QStringLiteral("background:transparent;")); - auto* conversation = new QVBoxLayout(content); - conversation->setContentsMargins(0, 0, 0, 16); - conversation->setSpacing(0); - conversation->setAlignment(Qt::AlignTop); - - timelineWindowNotice = new QFrame; - timelineWindowNotice->setObjectName(QStringLiteral("conversationWindowNotice")); - timelineWindowNotice->setStyleSheet( - QStringLiteral("QFrame#conversationWindowNotice{background:#f8fafc;border:1px solid #d7dee8;border-radius:7px;}")); - auto* timelineWindowLayout = new QVBoxLayout(timelineWindowNotice); - timelineWindowLayout->setContentsMargins(12, 8, 12, 8); - timelineWindowDetail = wrappingLabel({}, "meta"); - timelineWindowLayout->addWidget(timelineWindowDetail); - timelineWindowNotice->hide(); - conversation->addWidget(timelineWindowNotice); - - timelineHost = new QWidget; - timelineHost->setObjectName(QStringLiteral("conversationTimeline")); - timelineHost->setProperty( - "recoveryInspectionTurnBudget", recoveryInspectionTurnBudget); - timelineHost->setProperty( - "recoveryInspectionItemBudget", recoveryInspectionItemBudget); - timeline = new QVBoxLayout(timelineHost); - timeline->setContentsMargins(0, 0, 0, 0); - timeline->setSpacing(0); - timeline->setAlignment(Qt::AlignTop); - conversation->addWidget(timelineHost); - - scrollArea->setWidget(content); - - anchoredSurface = new AnchoredTurnSurface; - upcomingTurnDock = new UpcomingTurnDock; - anchoredSurface->setConversationWidget(scrollArea); - anchoredSurface->setUpcomingTurnDock(upcomingTurnDock); - root->addWidget(anchoredSurface, 1); - - connect(upcomingTurnDock, &UpcomingTurnDock::sendRequested, - this, &ConversationWidget::sendRequested); - connect(upcomingTurnDock, &UpcomingTurnDock::stopRequested, - this, &ConversationWidget::stopRequested); - connect(upcomingTurnDock, &UpcomingTurnDock::settingsChanged, - this, &ConversationWidget::upcomingTurnSettingsChanged); - - addEmptyState(timeline, QStringLiteral("No thread selected"), - QStringLiteral("Choose a synchronized thread from the sidebar.")); -} - -void ConversationWidget::setModelCatalog( - const std::vector& catalog) -{ - upcomingTurnDock->setModelCatalog(catalog); -} - -bool ConversationWidget::shouldFreezePresentation(const QString& threadId, - bool newThreadDraft) const -{ - if (threadId.isEmpty() || threadId != renderedThreadId - || newThreadDraft != renderedNewThreadDraft || pinLatestDuringLayout) - return false; - const auto* bar = scrollArea->verticalScrollBar(); - return bar->maximum() - bar->value() > 72; -} - -void ConversationWidget::markPresentationDeferred() -{ - deferredPresentationPending = true; -} - -void ConversationWidget::requestDeferredPresentationAtTail() -{ - if (!deferredPresentationPending || deferredPresentationRequestScheduled) - return; - const auto* bar = scrollArea->verticalScrollBar(); - if (bar->maximum() - bar->value() > 72) - return; - - deferredPresentationRequestScheduled = true; - QTimer::singleShot(0, this, - [this] - { - deferredPresentationRequestScheduled = false; - if (!deferredPresentationPending) - return; - const auto* settledBar = scrollArea->verticalScrollBar(); - if (settledBar->maximum() - settledBar->value() > 72) - return; - deferredPresentationPending = false; - emit latestPresentationRequested(); - }); -} - -void ConversationWidget::render(const sdk::State& state, - const QString& threadId, - bool newThreadDraft, - const ConversationContentUpdates* exactContentChanges, - bool structurallyAffected) -{ - auto* scrollBar = scrollArea->verticalScrollBar(); - const int previousScroll = scrollBar->value(); - const bool wasNearBottom = scrollBar->maximum() - previousScroll <= 72; - const bool threadChanged = renderedThreadId != threadId || renderedNewThreadDraft != newThreadDraft; - const auto* thread = threadId.isEmpty() ? nullptr : state.thread(threadId.toStdString()); - // Capacity provenance is only needed to classify a missing selection. - // Avoid decoding its complete diagnostic object for every live delta of - // a thread that is already present. - const auto capacityProvenance = thread - ? decltype(state.capacityProvenance()){} - : state.capacityProvenance(); - const bool selectedThreadOmitted = !newThreadDraft && !thread - && !threadId.isEmpty() - && capacityProvenance - && capacityProvenance->omittedThreads > 0; - const bool selectedThreadUnresolved = !newThreadDraft && !thread - && !threadId.isEmpty() - && (selectedThreadOmitted - || (state.threadList().value - && !state.threadList().value->complete)); - const bool missingThreadPresentationChanged = - !thread && !threadChanged - && (renderedThreadFullyLoaded.has_value() - || renderedSelectedThreadOmitted != selectedThreadOmitted); - const bool threadCompletenessChanged = thread - && (!renderedThreadFullyLoaded - || *renderedThreadFullyLoaded - != thread->fullyLoaded); - const bool structuralReconciliation = structurallyAffected - && !threadChanged - && !threadCompletenessChanged - && thread && !newThreadDraft; - if (!structuralReconciliation) - { - upcomingTurnDock->setCanonicalConfiguration( - thread ? thread->executionConfiguration - : std::optional{}, - thread ? threadId : QString{}, - newThreadDraft); - } - if (!threadChanged && shouldFreezePresentation(threadId, newThreadDraft)) - { - markPresentationDeferred(); - return; - } - // Exact content appends cannot remove turns or items. Apply them before - // the bounded incomplete-history proof so streaming on a partial thread - // remains proportional to the changed bytes. A structural publication - // still has to derive the new segment topology, but must not apply these - // mutations a second time during that reconciliation. - const bool exactContentApplied = exactContentChanges - && !threadChanged - && !threadCompletenessChanged - && thread && !newThreadDraft - && updateExactMessageContent( - state, threadId, *exactContentChanges); - if (exactContentApplied && !structuralReconciliation) - return; - // A bounded replacement is not deletion authority. This proof also gates - // structural item upserts: their scope says that topology may have grown, - // not that an incomplete State is authoritative for deleting every item it - // omitted. Keep the same-thread widgets until the publication can account - // for every rendered descendant; requester-local Merge will make that - // true, while Replace is fullyLoaded and exact Absent changes selection. - { - const bool renderedTimelineRetained = !renderedTurnIds.isEmpty(); - qsizetype recoveryInspectedItems = 0; - const bool incompleteTimelineRetained = - !thread || thread->fullyLoaded - || incompleteStateContainsRenderedTimeline( - state, - *thread, - renderedTurnIds, - renderedTurnItemRanges, - renderedSegmentIds, - renderedSegmentItemIds, - recoveryInspectedItems); - timelineHost->setProperty( - "recoveryInspectedTimelineItems", recoveryInspectedItems); - const bool incompleteTimelineRegressed = - !threadChanged && renderedTimelineRetained - && (selectedThreadUnresolved - || !incompleteTimelineRetained); - if (incompleteTimelineRegressed) - { - const QString recovery = QStringLiteral("History recovery pending"); - if (!threadDetail->text().contains(recovery)) - { - const QString detail = threadDetail->text(); - threadDetail->setText( - detail.isEmpty() - ? recovery - : detail + QStringLiteral(" · ") + recovery); - threadDetail->setToolTip(threadDetail->text()); - } - return; - } - } - if (!thread && !threadChanged && !missingThreadPresentationChanged) - return; - if (threadChanged) - { - deferredPresentationPending = false; - deferredPresentationRequestScheduled = false; - } - const bool followLatest = threadChanged || wasNearBottom || followingLatest; - const bool exactContentOnly = !structuralReconciliation - && exactContentChanges && !threadChanged - && !threadCompletenessChanged && thread && !newThreadDraft - && !renderedSummaryKey.isEmpty(); - const std::uint64_t generation = ++renderGeneration; - bool timelineShrank = false; - bool timelineGeometryChanged = threadChanged; - if (threadChanged) - pendingViewportAnchor.clear(); - else if (!followLatest && !layoutSettleTimer->isActive()) - captureTimelineAnchor(); - else if (followLatest) - pendingViewportAnchor.clear(); - renderedThreadId = threadId; - renderedNewThreadDraft = newThreadDraft; - if (thread) - renderedThreadFullyLoaded = thread->fullyLoaded; - else - renderedThreadFullyLoaded.reset(); - renderedSelectedThreadOmitted = selectedThreadOmitted; - if (threadChanged) - { - followingLatest = false; - pinLatestDuringLayout = true; - pinLatestGeneration = generation; - scrollArea->viewport()->setUpdatesEnabled(false); - } - - const auto clearTimelineState = [this, &timelineShrank, &timelineGeometryChanged] - { - timelineShrank = timelineShrank || timeline->count() > 0; - timelineGeometryChanged = timelineGeometryChanged || timeline->count() > 0; - renderedTurnIds.clear(); - renderedTurnWidgets.clear(); - renderedTurnLabels.clear(); - renderedTurnStatusLabels.clear(); - renderedTurnItemLayouts.clear(); - renderedTurnItemRanges.clear(); - renderedSegmentIds.clear(); - renderedSegmentItemIds.clear(); - renderedSegmentKeys.clear(); - renderedSegmentWidgets.clear(); - renderedActivityRows.clear(); - renderedActivityRowSegments.clear(); - clearLayout(timeline); - }; - - if (!thread) - { - if (timeline->count() > 0) - clearTimelineState(); - if (!renderedSummaryKey.isEmpty() || turnFailure->isVisible()) - { - renderedSummaryKey.clear(); - turnFailure->hide(); - } - QString pathText; - QString titleText; - QString detailText; - QString emptyTitle; - QString emptyDetail; - if (newThreadDraft) { - pathText = QStringLiteral("New thread draft"); - titleText = QStringLiteral("New conversation"); - detailText = QStringLiteral( - "A real thread will be created when the first prompt is sent"); - emptyTitle = QStringLiteral("Start a new conversation"); - emptyDetail = QStringLiteral( - "Type a prompt below. Backend defaults will be used for the new thread."); - } else if (selectedThreadOmitted) { - pathText = QStringLiteral("History incomplete"); - titleText = QStringLiteral("Conversation history incomplete"); - detailText = QStringLiteral( - "This conversation is not available in the current synchronized view."); - emptyTitle = titleText; - emptyDetail = detailText; - } else { - pathText = QStringLiteral("No thread selected"); - titleText = QStringLiteral("No synchronized thread"); - detailText = QStringLiteral( - "Select a synchronized thread to view its conversation"); - emptyTitle = QStringLiteral("No thread selected"); - emptyDetail = QStringLiteral( - "Choose a synchronized thread from the sidebar."); - } - contextPath->setText(pathText); - contextPath->setToolTip({}); - threadTitle->setText(titleText); - threadTitle->setToolTip({}); - threadDetail->setText(detailText); - threadDetail->setToolTip({}); - timelineWindowNotice->hide(); - timelineHost->setProperty("renderedTimelineItems", 0); - timelineHost->setProperty("retainedTimelineItems", 0); - addEmptyState(timeline, emptyTitle, emptyDetail); - timelineGeometryChanged = true; - } - else - { - if (!structuralReconciliation) - { - const QString id = fromUtf8(thread->id.value); - const QString title = thread->title && !thread->title->empty() - ? fromUtf8(*thread->title) - : id; - threadTitle->setText(title); - threadTitle->setToolTip(title); - QStringList metadata; - if (thread->archived.value_or(false)) - metadata.append(QStringLiteral("Archived")); - else if (thread->status && !thread->status->empty()) - metadata.append(humanize(fromUtf8(*thread->status))); - if (thread->ephemeral.value_or(false)) - metadata.append(QStringLiteral("Temporary")); - metadata.append(QStringLiteral("%1 turn%2") - .arg(thread->orderedTurns.size()) - .arg(thread->orderedTurns.size() == 1 - ? QString{} - : QStringLiteral("s"))); - if (!thread->fullyLoaded) - metadata.append(QStringLiteral("History incomplete")); - threadDetail->setText(metadata.join(QStringLiteral(" · "))); - threadDetail->setToolTip(threadDetail->text()); - } - - const sdk::TurnState* currentTurn = nullptr; - qsizetype currentIndex = -1; - std::optional structuralWindow; - if (structuralReconciliation) - { - structuralWindow.emplace(retainedTimelineWindow(state, *thread)); - if (!structuralWindow->turns.empty()) - currentTurn = structuralWindow->turns.back().turn; - } - else - { - for (qsizetype index = 0; - index < static_cast(thread->orderedTurns.size()); - ++index) - { - if (const auto* turn = state.turn( - thread->id, thread->orderedTurns.at(index))) - { - currentTurn = turn; - currentIndex = index; - } - } - } - - if (!structuralReconciliation) - { - const QByteArray summaryKey = exactContentOnly - ? renderedSummaryKey - : turnSummaryPresentationKey( - currentTurn, currentIndex); - if (!exactContentOnly && summaryKey != renderedSummaryKey) - { - renderedSummaryKey = summaryKey; - turnFailure->hide(); - if (currentTurn) - { - const QString failure = failureText(*currentTurn); - if (!failure.isEmpty()) - { - turnFailure->setText(failure); - turnFailure->setToolTip(failure); - turnFailure->show(); - } - } - } - } - - if (!currentTurn) - { - timelineWindowNotice->hide(); - timelineHost->setProperty("renderedTimelineItems", 0); - timelineHost->setProperty("retainedTimelineItems", 0); - if (threadChanged || threadCompletenessChanged - || !renderedTurnIds.isEmpty() || timeline->count() == 0) - { - clearTimelineState(); - addEmptyState( - timeline, - thread->fullyLoaded ? QStringLiteral("Ready for the first turn") - : QStringLiteral("Conversation history incomplete"), - thread->fullyLoaded - ? QStringLiteral("Use the upcoming-turn dock below to start this thread.") - : QStringLiteral( - "Some turns or items are unavailable in the current synchronized view.")); - timelineGeometryChanged = true; - } - } - else - { - const TimelineWindow window = structuralWindow - ? std::move(*structuralWindow) - : retainedTimelineWindow(state, *thread); - std::vector entries; - entries.reserve(static_cast(window.renderedItems)); - for (const TimelineTurnSlice& slice : window.turns) - { - auto segments = timelineSegments(state, *thread, *slice.turn, slice.firstItem); - for (TimelineSegment& segment : segments) - entries.push_back({slice.turn, slice.turnNumber, std::move(segment)}); - } - timelineHost->setProperty("renderedTimelineItems", window.renderedItems); - timelineHost->setProperty("retainedTimelineItems", window.totalItems); - timelineWindowNotice->hide(); - - struct VisibleTimelineTurn - { - const sdk::TurnState* turn = nullptr; - qsizetype turnNumber = 0; - std::vector segments; - }; - std::vector visibleTurns; - for (const TimelineEntry& entry : entries) - { - if (visibleTurns.empty() || visibleTurns.back().turn != entry.turn) - visibleTurns.push_back({entry.turn, entry.turnNumber, {}}); - visibleTurns.back().segments.push_back(&entry.segment); - } - QStringList visibleTurnIds; - visibleTurnIds.reserve(static_cast(visibleTurns.size())); - for (const VisibleTimelineTurn& visibleTurn : visibleTurns) - visibleTurnIds.append(fromUtf8(visibleTurn.turn->id.value)); - - const auto forgetRenderedActivityRows = [this](const QString& storage) - { - for (auto row = renderedActivityRowSegments.begin(); - row != renderedActivityRowSegments.end();) - { - if (row.value() != storage) - { - ++row; - continue; - } - renderedActivityRows.remove(row.key()); - row = renderedActivityRowSegments.erase(row); - } - }; - const auto forgetRenderedSegment = - [this, &forgetRenderedActivityRows](const QString& storage) - { - forgetRenderedActivityRows(storage); - renderedSegmentItemIds.remove(storage); - renderedSegmentKeys.remove(storage); - renderedSegmentWidgets.remove(storage); - }; - const auto removeRenderedTurn = [this, - &forgetRenderedSegment, - &timelineShrank, - &timelineGeometryChanged](const QString& turnId) - { - for (const QString& segmentId : renderedSegmentIds.take(turnId)) - { - const QString storage = segmentStorageKey(turnId, segmentId); - forgetRenderedSegment(storage); - } - renderedTurnLabels.remove(turnId); - renderedTurnStatusLabels.remove(turnId); - renderedTurnItemLayouts.remove(turnId); - renderedTurnItemRanges.remove(turnId); - if (QWidget* widget = renderedTurnWidgets.take(turnId)) - { - if (pendingViewportAnchor == widget - || (pendingViewportAnchor && widget->isAncestorOf(pendingViewportAnchor))) - pendingViewportAnchor.clear(); - timeline->removeWidget(widget); - widget->hide(); - widget->deleteLater(); - timelineShrank = true; - timelineGeometryChanged = true; - } - }; - - bool compatibleTurns = !threadChanged && timeline->count() == renderedTurnIds.size(); - qsizetype removedTurnPrefix = 0; - if (compatibleTurns && !renderedTurnIds.isEmpty()) - { - if (visibleTurnIds.isEmpty()) - { - compatibleTurns = false; - } - else - { - removedTurnPrefix = renderedTurnIds.indexOf(visibleTurnIds.front()); - qsizetype visibleTurnPrefix = 0; - if (removedTurnPrefix < 0) - { - removedTurnPrefix = 0; - visibleTurnPrefix = visibleTurnIds.indexOf( - renderedTurnIds.front()); - } - compatibleTurns = visibleTurnPrefix >= 0 - && renderedTurnIds.size() - removedTurnPrefix - <= visibleTurnIds.size() - visibleTurnPrefix; - for (qsizetype index = 0; - compatibleTurns - && index < renderedTurnIds.size() - removedTurnPrefix; - ++index) - { - const QString oldId = renderedTurnIds.at(removedTurnPrefix + index); - compatibleTurns = oldId == visibleTurnIds.at(visibleTurnPrefix + index) - && renderedTurnWidgets.contains(oldId) - && renderedTurnLabels.contains(oldId) - && renderedTurnItemLayouts.contains(oldId) - && renderedTurnStatusLabels.contains(oldId); - } - } - } - if (!compatibleTurns) - { - clearTimelineState(); - } - else - { - for (qsizetype index = 0; index < removedTurnPrefix; ++index) - { - const QString removed = renderedTurnIds.front(); - renderedTurnIds.removeFirst(); - removeRenderedTurn(removed); - } - } - - for (qsizetype visibleTurnIndex = 0; - visibleTurnIndex < static_cast(visibleTurns.size()); - ++visibleTurnIndex) - { - const VisibleTimelineTurn& visibleTurn = visibleTurns.at( - static_cast(visibleTurnIndex)); - const auto* turn = visibleTurn.turn; - const QString turnId = fromUtf8(turn->id.value); - const auto windowSlice = std::ranges::find_if( - window.turns, - [turn](const TimelineTurnSlice& slice) { - return slice.turn == turn; - }); - if (windowSlice != window.turns.cend()) - { - renderedTurnItemRanges.insert( - turnId, - qMakePair( - windowSlice->firstItem, - static_cast( - turn->orderedItems.size()))); - } - QVBoxLayout* itemLayout = renderedTurnItemLayouts.value(turnId); - QLabel* turnLabel = renderedTurnLabels.value(turnId); - QLabel* statusLabel = renderedTurnStatusLabels.value(turnId); - if (!renderedTurnWidgets.contains(turnId)) - { - auto* turnWidget = timelineTurnWidget( - *turn, - visibleTurn.turnNumber, - itemLayout, - turnLabel, - statusLabel, - [this, turnId] { emit turnDetailsRequested(turnId); }); - timeline->insertWidget( - visibleTurnIndex, turnWidget, 0, Qt::AlignTop); - renderedTurnWidgets.insert(turnId, turnWidget); - renderedTurnLabels.insert(turnId, turnLabel); - renderedTurnItemLayouts.insert(turnId, itemLayout); - renderedTurnStatusLabels.insert(turnId, statusLabel); - timelineGeometryChanged = true; - } - else - { - const QString heading = QStringLiteral("TURN %1").arg(visibleTurn.turnNumber); - if (turnLabel && turnLabel->text() != heading) - turnLabel->setText(heading); - const QString status = humanize(fromUtf8(turn->status.value)); - if (statusLabel && statusLabel->text() != status) - { - statusLabel->setText(status); - statusLabel->setStyleSheet( - QStringLiteral("color:%1;font-size:9px;font-weight:600;").arg(statusColor(status))); - } - } - - QStringList segmentIds; - segmentIds.reserve(static_cast(visibleTurn.segments.size())); - for (const TimelineSegment* segment : visibleTurn.segments) - segmentIds.append(segment->id); - - const QStringList oldSegmentIds = renderedSegmentIds.value(turnId); - const QSet nextSegmentIds( - segmentIds.cbegin(), segmentIds.cend()); - for (const QString& oldId : oldSegmentIds) - { - if (nextSegmentIds.contains(oldId)) - continue; - const QString storage = segmentStorageKey(turnId, oldId); - if (QWidget* widget = renderedSegmentWidgets.value(storage)) - { - if (pendingViewportAnchor == widget - || (pendingViewportAnchor - && widget->isAncestorOf(pendingViewportAnchor))) - pendingViewportAnchor.clear(); - itemLayout->removeWidget(widget); - widget->hide(); - widget->deleteLater(); - timelineShrank = true; - timelineGeometryChanged = true; - } - forgetRenderedSegment(storage); - } - - for (qsizetype segmentIndex = 0; - segmentIndex < static_cast(visibleTurn.segments.size()); - ++segmentIndex) - { - const TimelineSegment* segment = visibleTurn.segments.at( - static_cast(segmentIndex)); - const QString storage = segmentStorageKey(turnId, segment->id); - QStringList itemIds; - itemIds.reserve(static_cast(segment->items.size())); - for (const sdk::ItemState* item : segment->items) - { - if (item) - itemIds.append(fromUtf8(item->id.value)); - } - renderedSegmentItemIds.insert(storage, std::move(itemIds)); - QWidget* oldWidget = renderedSegmentWidgets.value(storage); - if (oldWidget) - oldWidget->setProperty( - "timelineItemCount", timelineItemCount(*segment)); - if (oldWidget && itemLayout->indexOf(oldWidget) != segmentIndex) - { - itemLayout->removeWidget(oldWidget); - itemLayout->insertWidget( - segmentIndex, oldWidget, 0, Qt::AlignTop); - timelineGeometryChanged = true; - } - const ConversationContentUpdates* segmentContentChanges = nullptr; - ConversationContentUpdates segmentContentStorage; - bool explicitlyAffected = false; - if (oldWidget && (exactContentOnly || exactContentApplied)) - { - for (const ConversationContentUpdate& update : *exactContentChanges) - { - if (update.turnId != turnId) - continue; - const bool segmentContainsItem = std::any_of( - segment->items.cbegin(), - segment->items.cend(), - [&update](const sdk::ItemState* item) - { - return item && update.itemId == fromUtf8(item->id.value); - }); - if (segmentContainsItem) - segmentContentStorage.push_back(update); - } - explicitlyAffected = !segmentContentStorage.empty(); - if (exactContentOnly && !explicitlyAffected) - continue; - if (exactContentOnly && explicitlyAffected) - segmentContentChanges = &segmentContentStorage; - } - const bool typedPlanAvailable = turn->plan.has_value(); - const bool turnStreaming = turnStreamsMessages(*turn); - const QByteArray segmentKey = segmentPresentationKey( - state, - *segment, - typedPlanAvailable, - turnStreaming, - thread->fullyLoaded); - if (oldWidget && !explicitlyAffected - && renderedSegmentKeys.value(storage) == segmentKey) - continue; - - const bool exactMessageAlreadyApplied = - exactContentApplied && explicitlyAffected - && segment->items.size() == 1 - && segment->items.front() - && (segment->items.front()->kind.is( - frontend::ThreadItemKind::UserMessage) - || segment->items.front()->kind.is( - frontend::ThreadItemKind::AgentMessage)); - if (oldWidget && exactMessageAlreadyApplied) - { - const sdk::ItemState* item = segment->items.front(); - auto* status = oldWidget->findChild( - QStringLiteral("conversationMessageStatus")); - auto* content = oldWidget->findChild( - QStringLiteral("conversationMessageContent")); - auto* truncation = oldWidget->findChild( - QStringLiteral("conversationMessageTruncation")); - if (status && content && truncation) - { - const bool user = item->kind.is( - frontend::ThreadItemKind::UserMessage); - const bool metadataGeometryChanged = - applyMessageMetadata( - status, - content, - truncation, - messagePresentationMetadata( - *item, user, turnStreaming)); - renderedSegmentKeys.insert(storage, segmentKey); - timelineShrank = timelineShrank - || metadataGeometryChanged; - timelineGeometryChanged = - timelineGeometryChanged - || metadataGeometryChanged; - continue; - } - } - - bool messageMayShrink = false; - bool messageGeometryChanged = false; - if (oldWidget - && updateTimelineMessageSegment( - oldWidget, - *segment, - turnStreaming, - &messageGeometryChanged, - &messageMayShrink)) - { - renderedSegmentKeys.insert(storage, segmentKey); - timelineShrank = timelineShrank || messageMayShrink; - timelineGeometryChanged = timelineGeometryChanged - || messageGeometryChanged; - continue; - } - - if (oldWidget - && updateTimelineActivitySegment( - oldWidget, - state, - *segment, - typedPlanAvailable, - segmentContentChanges, - &messageGeometryChanged, - &messageMayShrink, - [this] { activityLayoutChanged(); })) - { - renderedSegmentKeys.insert(storage, segmentKey); - timelineShrank = timelineShrank || messageMayShrink; - timelineGeometryChanged = timelineGeometryChanged - || messageGeometryChanged; - continue; - } - - const ActivityExpansionState expansion = activityExpansionState(oldWidget); - QWidget* newWidget = timelineSegmentWidget( - state, - *segment, - typedPlanAvailable, - turnStreaming, - thread->fullyLoaded, - expansion, - [this] { activityLayoutChanged(); }); - newWidget->setProperty("turnId", turnId); - if (oldWidget) - { - const bool replacesAnchor = pendingViewportAnchor == oldWidget - || (pendingViewportAnchor - && oldWidget->isAncestorOf(pendingViewportAnchor)); - itemLayout->removeWidget(oldWidget); - oldWidget->hide(); - oldWidget->deleteLater(); - forgetRenderedActivityRows(storage); - itemLayout->insertWidget( - segmentIndex, newWidget, 0, Qt::AlignTop); - if (replacesAnchor) - pendingViewportAnchor.clear(); - timelineShrank = true; - timelineGeometryChanged = true; - } - else - { - itemLayout->insertWidget( - segmentIndex, newWidget, 0, Qt::AlignTop); - timelineGeometryChanged = true; - } - renderedSegmentWidgets.insert(storage, newWidget); - renderedSegmentKeys.insert(storage, segmentKey); - } - renderedSegmentIds.insert(turnId, segmentIds); - } - renderedTurnIds = visibleTurnIds; - } - } - - if (timelineGeometryChanged) - scheduleTimelineLayout(previousScroll, followLatest, threadChanged, timelineShrank); - else if (followLatest) - scrollBar->setValue(scrollBar->maximum()); -} - -bool ConversationWidget::updateExactMessageContent( - const sdk::State& state, - const QString& threadId, - const ConversationContentUpdates& exactContentChanges) -{ - if (threadId.isEmpty() || renderedThreadId != threadId || renderedNewThreadDraft - || exactContentChanges.empty()) - return false; - if (shouldFreezePresentation(threadId, false)) - { - markPresentationDeferred(); - return true; - } - auto* scrollBar = scrollArea->verticalScrollBar(); - const int previousScroll = scrollBar->value(); - const bool followLatest = scrollBar->maximum() - previousScroll <= 72 - || followingLatest; - if (!followLatest && !layoutSettleTimer->isActive()) - captureTimelineAnchor(); - else if (followLatest) - pendingViewportAnchor.clear(); - - bool timelineShrank = false; - bool geometryChanged = false; - for (const ConversationContentUpdate& update : exactContentChanges) - { - if (!update.append) - return false; - if (!renderedTurnIds.contains(update.turnId)) - continue; - - const QString messageSegmentId = QStringLiteral("message:") + update.itemId; - const QString messageStorage = segmentStorageKey(update.turnId, messageSegmentId); - QWidget* messageWidget = renderedSegmentWidgets.value(messageStorage); - bool contentMayShrink = false; - bool contentGeometryChanged = false; - QString affectedStorage; - if (messageWidget) - { - if (messageWidget->property("messageUser").toBool() - || update.channel != sdk::ItemContentChannel::AgentText) - return false; - const ai::openai::codex::typed::ThreadId typedThreadId{ - threadId.toStdString()}; - const ai::openai::codex::typed::TurnId typedTurnId{ - update.turnId.toStdString()}; - const ai::openai::codex::typed::ItemId typedItemId{ - update.itemId.toStdString()}; - const auto expectedBytes = exactAppendResultBytes(*update.append); - const auto descriptor = state.itemContentDescriptor( - typedThreadId, - typedTurnId, - typedItemId, - update.channel); - const auto* turn = state.turn(typedThreadId, typedTurnId); - const auto* item = state.item(typedThreadId, typedTurnId, typedItemId); - if (!expectedBytes || !descriptor || !descriptor->present - || descriptor->retainedUtf8Bytes != *expectedBytes - || !turn || !item - || !item->kind.is(frontend::ThreadItemKind::AgentMessage)) - return false; - auto* content = messageWidget->findChild( - QStringLiteral("conversationMessageContent")); - if (!content) - return false; - const bool emptyCanonicalPlaceholder = - content->property("kind").toString() == QStringLiteral("meta") - && update.append->baseContentBytes == 0; - const std::uint64_t currentUtf8Bytes = emptyCanonicalPlaceholder - ? 0 - : messageContentUtf8Bytes(content); - if (currentUtf8Bytes != update.append->baseContentBytes - || update.append->discardPrefixBytes - > update.append->baseContentBytes) - return false; - - auto* contentLayout = qobject_cast( - content->parentWidget()->layout()); - if (!contentLayout) - return false; - QWidget* const previousContent = content; - const bool authoritativeStreaming = - turnStreamsMessages(*turn) - || streamingMessageStatus(itemStatus(*item)); - if (!authoritativeStreaming) - { - auto* status = messageWidget->findChild( - QStringLiteral("conversationMessageStatus")); - auto* truncation = messageWidget->findChild( - QStringLiteral("conversationMessageTruncation")); - if (!status || !truncation) - return false; - const MessagePresentation presentation = messagePresentation( - *item, false, false); - content = ensureMessageContentWidget( - contentLayout, - content, - presentation.content, - false); - const bool rendererChanged = previousContent != content; - contentGeometryChanged = rendererChanged - || applyMessagePresentation( - status, - content, - truncation, - presentation); - // Markdown reparsing can reduce its preferred height even - // when the raw source only grew (for example, when this - // append closes an emphasis span or fenced block). - contentMayShrink = true; - } - else - { - bool mutationGeometryChanged = false; - if (emptyCanonicalPlaceholder) - { - // The visible copy is explanatory UI text, not canonical - // message content. Reset that small placeholder directly - // so the first real delta still enters the O(delta) path. - if (dynamic_cast(content)) - mutationGeometryChanged = setMessageContentText( - content, QString{}); - else - content = ensureMessageContentWidget( - contentLayout, content, QString{}, true); - if (content->property("kind").toString() - != QStringLiteral("body")) - { - content->setProperty("kind", QStringLiteral("body")); - content->style()->unpolish(content); - content->style()->polish(content); - } - } - else if (!dynamic_cast(content) - && !qobject_cast(content)) - { - content = ensureMessageContentWidget( - contentLayout, - content, - messageContentText(content), - true); - } - - const auto applied = appendMessageContent( - content, - update.append->baseContentBytes, - update.append->discardPrefixBytes, - update.append->delta); - if (!applied) - return false; - mutationGeometryChanged = mutationGeometryChanged || *applied; - - // Select the renderer from the post-append size. In - // particular, the append that crosses 64 KiB performs the - // one required source materialization immediately; it does - // not leave the small-message renderer alive until a later - // event happens to arrive. - if (!messageContentWidgetMatches( - content, messageContentSize(content), true)) - { - content = ensureMessageContentWidget( - contentLayout, - content, - messageContentText(content), - true); - } - contentGeometryChanged = previousContent != content - || mutationGeometryChanged; - contentMayShrink = update.append->discardPrefixBytes - > update.append->deltaUtf8Bytes; - } - affectedStorage = messageStorage; - } - else - { - const QString activityIdentity = update.turnId - + QChar::Null - + update.itemId; - QWidget* activityRow = renderedActivityRows.value(activityIdentity); - if (!activityRow) { - for (const QString& segmentId : renderedSegmentIds.value(update.turnId)) - { - const QString storage = segmentStorageKey(update.turnId, segmentId); - QWidget* candidate = renderedSegmentWidgets.value(storage); - if (!candidate) - continue; - const auto rows = candidate->findChildren( - QStringLiteral("conversationActivityRow")); - const auto found = std::find_if( - rows.cbegin(), rows.cend(), [&update](const QWidget* row) { - return row->property("itemId").toString() == update.itemId; - }); - if (found == rows.cend()) - continue; - activityRow = *found; - affectedStorage = storage; - renderedActivityRows.insert(activityIdentity, activityRow); - renderedActivityRowSegments.insert(activityIdentity, storage); - break; - } - } else { - affectedStorage = renderedActivityRowSegments.value(activityIdentity); - } - if (!activityRow) - continue; - if (!applyExactActivityAppend( - state, - ai::openai::codex::typed::ThreadId{threadId.toStdString()}, - activityRow, - update, - &contentGeometryChanged, &contentMayShrink)) - return false; - } - renderedSegmentKeys.remove(affectedStorage); - timelineShrank = timelineShrank || contentMayShrink; - geometryChanged = geometryChanged || contentGeometryChanged; - } - if (geometryChanged) - { - // Document and layout repaints are queued. Hide the intermediate old - // extent until the existing settle pass has resized and pinned the - // conversation, then expose one final frame. - if (followLatest && scrollArea->viewport()->updatesEnabled()) - scrollArea->viewport()->setUpdatesEnabled(false); - scheduleTimelineLayout(previousScroll, followLatest, false, timelineShrank); - } - else if (followLatest) - scrollBar->setValue(scrollBar->maximum()); - return true; -} - -void ConversationWidget::scheduleTimelineLayout(int previousScroll, - bool followLatest, - bool threadChanged, - bool timelineShrank) -{ - if (!layoutSettleTimer->isActive()) - { - pendingPreviousScroll = previousScroll; - layoutSettleTimer->start(); - } - pendingFollowLatest = pendingFollowLatest || followLatest; - pendingThreadChanged = pendingThreadChanged || threadChanged; - pendingTimelineShrink = pendingTimelineShrink || timelineShrank; -} - -void ConversationWidget::activityLayoutChanged() -{ - auto* bar = scrollArea->verticalScrollBar(); - const int previousScroll = bar->value(); - const bool followLatest = bar->maximum() - previousScroll <= 72 - || followingLatest; - if (!followLatest && !layoutSettleTimer->isActive()) - captureTimelineAnchor(); - else if (followLatest) - pendingViewportAnchor.clear(); - scheduleTimelineLayout(previousScroll, followLatest, false, true); -} - -void ConversationWidget::captureTimelineAnchor() -{ - pendingViewportAnchor.clear(); - auto* viewport = scrollArea->viewport(); - const auto captureIfVisible = [this, viewport](QWidget* candidate) - { - if (!candidate) - return false; - const int y = viewport->mapFromGlobal(candidate->mapToGlobal(QPoint{})).y(); - if (y >= viewport->height() || y + candidate->height() <= 0) - return false; - pendingViewportAnchor = candidate; - pendingViewportAnchorY = y; - return true; - }; - for (const QString& turnId : renderedTurnIds) - { - for (const QString& segmentId : renderedSegmentIds.value(turnId)) - { - if (captureIfVisible(renderedSegmentWidgets.value(segmentStorageKey(turnId, segmentId)))) - return; - } - QWidget* turn = renderedTurnWidgets.value(turnId); - if (captureIfVisible(turn)) - return; - } -} - -void ConversationWidget::settleTimelineLayout() -{ - const int previousScroll = pendingPreviousScroll; - const bool followLatest = pendingFollowLatest; - const bool threadChanged = pendingThreadChanged; - const bool timelineShrank = pendingTimelineShrink; - pendingFollowLatest = false; - pendingThreadChanged = false; - pendingTimelineShrink = false; - - if (threadChanged || pinLatestDuringLayout) - { - settleThreadSwitchLayout(pinLatestGeneration, 1); - return; - } - - synchronizeTimelineHeight(timelineShrank); - scrollArea->widget()->layout()->activate(); - - auto* bar = scrollArea->verticalScrollBar(); - if (followLatest) - { - pendingViewportAnchor.clear(); - followingLatest = !renderedThreadId.isEmpty(); - bar->setValue(bar->maximum()); - if (!scrollArea->viewport()->updatesEnabled()) - scrollArea->viewport()->setUpdatesEnabled(true); - return; - } - - bar->setValue(qMin(previousScroll, bar->maximum())); - if (pendingViewportAnchor) - { - auto* viewport = scrollArea->viewport(); - const int settledY = viewport->mapFromGlobal(pendingViewportAnchor->mapToGlobal(QPoint{})).y(); - const int correction = settledY - pendingViewportAnchorY; - bar->setValue(qBound(0, bar->value() + correction, bar->maximum())); - } - pendingViewportAnchor.clear(); - if (!scrollArea->viewport()->updatesEnabled()) - scrollArea->viewport()->setUpdatesEnabled(true); - followingLatest = false; -} - -void ConversationWidget::settleThreadSwitchLayout(std::uint64_t generation, int remainingPasses) -{ - // An older generation must leave ownership to the newer settle pass. If - // the current generation was cancelled, however, no later callback owns - // the viewport freeze and updates must be restored here. - if (generation != pinLatestGeneration) - return; - if (!pinLatestDuringLayout) { - scrollArea->viewport()->setUpdatesEnabled(true); - return; - } - synchronizeTimelineHeight(true); - scrollArea->widget()->layout()->activate(); - scrollArea->widget()->adjustSize(); - if (remainingPasses > 0) - { - QTimer::singleShot(0, this, - [this, generation, remainingPasses] - { - settleThreadSwitchLayout(generation, remainingPasses - 1); - }); - return; - } - - auto* bar = scrollArea->verticalScrollBar(); - bar->setValue(bar->maximum()); - followingLatest = true; - pinLatestDuringLayout = false; - pendingViewportAnchor.clear(); - scrollArea->viewport()->setUpdatesEnabled(true); -} - -void ConversationWidget::synchronizeTimelineHeight(bool allowShrink) -{ - if (allowShrink) - { - timelineHost->setMinimumHeight(0); - timelineHost->setMaximumHeight(QWIDGETSIZE_MAX); - } - timeline->invalidate(); - timeline->activate(); - const int width = timelineHost->contentsRect().width(); - const int preferredHeight = width > 0 && timeline->hasHeightForWidth() - ? timeline->heightForWidth(width) - : timeline->sizeHint().height(); - const int target = qMax(0, qMax(timeline->minimumSize().height(), preferredHeight)); - if (allowShrink || target > timelineHost->height()) - timelineHost->setFixedHeight(target); -} - -void ConversationWidget::resizeEvent(QResizeEvent* event) -{ - QWidget::resizeEvent(event); - if (resizeLayoutPending) - return; - resizeLayoutPending = true; - QTimer::singleShot(0, this, - [this] - { - synchronizeTimelineHeight(); - scrollArea->widget()->layout()->activate(); - scrollArea->widget()->adjustSize(); - QTimer::singleShot(0, this, - [this] - { - synchronizeTimelineHeight(); - scrollArea->widget()->layout()->activate(); - scrollArea->widget()->adjustSize(); - resizeLayoutPending = false; - }); - }); -} - -void ConversationWidget::clearPrompt() -{ - upcomingTurnDock->clearPrompt(); -} - -void ConversationWidget::clearPromptIfUnchanged(const QString& submittedPrompt) -{ - upcomingTurnDock->clearPromptIfUnchanged(submittedPrompt); -} - -const QList& ConversationWidget::attachments() const noexcept -{ - return upcomingTurnDock->attachments(); -} - -QString ConversationWidget::attachmentWorkspace() const -{ - return upcomingTurnDock->attachmentWorkspace(); -} - -void ConversationWidget::clearAttachmentsIfUnchanged( - const QList& submittedAttachments) -{ - upcomingTurnDock->clearAttachmentsIfUnchanged(submittedAttachments); -} - -void ConversationWidget::focusComposer() -{ - upcomingTurnDock->focusPrompt(); -} - -UpcomingTurnDraft ConversationWidget::upcomingTurnDraft() const -{ - return upcomingTurnDock->draft(); -} - -void ConversationWidget::clearUpcomingTurnSettings() -{ - upcomingTurnDock->clearTouchedSettings(); -} - -void ConversationWidget::acknowledgeSubmittedSettings(const UpcomingTurnDraft& submitted) -{ - upcomingTurnDock->acknowledgeSubmittedSettings(submitted); -} - -void ConversationWidget::setActionState(bool primaryAllowed, - bool stopAllowed, - bool editorAllowed, - bool settingsAllowed, - bool stopVisible, - bool steerMode, - const QString& actionThreadIdentity, - const QString& activeTurnIdentity) -{ - upcomingTurnDock->setActionState( - primaryAllowed, - stopAllowed, - editorAllowed, - settingsAllowed, - stopVisible, - steerMode, - actionThreadIdentity, - activeTurnIdentity); -} - -void ConversationWidget::setWriteStatus(const QString& text, bool error) -{ - upcomingTurnDock->setStatus(text, error); -} - -} // namespace codexui diff --git a/src/ui/ConversationWidget.h b/src/ui/ConversationWidget.h deleted file mode 100644 index e4b6a13..0000000 --- a/src/ui/ConversationWidget.h +++ /dev/null @@ -1,182 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#ifndef CODEXUI_UI_CONVERSATIONWIDGET_H -#define CODEXUI_UI_CONVERSATIONWIDGET_H - -#include "app/AttachmentManager.h" - -#include - -#include - -#include -#include -#include -#include -#include -#include - -#include -#include -#include - -class QFrame; -class QLabel; -class QResizeEvent; -class QScrollArea; -class QTimer; -class QVBoxLayout; - -namespace ai::openai::codex::frontend::client { -class State; -} -namespace ai::openai::codex::typed { -struct Model; -} - -namespace codexui { - -class AnchoredTurnSurface; -class UpcomingTurnDock; -struct UpcomingTurnDraft; -struct ConversationWidgetTestAccess; - -struct ConversationContentAppend -{ - std::uint64_t baseContentBytes = 0; - std::uint64_t discardPrefixBytes = 0; - std::uint64_t deltaUtf8Bytes = 0; - QString delta; -}; - -struct ConversationContentUpdate -{ - QString turnId; - QString itemId; - ai::openai::codex::frontend::client::ItemContentChannel channel = - ai::openai::codex::frontend::client::ItemContentChannel::AgentText; - std::optional append; -}; - -using ConversationContentUpdates = std::vector; - -class ConversationWidget : public QWidget -{ - Q_OBJECT - -public: - explicit ConversationWidget(QWidget* parent = nullptr); - void render(const ai::openai::codex::frontend::client::State& state, - const QString& threadId, - bool newThreadDraft = false, - const ConversationContentUpdates* exactContentChanges = nullptr, - bool structurallyAffected = false); - void setModelCatalog(const std::vector& catalog); - [[nodiscard]] bool updateExactMessageContent( - const ai::openai::codex::frontend::client::State& state, - const QString& threadId, - const ConversationContentUpdates& exactContentChanges); - void clearPrompt(); - void clearPromptIfUnchanged(const QString& submittedPrompt); - [[nodiscard]] const QList& attachments() const noexcept; - [[nodiscard]] QString attachmentWorkspace() const; - void clearAttachmentsIfUnchanged(const QList& submittedAttachments); - void focusComposer(); - [[nodiscard]] UpcomingTurnDraft upcomingTurnDraft() const; - void clearUpcomingTurnSettings(); - void acknowledgeSubmittedSettings(const UpcomingTurnDraft& submitted); - void setActionState(bool primaryAllowed, - bool stopAllowed, - bool editorAllowed, - bool settingsAllowed, - bool stopVisible, - bool steerMode, - const QString& actionThreadIdentity, - const QString& activeTurnIdentity); - void setWriteStatus(const QString& text, bool error = false); - -signals: - void sendRequested(const QString& prompt, bool steerRequested); - void stopRequested(); - void upcomingTurnSettingsChanged(); - void turnDetailsRequested(const QString& turnId); - void latestPresentationRequested(); - -protected: - void resizeEvent(QResizeEvent* event) override; - -private: - friend struct ConversationWidgetTestAccess; - - void scheduleTimelineLayout(int previousScroll, - bool followLatest, - bool threadChanged, - bool timelineShrank); - void captureTimelineAnchor(); - void settleTimelineLayout(); - void settleThreadSwitchLayout(std::uint64_t generation, int remainingPasses); - void synchronizeTimelineHeight(bool allowShrink = true); - void activityLayoutChanged(); - [[nodiscard]] bool shouldFreezePresentation(const QString& threadId, - bool newThreadDraft) const; - void markPresentationDeferred(); - void requestDeferredPresentationAtTail(); - AnchoredTurnSurface* anchoredSurface = nullptr; - UpcomingTurnDock* upcomingTurnDock = nullptr; - QLabel* contextPath = nullptr; - QLabel* threadTitle = nullptr; - QLabel* threadDetail = nullptr; - QLabel* turnFailure = nullptr; - QScrollArea* scrollArea = nullptr; - QFrame* timelineWindowNotice = nullptr; - QLabel* timelineWindowDetail = nullptr; - QWidget* timelineHost = nullptr; - QVBoxLayout* timeline = nullptr; - QTimer* layoutSettleTimer = nullptr; - QString renderedThreadId; - std::optional renderedThreadFullyLoaded; - bool renderedSelectedThreadOmitted = false; - // Identity only; conversation content remains owned by immutable AISuite State. - QByteArray renderedSummaryKey; - QStringList renderedTurnIds; - QHash renderedTurnWidgets; - QHash renderedTurnLabels; - QHash renderedTurnStatusLabels; - QHash renderedTurnItemLayouts; - // Original bounded item ranges for the materialized tail of each turn. - // Incomplete replacement recovery rechecks only these ranges, so a large - // retained prefix or a burst of later appends cannot turn proof of the - // existing presentation into an unbounded GUI-thread scan. - QHash> renderedTurnItemRanges; - QHash renderedSegmentIds; - // Exact item identities retained by each rendered segment. Activity cards - // group several descendants under one stable segment identity, so segment - // IDs alone cannot prove that an incomplete State still contains every - // rendered row. - QHash renderedSegmentItemIds; - QHash renderedSegmentKeys; - QHash renderedSegmentWidgets; - // Exact streaming updates are the hottest presentation path. Keep direct - // guarded identities instead of repeatedly walking every activity-card - // subtree for each content delta. - QHash> renderedActivityRows; - QHash renderedActivityRowSegments; - QPointer pendingViewportAnchor; - std::uint64_t renderGeneration = 0; - std::uint64_t pinLatestGeneration = 0; - bool pinLatestDuringLayout = false; - bool followingLatest = false; - bool pendingFollowLatest = false; - bool pendingThreadChanged = false; - bool pendingTimelineShrink = false; - bool resizeLayoutPending = false; - bool deferredPresentationPending = false; - bool deferredPresentationRequestScheduled = false; - int pendingPreviousScroll = 0; - int pendingViewportAnchorY = 0; - bool renderedNewThreadDraft = false; -}; - -} // namespace codexui - -#endif // CODEXUI_UI_CONVERSATIONWIDGET_H diff --git a/src/ui/ExpandingPromptEditor.cpp b/src/ui/ExpandingPromptEditor.cpp deleted file mode 100644 index f16d433..0000000 --- a/src/ui/ExpandingPromptEditor.cpp +++ /dev/null @@ -1,96 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "ui/ExpandingPromptEditor.h" - -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -namespace codexui { - -ExpandingPromptEditor::ExpandingPromptEditor(QWidget* parent) - : QPlainTextEdit(parent) -{ - setObjectName(QStringLiteral("upcomingPromptEditor")); - setPlaceholderText(QStringLiteral("Message Codex")); - setLineWrapMode(QPlainTextEdit::WidgetWidth); - setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - setMinimumHeight(compactHeight()); - setMaximumHeight(maximumContentHeight()); - setFixedHeight(compactHeight()); - setStyleSheet(QStringLiteral( - "QPlainTextEdit{background:transparent;color:#1d2633;border:0;padding:3px 2px;font-size:13px;}")); - - connect(this, &QPlainTextEdit::textChanged, this, &ExpandingPromptEditor::remeasure); -} - -void ExpandingPromptEditor::focusInEvent(QFocusEvent* event) -{ - QPlainTextEdit::focusInEvent(event); - emit focusStateChanged(true); -} - -void ExpandingPromptEditor::focusOutEvent(QFocusEvent* event) -{ - QPlainTextEdit::focusOutEvent(event); - emit focusStateChanged(false); -} - -void ExpandingPromptEditor::keyPressEvent(QKeyEvent* event) -{ - if ((event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) - && event->modifiers().testFlag(Qt::ControlModifier) && !event->isAutoRepeat()) { - emit submitRequested(); - event->accept(); - return; - } - QPlainTextEdit::keyPressEvent(event); -} - -void ExpandingPromptEditor::resizeEvent(QResizeEvent* event) -{ - QPlainTextEdit::resizeEvent(event); - scheduleRemeasure(); -} - -void ExpandingPromptEditor::scheduleRemeasure() -{ - if (remeasureScheduled) - return; - remeasureScheduled = true; - QTimer::singleShot(0, this, [this] { - remeasureScheduled = false; - remeasure(); - }); -} - -void ExpandingPromptEditor::remeasure() -{ - if (viewport()->width() <= 0) - return; - - document()->setTextWidth(viewport()->width()); - qreal laidOutHeight = 0; - QAbstractTextDocumentLayout* documentLayout = document()->documentLayout(); - for (QTextBlock block = document()->begin(); block.isValid(); block = block.next()) - laidOutHeight += documentLayout->blockBoundingRect(block).height(); - const int documentHeight = static_cast(std::ceil(laidOutHeight)) + 10; - const int wanted = std::clamp(documentHeight, compactHeight(), maximumContentHeight()); - setVerticalScrollBarPolicy(wanted >= maximumContentHeight() ? Qt::ScrollBarAsNeeded - : Qt::ScrollBarAlwaysOff); - if (wanted == currentContentHeight) - return; - currentContentHeight = wanted; - setFixedHeight(wanted); - emit editorHeightChanged(wanted); -} - -} // namespace codexui diff --git a/src/ui/InspectorWidget.cpp b/src/ui/InspectorWidget.cpp deleted file mode 100644 index 09b7761..0000000 --- a/src/ui/InspectorWidget.cpp +++ /dev/null @@ -1,1696 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "ui/InspectorWidget.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -namespace codexui { -namespace { -namespace sdk = ai::openai::codex::frontend::client; -namespace typed = ai::openai::codex::typed; - -struct AgentPresentation -{ - QStringList itemIds; - QString agentPath; - QString agentThreadId; - QString kind; - QString status; - QString summary; - QString duration; -}; - -struct CollaborationPresentation -{ - QString itemId; - QString title; - QString status; - QString detail; - bool truncated = false; -}; - -struct ExecutionConfigurationPresentation -{ - bool recorded = false; - QString turnId; - QString unavailableDetail; - QString model; - QString effort; - QString personality; - QString workspace; - QString sandbox; - QString approvalPolicy; - QString approvalsReviewer; - QString serviceTier; - QString summary; - QString collaborationMode; - QString activePermissionProfile; - QString provenance; -}; - -QString fromUtf8(std::string_view value) -{ - return QString::fromUtf8(value.data(), static_cast(value.size())); -} - -QString fromUtf8(const std::string& value) -{ - return QString::fromStdString(value); -} - -QString humanize(QString value) -{ - value.replace(QLatin1Char('_'), QLatin1Char(' ')); - value.replace(QLatin1Char('-'), QLatin1Char(' ')); - for (qsizetype index = 1; index < value.size(); ++index) { - if (value.at(index).isUpper() && value.at(index - 1).isLower()) { - value.insert(index, QLatin1Char(' ')); - ++index; - } - } - if (!value.isEmpty()) - value[0] = value.at(0).toUpper(); - return value; -} - -QString compactId(const std::string& id) -{ - const QString value = fromUtf8(id); - return value.size() > 18 ? value.left(8) + QChar(0x2026) + value.right(7) : value; -} - -QString compact(QString value, qsizetype maximum = 500) -{ - value = value.trimmed(); - return value.size() > maximum ? value.left(maximum).trimmed() + QChar(0x2026) : value; -} - -QLabel* textLabel(const QString& text, const char* kind = nullptr) -{ - auto* result = new QLabel(text); - result->setTextFormat(Qt::PlainText); - if (kind) - result->setProperty("kind", kind); - return result; -} - -QFrame* divider() -{ - auto* line = new QFrame; - line->setFixedHeight(1); - line->setStyleSheet(QStringLiteral("background:#d7dee8;")); - return line; -} - -void clearLayout(QLayout* layout) -{ - while (QLayoutItem* item = layout->takeAt(0)) { - if (QLayout* child = item->layout()) { - clearLayout(child); - } else if (QWidget* widget = item->widget()) { - widget->hide(); - widget->deleteLater(); - } - delete item; - } -} - -QWidget* scrollPage(QVBoxLayout*& content) -{ - auto* scroll = new QScrollArea; - scroll->setWidgetResizable(true); - scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - auto* page = new QWidget; - page->setStyleSheet(QStringLiteral("background:transparent;")); - content = new QVBoxLayout(page); - content->setContentsMargins(0, 14, 0, 24); - content->setSpacing(0); - content->setAlignment(Qt::AlignTop); - scroll->setWidget(page); - return scroll; -} - -void addEmpty(QVBoxLayout* layout, const QString& title, const QString& detail) -{ - layout->addWidget(textLabel(title.toUpper(), "section")); - layout->addSpacing(8); - auto* card = new QFrame; - card->setProperty("kind", "raised"); - auto* cardLayout = new QVBoxLayout(card); - cardLayout->setContentsMargins(14, 14, 14, 14); - cardLayout->setSpacing(6); - auto* heading = textLabel(title); - heading->setStyleSheet(QStringLiteral("font-size:13px;font-weight:600;")); - cardLayout->addWidget(heading); - auto* copy = textLabel(detail, "muted"); - copy->setWordWrap(true); - cardLayout->addWidget(copy); - layout->addWidget(card); - layout->addStretch(); -} - -QString statusColor(const QString& status) -{ - const QString normalized = status.toLower(); - if (normalized.contains(QStringLiteral("fail")) || normalized.contains(QStringLiteral("error"))) - return QStringLiteral("#b83a3a"); - if (normalized.contains(QStringLiteral("complete")) || normalized.contains(QStringLiteral("success")) - || normalized == QStringLiteral("done")) - return QStringLiteral("#23845a"); - if (normalized.contains(QStringLiteral("progress")) || normalized.contains(QStringLiteral("running")) - || normalized.contains(QStringLiteral("active")) || normalized.contains(QStringLiteral("stream"))) - return QStringLiteral("#2f6feb"); - if (normalized.contains(QStringLiteral("interrupt")) || normalized.contains(QStringLiteral("cancel"))) - return QStringLiteral("#a76812"); - return QStringLiteral("#667085"); -} - -QString planStatusGlyph(const QString& status) -{ - const QString normalized = status.toLower(); - if (normalized.contains(QStringLiteral("complete"))) - return QStringLiteral("✓"); - if (normalized.contains(QStringLiteral("progress"))) - return QStringLiteral("●"); - return QStringLiteral("○"); -} - -QString itemStatus(const sdk::ItemState& item) -{ - return item.status && !item.status->empty() ? humanize(fromUtf8(*item.status)) : QString{}; -} - -QString durationText(const sdk::ItemState& item) -{ - if (!item.startedAtMs || !item.completedAtMs || *item.completedAtMs < *item.startedAtMs) - return {}; - const qint64 duration = *item.completedAtMs - *item.startedAtMs; - if (duration < 1000) - return QStringLiteral("%1 ms").arg(duration); - if (duration < 60000) - return QStringLiteral("%1 s").arg(QString::number(duration / 1000.0, 'f', 1)); - return QStringLiteral("%1 min").arg(QString::number(duration / 60000.0, 'f', 1)); -} - -const sdk::TurnState* latestTurn(const sdk::State& state, const sdk::ThreadState& thread) -{ - for (auto iterator = thread.orderedTurns.rbegin(); iterator != thread.orderedTurns.rend(); ++iterator) { - if (const auto* turn = state.turn(thread.id, *iterator)) - return turn; - } - return nullptr; -} - -QFrame* detailCard() -{ - auto* card = new QFrame; - card->setObjectName(QStringLiteral("inspectorDetailCard")); - card->setProperty("kind", "raised"); - card->setStyleSheet(QStringLiteral( - "QFrame#inspectorDetailCard{background:#ffffff;border:1px solid #d7dee8;border-radius:8px;}")); - return card; -} - -QFrame* executionConfigurationCard() -{ - auto* card = new QFrame; - card->setObjectName(QStringLiteral("turnExecutionConfigurationCard")); - card->setStyleSheet(QStringLiteral( - "QFrame#turnExecutionConfigurationCard{background:#f8fafc;border:1px solid #d7dee8;border-radius:8px;}")); - return card; -} - -QLabel* addFact(QGridLayout* grid, int& row, const QString& name, const QString& value, - const QString& color = QStringLiteral("#1d2633")) -{ - if (value.isEmpty()) - return nullptr; - grid->addWidget(textLabel(name, "meta"), row, 0, Qt::AlignTop); - auto* copy = textLabel(value); - copy->setWordWrap(true); - copy->setTextInteractionFlags(Qt::TextSelectableByMouse); - copy->setStyleSheet(QStringLiteral("color:%1;font-size:11px;font-weight:500;").arg(color)); - grid->addWidget(copy, row, 1); - ++row; - return copy; -} - -void addExecutionConfigurationFact(QGridLayout* grid, int& row, const QString& name, const QString& value) -{ - if (value.isEmpty()) - return; - auto* label = textLabel(name); - label->setStyleSheet(QStringLiteral("color:#667085;font-size:10px;font-weight:600;")); - grid->addWidget(label, row, 0, Qt::AlignTop); - auto* copy = textLabel(value); - copy->setWordWrap(true); - copy->setTextInteractionFlags(Qt::TextSelectableByMouse); - copy->setStyleSheet(QStringLiteral("color:#1d2633;font-size:11px;font-weight:500;")); - grid->addWidget(copy, row, 1); - ++row; -} - -template -QString optionalOpenValueText(const typed::OptionalNullable& value) -{ - if (value.isOmitted()) - return {}; - if (value.isNull()) - return QStringLiteral("Default"); - return humanize(fromUtf8(value->value)); -} - -QString optionalStringText(const typed::OptionalNullable& value) -{ - if (value.isOmitted()) - return {}; - if (value.isNull()) - return QStringLiteral("Default"); - return fromUtf8(*value); -} - -QString approvalPolicyText(const typed::AskForApproval& policy) -{ - if (const auto* scalar = std::get_if(&policy)) - return humanize(fromUtf8(scalar->value)); - if (const auto* granular = std::get_if(&policy)) { - QStringList enabled; - if (granular->granular.mcpElicitations) - enabled.append(QStringLiteral("MCP elicitations")); - if (granular->granular.requestPermissionsOrDefault()) - enabled.append(QStringLiteral("permission requests")); - if (granular->granular.rules) - enabled.append(QStringLiteral("rules")); - if (granular->granular.sandboxApproval) - enabled.append(QStringLiteral("sandbox escalation")); - if (granular->granular.skillApprovalOrDefault()) - enabled.append(QStringLiteral("skill approval")); - return enabled.isEmpty() ? QStringLiteral("Granular · No categories enabled") - : QStringLiteral("Granular · %1").arg(enabled.join(QStringLiteral(", "))); - } - const auto* unknown = std::get_if(&policy); - return unknown && unknown->discriminator - ? QStringLiteral("Unrecognized · %1").arg(humanize(fromUtf8(*unknown->discriminator))) - : QStringLiteral("Unrecognized policy"); -} - -QString sandboxPolicyText(const typed::SandboxPolicy& policy) -{ - if (std::holds_alternative(policy)) - return QStringLiteral("Danger full access · Network included"); - if (const auto* readOnly = std::get_if(&policy)) - return readOnly->networkAccessOrDefault() ? QStringLiteral("Read only · Network enabled") - : QStringLiteral("Read only · Network restricted"); - if (const auto* external = std::get_if(&policy)) - return QStringLiteral("External · Network %1") - .arg(humanize(fromUtf8(external->networkAccessOrDefault().value)).toLower()); - if (const auto* workspace = std::get_if(&policy)) - return workspace->networkAccessOrDefault() ? QStringLiteral("Workspace write · Network enabled") - : QStringLiteral("Workspace write · Network restricted"); - const auto* unknown = std::get_if(&policy); - return unknown && unknown->type - ? QStringLiteral("Unrecognized · %1").arg(humanize(fromUtf8(*unknown->type))) - : QStringLiteral("Unrecognized sandbox policy"); -} - -QString activePermissionProfileText(const typed::OptionalNullable& profile) -{ - if (profile.isOmitted()) - return {}; - if (profile.isNull()) - return QStringLiteral("None"); - QString result = fromUtf8(profile->id); - if (profile->extends.hasValue()) - result += QStringLiteral(" · Extends %1").arg(fromUtf8(*profile->extends)); - return result; -} - -QString executionConfigurationProvenanceText( - std::optional provenance) -{ - if (!provenance) - return QStringLiteral("Authoritative turn state"); - switch (*provenance) { - case sdk::EffectiveExecutionConfigurationProvenance::TurnStartAccepted: - return QStringLiteral("Recorded when turn start was accepted"); - case sdk::EffectiveExecutionConfigurationProvenance::ThreadSettingsUpdated: - return QStringLiteral("Confirmed by thread settings update"); - } - return QStringLiteral("Authoritative turn state"); -} - -ExecutionConfigurationPresentation executionConfigurationPresentation(const sdk::TurnState* turn, - QString unavailableDetail) -{ - ExecutionConfigurationPresentation result; - result.unavailableDetail = std::move(unavailableDetail); - if (!turn) - return result; - result.turnId = fromUtf8(turn->id.value); - if (!turn->effectiveExecutionConfiguration) - return result; - - const auto& configuration = *turn->effectiveExecutionConfiguration; - result.recorded = true; - result.model = fromUtf8(configuration.model.value); - result.effort = optionalOpenValueText(configuration.effort); - result.personality = optionalOpenValueText(configuration.personality); - result.workspace = configuration.cwd ? fromUtf8(configuration.cwd->value) : QString{}; - result.sandbox = sandboxPolicyText(configuration.sandboxPolicy); - result.approvalPolicy = approvalPolicyText(configuration.approvalPolicy); - result.approvalsReviewer = humanize(fromUtf8(configuration.approvalsReviewer.value)); - result.serviceTier = optionalStringText(configuration.serviceTier); - result.summary = optionalOpenValueText(configuration.summary); - result.collaborationMode = humanize(fromUtf8(configuration.collaborationMode.mode.value)); - result.activePermissionProfile = activePermissionProfileText(configuration.activePermissionProfile); - result.provenance = executionConfigurationProvenanceText( - turn->effectiveExecutionConfigurationProvenance); - return result; -} - -QString freshnessText(sdk::StateFreshness freshness) -{ - switch (freshness) { - case sdk::StateFreshness::Current: - return QStringLiteral("Current"); - case sdk::StateFreshness::Stale: - return QStringLiteral("Stale"); - case sdk::StateFreshness::Synchronizing: - return QStringLiteral("Synchronizing"); - } - return QStringLiteral("Unavailable"); -} - -QString representationText(sdk::RepresentationMode mode) -{ - switch (mode) { - case sdk::RepresentationMode::LegacyV1: - return QStringLiteral("Legacy v1"); - case sdk::RepresentationMode::ExpandedV1: - return QStringLiteral("Expanded v1"); - case sdk::RepresentationMode::Unknown: - break; - } - return QStringLiteral("Unknown"); -} - -QString providerLifecycleText(sdk::ProviderLifecycle lifecycle) -{ - switch (lifecycle) { - case sdk::ProviderLifecycle::Stopped: - return QStringLiteral("Stopped"); - case sdk::ProviderLifecycle::Starting: - return QStringLiteral("Starting"); - case sdk::ProviderLifecycle::Initializing: - return QStringLiteral("Initializing"); - case sdk::ProviderLifecycle::Ready: - return QStringLiteral("Ready"); - case sdk::ProviderLifecycle::Stopping: - return QStringLiteral("Stopping"); - case sdk::ProviderLifecycle::Failed: - return QStringLiteral("Failed"); - case sdk::ProviderLifecycle::Recovering: - return QStringLiteral("Recovering"); - } - return {}; -} - -QString tokenCountsText(const sdk::TokenCountsView& counts) -{ - QStringList values; - if (counts.inputTokens) - values.append(QStringLiteral("%1 input").arg(*counts.inputTokens)); - if (counts.outputTokens) - values.append(QStringLiteral("%1 output").arg(*counts.outputTokens)); - if (counts.reasoningOutputTokens) - values.append(QStringLiteral("%1 reasoning").arg(*counts.reasoningOutputTokens)); - if (counts.cachedInputTokens) - values.append(QStringLiteral("%1 cached").arg(*counts.cachedInputTokens)); - if (counts.totalTokens) - values.append(QStringLiteral("%1 total").arg(*counts.totalTokens)); - return values.join(QStringLiteral(" · ")); -} - -QString tokenUsageText(const sdk::TurnState& turn) -{ - const auto usage = sdk::tokenUsageView(turn); - if (!usage) - return {}; - QStringList values; - if (usage->total) { - const QString total = tokenCountsText(*usage->total); - if (!total.isEmpty()) - values.append(total); - } else if (usage->last) { - const QString last = tokenCountsText(*usage->last); - if (!last.isEmpty()) - values.append(QStringLiteral("Latest: %1").arg(last)); - } - if (usage->modelContextWindowPresent && usage->modelContextWindow) - values.append(QStringLiteral("%1 context window").arg(*usage->modelContextWindow)); - if (usage->truncated || !usage->omittedFields.empty()) - values.append(QStringLiteral("usage projection truncated")); - return values.join(QStringLiteral(" · ")); -} - -QString failureText(const sdk::TurnState& turn) -{ - const auto failure = sdk::failureView(turn); - if (!failure) - return {}; - QStringList values; - if (failure->message) - values.append(fromUtf8(*failure->message)); - if (failure->additionalDetails) - values.append(fromUtf8(*failure->additionalDetails)); - if (failure->codexErrorCategory) - values.append(humanize(fromUtf8(*failure->codexErrorCategory))); - else if (failure->unknownErrorDiscriminator) - values.append(humanize(fromUtf8(*failure->unknownErrorDiscriminator))); - if (failure->httpStatusCode) - values.append(QStringLiteral("HTTP %1").arg(*failure->httpStatusCode)); - if (failure->redacted) - values.append(QStringLiteral("Sensitive detail redacted")); - if (failure->decodingOmitted) - values.append(QStringLiteral("Additional detail omitted")); - return values.isEmpty() ? QStringLiteral("Failure details unavailable") : values.join(QStringLiteral(" · ")); -} - -std::vector agentPresentations(const sdk::State& state, - const sdk::ThreadState& thread, - const sdk::TurnState& turn) -{ - std::vector result; - for (const auto& itemId : turn.orderedItems) { - const auto* item = state.item(thread.id, turn.id, itemId); - if (!item) - continue; - const auto semantic = sdk::itemSemanticView(*item); - const auto* activity = semantic ? std::get_if(&semantic->details) : nullptr; - if (!activity) - continue; - - const QString path = activity->agentPath ? fromUtf8(*activity->agentPath) : QString{}; - const QString agentThreadId = activity->agentThreadId ? fromUtf8(activity->agentThreadId->value) : QString{}; - const auto match = std::find_if(result.begin(), result.end(), [&](const AgentPresentation& existing) { - if (!agentThreadId.isEmpty() && existing.agentThreadId == agentThreadId) - return true; - return agentThreadId.isEmpty() && existing.agentThreadId.isEmpty() && !path.isEmpty() - && existing.agentPath == path; - }); - AgentPresentation* presentation = nullptr; - if (match == result.end()) { - result.push_back({}); - presentation = &result.back(); - presentation->agentPath = path; - presentation->agentThreadId = agentThreadId; - } else { - presentation = &*match; - } - presentation->itemIds.append(fromUtf8(item->id.value)); - if (activity->kind) - presentation->kind = humanize(fromUtf8(*activity->kind)); - if (item->status && !item->status->empty()) - presentation->status = humanize(fromUtf8(*item->status)); - if (item->summary && !item->summary->empty()) - presentation->summary = fromUtf8(*item->summary); - const QString duration = durationText(*item); - if (!duration.isEmpty()) - presentation->duration = duration; - } - return result; -} - -std::vector collaborationPresentations(const sdk::State& state, - const sdk::ThreadState& thread, - const sdk::TurnState& turn) -{ - std::vector result; - for (const auto& itemId : turn.orderedItems) { - const auto* item = state.item(thread.id, turn.id, itemId); - if (!item) - continue; - const auto semantic = sdk::itemSemanticView(*item); - const auto* collab = semantic ? std::get_if(&semantic->details) : nullptr; - if (!collab) - continue; - CollaborationPresentation presentation; - presentation.itemId = fromUtf8(item->id.value); - presentation.title = collab->tool ? humanize(fromUtf8(*collab->tool)) : QStringLiteral("Collaboration activity"); - presentation.status = collab->status ? humanize(fromUtf8(*collab->status)) : itemStatus(*item); - QStringList detail; - if (collab->senderThreadId) - detail.append(QStringLiteral("Sender %1").arg(compactId(collab->senderThreadId->value))); - if (collab->receiverCount) - detail.append(QStringLiteral("%1 receiver%2").arg(*collab->receiverCount) - .arg(*collab->receiverCount == 1 ? QString{} : QStringLiteral("s"))); - if (collab->agentStateCount) - detail.append(QStringLiteral("%1 agent state%2").arg(*collab->agentStateCount) - .arg(*collab->agentStateCount == 1 ? QString{} : QStringLiteral("s"))); - presentation.detail = detail.join(QStringLiteral(" · ")); - presentation.truncated = semantic->truncated || !semantic->omittedFields.empty() || item->truncated; - result.push_back(std::move(presentation)); - } - return result; -} - -std::vector retainedAgentPresentations( - const sdk::State& state, - const sdk::ThreadState& thread) -{ - std::vector result; - for (const auto& turnId : thread.orderedTurns) { - const auto* turn = state.turn(thread.id, turnId); - if (!turn) - continue; - for (AgentPresentation projected : agentPresentations(state, thread, *turn)) { - const auto existing = std::find_if( - result.begin(), - result.end(), - [&projected](const AgentPresentation& retained) { - if (!projected.agentThreadId.isEmpty()) - return retained.agentThreadId == projected.agentThreadId; - return retained.agentThreadId.isEmpty() - && !projected.agentPath.isEmpty() - && retained.agentPath == projected.agentPath; - }); - if (existing == result.end()) { - result.push_back(std::move(projected)); - continue; - } - existing->itemIds.append(projected.itemIds); - if (!projected.kind.isEmpty()) - existing->kind = std::move(projected.kind); - if (!projected.status.isEmpty()) - existing->status = std::move(projected.status); - if (!projected.summary.isEmpty()) - existing->summary = std::move(projected.summary); - if (!projected.duration.isEmpty()) - existing->duration = std::move(projected.duration); - } - } - return result; -} - -std::vector retainedCollaborationPresentations( - const sdk::State& state, - const sdk::ThreadState& thread) -{ - std::vector result; - for (const auto& turnId : thread.orderedTurns) { - const auto* turn = state.turn(thread.id, turnId); - if (!turn) - continue; - auto projected = collaborationPresentations(state, thread, *turn); - result.insert( - result.end(), - std::make_move_iterator(projected.begin()), - std::make_move_iterator(projected.end())); - } - return result; -} - -void addPresentationValue(QCryptographicHash& hash, const QByteArray& value) -{ - hash.addData(QByteArray::number(value.size())); - hash.addData(QByteArrayLiteral(":")); - hash.addData(value); -} - -void addPresentationValue(QCryptographicHash& hash, const QString& value) -{ - addPresentationValue(hash, value.toUtf8()); -} - -void addPresentationValue(QCryptographicHash& hash, std::string_view value) -{ - addPresentationValue(hash, QByteArray(value.data(), static_cast(value.size()))); -} - -void addPresentationValue(QCryptographicHash& hash, bool value) -{ - addPresentationValue(hash, value ? QByteArrayLiteral("1") : QByteArrayLiteral("0")); -} - -} // namespace - -InspectorWidget::InspectorWidget(QWidget* parent) - : QWidget(parent) -{ - setObjectName(QStringLiteral("inspector")); - setStyleSheet(QStringLiteral("QWidget#inspector{background:#fbfcfe;}")); - setMinimumWidth(300); - setMaximumWidth(520); - - auto* root = new QVBoxLayout(this); - root->setContentsMargins(18, 14, 20, 0); - root->setSpacing(0); - - auto* header = new QHBoxLayout; - inspectorHeading = textLabel(QStringLiteral("INSPECTOR"), "section"); - inspectorHeading->setObjectName(QStringLiteral("inspectorHeading")); - header->addWidget(inspectorHeading); - header->addStretch(); - historicalBack = new QPushButton(QStringLiteral("Back")); - historicalBack->setObjectName(QStringLiteral("historicalTurnBack")); - historicalBack->setProperty("kind", "subtle"); - historicalBack->setFixedSize(58, 24); - historicalBack->hide(); - header->addWidget(historicalBack); - auto* hide = new QPushButton(QStringLiteral("Hide")); - hide->setProperty("kind", "subtle"); - hide->setFixedSize(58, 24); - header->addWidget(hide); - root->addLayout(header); - root->addSpacing(7); - - tabs = new QTabBar; - tabs->setObjectName(QStringLiteral("inspectorTabs")); - tabs->setExpanding(false); - tabs->addTab(QStringLiteral("Plan")); - tabs->addTab(QStringLiteral("Agents")); - tabs->addTab(QStringLiteral("Changes")); - tabs->addTab(QStringLiteral("Info")); - tabs->setCurrentIndex(1); - root->addWidget(tabs); - root->addSpacing(7); - root->addWidget(divider()); - - auto* pages = new QStackedWidget; - pages->addWidget(scrollPage(planContent)); - pages->addWidget(scrollPage(agentsContent)); - pages->addWidget(scrollPage(changesContent)); - infoScroll = qobject_cast(scrollPage(infoContent)); - pages->addWidget(infoScroll); - pages->setCurrentIndex(1); - root->addWidget(pages, 1); - - connect(tabs, &QTabBar::currentChanged, pages, &QStackedWidget::setCurrentIndex); - connect(tabs, &QTabBar::currentChanged, this, [this](int index) { - if (!historicalTurnMode) - normalTabIndex = index; - }); - connect(hide, &QPushButton::clicked, this, &InspectorWidget::hideRequested); - connect(historicalBack, &QPushButton::clicked, - this, &InspectorWidget::historicalTurnCloseRequested); - renderUnavailable(QStringLiteral("Select a thread"), - QStringLiteral("Choose a synchronized thread to inspect its current state.")); -} - -void InspectorWidget::renderUnavailable(const QString& title, const QString& detail) -{ - setHistoricalTurnMode(false); - QCryptographicHash hash(QCryptographicHash::Sha256); - addPresentationValue(hash, title); - addPresentationValue(hash, detail); - const QByteArray presentationKey = hash.result(); - if (presentationKey == unavailablePresentationKey) - return; - unavailablePresentationKey = presentationKey; - inspectedThreadId.clear(); - dependentThreadIds.clear(); - presentedAgentActivityItemIds.clear(); - selectedAgentItemId.clear(); - planPresentationKey.clear(); - agentsPresentationKey.clear(); - changesPresentationKey.clear(); - infoPresentationKey.clear(); - infoRevisionValue = nullptr; - for (QVBoxLayout* layout : {planContent, agentsContent, changesContent, infoContent}) - { - clearLayout(layout); - addEmpty(layout, title, detail); - refreshLayoutGeometry(layout); - } -} - -bool InspectorWidget::dependsOnThread(const QString& threadId) const -{ - return !threadId.isEmpty() - && (threadId == inspectedThreadId || dependentThreadIds.contains(threadId)); -} - -void InspectorWidget::setHistoricalTurnMode(bool enabled) -{ - if (historicalTurnMode == enabled) - return; - if (enabled) - normalTabIndex = tabs->currentIndex(); - historicalTurnMode = enabled; - inspectorHeading->setText(enabled ? QStringLiteral("TURN DETAILS") - : QStringLiteral("INSPECTOR")); - historicalBack->setVisible(enabled); - tabs->setVisible(!enabled); - tabs->setCurrentIndex(enabled ? 3 : normalTabIndex); -} - -void InspectorWidget::refreshLayoutGeometry(QVBoxLayout* layout) -{ - layout->invalidate(); - layout->activate(); - if (QWidget* page = layout->parentWidget()) { - page->adjustSize(); - page->updateGeometry(); - page->update(); - } -} - -void InspectorWidget::updateStateRevision(std::uint64_t revision) -{ - if (!infoRevisionValue) - return; - const QString revisionText = QString::number(revision); - if (infoRevisionValue->text() != revisionText) - infoRevisionValue->setText(revisionText); -} - -void InspectorWidget::showInfo() -{ - tabs->setCurrentIndex(3); - QTimer::singleShot(0, this, [this] { - if (!infoScroll) - return; - if (auto* configuration = infoScroll->findChild( - QStringLiteral("turnExecutionConfigurationCard"))) - infoScroll->ensureWidgetVisible(configuration, 0, 12); - }); -} - -void InspectorWidget::render(const sdk::State& state, - const QString& threadId, - bool backendReady, - const QString& backendStatus, - const QString& selectedTurnId) -{ - if (!backendReady) { - renderUnavailable(QStringLiteral("Inspector unavailable"), - backendStatus.isEmpty() ? QStringLiteral("The synchronized backend is not ready.") - : backendStatus); - return; - } - if (threadId.isEmpty()) { - renderUnavailable(QStringLiteral("Select a thread"), - QStringLiteral("Choose a synchronized thread to inspect its current state.")); - return; - } - const auto* thread = state.thread(threadId.toStdString()); - if (!thread) { - const auto capacity = state.capacityProvenance(); - const bool boundedSelectionUnresolved = threadId == inspectedThreadId - && ((capacity && capacity->omittedThreads > 0) - || (state.threadList().value - && !state.threadList().value->complete)); - if (boundedSelectionUnresolved) - return; - renderUnavailable(QStringLiteral("Thread unavailable"), - QStringLiteral("The selected thread is not retained in the current State.")); - return; - } - unavailablePresentationKey.clear(); - if (inspectedThreadId != threadId) { - inspectedThreadId = threadId; - dependentThreadIds.clear(); - presentedAgentActivityItemIds.clear(); - selectedAgentItemId.clear(); - planPresentationKey.clear(); - agentsPresentationKey.clear(); - changesPresentationKey.clear(); - infoPresentationKey.clear(); - infoRevisionValue = nullptr; - } - - const auto* turn = latestTurn(state, *thread); - const bool hasSelectedConfigurationTurn = !selectedTurnId.isEmpty(); - setHistoricalTurnMode(hasSelectedConfigurationTurn); - const sdk::TurnState* configurationTurn = turn; - bool requestedConfigurationTurnUnavailable = false; - if (hasSelectedConfigurationTurn) { - configurationTurn = state.turn(thread->id, typed::TurnId{selectedTurnId.toStdString()}); - if (!configurationTurn) { - configurationTurn = nullptr; - requestedConfigurationTurnUnavailable = true; - } - } - qsizetype configurationTurnNumber = -1; - if (hasSelectedConfigurationTurn && configurationTurn) { - const auto iterator = std::find(thread->orderedTurns.begin(), - thread->orderedTurns.end(), - configurationTurn->id); - if (iterator != thread->orderedTurns.end()) - configurationTurnNumber = static_cast( - std::distance(thread->orderedTurns.begin(), iterator)) + 1; - } - const QString configurationUnavailableDetail = requestedConfigurationTurnUnavailable - ? QStringLiteral("The requested turn is not retained for this thread. Current thread settings are not substituted.") - : configurationTurn - ? QStringLiteral("AISuite has no authoritative effective execution configuration recorded for this turn. " - "Current thread settings are not substituted.") - : QStringLiteral("No retained turn is available. Current thread settings are not substituted."); - const ExecutionConfigurationPresentation executionConfiguration = - executionConfigurationPresentation(configurationTurn, configurationUnavailableDetail); - - // Prefer the authoritative structured turn plan. Plan items remain a - // compatibility fallback for app-server versions that only emit the item. - const sdk::TurnPlanState* structuredPlan = turn && turn->plan ? &*turn->plan : nullptr; - const sdk::ItemState* planItem = nullptr; - std::optional planView; - if (turn && !structuredPlan) { - for (auto iterator = turn->orderedItems.rbegin(); iterator != turn->orderedItems.rend(); ++iterator) { - const auto* item = state.item(thread->id, turn->id, *iterator); - if (!item) - continue; - const auto semantic = sdk::itemSemanticView(*item); - const auto* plan = semantic ? std::get_if(&semantic->details) : nullptr; - if (!plan) - continue; - planItem = item; - planView = *plan; - break; - } - } - QCryptographicHash planHash(QCryptographicHash::Sha256); - addPresentationValue(planHash, turn != nullptr); - addPresentationValue(planHash, thread->fullyLoaded); - addPresentationValue(planHash, structuredPlan != nullptr); - if (structuredPlan) { - addPresentationValue(planHash, - structuredPlan->explanation - ? std::string_view(*structuredPlan->explanation) - : std::string_view{}); - addPresentationValue(planHash, QByteArray::number(structuredPlan->totalSteps)); - addPresentationValue(planHash, structuredPlan->truncated); - for (const auto& step : structuredPlan->steps) { - addPresentationValue(planHash, step.step); - addPresentationValue(planHash, step.status.value); - } - } else if (planItem && planView) { - addPresentationValue(planHash, planItem->id.value); - addPresentationValue(planHash, itemStatus(*planItem)); - addPresentationValue(planHash, planView->text ? std::string_view(*planView->text) : std::string_view{}); - addPresentationValue(planHash, planView->textTruncated); - addPresentationValue(planHash, planItem->truncated || !planItem->omittedFields.empty()); - } - const QByteArray nextPlanKey = planHash.result(); - const bool planChanged = nextPlanKey != planPresentationKey; - if (planChanged) { - planPresentationKey = nextPlanKey; - clearLayout(planContent); - if (!turn) { - addEmpty(planContent, QStringLiteral("No plan"), QStringLiteral("This thread has no retained turns.")); - } else if (!structuredPlan && (!planItem || !planView)) { - addEmpty(planContent, QStringLiteral("No plan"), - thread->fullyLoaded ? QStringLiteral("No plan is projected for the latest turn.") - : QStringLiteral("No plan is retained in this partial thread projection.")); - } else { - planContent->addWidget(textLabel(QStringLiteral("CURRENT PLAN"), "section")); - planContent->addSpacing(8); - auto* card = detailCard(); - auto* layout = new QVBoxLayout(card); - layout->setContentsMargins(14, 14, 14, 14); - layout->setSpacing(7); - auto* header = new QHBoxLayout; - auto* title = textLabel(QStringLiteral("Latest turn plan")); - title->setStyleSheet(QStringLiteral("font-size:13px;font-weight:600;")); - header->addWidget(title); - header->addStretch(); - const QString status = structuredPlan ? QStringLiteral("Current") - : itemStatus(*planItem); - if (!status.isEmpty()) { - auto* statusLabel = textLabel(status, "small"); - statusLabel->setStyleSheet(QStringLiteral("color:%1;font-size:9px;font-weight:600;") - .arg(statusColor(status))); - header->addWidget(statusLabel); - } - layout->addLayout(header); - if (structuredPlan) { - if (structuredPlan->explanation && !structuredPlan->explanation->empty()) { - auto* explanation = textLabel(fromUtf8(*structuredPlan->explanation)); - explanation->setWordWrap(true); - explanation->setTextInteractionFlags(Qt::TextSelectableByMouse); - explanation->setStyleSheet(QStringLiteral("font-size:12px;color:#475467;")); - layout->addWidget(explanation); - } - for (const auto& step : structuredPlan->steps) { - auto* stepRow = new QWidget; - stepRow->setObjectName(QStringLiteral("inspectorPlanStep")); - auto* stepLayout = new QHBoxLayout(stepRow); - stepLayout->setContentsMargins(0, 3, 0, 3); - stepLayout->setSpacing(7); - const QString stepStatus = humanize(fromUtf8(step.status.value)); - auto* marker = textLabel(planStatusGlyph(stepStatus)); - marker->setFixedWidth(14); - marker->setStyleSheet( - QStringLiteral("color:%1;font-size:11px;font-weight:600;") - .arg(statusColor(stepStatus))); - stepLayout->addWidget(marker, 0, Qt::AlignTop); - auto* stepText = textLabel(fromUtf8(step.step)); - stepText->setObjectName(QStringLiteral("inspectorPlanStepText")); - stepText->setWordWrap(true); - stepText->setTextInteractionFlags(Qt::TextSelectableByMouse); - stepText->setStyleSheet(QStringLiteral("font-size:12px;")); - stepLayout->addWidget(stepText, 1); - auto* stepState = textLabel(stepStatus, "small"); - stepState->setStyleSheet( - QStringLiteral("color:%1;font-size:9px;font-weight:600;") - .arg(statusColor(stepStatus))); - stepLayout->addWidget(stepState, 0, Qt::AlignTop); - layout->addWidget(stepRow); - } - if (structuredPlan->steps.empty()) { - auto* absent = textLabel(QStringLiteral("The current plan contains no steps."), "muted"); - absent->setWordWrap(true); - layout->addWidget(absent); - } - if (structuredPlan->truncated) { - auto* truncated = textLabel( - QStringLiteral("Showing %1 of %2 plan steps") - .arg(structuredPlan->steps.size()) - .arg(structuredPlan->totalSteps), - "small"); - truncated->setObjectName(QStringLiteral("inspectorPlanTruncation")); - truncated->setStyleSheet(QStringLiteral("color:#a76812;font-size:9px;")); - layout->addWidget(truncated); - } - } else if (planView->text && !planView->text->empty()) { - auto* text = textLabel(fromUtf8(*planView->text)); - text->setWordWrap(true); - text->setTextInteractionFlags(Qt::TextSelectableByMouse); - text->setStyleSheet(QStringLiteral("font-size:12px;")); - layout->addWidget(text); - } else { - auto* absent = textLabel(QStringLiteral("Plan text is unavailable in the current projection."), "muted"); - absent->setWordWrap(true); - layout->addWidget(absent); - } - if (!structuredPlan - && (planView->textTruncated || planItem->truncated || !planItem->omittedFields.empty())) { - auto* truncated = textLabel(QStringLiteral("Plan projection is truncated or partially omitted"), "small"); - truncated->setStyleSheet(QStringLiteral("color:#a76812;font-size:9px;")); - layout->addWidget(truncated); - } - planContent->addWidget(card); - planContent->addStretch(); - } - } - - // Agents: SubAgentActivitySemanticView is flat in this SDK. Keep the - // representation flat rather than inferring a parent/child tree. - std::vector agents; - std::vector collaborations; - agents = retainedAgentPresentations(state, *thread); - collaborations = retainedCollaborationPresentations(state, *thread); - QSet projectedAgentActivityItemIds; - for (const AgentPresentation& agent : agents) { - for (const QString& itemId : agent.itemIds) - projectedAgentActivityItemIds.insert(itemId); - } - for (const CollaborationPresentation& collaboration : collaborations) - projectedAgentActivityItemIds.insert(collaboration.itemId); - const bool incompleteAgentsRegressed = !thread->fullyLoaded - && !presentedAgentActivityItemIds.isEmpty() - && std::ranges::any_of( - presentedAgentActivityItemIds, - [&projectedAgentActivityItemIds](const QString& itemId) { - return !projectedAgentActivityItemIds.contains(itemId); - }); - - bool agentsChanged = false; - if (!incompleteAgentsRegressed) { - presentedAgentActivityItemIds = projectedAgentActivityItemIds; - dependentThreadIds.clear(); - for (const AgentPresentation& agent : agents) { - if (!agent.agentThreadId.isEmpty()) - dependentThreadIds.insert(agent.agentThreadId); - } - const auto selected = std::find_if(agents.begin(), agents.end(), [this](const AgentPresentation& agent) { - return agent.itemIds.contains(selectedAgentItemId); - }); - if (selected == agents.end()) - selectedAgentItemId = agents.empty() ? QString{} : agents.front().itemIds.back(); - - QCryptographicHash agentsHash(QCryptographicHash::Sha256); - addPresentationValue(agentsHash, turn != nullptr); - addPresentationValue(agentsHash, selectedAgentItemId); - for (const auto& agent : agents) { - for (const QString& id : agent.itemIds) - addPresentationValue(agentsHash, id); - addPresentationValue(agentsHash, agent.agentPath); - addPresentationValue(agentsHash, agent.agentThreadId); - addPresentationValue(agentsHash, agent.kind); - addPresentationValue(agentsHash, agent.status); - addPresentationValue(agentsHash, agent.summary); - addPresentationValue(agentsHash, agent.duration); - if (const auto* agentThread = agent.agentThreadId.isEmpty() - ? nullptr - : state.thread(agent.agentThreadId.toStdString())) { - addPresentationValue(agentsHash, - agentThread->status ? std::string_view(*agentThread->status) : std::string_view{}); - addPresentationValue(agentsHash, - agentThread->model ? std::string_view(agentThread->model->value) : std::string_view{}); - addPresentationValue(agentsHash, - agentThread->modelProvider ? std::string_view(*agentThread->modelProvider) - : std::string_view{}); - } - } - for (const auto& collaboration : collaborations) { - addPresentationValue(agentsHash, collaboration.itemId); - addPresentationValue(agentsHash, collaboration.title); - addPresentationValue(agentsHash, collaboration.status); - addPresentationValue(agentsHash, collaboration.detail); - addPresentationValue(agentsHash, collaboration.truncated); - } - if (!agents.empty() && state.hasPendingRequestProjection()) { - for (const auto& request : state.pendingRequests()) { - if (request.threadId) - addPresentationValue(agentsHash, request.threadId->value); - } - } - const QByteArray nextAgentsKey = agentsHash.result(); - agentsChanged = nextAgentsKey != agentsPresentationKey; - if (agentsChanged) { - agentsPresentationKey = nextAgentsKey; - clearLayout(agentsContent); - if (agents.empty() && collaborations.empty()) { - addEmpty(agentsContent, QStringLiteral("No agent activity"), - turn ? QStringLiteral("No retained collab or subagent activity is projected for this thread.") - : QStringLiteral("This thread has no retained turns.")); - } else { - if (!agents.empty()) { - agentsContent->addWidget(textLabel(QStringLiteral("AGENT ACTIVITY"), "section")); - agentsContent->addSpacing(8); - for (const auto& agent : agents) { - const QString primaryId = agent.itemIds.back(); - const bool active = agent.itemIds.contains(selectedAgentItemId); - const QString name = agent.agentPath.isEmpty() ? QStringLiteral("Subagent activity") : agent.agentPath; - QStringList details; - if (!agent.kind.isEmpty()) - details.append(agent.kind); - if (!agent.status.isEmpty()) - details.append(agent.status); - if (!agent.duration.isEmpty()) - details.append(agent.duration); - if (!agent.agentThreadId.isEmpty()) - details.append(QStringLiteral("thread %1").arg(compactId(agent.agentThreadId.toStdString()))); - if (agent.itemIds.size() > 1) - details.append(QStringLiteral("%1 activities").arg(agent.itemIds.size())); - auto* row = new QPushButton(QStringLiteral("%1\n%2").arg(name, details.join(QStringLiteral(" · ")))); - row->setCursor(Qt::PointingHandCursor); - row->setMinimumHeight(details.isEmpty() ? 38 : 52); - row->setStyleSheet(QStringLiteral( - "QPushButton{background:%1;color:#1d2633;border:1px solid %2;border-radius:8px;text-align:left;padding:6px 10px;" - "font-size:11px;font-weight:500;}QPushButton:hover{background:#f1f5fb;}") - .arg(active ? QStringLiteral("#e5eeff") : QStringLiteral("transparent"), - active ? QStringLiteral("#bfd3f9") : QStringLiteral("transparent"))); - row->setToolTip(name); - connect(row, &QPushButton::clicked, this, [this, primaryId] { - selectedAgentItemId = primaryId; - emit selectionChanged(); - }); - agentsContent->addWidget(row); - } - } - - if (!collaborations.empty()) { - if (!agents.empty()) { - agentsContent->addSpacing(16); - agentsContent->addWidget(divider()); - agentsContent->addSpacing(16); - } - agentsContent->addWidget(textLabel(QStringLiteral("COLLABORATION"), "section")); - agentsContent->addSpacing(8); - for (const auto& collaboration : collaborations) { - auto* card = detailCard(); - auto* layout = new QVBoxLayout(card); - layout->setContentsMargins(12, 10, 12, 10); - layout->setSpacing(4); - auto* header = new QHBoxLayout; - header->addWidget(textLabel(collaboration.title)); - header->addStretch(); - if (!collaboration.status.isEmpty()) { - auto* status = textLabel(collaboration.status, "small"); - status->setStyleSheet(QStringLiteral("color:%1;font-size:9px;font-weight:600;") - .arg(statusColor(collaboration.status))); - header->addWidget(status); - } - layout->addLayout(header); - if (!collaboration.detail.isEmpty()) { - auto* detail = textLabel(collaboration.detail, "meta"); - detail->setWordWrap(true); - layout->addWidget(detail); - } - if (collaboration.truncated) { - auto* truncated = textLabel(QStringLiteral("Projected detail is truncated or omitted"), "small"); - truncated->setStyleSheet(QStringLiteral("color:#a76812;font-size:9px;")); - layout->addWidget(truncated); - } - agentsContent->addWidget(card); - agentsContent->addSpacing(6); - } - } - - const auto selectedAgent = std::find_if(agents.begin(), agents.end(), [this](const AgentPresentation& agent) { - return agent.itemIds.contains(selectedAgentItemId); - }); - if (selectedAgent != agents.end()) { - agentsContent->addSpacing(18); - agentsContent->addWidget(divider()); - agentsContent->addSpacing(16); - agentsContent->addWidget(textLabel(QStringLiteral("SELECTED AGENT"), "section")); - agentsContent->addSpacing(9); - const QString name = selectedAgent->agentPath.isEmpty() ? QStringLiteral("Subagent activity") - : selectedAgent->agentPath; - auto* heading = textLabel(name); - heading->setWordWrap(true); - heading->setStyleSheet(QStringLiteral("font-size:17px;font-weight:600;")); - agentsContent->addWidget(heading); - if (!selectedAgent->summary.isEmpty()) { - auto* summary = textLabel(selectedAgent->summary, "muted"); - summary->setWordWrap(true); - agentsContent->addWidget(summary); - } - agentsContent->addSpacing(20); - - auto* facts = new QGridLayout; - facts->setContentsMargins(0, 0, 0, 0); - facts->setHorizontalSpacing(18); - facts->setVerticalSpacing(9); - facts->setColumnMinimumWidth(0, 84); - int row = 0; - addFact(facts, row, QStringLiteral("Activity"), selectedAgent->kind); - addFact(facts, row, QStringLiteral("Status"), selectedAgent->status, - statusColor(selectedAgent->status)); - addFact(facts, row, QStringLiteral("Duration"), selectedAgent->duration); - addFact(facts, row, QStringLiteral("Thread"), - selectedAgent->agentThreadId.isEmpty() - ? QString{} - : compactId(selectedAgent->agentThreadId.toStdString())); - const auto* agentThread = selectedAgent->agentThreadId.isEmpty() - ? nullptr - : state.thread(selectedAgent->agentThreadId.toStdString()); - if (selectedAgent->status.isEmpty() && agentThread && agentThread->status) - addFact(facts, row, QStringLiteral("Status"), humanize(fromUtf8(*agentThread->status)), - statusColor(fromUtf8(*agentThread->status))); - if (agentThread && agentThread->model) - addFact(facts, row, QStringLiteral("Model"), fromUtf8(agentThread->model->value)); - if (agentThread && agentThread->modelProvider) - addFact(facts, row, QStringLiteral("Provider"), fromUtf8(*agentThread->modelProvider)); - std::size_t pending = 0; - if (!selectedAgent->agentThreadId.isEmpty() && state.hasPendingRequestProjection()) { - for (const auto& request : state.pendingRequests()) - pending += request.threadId && request.threadId->value == selectedAgent->agentThreadId.toStdString(); - } - if (pending > 0) - addFact(facts, row, QStringLiteral("Attention"), - QStringLiteral("%1 pending request%2").arg(pending) - .arg(pending == 1 ? QString{} : QStringLiteral("s")), - QStringLiteral("#a76812")); - agentsContent->addLayout(facts); - - if (agentThread) { - agentsContent->addSpacing(18); - auto* open = new QPushButton(QStringLiteral("Open thread ↗")); - open->setProperty("kind", "agentLink"); - open->setFixedHeight(34); - const QString target = selectedAgent->agentThreadId; - connect(open, &QPushButton::clicked, this, [this, target] { emit threadOpenRequested(target); }); - agentsContent->addWidget(open); - } - } - agentsContent->addStretch(); - } - } - } - - // Changes: only canonical projected metadata is shown. The installed view - // does not expose path strings, typed change kinds, or line counts. - std::vector> fileChanges; - if (turn) { - for (const auto& itemId : turn->orderedItems) { - const auto* item = state.item(thread->id, turn->id, itemId); - if (!item) - continue; - const auto semantic = sdk::itemSemanticView(*item); - const auto* changes = semantic ? std::get_if(&semantic->details) : nullptr; - if (changes) - fileChanges.emplace_back(item, *changes); - } - } - QCryptographicHash changesHash(QCryptographicHash::Sha256); - addPresentationValue(changesHash, turn != nullptr); - for (const auto& [item, view] : fileChanges) { - addPresentationValue(changesHash, item->id.value); - addPresentationValue(changesHash, itemStatus(*item)); - addPresentationValue(changesHash, - item->summary ? std::string_view(*item->summary) : std::string_view{}); - addPresentationValue(changesHash, - view.status ? std::string_view(*view.status) : std::string_view{}); - addPresentationValue(changesHash, - view.changeCount ? QByteArray::number(*view.changeCount) : QByteArray{}); - addPresentationValue(changesHash, view.changesTruncated); - addPresentationValue(changesHash, item->truncated || !item->omittedFields.empty()); - for (const auto& change : view.changes) { - addPresentationValue(changesHash, change.pathRedacted); - addPresentationValue(changesHash, - change.pathBytes ? QByteArray::number(*change.pathBytes) : QByteArray{}); - addPresentationValue(changesHash, change.diffOmitted); - addPresentationValue(changesHash, - change.diffBytes ? QByteArray::number(*change.diffBytes) : QByteArray{}); - } - } - const QByteArray nextChangesKey = changesHash.result(); - const bool changesChanged = nextChangesKey != changesPresentationKey; - if (changesChanged) { - changesPresentationKey = nextChangesKey; - clearLayout(changesContent); - if (fileChanges.empty()) { - addEmpty(changesContent, QStringLiteral("No file changes"), - turn ? QStringLiteral("No file-change items are projected for the latest turn.") - : QStringLiteral("This thread has no retained turns.")); - } else { - changesContent->addWidget(textLabel(QStringLiteral("REPORTED CHANGES"), "section")); - changesContent->addSpacing(8); - std::size_t reportedChanges = 0; - bool hasCompleteReportedCount = true; - for (const auto& [item, view] : fileChanges) { - if (view.changeCount) - reportedChanges += *view.changeCount; - else - hasCompleteReportedCount = false; - } - auto* summary = textLabel(hasCompleteReportedCount - ? QStringLiteral("%1 reported change entr%2") - .arg(reportedChanges) - .arg(reportedChanges == 1 ? QStringLiteral("y") : QStringLiteral("ies")) - : QStringLiteral("%1 file-change item%2") - .arg(fileChanges.size()) - .arg(fileChanges.size() == 1 ? QString{} : QStringLiteral("s"))); - summary->setStyleSheet(QStringLiteral("font-size:13px;font-weight:600;")); - changesContent->addWidget(summary); - changesContent->addSpacing(10); - - for (const auto& [item, view] : fileChanges) { - auto* card = detailCard(); - auto* layout = new QVBoxLayout(card); - layout->setContentsMargins(12, 11, 12, 11); - layout->setSpacing(5); - auto* header = new QHBoxLayout; - const QString title = item->summary && !item->summary->empty() - ? compact(fromUtf8(*item->summary), 120) - : view.changeCount ? QStringLiteral("%1 change entr%2") - .arg(*view.changeCount) - .arg(*view.changeCount == 1 ? QStringLiteral("y") - : QStringLiteral("ies")) - : QStringLiteral("File-change item"); - auto* heading = textLabel(title); - heading->setWordWrap(true); - heading->setStyleSheet(QStringLiteral("font-size:11px;font-weight:600;")); - header->addWidget(heading, 1); - const QString status = view.status ? humanize(fromUtf8(*view.status)) : itemStatus(*item); - if (!status.isEmpty()) { - auto* statusLabel = textLabel(status, "small"); - statusLabel->setStyleSheet(QStringLiteral("color:%1;font-size:9px;font-weight:600;") - .arg(statusColor(status))); - header->addWidget(statusLabel, 0, Qt::AlignTop); - } - layout->addLayout(header); - - for (const auto& change : view.changes) { - QStringList detail; - if (change.pathRedacted) - detail.append(QStringLiteral("Affected path redacted")); - else if (change.pathBytes) - detail.append(QStringLiteral("Affected path unavailable · %1 projected bytes").arg(*change.pathBytes)); - else - detail.append(QStringLiteral("Affected path unavailable")); - if (change.diffOmitted) - detail.append(QStringLiteral("diff omitted")); - else if (change.diffBytes) - detail.append(QStringLiteral("%1 diff bytes").arg(*change.diffBytes)); - auto* entry = textLabel(QStringLiteral("• %1").arg(detail.join(QStringLiteral(" · "))), "meta"); - entry->setWordWrap(true); - layout->addWidget(entry); - } - if (view.changes.empty()) { - auto* unavailable = textLabel(QStringLiteral("Per-change detail is not retained."), "meta"); - unavailable->setWordWrap(true); - layout->addWidget(unavailable); - } - if (view.changesTruncated || item->truncated || !item->omittedFields.empty()) { - auto* truncated = textLabel(QStringLiteral("Change projection is truncated or partially omitted"), "small"); - truncated->setStyleSheet(QStringLiteral("color:#a76812;font-size:9px;")); - layout->addWidget(truncated); - } - changesContent->addWidget(card); - changesContent->addSpacing(7); - } - changesContent->addStretch(); - } - } - - // Info: a compact product view over typed thread, turn, provider, usage, - // failure, synchronization, and projection state. - const auto& provider = state.provider(); - const auto& controller = state.controller(); - const auto& truncation = state.truncation(); - const auto& threadList = state.threadList(); - const auto& projection = state.projectionMetadata(); - std::size_t pendingForThread = 0; - if (state.hasPendingRequestProjection()) { - for (const auto& request : state.pendingRequests()) - pendingForThread += request.threadId && request.threadId->value == thread->id.value; - } - - QCryptographicHash infoHash(QCryptographicHash::Sha256); - addPresentationValue(infoHash, threadId); - addPresentationValue(infoHash, - thread->title ? std::string_view(*thread->title) : std::string_view{}); - addPresentationValue(infoHash, thread->id.value); - addPresentationValue(infoHash, - thread->cwd ? std::string_view(thread->cwd->value) : std::string_view{}); - addPresentationValue(infoHash, - thread->status ? std::string_view(*thread->status) : std::string_view{}); - addPresentationValue(infoHash, - thread->model ? std::string_view(thread->model->value) : std::string_view{}); - addPresentationValue(infoHash, - thread->modelProvider ? std::string_view(*thread->modelProvider) : std::string_view{}); - addPresentationValue(infoHash, thread->ephemeral.has_value()); - addPresentationValue(infoHash, thread->ephemeral.value_or(false)); - addPresentationValue(infoHash, thread->archived.has_value()); - addPresentationValue(infoHash, thread->archived.value_or(false)); - addPresentationValue(infoHash, thread->fullyLoaded); - addPresentationValue(infoHash, turn != nullptr); - if (turn) { - addPresentationValue(infoHash, turn->id.value); - addPresentationValue(infoHash, turn->status.value); - addPresentationValue(infoHash, turn->active); - addPresentationValue(infoHash, turn->terminal); - addPresentationValue(infoHash, tokenUsageText(*turn)); - addPresentationValue(infoHash, failureText(*turn)); - } - addPresentationValue(infoHash, hasSelectedConfigurationTurn); - addPresentationValue(infoHash, QByteArray::number(configurationTurnNumber)); - addPresentationValue(infoHash, executionConfiguration.recorded); - addPresentationValue(infoHash, executionConfiguration.turnId); - addPresentationValue(infoHash, executionConfiguration.unavailableDetail); - addPresentationValue(infoHash, executionConfiguration.model); - addPresentationValue(infoHash, executionConfiguration.effort); - addPresentationValue(infoHash, executionConfiguration.personality); - addPresentationValue(infoHash, executionConfiguration.workspace); - addPresentationValue(infoHash, executionConfiguration.sandbox); - addPresentationValue(infoHash, executionConfiguration.approvalPolicy); - addPresentationValue(infoHash, executionConfiguration.approvalsReviewer); - addPresentationValue(infoHash, executionConfiguration.serviceTier); - addPresentationValue(infoHash, executionConfiguration.summary); - addPresentationValue(infoHash, executionConfiguration.collaborationMode); - addPresentationValue(infoHash, executionConfiguration.activePermissionProfile); - addPresentationValue(infoHash, executionConfiguration.provenance); - addPresentationValue(infoHash, freshnessText(state.freshness())); - addPresentationValue(infoHash, representationText(state.representationMode())); - addPresentationValue(infoHash, provider.value.has_value()); - if (provider.value) { - addPresentationValue(infoHash, providerLifecycleText(provider.value->lifecycle)); - addPresentationValue(infoHash, provider.value->ready); - addPresentationValue( - infoHash, - provider.value->lastError && provider.value->lastError->message - ? std::string_view(*provider.value->lastError->message) - : std::string_view{}); - } - addPresentationValue(infoHash, controller.value.has_value()); - if (controller.value) { - addPresentationValue(infoHash, controller.value->present); - addPresentationValue(infoHash, controller.value->ownedByThisClient); - } - addPresentationValue(infoHash, state.hasPendingRequestProjection()); - addPresentationValue(infoHash, QByteArray::number(pendingForThread)); - addPresentationValue(infoHash, truncation.truncated); - addPresentationValue(infoHash, truncation.value.has_value()); - if (truncation.value) { - addPresentationValue(infoHash, truncation.value->truncated); - addPresentationValue(infoHash, - truncation.value->omittedEntries - ? QByteArray::number(*truncation.value->omittedEntries) - : QByteArray{}); - } - addPresentationValue(infoHash, threadList.value.has_value()); - if (threadList.value) - addPresentationValue(infoHash, threadList.value->complete); - addPresentationValue(infoHash, QByteArray::number(projection.omittedFields.size())); - addPresentationValue(infoHash, QByteArray::number(projection.redactedFields.size())); - addPresentationValue(infoHash, thread->realtime.has_value()); - if (thread->realtime) { - const auto realtime = sdk::realtimeSemanticView(*thread->realtime); - addPresentationValue(infoHash, realtime.lifecycle); - addPresentationValue(infoHash, QByteArray::number(realtime.itemCount)); - addPresentationValue(infoHash, realtime.transcriptTruncated); - addPresentationValue(infoHash, - realtime.lastError ? std::string_view(*realtime.lastError) - : std::string_view{}); - } - const QByteArray nextInfoKey = infoHash.result(); - const bool infoChanged = nextInfoKey != infoPresentationKey; - const QString revisionText = QString::number(state.revision()); - if (!infoChanged) - updateStateRevision(state.revision()); - if (infoChanged) { - infoPresentationKey = nextInfoKey; - infoRevisionValue = nullptr; - clearLayout(infoContent); - if (hasSelectedConfigurationTurn) { - auto* title = textLabel(configurationTurnNumber > 0 - ? QStringLiteral("Effective configuration · Turn %1") - .arg(configurationTurnNumber) - : QStringLiteral("Effective configuration")); - title->setObjectName(QStringLiteral("historicalTurnConfigurationTitle")); - title->setStyleSheet(QStringLiteral("color:#1d2633;font-size:16px;font-weight:600;")); - infoContent->addWidget(title); - infoContent->addSpacing(5); - auto* readOnly = textLabel(QStringLiteral("Read-only historical record")); - readOnly->setStyleSheet(QStringLiteral("color:#667085;font-size:11px;")); - infoContent->addWidget(readOnly); - infoContent->addSpacing(12); - infoContent->addWidget(divider()); - } else { - infoContent->addWidget(textLabel(QStringLiteral("THREAD"), "section")); - infoContent->addSpacing(8); - auto* threadCard = detailCard(); - auto* threadFacts = new QGridLayout(threadCard); - threadFacts->setContentsMargins(12, 11, 12, 11); - threadFacts->setHorizontalSpacing(16); - threadFacts->setVerticalSpacing(8); - threadFacts->setColumnMinimumWidth(0, 78); - int threadRow = 0; - addFact(threadFacts, threadRow, QStringLiteral("Title"), - thread->title && !thread->title->empty() ? fromUtf8(*thread->title) : QString{}); - addFact(threadFacts, threadRow, QStringLiteral("Thread ID"), fromUtf8(thread->id.value)); - addFact(threadFacts, threadRow, QStringLiteral("CWD"), thread->cwd ? fromUtf8(thread->cwd->value) : QString{}); - addFact(threadFacts, threadRow, QStringLiteral("Status"), - thread->status ? humanize(fromUtf8(*thread->status)) : QString{}); - addFact(threadFacts, threadRow, QStringLiteral("Model"), thread->model ? fromUtf8(thread->model->value) : QString{}); - addFact(threadFacts, threadRow, QStringLiteral("Provider"), - thread->modelProvider ? fromUtf8(*thread->modelProvider) : QString{}); - if (thread->ephemeral) - addFact(threadFacts, threadRow, QStringLiteral("Lifetime"), - *thread->ephemeral ? QStringLiteral("Temporary") : QStringLiteral("Persistent")); - if (thread->archived) - addFact(threadFacts, threadRow, QStringLiteral("Archive"), - *thread->archived ? QStringLiteral("Archived") : QStringLiteral("Active")); - addFact(threadFacts, threadRow, QStringLiteral("Projection"), - thread->fullyLoaded ? QStringLiteral("Fully loaded") : QStringLiteral("Partial"), - thread->fullyLoaded ? QStringLiteral("#23845a") : QStringLiteral("#a76812")); - infoContent->addWidget(threadCard); - - if (turn) { - infoContent->addSpacing(16); - infoContent->addWidget(textLabel(QStringLiteral("LATEST TURN"), "section")); - infoContent->addSpacing(8); - auto* turnCard = detailCard(); - auto* turnFacts = new QGridLayout(turnCard); - turnFacts->setContentsMargins(12, 11, 12, 11); - turnFacts->setHorizontalSpacing(16); - turnFacts->setVerticalSpacing(8); - turnFacts->setColumnMinimumWidth(0, 78); - int turnRow = 0; - addFact(turnFacts, turnRow, QStringLiteral("Turn ID"), fromUtf8(turn->id.value)); - addFact(turnFacts, turnRow, QStringLiteral("Status"), humanize(fromUtf8(turn->status.value)), - statusColor(fromUtf8(turn->status.value))); - addFact(turnFacts, turnRow, QStringLiteral("Lifecycle"), - turn->active ? QStringLiteral("Active") - : turn->terminal ? QStringLiteral("Terminal") : QStringLiteral("Retained")); - addFact(turnFacts, turnRow, QStringLiteral("Token usage"), tokenUsageText(*turn)); - addFact(turnFacts, turnRow, QStringLiteral("Failure"), failureText(*turn), QStringLiteral("#b83a3a")); - infoContent->addWidget(turnCard); - } - - } - - infoContent->addSpacing(16); - if (!hasSelectedConfigurationTurn) - infoContent->addWidget(textLabel(QStringLiteral("LATEST TURN CONFIGURATION"), "section")); - infoContent->addSpacing(8); - auto* configurationCard = executionConfigurationCard(); - auto* configurationLayout = new QVBoxLayout(configurationCard); - configurationLayout->setContentsMargins(13, 12, 13, 12); - configurationLayout->setSpacing(7); - if (!executionConfiguration.recorded) { - auto* heading = textLabel(requestedConfigurationTurnUnavailable || !configurationTurn - ? QStringLiteral("Unavailable") - : QStringLiteral("Not recorded")); - heading->setStyleSheet(QStringLiteral("color:#1d2633;font-size:13px;font-weight:600;")); - configurationLayout->addWidget(heading); - auto* detail = textLabel(executionConfiguration.unavailableDetail); - detail->setWordWrap(true); - detail->setStyleSheet(QStringLiteral("color:#667085;font-size:11px;")); - configurationLayout->addWidget(detail); - } else { - auto* header = new QHBoxLayout; - auto* heading = textLabel(QStringLiteral("Effective settings")); - heading->setStyleSheet(QStringLiteral("color:#1d2633;font-size:13px;font-weight:600;")); - header->addWidget(heading); - header->addStretch(); - auto* identity = textLabel(compactId(executionConfiguration.turnId.toStdString())); - identity->setStyleSheet(QStringLiteral("color:#2f6feb;font-size:9px;font-weight:600;")); - identity->setToolTip(executionConfiguration.turnId); - header->addWidget(identity, 0, Qt::AlignTop); - configurationLayout->addLayout(header); - - auto* provenance = textLabel(executionConfiguration.provenance); - provenance->setWordWrap(true); - provenance->setStyleSheet(QStringLiteral("color:#667085;font-size:10px;")); - configurationLayout->addWidget(provenance); - configurationLayout->addSpacing(2); - - auto* facts = new QGridLayout; - facts->setContentsMargins(0, 0, 0, 0); - facts->setHorizontalSpacing(16); - facts->setVerticalSpacing(7); - facts->setColumnMinimumWidth(0, 100); - int row = 0; - addExecutionConfigurationFact(facts, row, QStringLiteral("Model"), executionConfiguration.model); - addExecutionConfigurationFact(facts, row, QStringLiteral("Reasoning effort"), executionConfiguration.effort); - addExecutionConfigurationFact(facts, row, QStringLiteral("Style"), executionConfiguration.personality); - addExecutionConfigurationFact(facts, row, QStringLiteral("Workspace"), executionConfiguration.workspace); - addExecutionConfigurationFact(facts, row, QStringLiteral("Sandbox / access"), executionConfiguration.sandbox); - addExecutionConfigurationFact(facts, row, QStringLiteral("Approval policy"), executionConfiguration.approvalPolicy); - addExecutionConfigurationFact(facts, row, QStringLiteral("Reviewer"), executionConfiguration.approvalsReviewer); - addExecutionConfigurationFact(facts, row, QStringLiteral("Service tier"), executionConfiguration.serviceTier); - addExecutionConfigurationFact(facts, row, QStringLiteral("Reasoning summary"), executionConfiguration.summary); - addExecutionConfigurationFact(facts, row, QStringLiteral("Collaboration mode"), - executionConfiguration.collaborationMode); - addExecutionConfigurationFact(facts, row, QStringLiteral("Permission profile"), - executionConfiguration.activePermissionProfile); - configurationLayout->addLayout(facts); - } - infoContent->addWidget(configurationCard); - - if (hasSelectedConfigurationTurn) { - infoContent->addSpacing(18); - infoContent->addWidget(divider()); - infoContent->addSpacing(10); - auto* provenance = textLabel( - QStringLiteral("Loaded from authoritative AISuite turn state — never inferred from current thread settings.")); - provenance->setWordWrap(true); - provenance->setStyleSheet(QStringLiteral("color:#667085;font-size:10px;")); - infoContent->addWidget(provenance); - } - - if (!hasSelectedConfigurationTurn) { - infoContent->addSpacing(16); - infoContent->addWidget(textLabel(QStringLiteral("SYNCHRONIZATION"), "section")); - infoContent->addSpacing(8); - auto* stateCard = detailCard(); - auto* stateFacts = new QGridLayout(stateCard); - stateFacts->setContentsMargins(12, 11, 12, 11); - stateFacts->setHorizontalSpacing(16); - stateFacts->setVerticalSpacing(8); - stateFacts->setColumnMinimumWidth(0, 78); - int stateRow = 0; - const QString freshness = freshnessText(state.freshness()); - addFact(stateFacts, stateRow, QStringLiteral("State"), freshness, - freshness == QStringLiteral("Current") ? QStringLiteral("#23845a") - : QStringLiteral("#a76812")); - infoRevisionValue = addFact(stateFacts, stateRow, QStringLiteral("Revision"), revisionText); - if (infoRevisionValue) - infoRevisionValue->setObjectName(QStringLiteral("inspectorStateRevision")); - addFact(stateFacts, stateRow, QStringLiteral("Representation"), representationText(state.representationMode())); - if (provider.value) { - addFact(stateFacts, stateRow, QStringLiteral("Provider"), providerLifecycleText(provider.value->lifecycle), - provider.value->ready ? QStringLiteral("#23845a") : statusColor(providerLifecycleText(provider.value->lifecycle))); - if (provider.value->lastError && provider.value->lastError->message) - addFact(stateFacts, stateRow, QStringLiteral("Provider error"), - fromUtf8(*provider.value->lastError->message), QStringLiteral("#b83a3a")); - } - if (controller.value) { - addFact(stateFacts, stateRow, QStringLiteral("Controller"), - controller.value->ownedByThisClient - ? QStringLiteral("Owned by this frontend") - : controller.value->present ? QStringLiteral("Owned by another frontend") - : QStringLiteral("Unowned")); - } - if (state.hasPendingRequestProjection()) { - addFact(stateFacts, stateRow, QStringLiteral("Attention"), - QStringLiteral("%1 pending request%2").arg(pendingForThread) - .arg(pendingForThread == 1 ? QString{} : QStringLiteral("s")), - pendingForThread > 0 ? QStringLiteral("#a76812") : QStringLiteral("#667085")); - } - if (truncation.truncated || (truncation.value && truncation.value->truncated)) { - QString detail = QStringLiteral("State projection truncated"); - if (truncation.value && truncation.value->omittedEntries) - detail += QStringLiteral(" · %1 entries omitted").arg(*truncation.value->omittedEntries); - addFact(stateFacts, stateRow, QStringLiteral("Truncation"), detail, QStringLiteral("#a76812")); - } - if (threadList.value) - addFact(stateFacts, stateRow, QStringLiteral("Thread list"), - threadList.value->complete ? QStringLiteral("Complete") : QStringLiteral("Partial"), - threadList.value->complete ? QStringLiteral("#23845a") : QStringLiteral("#a76812")); - if (!projection.omittedFields.empty() || !projection.redactedFields.empty()) { - QStringList detail; - if (!projection.omittedFields.empty()) - detail.append(QStringLiteral("%1 fields omitted").arg(projection.omittedFields.size())); - if (!projection.redactedFields.empty()) - detail.append(QStringLiteral("%1 fields redacted").arg(projection.redactedFields.size())); - addFact(stateFacts, stateRow, QStringLiteral("Scope"), detail.join(QStringLiteral(" · ")), - QStringLiteral("#a76812")); - } - if (thread->realtime) { - const auto realtime = sdk::realtimeSemanticView(*thread->realtime); - QStringList detail{humanize(fromUtf8(realtime.lifecycle)), QStringLiteral("%1 items").arg(realtime.itemCount)}; - if (realtime.transcriptTruncated) - detail.append(QStringLiteral("transcript truncated")); - if (realtime.lastError) - detail.append(fromUtf8(*realtime.lastError)); - addFact(stateFacts, stateRow, QStringLiteral("Realtime"), detail.join(QStringLiteral(" · "))); - } - infoContent->addWidget(stateCard); - } - infoContent->addStretch(); - } - - if (planChanged) - refreshLayoutGeometry(planContent); - if (agentsChanged) - refreshLayoutGeometry(agentsContent); - if (changesChanged) - refreshLayoutGeometry(changesContent); - if (infoChanged) - refreshLayoutGeometry(infoContent); -} - -} // namespace codexui diff --git a/src/ui/InspectorWidget.h b/src/ui/InspectorWidget.h deleted file mode 100644 index 330ee5d..0000000 --- a/src/ui/InspectorWidget.h +++ /dev/null @@ -1,77 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#ifndef CODEXUI_UI_INSPECTORWIDGET_H -#define CODEXUI_UI_INSPECTORWIDGET_H - -#include - -#include -#include -#include -#include - -#include - -class QVBoxLayout; -class QLabel; -class QScrollArea; -class QTabBar; -class QPushButton; - -namespace codexui { - -class InspectorWidget : public QWidget -{ - Q_OBJECT - -public: - explicit InspectorWidget(QWidget* parent = nullptr); - - void render(const ai::openai::codex::frontend::client::State& state, - const QString& threadId, - bool backendReady, - const QString& backendStatus, - const QString& selectedTurnId = {}); - void updateStateRevision(std::uint64_t revision); - [[nodiscard]] bool dependsOnThread(const QString& threadId) const; - void showInfo(); - -signals: - void hideRequested(); - void historicalTurnCloseRequested(); - void selectionChanged(); - void threadOpenRequested(const QString& threadId); - -private: - void renderUnavailable(const QString& title, const QString& detail); - void refreshLayoutGeometry(QVBoxLayout* layout); - void setHistoricalTurnMode(bool enabled); - - QVBoxLayout* planContent = nullptr; - QVBoxLayout* agentsContent = nullptr; - QVBoxLayout* changesContent = nullptr; - QVBoxLayout* infoContent = nullptr; - QString inspectedThreadId; - QSet dependentThreadIds; - // An incomplete latest-turn projection may omit previously rendered - // activities. Retain their identities until a complete projection has - // authority to remove them. - QSet presentedAgentActivityItemIds; - QString selectedAgentItemId; - QByteArray unavailablePresentationKey; - QByteArray planPresentationKey; - QByteArray agentsPresentationKey; - QByteArray changesPresentationKey; - QByteArray infoPresentationKey; - QLabel* infoRevisionValue = nullptr; - QLabel* inspectorHeading = nullptr; - QPushButton* historicalBack = nullptr; - QScrollArea* infoScroll = nullptr; - QTabBar* tabs = nullptr; - bool historicalTurnMode = false; - int normalTabIndex = 1; -}; - -} // namespace codexui - -#endif // CODEXUI_UI_INSPECTORWIDGET_H diff --git a/src/ui/InteractiveRequestDialog.cpp b/src/ui/InteractiveRequestDialog.cpp deleted file mode 100644 index c8d2d00..0000000 --- a/src/ui/InteractiveRequestDialog.cpp +++ /dev/null @@ -1,646 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "ui/InteractiveRequestDialog.h" - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include - -namespace codexui { -namespace sdk = ai::openai::codex::frontend::client; -namespace frontend = ai::openai::codex::frontend; -namespace typed = ai::openai::codex::typed; -namespace { - -QString text(const std::string& value) -{ - return QString::fromUtf8(value.data(), static_cast(value.size())); -} - -QString kindTitle(frontend::PendingRequestKind kind) -{ - switch (kind) { - case frontend::PendingRequestKind::CommandExecutionApproval: - return QStringLiteral("Command execution approval"); - case frontend::PendingRequestKind::FileChangeApproval: - return QStringLiteral("File change approval"); - case frontend::PendingRequestKind::UserInput: - return QStringLiteral("Codex needs your input"); - case frontend::PendingRequestKind::ApplyPatchApproval: - return QStringLiteral("Patch approval"); - case frontend::PendingRequestKind::ExecCommandApproval: - return QStringLiteral("Command approval"); - case frontend::PendingRequestKind::PermissionsApproval: - return QStringLiteral("Permission approval"); - case frontend::PendingRequestKind::Authentication: - return QStringLiteral("Authentication request"); - case frontend::PendingRequestKind::Attestation: - return QStringLiteral("Attestation request"); - case frontend::PendingRequestKind::DynamicToolCall: - return QStringLiteral("Dynamic tool request"); - case frontend::PendingRequestKind::McpElicitation: - return QStringLiteral("MCP elicitation request"); - } - return QStringLiteral("Interactive request"); -} - -bool isSimpleApproval(frontend::PendingRequestKind kind) -{ - return kind == frontend::PendingRequestKind::CommandExecutionApproval - || kind == frontend::PendingRequestKind::FileChangeApproval; -} - -bool isReviewApproval(frontend::PendingRequestKind kind) -{ - return kind == frontend::PendingRequestKind::ApplyPatchApproval - || kind == frontend::PendingRequestKind::ExecCommandApproval; -} - -QLabel* wrappedLabel(const QString& value, const char* kind = nullptr) -{ - auto* result = new QLabel(value); - result->setTextFormat(Qt::PlainText); - result->setWordWrap(true); - result->setTextInteractionFlags(Qt::TextSelectableByMouse); - if (kind) - result->setProperty("kind", kind); - return result; -} - -QString plainTooltip(const QString& value) -{ - return Qt::convertFromPlainText(value, Qt::WhiteSpaceNormal); -} - -QString checkBoxText(const QString& value) -{ - QString result = value; - return result.replace(QLatin1Char('&'), QStringLiteral("&&")); -} - -void addDetail(QVBoxLayout* layout, const QString& value) -{ - if (!value.isEmpty()) - layout->addWidget(wrappedLabel(value, "muted")); -} - -void clearLayout(QLayout* layout) -{ - while (QLayoutItem* item = layout->takeAt(0)) { - if (QLayout* child = item->layout()) - clearLayout(child); - else if (QWidget* widget = item->widget()) - widget->deleteLater(); - delete item; - } -} - -} // namespace - -namespace detail { - -std::optional interactiveRequestSource(const sdk::State& state, - const sdk::PendingRequestId& requestId) -{ - const auto* request = state.pendingRequest(requestId); - if (!request) - return std::nullopt; - - InteractiveRequestSource result{*request, std::nullopt}; - if (request->itemId && request->threadId && request->turnId) { - if (const auto* item = state.item(*request->threadId, *request->turnId, *request->itemId)) { - result.linkedItem = sdk::itemSemanticView(*item); - if (result.linkedItem) - result.linkedItem->stamp.reset(); - } - } - return result; -} - -InteractiveRequestResponseSafety interactiveRequestResponseSafety(const InteractiveRequestSource& source) -{ - const auto view = sdk::pendingRequestPresentation(source.request); - if (source.request.connectionInvalidated) - return InteractiveRequestResponseSafety::Disabled; - - const bool requestComplete = !view.truncated && view.omittedFields.empty(); - const bool linkedItemComplete = !source.request.itemId - || (source.request.threadId && source.request.turnId && source.linkedItem - && !source.linkedItem->connectionInvalidated - && !source.linkedItem->truncated - && source.linkedItem->omittedFields.empty()); - if (requestComplete && linkedItemComplete) - return InteractiveRequestResponseSafety::Complete; - if (isSimpleApproval(source.request.kind) || isReviewApproval(source.request.kind)) - return InteractiveRequestResponseSafety::NegativeOnly; - return InteractiveRequestResponseSafety::Disabled; -} - -} // namespace detail - -namespace detail { - -bool interactiveResponseIsNegative(const InteractiveRequestResponse& response) -{ - if (const auto* approval = std::get_if(&response.value)) - return approval->value == "decline" || approval->value == "cancel"; - - const typed::ReviewDecision* decision = nullptr; - if (const auto* patch = std::get_if(&response.value)) - decision = &patch->decision; - else if (const auto* command = std::get_if(&response.value)) - decision = &command->decision; - return decision - && (std::holds_alternative(*decision) - || std::holds_alternative(*decision) - || std::holds_alternative(*decision)); -} - -} // namespace detail - -InteractiveRequestDialog::InteractiveRequestDialog(StateProvider provider, - ResponseHandler handler, - QWidget* parent) - : QDialog(parent) - , stateProvider(std::move(provider)) - , responseHandler(std::move(handler)) -{ - setWindowTitle(QStringLiteral("Needs attention")); - setWindowModality(Qt::NonModal); - setModal(false); - setMinimumWidth(520); - resize(580, 430); - - root = new QVBoxLayout(this); - root->setContentsMargins(22, 20, 22, 18); - root->setSpacing(14); - - auto* header = new QHBoxLayout; - auto* title = wrappedLabel(QStringLiteral("NEEDS ATTENTION"), "attentionSection"); - header->addWidget(title); - header->addStretch(); - queueLabel = wrappedLabel({}, "meta"); - header->addWidget(queueLabel); - root->addLayout(header); - - auto* scroll = new QScrollArea; - scroll->setWidgetResizable(true); - body = new QWidget; - body->setLayout(new QVBoxLayout); - body->layout()->setContentsMargins(0, 0, 0, 0); - body->layout()->setSpacing(10); - scroll->setWidget(body); - root->addWidget(scroll, 1); - - statusLabel = wrappedLabel({}, "meta"); - statusLabel->hide(); - root->addWidget(statusLabel); - - auto* buttons = new QHBoxLayout; - auto* close = new QPushButton(QStringLiteral("Close")); - close->setProperty("kind", "subtle"); - connect(close, &QPushButton::clicked, this, &QDialog::reject); - buttons->addWidget(close); - buttons->addStretch(); - nextButton = new QPushButton(QStringLiteral("Next request")); - nextButton->setProperty("kind", "subtle"); - connect(nextButton, &QPushButton::clicked, this, [this] { showNext(); }); - buttons->addWidget(nextButton); - submitButton = new QPushButton(QStringLiteral("Submit response")); - submitButton->setObjectName(QStringLiteral("interactiveRequestSubmit")); - submitButton->setProperty("kind", "primary"); - connect(submitButton, &QPushButton::clicked, this, [this] { submitCurrent(); }); - buttons->addWidget(submitButton); - root->addLayout(buttons); -} - -void InteractiveRequestDialog::synchronize(const sdk::State& state) -{ - saveCurrentDraft(); - - const std::vector previousRequestIds = orderedRequestIds; - const std::string previousRequestId = currentRequestId; - orderedRequestIds.clear(); - std::set currentIds; - for (const auto& request : state.pendingRequests()) { - orderedRequestIds.push_back(request.id.value); - currentIds.insert(request.id.value); - } - for (auto iterator = drafts.begin(); iterator != drafts.end();) { - if (!currentIds.contains(iterator->first)) - iterator = drafts.erase(iterator); - else - ++iterator; - } - std::erase_if(submittedRequestIds, [¤tIds](const auto& id) { return !currentIds.contains(id); }); - - const bool newlyNeedsAttention = previousCount == 0 && !orderedRequestIds.empty(); - previousCount = orderedRequestIds.size(); - if (orderedRequestIds.empty()) { - clearSecretEditors(); - currentRequestId.clear(); - submittingRequestId.clear(); - presentedSource.reset(); - currentResponseSafety = InteractiveRequestResponseSafety::Disabled; - hide(); - return; - } - if (!currentIds.contains(currentRequestId)) - currentRequestId = orderedRequestIds.front(); - - const auto source = detail::interactiveRequestSource(state, sdk::PendingRequestId{currentRequestId}); - const bool sameRequestChanged = source && presentedSource - && source->request.id == presentedSource->request.id - && *source != *presentedSource; - if (sameRequestChanged) - drafts.erase(currentRequestId); - const bool presentationChanged = previousRequestIds != orderedRequestIds - || previousRequestId != currentRequestId - || source != presentedSource; - if (presentationChanged && !newlyNeedsAttention) - rebuild(state); - if (newlyNeedsAttention) - present(); -} - -void InteractiveRequestDialog::present() -{ - if (orderedRequestIds.empty()) - return; - saveCurrentDraft(); - rebuild(stateProvider()); - show(); - raise(); - activateWindow(); -} - -void InteractiveRequestDialog::setSubmitting(const std::string& requestId, const QString& status) -{ - submittingRequestId = requestId; - if (currentRequestId == requestId) - setStatus(status); - updateSubmitEnabled(); -} - -void InteractiveRequestDialog::responseAccepted(const std::string& requestId) -{ - if (submittingRequestId == requestId) - submittingRequestId.clear(); - submittedRequestIds.insert(requestId); - if (currentRequestId == requestId) - setStatus(QStringLiteral("Response submitted… Waiting for canonical state.")); - updateSubmitEnabled(); -} - -void InteractiveRequestDialog::responseFailed(const std::string& requestId, const QString& error) -{ - if (submittingRequestId == requestId) - submittingRequestId.clear(); - submittedRequestIds.erase(requestId); - if (currentRequestId == requestId) - setStatus(error.isEmpty() ? QStringLiteral("Response could not be submitted") : error, true); - updateSubmitEnabled(); -} - -void InteractiveRequestDialog::saveCurrentDraft() -{ - if (currentRequestId.empty()) - return; - RequestDraft& draft = drafts[currentRequestId]; - for (std::size_t index = 0; index < approvalChoices.size(); ++index) { - if (approvalChoices[index]->isChecked()) - draft.approvalIndex = static_cast(index + 1); - } - for (const QuestionEditor& editor : questionEditors) { - QuestionDraft& question = draft.questions[editor.id]; - question.selectedOptions.clear(); - for (const auto& [label, checkbox] : editor.options) { - if (checkbox->isChecked()) - question.selectedOptions.insert(label); - } - if (editor.freeText) { - if (editor.secret) - question.freeText.clear(); - else - question.freeText = editor.freeText->text(); - } - } -} - -void InteractiveRequestDialog::clearSecretEditors() -{ - for (const QuestionEditor& editor : questionEditors) { - if (!editor.secret || !editor.freeText) - continue; - const QSignalBlocker blocker(editor.freeText); - editor.freeText->clear(); - if (const auto draft = drafts.find(currentRequestId); draft != drafts.end()) { - if (const auto question = draft->second.questions.find(editor.id); - question != draft->second.questions.end()) - question->second.freeText.clear(); - } - } -} - -void InteractiveRequestDialog::rebuild(const sdk::State& state) -{ - const auto source = detail::interactiveRequestSource(state, sdk::PendingRequestId{currentRequestId}); - if (!source) { - clearSecretEditors(); - presentedSource.reset(); - currentResponseSafety = InteractiveRequestResponseSafety::Disabled; - hide(); - return; - } - const sdk::PendingRequestState& request = source->request; - - clearSecretEditors(); - approvalChoices.clear(); - questionEditors.clear(); - clearLayout(body->layout()); - auto* content = static_cast(body->layout()); - const auto view = sdk::pendingRequestPresentation(request); - currentResponseSafety = detail::interactiveRequestResponseSafety(*source); - - auto* heading = wrappedLabel(kindTitle(request.kind), "heading"); - heading->setToolTip(plainTooltip(QStringLiteral("Request ID: %1").arg(text(request.id.value)))); - content->addWidget(heading); - if (request.summary && !request.summary->empty()) - addDetail(content, text(*request.summary)); - - QStringList provenance; - if (view.threadId) - provenance.append(QStringLiteral("Thread %1").arg(text(view.threadId->value))); - if (view.turnId) - provenance.append(QStringLiteral("Turn %1").arg(text(view.turnId->value))); - if (view.itemId) - provenance.append(QStringLiteral("Item %1").arg(text(view.itemId->value))); - addDetail(content, provenance.join(QStringLiteral(" · "))); - - if (source->linkedItem) { - const auto& semantic = *source->linkedItem; - if (const auto* command = std::get_if(&semantic.details)) { - if (command->command) - addDetail(content, QStringLiteral("Command: %1").arg(text(*command->command))); - if (command->cwd) - addDetail(content, QStringLiteral("Working directory: %1").arg(text(command->cwd->value))); - } else if (const auto* changes = std::get_if(&semantic.details)) { - if (changes->changeCount) - addDetail(content, QStringLiteral("Affected changes: %1").arg(*changes->changeCount)); - } - } - if (view.fileChangeCount) - addDetail(content, QStringLiteral("Files changed: %1").arg(*view.fileChangeCount)); - if (view.commandArgumentCount) - addDetail(content, QStringLiteral("Command arguments: %1").arg(*view.commandArgumentCount)); - if (view.parsedCommandCount) - addDetail(content, QStringLiteral("Parsed commands: %1").arg(*view.parsedCommandCount)); - if (view.commandRedacted) - addDetail(content, QStringLiteral("Command details are redacted by AISuite")); - if (view.cwdRedacted) - addDetail(content, QStringLiteral("Working directory is redacted by AISuite")); - if (view.reasonRedacted) - addDetail(content, QStringLiteral("Approval reason is redacted by AISuite")); - if (view.truncated || !view.omittedFields.empty()) - addDetail(content, QStringLiteral("Some projected request details were omitted")); - if (request.connectionInvalidated) - addDetail(content, QStringLiteral("This request belongs to an earlier connection and cannot be answered")); - if (request.itemId && !source->linkedItem) - addDetail(content, QStringLiteral("Linked request details are unavailable and cannot be safely approved")); - if (source->linkedItem - && (source->linkedItem->connectionInvalidated || source->linkedItem->truncated - || !source->linkedItem->omittedFields.empty())) - addDetail(content, QStringLiteral("Linked request details are incomplete and cannot be safely approved")); - if (currentResponseSafety == InteractiveRequestResponseSafety::NegativeOnly) - addDetail(content, QStringLiteral("Incomplete approval details may only be declined or cancelled")); - - RequestDraft& draft = drafts[currentRequestId]; - if (isSimpleApproval(request.kind) || isReviewApproval(request.kind)) { - if (currentResponseSafety != InteractiveRequestResponseSafety::Complete && draft.approvalIndex < 3) - draft.approvalIndex = 0; - auto* prompt = wrappedLabel(QStringLiteral("Choose how Codex should continue:"), "body"); - content->addWidget(prompt); - const QStringList decisions{ - QStringLiteral("Approve"), - QStringLiteral("Approve for this session"), - isSimpleApproval(request.kind) ? QStringLiteral("Decline") : QStringLiteral("Deny"), - isSimpleApproval(request.kind) ? QStringLiteral("Cancel") : QStringLiteral("Abort"), - }; - for (int index = 0; index < decisions.size(); ++index) { - auto* choice = new QRadioButton(decisions[index]); - choice->setChecked(draft.approvalIndex == index + 1); - choice->setEnabled(currentResponseSafety == InteractiveRequestResponseSafety::Complete - || (currentResponseSafety == InteractiveRequestResponseSafety::NegativeOnly - && index >= 2)); - connect(choice, &QRadioButton::toggled, this, [this](bool) { updateSubmitEnabled(); }); - approvalChoices.push_back(choice); - content->addWidget(choice); - } - } else if (request.kind == frontend::PendingRequestKind::UserInput && request.questions - && !request.questions->empty()) { - for (const auto& question : *request.questions) { - auto* section = new QFrame; - section->setProperty("kind", "raised"); - auto* sectionLayout = new QVBoxLayout(section); - sectionLayout->setContentsMargins(14, 12, 14, 12); - sectionLayout->setSpacing(7); - if (!question.header.empty()) - sectionLayout->addWidget(wrappedLabel(text(question.header), "title")); - sectionLayout->addWidget(wrappedLabel(text(question.prompt), "body")); - - QuestionEditor editor; - editor.id = question.id; - const QuestionDraft& questionDraft = draft.questions[question.id]; - for (const auto& option : question.options) { - auto* checkbox = new QCheckBox(checkBoxText(text(option.label))); - checkbox->setChecked(questionDraft.selectedOptions.contains(option.label)); - if (!option.description.empty()) - checkbox->setToolTip(plainTooltip(text(option.description))); - connect(checkbox, &QCheckBox::toggled, this, [this](bool) { updateSubmitEnabled(); }); - sectionLayout->addWidget(checkbox); - if (!option.description.empty()) { - auto* description = wrappedLabel(text(option.description), "meta"); - description->setContentsMargins(24, 0, 0, 2); - sectionLayout->addWidget(description); - } - editor.options.emplace_back(option.label, checkbox); - } - if (question.allowsFreeText) { - editor.freeText = new QLineEdit; - editor.secret = question.isSecret; - editor.freeText->setPlaceholderText(question.options.empty() ? QStringLiteral("Type your answer") - : QStringLiteral("Other answer")); - if (editor.secret) - editor.freeText->setEchoMode(QLineEdit::Password); - else - editor.freeText->setText(questionDraft.freeText); - connect(editor.freeText, &QLineEdit::textChanged, this, [this](const QString&) { updateSubmitEnabled(); }); - sectionLayout->addWidget(editor.freeText); - } - questionEditors.push_back(std::move(editor)); - content->addWidget(section); - } - } else { - content->addWidget(wrappedLabel( - QStringLiteral("This Codex request is visible, but this version of CodexUI has no safe typed response UI for it."), - "body")); - } - content->addStretch(); - - const auto current = std::find(orderedRequestIds.begin(), orderedRequestIds.end(), currentRequestId); - const auto index = current == orderedRequestIds.end() ? 0 : std::distance(orderedRequestIds.begin(), current); - queueLabel->setText(QStringLiteral("Request %1 of %2").arg(index + 1).arg(orderedRequestIds.size())); - nextButton->setVisible(orderedRequestIds.size() > 1); - if (submittingRequestId == currentRequestId) - setStatus(QStringLiteral("Submitting response…")); - else if (submittedRequestIds.contains(currentRequestId)) - setStatus(QStringLiteral("Response submitted… Waiting for canonical state.")); - else - setStatus({}); - presentedSource = std::move(source); - updateSubmitEnabled(); -} - -void InteractiveRequestDialog::showNext() -{ - if (orderedRequestIds.size() < 2) - return; - saveCurrentDraft(); - const auto current = std::find(orderedRequestIds.begin(), orderedRequestIds.end(), currentRequestId); - const auto next = current == orderedRequestIds.end() || std::next(current) == orderedRequestIds.end() - ? orderedRequestIds.begin() - : std::next(current); - currentRequestId = *next; - rebuild(stateProvider()); -} - -void InteractiveRequestDialog::submitCurrent() -{ - saveCurrentDraft(); - const auto& state = stateProvider(); - const auto source = detail::interactiveRequestSource(state, sdk::PendingRequestId{currentRequestId}); - if (!source) { - responseFailed(currentRequestId, QStringLiteral("This request is no longer pending")); - synchronize(state); - return; - } - if (!presentedSource || *source != *presentedSource) { - drafts.erase(currentRequestId); - rebuild(state); - setStatus(QStringLiteral("This request changed; review it and retry"), true); - return; - } - const sdk::PendingRequestState& request = source->request; - if (submittingRequestId == currentRequestId || submittedRequestIds.contains(currentRequestId)) - return; - - const RequestDraft& draft = drafts[currentRequestId]; - const auto safety = detail::interactiveRequestResponseSafety(*source); - const bool negativeApproval = (isSimpleApproval(request.kind) || isReviewApproval(request.kind)) - && (draft.approvalIndex == 3 || draft.approvalIndex == 4); - if (safety == InteractiveRequestResponseSafety::Disabled - || (safety == InteractiveRequestResponseSafety::NegativeOnly && !negativeApproval)) { - setStatus(QStringLiteral("This request is incomplete and cannot be safely answered"), true); - updateSubmitEnabled(); - return; - } - - InteractiveRequestResponse response{request.id, request.kind, *source, typed::ApprovalDecision::cancel()}; - if (isSimpleApproval(request.kind)) { - switch (draft.approvalIndex) { - case 1: response.value = typed::ApprovalDecision::accept(); break; - case 2: response.value = typed::ApprovalDecision::acceptForSession(); break; - case 3: response.value = typed::ApprovalDecision::decline(); break; - case 4: response.value = typed::ApprovalDecision::cancel(); break; - default: - setStatus(QStringLiteral("Choose an approval decision"), true); - return; - } - } else if (isReviewApproval(request.kind)) { - typed::ReviewDecision decision; - switch (draft.approvalIndex) { - case 1: decision = typed::ApprovedReviewDecision{}; break; - case 2: decision = typed::ApprovedForSessionReviewDecision{}; break; - case 3: decision = typed::DeniedReviewDecision{}; break; - case 4: decision = typed::AbortReviewDecision{}; break; - default: - setStatus(QStringLiteral("Choose an approval decision"), true); - return; - } - if (request.kind == frontend::PendingRequestKind::ApplyPatchApproval) - response.value = typed::ApplyPatchApprovalResponse{std::move(decision)}; - else - response.value = typed::ExecCommandApprovalResponse{std::move(decision)}; - } else if (request.kind == frontend::PendingRequestKind::UserInput && request.questions - && !request.questions->empty()) { - std::vector answers; - answers.reserve(request.questions->size()); - for (const auto& question : *request.questions) { - const QuestionDraft& questionDraft = draft.questions.at(question.id); - std::vector values(questionDraft.selectedOptions.begin(), questionDraft.selectedOptions.end()); - const auto editor = std::find_if(questionEditors.begin(), questionEditors.end(), [&question](const auto& value) { - return value.id == question.id; - }); - if (question.isSecret) { - if (editor != questionEditors.end() && editor->freeText) { - const QString freeText = editor->freeText->text(); - if (!freeText.trimmed().isEmpty()) - values.push_back(freeText.toStdString()); - } - } else if (!questionDraft.freeText.trimmed().isEmpty()) { - values.push_back(questionDraft.freeText.toStdString()); - } - if (values.empty()) { - setStatus(QStringLiteral("Answer every question before submitting"), true); - return; - } - answers.push_back({question.id, std::move(values)}); - } - response.value = std::move(answers); - } else { - setStatus(QStringLiteral("This request type is not supported"), true); - return; - } - - clearSecretEditors(); - setSubmitting(currentRequestId, QStringLiteral("Preparing response…")); - responseHandler(std::move(response)); -} - -void InteractiveRequestDialog::setStatus(const QString& value, bool error) -{ - statusLabel->setText(value); - statusLabel->setVisible(!value.isEmpty()); - statusLabel->setStyleSheet(QStringLiteral("color:%1;font-size:10px;font-weight:600;") - .arg(error ? QStringLiteral("#b83a3a") : QStringLiteral("#667085"))); -} - -void InteractiveRequestDialog::updateSubmitEnabled() -{ - const bool supported = !approvalChoices.empty() || !questionEditors.empty(); - const auto selectedApproval = std::find_if( - approvalChoices.begin(), approvalChoices.end(), [](const auto* choice) { return choice->isChecked(); }); - const bool decisionChosen = approvalChoices.empty() || selectedApproval != approvalChoices.end(); - const bool responseAllowed = currentResponseSafety == InteractiveRequestResponseSafety::Complete - || (currentResponseSafety == InteractiveRequestResponseSafety::NegativeOnly - && selectedApproval != approvalChoices.end() - && std::distance(approvalChoices.begin(), selectedApproval) >= 2); - const bool busy = !submittingRequestId.empty() || submittedRequestIds.contains(currentRequestId); - submitButton->setEnabled(responseAllowed && supported && decisionChosen && !busy); -} - -} // namespace codexui diff --git a/src/ui/InteractiveRequestDialog.h b/src/ui/InteractiveRequestDialog.h deleted file mode 100644 index 61aae32..0000000 --- a/src/ui/InteractiveRequestDialog.h +++ /dev/null @@ -1,134 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#ifndef CODEXUI_UI_INTERACTIVEREQUESTDIALOG_H -#define CODEXUI_UI_INTERACTIVEREQUESTDIALOG_H - -#include -#include -#include - -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -class QCheckBox; -class QLabel; -class QLineEdit; -class QPushButton; -class QRadioButton; -class QVBoxLayout; -class QWidget; - -namespace ai::openai::codex::frontend::client { -class State; -} - -namespace codexui { - -struct InteractiveRequestSource { - ai::openai::codex::frontend::client::PendingRequestState request; - std::optional linkedItem; - - bool operator==(const InteractiveRequestSource&) const = default; -}; - -enum class InteractiveRequestResponseSafety { Disabled, NegativeOnly, Complete }; - -namespace detail { - -[[nodiscard]] std::optional -interactiveRequestSource(const ai::openai::codex::frontend::client::State& state, - const ai::openai::codex::frontend::client::PendingRequestId& requestId); -[[nodiscard]] InteractiveRequestResponseSafety -interactiveRequestResponseSafety(const InteractiveRequestSource& source); - -} // namespace detail - -struct InteractiveRequestResponse { - using Value = std::variant>; - - ai::openai::codex::frontend::client::PendingRequestId requestId; - ai::openai::codex::frontend::PendingRequestKind kind; - InteractiveRequestSource source; - Value value; -}; - -namespace detail { - -[[nodiscard]] bool interactiveResponseIsNegative(const InteractiveRequestResponse& response); - -} // namespace detail - -class InteractiveRequestDialog final : public QDialog -{ -public: - using ResponseHandler = std::function; - using StateProvider = std::function; - - InteractiveRequestDialog(StateProvider stateProvider, ResponseHandler responseHandler, QWidget* parent = nullptr); - - void synchronize(const ai::openai::codex::frontend::client::State& state); - void present(); - void setSubmitting(const std::string& requestId, const QString& status); - void responseAccepted(const std::string& requestId); - void responseFailed(const std::string& requestId, const QString& error); - -private: - friend struct InteractiveRequestDialogTestAccess; - - struct QuestionDraft { - std::set selectedOptions; - QString freeText; - }; - struct RequestDraft { - int approvalIndex = 0; - std::map questions; - }; - struct QuestionEditor { - std::string id; - std::vector> options; - QLineEdit* freeText = nullptr; - bool secret = false; - }; - - void saveCurrentDraft(); - void clearSecretEditors(); - void rebuild(const ai::openai::codex::frontend::client::State& state); - void showNext(); - void submitCurrent(); - void setStatus(const QString& text, bool error = false); - void updateSubmitEnabled(); - - StateProvider stateProvider; - ResponseHandler responseHandler; - QVBoxLayout* root = nullptr; - QWidget* body = nullptr; - QLabel* queueLabel = nullptr; - QLabel* statusLabel = nullptr; - QPushButton* nextButton = nullptr; - QPushButton* submitButton = nullptr; - std::vector approvalChoices; - std::vector questionEditors; - std::vector orderedRequestIds; - std::map drafts; - std::set submittedRequestIds; - std::optional presentedSource; - std::string currentRequestId; - std::string submittingRequestId; - std::size_t previousCount = 0; - InteractiveRequestResponseSafety currentResponseSafety = InteractiveRequestResponseSafety::Disabled; -}; - -} // namespace codexui - -#endif // CODEXUI_UI_INTERACTIVEREQUESTDIALOG_H diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp deleted file mode 100644 index 0edd2f7..0000000 --- a/src/ui/MainWindow.cpp +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "ui/MainWindow.h" - -#include "ui/UiStyle.h" -#include "ui/WorkbenchWidget.h" - -#include -#include - -namespace codexui { - -MainWindow::MainWindow(FrontendSession& frontendSession, QWidget* parent) - : QMainWindow(parent) -{ - setWindowTitle(QStringLiteral("CodexUI — Codex Workbench")); - setMinimumSize(1100, 700); - resize(1536, 960); - - QFont font(QStringLiteral("Inter")); - font.setPixelSize(12); - qApp->setFont(font); - qApp->setStyleSheet(UiStyle::applicationStyleSheet()); - - setCentralWidget(new WorkbenchWidget(frontendSession, this)); -} - -} // namespace codexui diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h deleted file mode 100644 index 6014948..0000000 --- a/src/ui/MainWindow.h +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#ifndef CODEXUI_UI_MAINWINDOW_H -#define CODEXUI_UI_MAINWINDOW_H - -#include - -namespace codexui { - -class FrontendSession; - -class MainWindow : public QMainWindow -{ -public: - explicit MainWindow(FrontendSession& frontendSession, QWidget* parent = nullptr); -}; - -} // namespace codexui - -#endif // CODEXUI_UI_MAINWINDOW_H diff --git a/src/ui/PresentationRefreshAccumulator.cpp b/src/ui/PresentationRefreshAccumulator.cpp deleted file mode 100644 index eb6a9f4..0000000 --- a/src/ui/PresentationRefreshAccumulator.cpp +++ /dev/null @@ -1,184 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "ui/PresentationRefreshAccumulator.h" - -#include -#include - -namespace codexui::detail { -namespace { - -[[nodiscard]] bool canRetainBytes(std::uint64_t retained, - std::uint64_t additional) noexcept -{ - return retained <= maximumCoalescedContentDeltaBytes - && additional <= maximumCoalescedContentDeltaBytes - retained; -} - -} // namespace - -void SelectedPresentationRefreshAccumulator::clear() noexcept -{ - refreshPending = false; - fullRefreshPending = false; - structuralReconciliationPending = false; - contentChanges.clear(); - retainedContentUtf8Bytes = 0; -} - -void mergeSelectedPresentationRefresh( - SelectedPresentationRefreshAccumulator& accumulator, - const StateUpdateScope& scope, - const QString& selectedThreadId, - bool awaitedSelectionAffected) -{ - accumulator.refreshPending = true; - const bool requiresFullRefresh = scope.allThreadsAffected - || awaitedSelectionAffected - || scope.fullyAffectedThreadIds.contains( - selectedThreadId); - if (requiresFullRefresh) - { - accumulator.fullRefreshPending = true; - accumulator.structuralReconciliationPending = false; - accumulator.contentChanges.clear(); - accumulator.retainedContentUtf8Bytes = 0; - return; - } - if (accumulator.fullRefreshPending) - return; - - const bool requiresStructuralReconciliation = - scope.structurallyAffectedThreadIds.contains(selectedThreadId); - accumulator.structuralReconciliationPending = - accumulator.structuralReconciliationPending - || requiresStructuralReconciliation; - - // A worker mailbox publication is individually bounded, but more than - // one publication can reach the GUI during this 16 ms frame window. - // Bound the aggregate again and fall back to the newest authoritative - // State instead of growing presentation metadata. - bool foundExactContent = false; - for (const auto& identity : scope.affectedItemContents) - { - if (identity.threadId != selectedThreadId) - continue; - foundExactContent = true; - if (mergeConversationContentUpdate( - accumulator.contentChanges, - accumulator.retainedContentUtf8Bytes, - identity) - == BoundedMergeResult::CapacityExceeded) - { - accumulator.fullRefreshPending = true; - accumulator.structuralReconciliationPending = false; - accumulator.contentChanges.clear(); - accumulator.retainedContentUtf8Bytes = 0; - return; - } - } - - // A structural addition deliberately carries no exact item identity: its - // segment list is reconciled from State while any accumulated exact text - // changes remain available. Other conversation-affecting updates without - // an identity still require the existing authoritative refresh. - if (!foundExactContent && !requiresStructuralReconciliation) - { - accumulator.fullRefreshPending = true; - accumulator.structuralReconciliationPending = false; - accumulator.contentChanges.clear(); - accumulator.retainedContentUtf8Bytes = 0; - } -} - -BoundedMergeResult mergeConversationContentUpdate( - ConversationContentUpdates& updates, - std::uint64_t& retainedUtf8Bytes, - const StateUpdateScope::ItemContentIdentity& identity) -{ - auto existing = std::find_if( - updates.begin(), updates.end(), - [&identity](const ConversationContentUpdate& update) - { - return update.turnId == identity.turnId - && update.itemId == identity.itemId - && update.channel == identity.channel; - }); - if (existing == updates.end()) - { - if (static_cast(updates.size()) - >= maximumCoalescedPresentationIdentities) - return BoundedMergeResult::CapacityExceeded; - - ConversationContentUpdate next{ - identity.turnId, - identity.itemId, - identity.channel, - std::nullopt}; - if (identity.append) - { - const std::uint64_t deltaBytes = static_cast( - identity.append->deltaUtf8.size()); - if (!canRetainBytes(retainedUtf8Bytes, deltaBytes)) - return BoundedMergeResult::CapacityExceeded; - next.append = ConversationContentAppend{ - identity.append->baseContentBytes, - identity.append->discardPrefixBytes, - deltaBytes, - QString::fromUtf8(identity.append->deltaUtf8)}; - retainedUtf8Bytes += deltaBytes; - } - updates.push_back(std::move(next)); - return BoundedMergeResult::Retained; - } - - if (existing->append && identity.append - && existing->append->discardPrefixBytes == 0 - && identity.append->discardPrefixBytes == 0 - && existing->append->baseContentBytes - <= std::numeric_limits::max() - - existing->append->deltaUtf8Bytes - && existing->append->baseContentBytes - + existing->append->deltaUtf8Bytes - == identity.append->baseContentBytes) - { - const std::uint64_t deltaBytes = static_cast( - identity.append->deltaUtf8.size()); - if (!canRetainBytes(retainedUtf8Bytes, deltaBytes)) - return BoundedMergeResult::CapacityExceeded; - existing->append->delta.append(QString::fromUtf8(identity.append->deltaUtf8)); - existing->append->deltaUtf8Bytes += deltaBytes; - retainedUtf8Bytes += deltaBytes; - return BoundedMergeResult::Retained; - } - - // A replacement, rolling window, or non-contiguous append is represented - // by an authoritative item refresh. It no longer retains the previous - // optional text delta in this frame accumulator. - if (existing->append) - { - retainedUtf8Bytes = existing->append->deltaUtf8Bytes - <= retainedUtf8Bytes - ? retainedUtf8Bytes - - existing->append->deltaUtf8Bytes - : 0; - } - existing->append.reset(); - return BoundedMergeResult::Retained; -} - -BoundedMergeResult appendUniqueSidebarThread( - QStringList& orderedThreadIds, - QSet& retainedThreadIds, - const QString& threadId) -{ - if (retainedThreadIds.contains(threadId)) - return BoundedMergeResult::Retained; - if (orderedThreadIds.size() >= maximumCoalescedPresentationIdentities) - return BoundedMergeResult::CapacityExceeded; - retainedThreadIds.insert(threadId); - orderedThreadIds.append(threadId); - return BoundedMergeResult::Retained; -} - -} // namespace codexui::detail diff --git a/src/ui/PresentationRefreshAccumulator.h b/src/ui/PresentationRefreshAccumulator.h deleted file mode 100644 index ca4fa02..0000000 --- a/src/ui/PresentationRefreshAccumulator.h +++ /dev/null @@ -1,47 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#ifndef CODEXUI_UI_PRESENTATIONREFRESHACCUMULATOR_H -#define CODEXUI_UI_PRESENTATIONREFRESHACCUMULATOR_H - -#include "app/FrontendSession.h" -#include "ui/ConversationWidget.h" - -#include -#include -#include - -#include - -namespace codexui::detail { - -enum class BoundedMergeResult { Retained, CapacityExceeded }; - -struct SelectedPresentationRefreshAccumulator { - bool refreshPending = false; - bool fullRefreshPending = false; - bool structuralReconciliationPending = false; - ConversationContentUpdates contentChanges; - std::uint64_t retainedContentUtf8Bytes = 0; - - void clear() noexcept; -}; - -void mergeSelectedPresentationRefresh( - SelectedPresentationRefreshAccumulator& accumulator, - const StateUpdateScope& scope, - const QString& selectedThreadId, - bool awaitedSelectionAffected); - -[[nodiscard]] BoundedMergeResult mergeConversationContentUpdate( - ConversationContentUpdates& updates, - std::uint64_t& retainedUtf8Bytes, - const StateUpdateScope::ItemContentIdentity& identity); - -[[nodiscard]] BoundedMergeResult appendUniqueSidebarThread( - QStringList& orderedThreadIds, - QSet& retainedThreadIds, - const QString& threadId); - -} // namespace codexui::detail - -#endif // CODEXUI_UI_PRESENTATIONREFRESHACCUMULATOR_H diff --git a/src/ui/SidebarWidget.cpp b/src/ui/SidebarWidget.cpp deleted file mode 100644 index b71eb6b..0000000 --- a/src/ui/SidebarWidget.cpp +++ /dev/null @@ -1,1650 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "ui/SidebarWidget.h" - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -namespace codexui { -namespace { - -constexpr int itemKindRole = Qt::UserRole; -constexpr int stableIdRole = Qt::UserRole + 1; -constexpr std::size_t maximumOrganizationFolders = 2'048; -constexpr qsizetype maximumOrganizationAssignments = 8'192; -constexpr std::size_t maximumOrganizationDepth = 32; -constexpr qsizetype maximumOrganizationStorageBytes = 4 * 1024 * 1024; -const QString folderItemKind = QStringLiteral("folder"); -const QString sectionItemKind = QStringLiteral("section"); - -QByteArray serializeOrganization(const std::vector& folders, - const QHash& threadFolders) -{ - QJsonArray serializedFolders; - for (const auto& folder : folders) { - serializedFolders.append(QJsonObject{{QStringLiteral("id"), folder.id}, - {QStringLiteral("name"), folder.name}, - {QStringLiteral("parentId"), folder.parentId}, - {QStringLiteral("expanded"), folder.expanded}}); - } - QJsonObject assignments; - for (auto iterator = threadFolders.constBegin(); iterator != threadFolders.constEnd(); ++iterator) - assignments.insert(iterator.key(), iterator.value()); - return QJsonDocument(QJsonObject{{QStringLiteral("folders"), serializedFolders}, - {QStringLiteral("threadFolders"), assignments}}) - .toJson(QJsonDocument::Compact); -} - -qsizetype serializedAssignmentMemberBytes(const QString& threadId, const QString& folderId) -{ - // Remove the surrounding object braces. The remaining bytes are exactly - // one JSON object member, including key/value escaping. - return QJsonDocument(QJsonObject{{threadId, folderId}}) - .toJson(QJsonDocument::Compact) - .size() - - 2; -} - -bool validThreadOrganizationId(const QString& threadId) -{ - return !threadId.isEmpty() && threadId.size() <= 1'024 - && std::ranges::all_of(threadId, [](QChar character) { - const auto category = character.category(); - return character.isPrint() && category != QChar::Separator_Line - && category != QChar::Separator_Paragraph - && category != QChar::Other_Format; - }); -} - -QLabel* textLabel(const QString& text, const char* kind = nullptr) -{ - auto* result = new QLabel(text); - result->setTextFormat(Qt::PlainText); - if (kind) - result->setProperty("kind", kind); - return result; -} - -QString plainTooltip(const QString& text) -{ - return Qt::convertFromPlainText(text, Qt::WhiteSpaceNormal); -} - -QString menuLabel(QString text) -{ - return text.replace(QLatin1Char('&'), QStringLiteral("&&")); -} - -QLabel* section(const QString& text, bool attention = false) -{ - auto* result = textLabel(text, attention ? "attentionSection" : "section"); - result->setMinimumHeight(attention ? 26 : 18); - return result; -} - -QString boundedRowText(QString value) -{ - constexpr qsizetype maximumCharacters = 512; - if (value.size() <= maximumCharacters) - return value; - value.truncate(maximumCharacters); - value.append(QChar(0x2026)); - return value; -} - -class ThreadRow final : public QFrame -{ -public: - ThreadRow(QString stableId, - QString title, - QString details, - QString color, - ThreadActionAvailability actions, - bool running, - bool attention, - bool archived, - QWidget* parent = nullptr) - : QFrame(parent) - , stableId(std::move(stableId)) - , actions(actions) - , running(running) - , attention(attention) - , archived(archived) - { - setObjectName(QStringLiteral("threadRow")); - setProperty("threadId", this->stableId); - setCursor(Qt::PointingHandCursor); - setFocusPolicy(Qt::StrongFocus); - setMinimumHeight(64); - setProperty("selected", false); - - auto* row = new QHBoxLayout(this); - row->setContentsMargins(12, 7, 10, 7); - row->setSpacing(10); - dot = new QFrame; - dot->setFixedSize(8, 8); - row->addWidget(dot, 0, Qt::AlignTop); - - auto* content = new QVBoxLayout; - content->setContentsMargins(0, 0, 0, 0); - content->setSpacing(4); - titleLabel = textLabel({}, "title"); - titleLabel->setStyleSheet(QStringLiteral("font-size:13px;font-weight:500;")); - titleLabel->setTextFormat(Qt::PlainText); - titleLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); - content->addWidget(titleLabel); - detailsLabel = textLabel({}, "meta"); - detailsLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); - content->addWidget(detailsLabel); - row->addLayout(content, 1); - updatePresentation(std::move(title), - std::move(details), - std::move(color), - this->actions, - this->running, - this->attention, - this->archived); - updateStyle(); - } - - void updatePresentation(QString title, - QString details, - QString color, - ThreadActionAvailability nextActions, - bool nextRunning, - bool nextAttention, - bool nextArchived) - { - actions = nextActions; - running = nextRunning; - attention = nextAttention; - archived = nextArchived; - if (dotColor != color) { - dotColor = std::move(color); - } - if (fullTitle != title) { - fullTitle = std::move(title); - titleLabel->setToolTip(plainTooltip(fullTitle)); - lastAvailableWidth = -1; - } - if (fullDetails != details) { - fullDetails = std::move(details); - detailsLabel->setToolTip(plainTooltip(fullDetails)); - detailsLabel->setVisible(!fullDetails.isEmpty()); - lastAvailableWidth = -1; - } - updateElision(width()); - updateStyle(); - } - - void setSelected(bool value) - { - if (selected == value) - return; - selected = value; - updateStyle(); - } - - void setInteractionEnabled(bool enabled) - { - if (isEnabled() == enabled) - return; - setEnabled(enabled); - updateStyle(); - } - - [[nodiscard]] const QString& id() const noexcept - { - return stableId; - } - - std::function clicked; - std::function contextRequested; - -protected: - void enterEvent(QEnterEvent* event) override - { - hovered = true; - updateStyle(); - QFrame::enterEvent(event); - } - - void leaveEvent(QEvent* event) override - { - hovered = false; - updateStyle(); - QFrame::leaveEvent(event); - } - - void mousePressEvent(QMouseEvent* event) override - { - if (event->button() == Qt::LeftButton && clicked) - clicked(this); - QFrame::mousePressEvent(event); - } - - void contextMenuEvent(QContextMenuEvent* event) override - { - if (contextRequested) - contextRequested(this, event->globalPos()); - event->accept(); - } - - void keyPressEvent(QKeyEvent* event) override - { - if ((event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter - || event->key() == Qt::Key_Space) - && clicked) - { - clicked(this); - event->accept(); - return; - } - if (event->key() == Qt::Key_Menu && contextRequested) - { - contextRequested(this, mapToGlobal(rect().center())); - event->accept(); - return; - } - QFrame::keyPressEvent(event); - } - - void focusInEvent(QFocusEvent* event) override - { - focused = true; - updateStyle(); - QFrame::focusInEvent(event); - } - - void focusOutEvent(QFocusEvent* event) override - { - focused = false; - updateStyle(); - QFrame::focusOutEvent(event); - } - -public: - [[nodiscard]] const ThreadActionAvailability& availability() const noexcept { return actions; } - [[nodiscard]] bool isRunning() const noexcept { return running; } - [[nodiscard]] bool isArchived() const noexcept { return archived; } - void setContextOpen(bool value) - { - if (contextOpen == value) - return; - contextOpen = value; - updateStyle(); - } - - void resizeEvent(QResizeEvent* event) override - { - updateElision(event->size().width()); - QFrame::resizeEvent(event); - } - -private: - void updateElision(int width) - { - const int availableWidth = qMax(0, width - 42); - if (availableWidth != lastAvailableWidth) { - lastAvailableWidth = availableWidth; - const QString title = titleLabel->fontMetrics().elidedText(fullTitle, Qt::ElideRight, availableWidth); - if (titleLabel->text() != title) - titleLabel->setText(title); - if (detailsLabel) { - const QString details = detailsLabel->fontMetrics().elidedText(fullDetails, Qt::ElideRight, availableWidth); - if (detailsLabel->text() != details) - detailsLabel->setText(details); - } - } - } - - void updateStyle() - { - if (!isEnabled()) { - dot->setStyleSheet(QStringLiteral("background:#98a2b3;border-radius:4px;")); - setStyleSheet(QStringLiteral( - "QFrame#threadRow{background:#f6f8fb;border:1px solid #d7dee8;border-radius:9px;}" - "QFrame#threadRow QLabel[kind=\"title\"]," - "QFrame#threadRow QLabel[kind=\"meta\"]{color:#98a2b3;}")); - return; - } - QString background = archived ? QStringLiteral("#f6f8fb") : QStringLiteral("#ffffff"); - QString border = QStringLiteral("#d7dee8"); - if (attention && !selected) { - background = QStringLiteral("#fff6df"); - border = QStringLiteral("#e5c77d"); - } - if (hovered || contextOpen) { - background = selected ? QStringLiteral("#d9e7ff") : QStringLiteral("#f1f5fb"); - border = selected ? QStringLiteral("#bfd3f9") : QStringLiteral("#b9c4d2"); - } - if (selected) { - background = hovered || contextOpen ? QStringLiteral("#d9e7ff") : QStringLiteral("#e5eeff"); - border = QStringLiteral("#bfd3f9"); - } - if (focused) - border = QStringLiteral("#2f6feb"); - const QString effectiveDot = attention ? QStringLiteral("#a76812") - : archived ? QStringLiteral("#98a2b3") : dotColor; - dot->setStyleSheet(QStringLiteral("background:%1;border-radius:4px;").arg(effectiveDot)); - setStyleSheet(QStringLiteral( - "QFrame#threadRow{background:%1;border:1px solid %2;border-radius:9px;}" - "QFrame#threadRow QLabel[kind=\"title\"]{color:%3;}" - "QFrame#threadRow QLabel[kind=\"meta\"]{color:#667085;}") - .arg(background, - border, - archived ? QStringLiteral("#667085") : QStringLiteral("#1d2633"))); - } - - QFrame* dot = nullptr; - QLabel* titleLabel = nullptr; - QLabel* detailsLabel = nullptr; - QString stableId; - QString fullTitle; - QString fullDetails; - QString dotColor; - int lastAvailableWidth = -1; - bool selected = false; - bool hovered = false; - bool focused = false; - bool contextOpen = false; - ThreadActionAvailability actions; - bool running = false; - bool attention = false; - bool archived = false; -}; - -QString threadStatusColor(const std::optional& status) -{ - if (!status) - return QStringLiteral("#98a2b3"); - const QString value = QString::fromStdString(*status).toLower(); - if (value.contains(QStringLiteral("fail")) || value.contains(QStringLiteral("error")) - || value.contains(QStringLiteral("approval")) || value.contains(QStringLiteral("attention"))) - return QStringLiteral("#a76812"); - if (value.contains(QStringLiteral("running")) || value.contains(QStringLiteral("active")) - || value.contains(QStringLiteral("complete"))) - return QStringLiteral("#23845a"); - return QStringLiteral("#2f6feb"); -} - -} // namespace - -namespace detail { - -ThreadUiStatus threadUiStatus(const ai::openai::codex::frontend::client::State& state, - const ai::openai::codex::frontend::client::ThreadState& thread, - bool awaitingResponse) -{ - bool hasInterruptibleTurn = false; - for (const auto& turnId : thread.orderedTurns) { - const auto* turn = state.turn(thread.id, turnId); - hasInterruptibleTurn = hasInterruptibleTurn || (turn && turn->active && !turn->terminal); - } - - ThreadUiStatus result; - const bool archived = thread.archived.value_or(false); - const bool fullyActionable = thread.fullyLoaded; - result.running = hasInterruptibleTurn; - result.awaitingResponse = awaitingResponse; - result.archived = archived; - result.actions.fork = fullyActionable; - result.actions.interrupt = hasInterruptibleTurn; - result.actions.resumeWithOptions = !result.running && fullyActionable; - result.actions.remove = !result.running && fullyActionable; - result.actions.archive = !result.running && fullyActionable - && thread.archived.has_value() && !archived; - result.actions.unarchive = !result.running && archived; - return result; -} - -ThreadActionAvailability -threadActionAvailability(const ai::openai::codex::frontend::client::State& state, - const ai::openai::codex::frontend::client::ThreadState& thread) -{ - return threadUiStatus(state, thread).actions; -} - -void ThreadOrganization::load(QSettings& settings) -{ - storedFolders.clear(); - threadFolders.clear(); - - const QByteArray serialized = - settings.value(QStringLiteral("sidebar/threadOrganizationV1")).toByteArray(); - const QJsonDocument document = serialized.size() <= maximumOrganizationStorageBytes - ? QJsonDocument::fromJson(serialized) - : QJsonDocument{}; - if (document.isObject()) { - const QJsonObject root = document.object(); - const QJsonArray folders = root.value(QStringLiteral("folders")).toArray(); - storedFolders.reserve(std::min(maximumOrganizationFolders, - static_cast(folders.size()))); - for (const auto& value : folders) { - if (storedFolders.size() >= maximumOrganizationFolders) - break; - const QJsonObject object = value.toObject(); - storedFolders.push_back({object.value(QStringLiteral("id")).toString(), - object.value(QStringLiteral("name")).toString(), - object.value(QStringLiteral("parentId")).toString(), - object.value(QStringLiteral("expanded")).toBool(true)}); - } - const QJsonObject assignments = root.value(QStringLiteral("threadFolders")).toObject(); - for (auto iterator = assignments.constBegin(); - iterator != assignments.constEnd() - && threadFolders.size() < maximumOrganizationAssignments; - ++iterator) - threadFolders.insert(iterator.key(), iterator.value().toString()); - } - normalize(); - ++currentRevision; -} - -bool ThreadOrganization::save(QSettings& settings) const -{ - const QByteArray serialized = serializeOrganization(storedFolders, threadFolders); - if (serialized.size() > maximumOrganizationStorageBytes) - return false; - settings.setValue(QStringLiteral("sidebar/threadOrganizationV1"), serialized); - return true; -} - -const std::vector& ThreadOrganization::folders() const noexcept -{ - return storedFolders; -} - -const ThreadFolder* ThreadOrganization::folder(const QString& folderId) const noexcept -{ - const auto iterator = std::ranges::find(storedFolders, folderId, &ThreadFolder::id); - return iterator == storedFolders.end() ? nullptr : &*iterator; -} - -QString ThreadOrganization::folderForThread(const QString& threadId) const -{ - const QString folderId = threadFolders.value(threadId); - return folder(folderId) ? folderId : QString{}; -} - -QString ThreadOrganization::folderPath(const QString& folderId) const -{ - QStringList parts; - QString currentId = folderId; - for (std::size_t depth = 0; depth < storedFolders.size() && !currentId.isEmpty(); ++depth) { - const auto* current = folder(currentId); - if (!current) - break; - parts.prepend(current->name); - currentId = current->parentId; - } - return parts.join(QStringLiteral(" › ")); -} - -quint64 ThreadOrganization::revision() const noexcept -{ - return currentRevision; -} - -QString ThreadOrganization::createFolder(const QString& name, const QString& parentId) -{ - const QString normalizedName = name.trimmed(); - std::size_t newDepth = 1; - for (QString ancestorId = parentId; !ancestorId.isEmpty(); ++newDepth) { - const auto* ancestor = folder(ancestorId); - if (!ancestor || newDepth >= maximumOrganizationDepth) - return {}; - ancestorId = ancestor->parentId; - } - if (storedFolders.size() >= maximumOrganizationFolders - || !validName(normalizedName, parentId)) - return {}; - const QString id = QUuid::createUuid().toString(QUuid::WithoutBraces); - storedFolders.push_back({id, normalizedName, parentId, true}); - const qsizetype candidateBytes = serializeOrganization(storedFolders, threadFolders).size(); - if (candidateBytes > maximumOrganizationStorageBytes) { - storedFolders.pop_back(); - return {}; - } - currentStorageBytes = candidateBytes; - ++currentRevision; - return id; -} - -bool ThreadOrganization::renameFolder(const QString& folderId, const QString& name) -{ - auto iterator = std::ranges::find(storedFolders, folderId, &ThreadFolder::id); - if (iterator == storedFolders.end()) - return false; - const QString normalizedName = name.trimmed(); - if (!validName(normalizedName, iterator->parentId, folderId)) - return false; - if (iterator->name == normalizedName) - return false; - const QString previousName = iterator->name; - iterator->name = normalizedName; - const qsizetype candidateBytes = serializeOrganization(storedFolders, threadFolders).size(); - if (candidateBytes > maximumOrganizationStorageBytes) { - iterator->name = previousName; - return false; - } - currentStorageBytes = candidateBytes; - ++currentRevision; - return true; -} - -bool ThreadOrganization::moveFolder(const QString& folderId, const QString& parentId) -{ - auto iterator = std::ranges::find(storedFolders, folderId, &ThreadFolder::id); - if (iterator == storedFolders.end() || iterator->parentId == parentId - || !canMoveFolder(folderId, parentId)) - return false; - const QString previousParentId = iterator->parentId; - iterator->parentId = parentId; - const qsizetype candidateBytes = serializeOrganization(storedFolders, threadFolders).size(); - if (candidateBytes > maximumOrganizationStorageBytes) { - iterator->parentId = previousParentId; - return false; - } - currentStorageBytes = candidateBytes; - ++currentRevision; - return true; -} - -bool ThreadOrganization::removeFolderAndPromoteContents(const QString& folderId) -{ - const auto iterator = std::ranges::find(storedFolders, folderId, &ThreadFolder::id); - if (iterator == storedFolders.end()) - return false; - const std::vector previousFolders = storedFolders; - const QHash previousThreadFolders = threadFolders; - const QString parentId = iterator->parentId; - for (auto& child : storedFolders) { - if (child.parentId == folderId) { - const QString baseName = child.name; - QString promotedName = baseName; - int suffix = 2; - while (!validName(promotedName, parentId, child.id)) { - const QString suffixText = QStringLiteral(" (%1)").arg(suffix++); - promotedName = baseName.left(128 - suffixText.size()) + suffixText; - } - child.name = promotedName; - child.parentId = parentId; - } - } - for (auto assignment = threadFolders.begin(); assignment != threadFolders.end();) { - if (assignment.value() != folderId) { - ++assignment; - continue; - } - if (parentId.isEmpty()) - assignment = threadFolders.erase(assignment); - else { - assignment.value() = parentId; - ++assignment; - } - } - storedFolders.erase(iterator); - const qsizetype candidateBytes = serializeOrganization(storedFolders, threadFolders).size(); - if (candidateBytes > maximumOrganizationStorageBytes) { - storedFolders = previousFolders; - threadFolders = previousThreadFolders; - return false; - } - currentStorageBytes = candidateBytes; - ++currentRevision; - return true; -} - -bool ThreadOrganization::moveThread(const QString& threadId, const QString& folderId) -{ - if (!validThreadOrganizationId(threadId) - || (!folderId.isEmpty() && !folder(folderId))) - return false; - const auto previous = threadFolders.constFind(threadId); - const bool hadPreviousAssignment = previous != threadFolders.constEnd(); - const QString previousFolderId = hadPreviousAssignment ? previous.value() : QString{}; - if (previousFolderId == folderId) - return false; - if (!hadPreviousAssignment && !folderId.isEmpty() - && threadFolders.size() >= maximumOrganizationAssignments) - return false; - - qsizetype candidateBytes = currentStorageBytes; - if (hadPreviousAssignment) { - candidateBytes -= serializedAssignmentMemberBytes(threadId, previousFolderId); - if (threadFolders.size() > 1) - --candidateBytes; - } - if (!folderId.isEmpty()) { - candidateBytes += serializedAssignmentMemberBytes(threadId, folderId); - if ((!hadPreviousAssignment && !threadFolders.isEmpty()) - || (hadPreviousAssignment && threadFolders.size() > 1)) - ++candidateBytes; - } - if (candidateBytes > maximumOrganizationStorageBytes) - return false; - - if (folderId.isEmpty()) - threadFolders.remove(threadId); - else - threadFolders.insert(threadId, folderId); - currentStorageBytes = candidateBytes; - ++currentRevision; - return true; -} - -bool ThreadOrganization::retainThreadAssignments(const QSet& threadIds) -{ - const qsizetype removed = threadFolders.removeIf([&threadIds](auto iterator) { - return !threadIds.contains(iterator.key()); - }); - if (removed == 0) - return false; - currentStorageBytes = serializeOrganization(storedFolders, threadFolders).size(); - ++currentRevision; - return true; -} - -bool ThreadOrganization::setFolderExpanded(const QString& folderId, bool expanded) -{ - auto iterator = std::ranges::find(storedFolders, folderId, &ThreadFolder::id); - if (iterator == storedFolders.end() || iterator->expanded == expanded) - return false; - const bool previousExpanded = iterator->expanded; - iterator->expanded = expanded; - const qsizetype candidateBytes = serializeOrganization(storedFolders, threadFolders).size(); - if (candidateBytes > maximumOrganizationStorageBytes) { - iterator->expanded = previousExpanded; - return false; - } - currentStorageBytes = candidateBytes; - ++currentRevision; - return true; -} - -QSet ThreadOrganization::movableFolderParents(const QString& folderId) const -{ - QSet result; - QHash foldersById; - foldersById.reserve(static_cast(storedFolders.size())); - for (const ThreadFolder& value : storedFolders) - foldersById.insert(value.id, &value); - const ThreadFolder* moved = foldersById.value(folderId, nullptr); - if (!moved) - return result; - - QSet descendants; - std::size_t subtreeDepth = 0; - for (const ThreadFolder& candidate : storedFolders) { - QString currentId = candidate.id; - QSet visited; - std::size_t distance = 0; - while (!currentId.isEmpty()) { - if (currentId == folderId) { - descendants.insert(candidate.id); - subtreeDepth = std::max(subtreeDepth, distance); - break; - } - if (visited.contains(currentId) || distance > maximumOrganizationDepth) - return {}; - visited.insert(currentId); - const ThreadFolder* current = foldersById.value(currentId, nullptr); - if (!current) - return {}; - currentId = current->parentId; - ++distance; - } - } - - QSet conflictingParents; - for (const ThreadFolder& sibling : storedFolders) { - if (sibling.id != folderId - && sibling.name.compare(moved->name, Qt::CaseInsensitive) == 0) - conflictingParents.insert(sibling.parentId); - } - const auto addDestination = [&](const QString& parentId) { - if (descendants.contains(parentId) || conflictingParents.contains(parentId)) - return; - QString currentId = parentId; - QSet visited; - std::size_t parentDepth = 0; - while (!currentId.isEmpty()) { - if (visited.contains(currentId) || ++parentDepth > maximumOrganizationDepth) - return; - visited.insert(currentId); - const ThreadFolder* current = foldersById.value(currentId, nullptr); - if (!current) - return; - currentId = current->parentId; - } - if (parentDepth + 1U + subtreeDepth <= maximumOrganizationDepth) - result.insert(parentId); - }; - addDestination({}); - for (const ThreadFolder& candidate : storedFolders) - addDestination(candidate.id); - return result; -} - -bool ThreadOrganization::canMoveFolder(const QString& folderId, const QString& parentId) const -{ - return movableFolderParents(folderId).contains(parentId); -} - -bool ThreadOrganization::validName(const QString& name, - const QString& parentId, - const QString& excludedFolderId) const -{ - if (name.isEmpty() || name.size() > 128 - || !std::ranges::all_of(name, [](QChar character) { - const auto category = character.category(); - return character.isPrint() && category != QChar::Separator_Line - && category != QChar::Separator_Paragraph - && category != QChar::Other_Format; - }) - || (!parentId.isEmpty() && !folder(parentId))) - return false; - return std::ranges::none_of(storedFolders, [&](const ThreadFolder& sibling) { - return sibling.id != excludedFolderId && sibling.parentId == parentId - && sibling.name.compare(name, Qt::CaseInsensitive) == 0; - }); -} - -bool ThreadOrganization::isDescendantOf(const QString& folderId, - const QString& possibleAncestorId) const -{ - QString currentId = folderId; - for (std::size_t depth = 0; depth <= storedFolders.size() && !currentId.isEmpty(); ++depth) { - if (currentId == possibleAncestorId) - return true; - const auto* current = folder(currentId); - if (!current) - return false; - currentId = current->parentId; - } - return false; -} - -void ThreadOrganization::normalize() -{ - QSet ids; - std::erase_if(storedFolders, [&ids](ThreadFolder& folder) { - folder.id = folder.id.trimmed(); - folder.name = folder.name.trimmed(); - folder.parentId = folder.parentId.trimmed(); - const bool printableName = std::ranges::all_of(folder.name, [](QChar character) { - const auto category = character.category(); - return character.isPrint() && category != QChar::Separator_Line - && category != QChar::Separator_Paragraph - && category != QChar::Other_Format; - }); - if (folder.id.isEmpty() || folder.id.size() > 128 || folder.name.isEmpty() - || folder.name.size() > 128 || !printableName || folder.parentId.size() > 128 - || ids.contains(folder.id)) - return true; - ids.insert(folder.id); - return false; - }); - for (auto& current : storedFolders) { - if (!current.parentId.isEmpty() - && (!folder(current.parentId) || current.parentId == current.id - || isDescendantOf(current.parentId, current.id))) - current.parentId.clear(); - QString ancestorId = current.parentId; - std::size_t depth = 1; - while (!ancestorId.isEmpty() && depth < maximumOrganizationDepth) { - const auto* ancestor = folder(ancestorId); - ancestorId = ancestor ? ancestor->parentId : QString{}; - ++depth; - } - if (!ancestorId.isEmpty()) - current.parentId.clear(); - } - QSet siblingNames; - for (auto& current : storedFolders) { - const QString baseName = current.name; - QString uniqueName = baseName; - int suffix = 2; - QString identity = current.parentId + QChar(u'\0') + uniqueName.toCaseFolded(); - while (siblingNames.contains(identity)) { - const QString suffixText = QStringLiteral(" (%1)").arg(suffix++); - uniqueName = baseName.left(128 - suffixText.size()) + suffixText; - identity = current.parentId + QChar(u'\0') + uniqueName.toCaseFolded(); - } - current.name = uniqueName; - siblingNames.insert(identity); - } - for (auto iterator = threadFolders.begin(); iterator != threadFolders.end();) { - const bool printableThreadId = validThreadOrganizationId(iterator.key()); - if (iterator.key().isEmpty() || iterator.key().size() > 1024 - || !printableThreadId || !folder(iterator.value())) - iterator = threadFolders.erase(iterator); - else - ++iterator; - } - - currentStorageBytes = serializeOrganization(storedFolders, threadFolders).size(); - if (currentStorageBytes <= maximumOrganizationStorageBytes) - return; - - // Normalization can make duplicate sibling names slightly longer. Keep a - // malformed-but-bounded settings value from becoming a value that this - // version cannot save again by pruning only presentation assignments. - QStringList assignmentIds = threadFolders.keys(); - std::ranges::sort(assignmentIds); - for (auto iterator = assignmentIds.crbegin(); - iterator != assignmentIds.crend() - && currentStorageBytes > maximumOrganizationStorageBytes; - ++iterator) { - const auto assignment = threadFolders.constFind(*iterator); - if (assignment == threadFolders.constEnd()) - continue; - currentStorageBytes -= serializedAssignmentMemberBytes(assignment.key(), assignment.value()); - if (threadFolders.size() > 1) - --currentStorageBytes; - threadFolders.erase(assignment); - } - - // Folder metadata alone is bounded well below the storage budget by the - // folder/name/depth limits. Recalculate defensively rather than relying on - // the arithmetic above when loading an externally edited value. - currentStorageBytes = serializeOrganization(storedFolders, threadFolders).size(); - if (currentStorageBytes > maximumOrganizationStorageBytes) { - storedFolders.clear(); - threadFolders.clear(); - currentStorageBytes = serializeOrganization(storedFolders, threadFolders).size(); - } -} - -} // namespace detail - -SidebarWidget::SidebarWidget(QWidget* parent) - : QWidget(parent) -{ - setObjectName(QStringLiteral("sidebar")); - setStyleSheet(QStringLiteral("QWidget#sidebar{background:#f8fafc;}")); - setMinimumWidth(220); - setMaximumWidth(440); - - auto* root = new QVBoxLayout(this); - root->setContentsMargins(10, 14, 10, 17); - root->setSpacing(0); - - auto* header = new QHBoxLayout; - header->setContentsMargins(8, 0, 6, 8); - header->addWidget(section(QStringLiteral("WORK"))); - header->addStretch(); - auto* hide = new QPushButton(QStringLiteral("Hide")); - hide->setProperty("kind", "subtle"); - hide->setFixedSize(52, 24); - header->addWidget(hide); - root->addLayout(header); - - newThread = new QPushButton(QStringLiteral("+ New thread")); - newThread->setFixedHeight(36); - newThread->setStyleSheet(QStringLiteral( - "QPushButton{background:#ffffff;color:#2f6feb;border:1px solid #bfd3f9;border-radius:8px;" - "text-align:left;padding-left:14px;font-weight:600;}" - "QPushButton:hover{background:#e5eeff;border-color:#2f6feb;}" - "QPushButton:disabled{background:#f6f8fb;color:#98a2b3;border-color:#d7dee8;}")); - root->addWidget(newThread); - root->addSpacing(8); - - newFolder = new QPushButton(QStringLiteral("+ New folder")); - newFolder->setObjectName(QStringLiteral("newThreadFolderButton")); - newFolder->setProperty("kind", "subtle"); - newFolder->setFixedHeight(28); - newFolder->setStyleSheet(QStringLiteral( - "QPushButton{text-align:left;padding-left:14px;color:#475467;}" - "QPushButton:hover{background:#eef3fa;color:#1d2633;}")); - root->addWidget(newFolder); - root->addSpacing(10); - - threadTree = new QTreeWidget; - threadTree->setObjectName(QStringLiteral("threadTree")); - threadTree->setHeaderHidden(true); - threadTree->setRootIsDecorated(true); - threadTree->setIndentation(16); - threadTree->setSelectionMode(QAbstractItemView::NoSelection); - threadTree->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - threadTree->setContextMenuPolicy(Qt::CustomContextMenu); - threadTree->setStyleSheet(QStringLiteral( - "QTreeWidget#threadTree{background:transparent;border:0;outline:0;}" - "QTreeWidget#threadTree::item{min-height:24px;border:0;padding:1px 2px;color:#344054;}" - "QTreeWidget#threadTree::item:hover{background:#f1f5fb;border-radius:5px;}" - "QTreeWidget#threadTree::branch{background:transparent;}")); - auto* waiting = new QTreeWidgetItem(threadTree, QStringList{QStringLiteral("Waiting for synchronized state…")}); - waiting->setData(0, itemKindRole, sectionItemKind); - waiting->setForeground(0, QColor(QStringLiteral("#667085"))); - root->addWidget(threadTree, 1); - - auto* divider = new QFrame; - divider->setFixedHeight(1); - divider->setStyleSheet(QStringLiteral("background:#d7dee8;")); - root->addWidget(divider); - root->addSpacing(26); - - auto* serverRow = new QHBoxLayout; - serverRow->setContentsMargins(8, 0, 0, 0); - serverRow->setSpacing(10); - serverDot = new QFrame; - serverDot->setFixedSize(8, 8); - serverDot->setStyleSheet(QStringLiteral("background:#23845a;border-radius:4px;")); - serverRow->addWidget(serverDot, 0, Qt::AlignTop); - auto* serverCopy = new QVBoxLayout; - serverCopy->setSpacing(3); - serverTitle = textLabel(QStringLiteral("Not connected"), "meta"); - serverTitle->setStyleSheet(QStringLiteral("color:#1d2633;font-size:11px;font-weight:500;")); - serverCopy->addWidget(serverTitle); - serverDetail = textLabel(QStringLiteral("Unix frontend"), "meta"); - serverCopy->addWidget(serverDetail); - serverRow->addLayout(serverCopy, 1); - root->addLayout(serverRow); - - connect(hide, &QPushButton::clicked, this, &SidebarWidget::hideRequested); - connect(newThread, &QPushButton::clicked, this, &SidebarWidget::newThreadRequested); - connect(newFolder, &QPushButton::clicked, this, [this] { createFolder(); }); - connect(threadTree, &QTreeWidget::customContextMenuRequested, this, - [this](const QPoint& position) { - auto* item = threadTree->itemAt(position); - if (item && item->data(0, itemKindRole).toString() == folderItemKind) - showFolderContextMenu(item, threadTree->viewport()->mapToGlobal(position)); - }); - const auto storeFolderExpansion = [this](QTreeWidgetItem* item, bool expanded) { - if (!organizationWritable || rebuildingTree || !item - || item->data(0, itemKindRole).toString() != folderItemKind) - return; - const QString folderId = item->data(0, stableIdRole).toString(); - if (!organization.setFolderExpanded(folderId, expanded)) - return; - rebuildingTree = true; - QTreeWidgetItemIterator iterator(threadTree); - while (*iterator) { - auto* candidate = *iterator; - ++iterator; - if (candidate != item && candidate->data(0, stableIdRole).toString() == folderId) - candidate->setExpanded(expanded); - } - rebuildingTree = false; - persistOrganization(); - renderedOrganizationRevision = organization.revision(); - }; - connect(threadTree, &QTreeWidget::itemExpanded, this, - [storeFolderExpansion](QTreeWidgetItem* item) { storeFolderExpansion(item, true); }); - connect(threadTree, &QTreeWidget::itemCollapsed, this, - [storeFolderExpansion](QTreeWidgetItem* item) { storeFolderExpansion(item, false); }); - - QSettings settings; - QDir().mkpath(QFileInfo(settings.fileName()).absolutePath()); - organizationLock = new QLockFile(settings.fileName() - + QStringLiteral(".thread-organization.lock")); - organizationWritable = organizationLock->tryLock(0); - organization.load(settings); - newFolder->setEnabled(organizationWritable); - if (!organizationWritable) - newFolder->setToolTip(QStringLiteral( - "Thread folders are read-only while another CodexUI window owns the organization lock.")); -} - -SidebarWidget::~SidebarWidget() -{ - delete organizationLock; -} - -SidebarWidget::ThreadPresentation SidebarWidget::threadPresentation( - const ai::openai::codex::frontend::client::State& state, - const ai::openai::codex::frontend::client::ThreadState& thread, - bool awaitingResponse) const -{ - const QString id = QString::fromStdString(thread.id.value); - const QString title = boundedRowText( - thread.title && !thread.title->empty() ? QString::fromStdString(*thread.title) : id); - const detail::ThreadUiStatus uiStatus = detail::threadUiStatus( - state, thread, awaitingResponse); - QStringList secondaryParts; - if (uiStatus.archived) - secondaryParts.append(QStringLiteral("Archived")); - else if (!thread.fullyLoaded) - secondaryParts.append(QStringLiteral("Loading")); - else if (uiStatus.running) - secondaryParts.append(QStringLiteral("Running")); - else if (!ai::openai::codex::frontend::client::threadIsIdle(thread)) - secondaryParts.append(QStringLiteral("Ready to resume")); - else - secondaryParts.append(QStringLiteral("Idle")); - secondaryParts.append(thread.orderedTurns.empty() - ? QStringLiteral("Ready for first turn") - : QStringLiteral("%1 turn%2") - .arg(thread.orderedTurns.size()) - .arg(thread.orderedTurns.size() == 1 - ? QString{} - : QStringLiteral("s"))); - if (thread.ephemeral.value_or(false)) - secondaryParts.append(QStringLiteral("Temporary")); - return {id, - title, - boundedRowText(secondaryParts.join(QStringLiteral(" · "))), - threadStatusColor(thread.status), - uiStatus.actions, - uiStatus.running, - uiStatus.awaitingResponse, - uiStatus.archived}; -} - -void SidebarWidget::rebuildRenderedThreadIndex() -{ - renderedThreadIndexes.clear(); - renderedThreadIndexes.reserve(static_cast(renderedThreads.size())); - for (std::size_t index = 0; index < renderedThreads.size(); ++index) - renderedThreadIndexes.insert(renderedThreads[index].id, static_cast(index)); -} - -void SidebarWidget::updateRenderedRows(const QSet& threadIds) -{ - for (const QString& threadId : threadIds) { - auto* row = static_cast(renderedThreadRows.value(threadId, nullptr)); - if (!row) - continue; - const auto index = renderedThreadIndexes.constFind(threadId); - if (index == renderedThreadIndexes.cend() - || *index < 0 || static_cast(*index) >= renderedThreads.size()) - continue; - const ThreadPresentation& presentation = renderedThreads[static_cast(*index)]; - row->updatePresentation(presentation.title, - presentation.details, - presentation.color, - presentation.actions, - presentation.running, - presentation.attention, - presentation.archived); - row->setSelected(presentation.id == renderedSelection); - row->setInteractionEnabled(threadInteractionEnabled); - } -} - -void SidebarWidget::tryAcquireOrganizationLock() -{ - if (organizationWritable || !organizationLock || !organizationLock->tryLock(0)) - return; - - // Another window may have changed the file while this instance was - // read-only. Reload before enabling writes so taking over the lock can - // never overwrite that newer organization with stale in-memory data. - QSettings settings; - organization.load(settings); - organizationWritable = true; - organizationPersistenceFailureReported = false; - newFolder->setEnabled(true); - newFolder->setToolTip({}); - threadsRendered = false; -} - -void SidebarWidget::setThreads(const ai::openai::codex::frontend::client::State& state, - const QString& selectedThreadId, - bool allThreadDiscoveryComplete) -{ - tryAcquireOrganizationLock(); - std::vector presentations; - const auto threads = state.threads(); - QSet threadsAwaitingResponse; - if (state.hasPendingRequestProjection()) { - for (const auto& request : state.pendingRequests()) { - if (request.threadId) - threadsAwaitingResponse.insert(QString::fromStdString(request.threadId->value)); - } - } - if (organizationWritable && allThreadDiscoveryComplete - && state.freshness() - == ai::openai::codex::frontend::client::StateFreshness::Current - && state.hasThreadProjection()) { - QSet retainedThreadIds; - retainedThreadIds.reserve(static_cast(threads.size())); - for (const auto& thread : threads) - retainedThreadIds.insert(QString::fromStdString(thread.id.value)); - if (organization.retainThreadAssignments(retainedThreadIds)) - persistOrganization(); - } - presentations.reserve(threads.size()); - for (const auto& thread : threads) { - const QString id = QString::fromStdString(thread.id.value); - presentations.push_back(threadPresentation( - state, thread, threadsAwaitingResponse.contains(id))); - } - std::stable_partition(presentations.begin(), presentations.end(), - [](const ThreadPresentation& presentation) { - return !presentation.archived; - }); - - if (threadsRendered && presentations == renderedThreads && selectedThreadId == renderedSelection - && renderedOrganizationRevision == organization.revision()) - return; - - bool sameOrder = threadsRendered && presentations.size() == renderedThreads.size(); - for (std::size_t index = 0; sameOrder && index < presentations.size(); ++index) { - sameOrder = presentations[index].id == renderedThreads[index].id - && presentations[index].archived == renderedThreads[index].archived; - } - sameOrder = sameOrder && renderedOrganizationRevision == organization.revision(); - if (sameOrder) { - renderedThreads = presentations; - rebuildRenderedThreadIndex(); - renderedSelection = selectedThreadId; - QSet allThreadIds; - allThreadIds.reserve(static_cast(renderedThreads.size())); - for (const ThreadPresentation& presentation : renderedThreads) - allThreadIds.insert(presentation.id); - if (renderedThreadRows.size() == static_cast(renderedThreads.size())) { - updateRenderedRows(allThreadIds); - return; - } - } - threadsRendered = true; - renderedThreads = std::move(presentations); - rebuildRenderedThreadIndex(); - renderedSelection = selectedThreadId; - renderThreadTree(); -} - -void SidebarWidget::updateThreads( - const ai::openai::codex::frontend::client::State& state, - const QString& selectedThreadId, - bool allThreadDiscoveryComplete, - const QStringList& affectedThreadIds) -{ - tryAcquireOrganizationLock(); - if (!threadsRendered || selectedThreadId != renderedSelection - || renderedOrganizationRevision != organization.revision()) { - setThreads(state, selectedThreadId, allThreadDiscoveryComplete); - return; - } - - QSet uniqueThreadIds(affectedThreadIds.cbegin(), affectedThreadIds.cend()); - if (uniqueThreadIds.isEmpty()) - return; - - QSet threadsAwaitingResponse; - if (state.hasPendingRequestProjection()) { - for (const auto& request : state.pendingRequests()) { - if (request.threadId) - threadsAwaitingResponse.insert(QString::fromStdString(request.threadId->value)); - } - } - - QSet changedRows; - for (const QString& threadId : uniqueThreadIds) { - const auto existingIndex = renderedThreadIndexes.constFind(threadId); - const auto* thread = state.thread(threadId.toStdString()); - if (!thread || existingIndex == renderedThreadIndexes.cend() - || *existingIndex < 0 - || static_cast(*existingIndex) >= renderedThreads.size()) { - // Insertions/removals and archive-boundary changes can alter the - // tree hierarchy and ordering. Reconcile those uncommon cases - // through the authoritative full path. - setThreads(state, selectedThreadId, allThreadDiscoveryComplete); - return; - } - ThreadPresentation& existing = - renderedThreads[static_cast(*existingIndex)]; - ThreadPresentation next = threadPresentation( - state, *thread, threadsAwaitingResponse.contains(threadId)); - if (next.archived != existing.archived) { - setThreads(state, selectedThreadId, allThreadDiscoveryComplete); - return; - } - if (next != existing) { - existing = std::move(next); - changedRows.insert(threadId); - } - } - updateRenderedRows(changedRows); -} - -void SidebarWidget::renderThreadTree() -{ - rebuildingTree = true; - renderedThreadRows.clear(); - threadTree->clear(); - renderedOrganizationRevision = organization.revision(); - - const auto makeSection = [this](const QString& title) { - auto* item = new QTreeWidgetItem(threadTree, QStringList{title}); - item->setData(0, itemKindRole, sectionItemKind); - item->setFirstColumnSpanned(true); - item->setFlags(Qt::ItemIsEnabled); - QFont font = item->font(0); - font.setBold(true); - font.setPointSizeF(qMax(8.0, font.pointSizeF() - 1.0)); - item->setFont(0, font); - item->setForeground(0, QColor(QStringLiteral("#475467"))); - item->setExpanded(true); - return item; - }; - - const auto addGroup = [this, &makeSection](const QString& heading, bool archived) { - const bool hasRows = std::ranges::any_of(renderedThreads, [archived](const auto& presentation) { - return presentation.archived == archived; - }); - - QSet neededFolders; - QSet foldersOccupiedByCurrentThreads; - const auto addFolderAndAncestors = [this](QSet& target, QString folderId) { - for (std::size_t depth = 0; - depth < organization.folders().size() && !folderId.isEmpty(); - ++depth) { - if (target.contains(folderId)) - break; - target.insert(folderId); - const auto* folder = organization.folder(folderId); - folderId = folder ? folder->parentId : QString{}; - } - }; - for (const auto& presentation : renderedThreads) { - const QString folderId = organization.folderForThread(presentation.id); - addFolderAndAncestors(foldersOccupiedByCurrentThreads, folderId); - if (presentation.archived == archived) - addFolderAndAncestors(neededFolders, folderId); - } - if (!archived) { - for (const auto& folder : organization.folders()) { - if (!foldersOccupiedByCurrentThreads.contains(folder.id)) - addFolderAndAncestors(neededFolders, folder.id); - } - } - if (!hasRows && neededFolders.isEmpty()) - return; - - auto* sectionItem = makeSection(heading); - QHash folderItems; - std::function ensureFolder; - ensureFolder = [this, sectionItem, &folderItems, &neededFolders, &ensureFolder](const QString& folderId) { - if (folderId.isEmpty()) - return sectionItem; - if (auto* existing = folderItems.value(folderId, nullptr)) - return existing; - const auto* folder = organization.folder(folderId); - if (!folder) - return sectionItem; - QTreeWidgetItem* parent = sectionItem; - if (neededFolders.contains(folder->parentId)) - parent = ensureFolder(folder->parentId); - auto* item = new QTreeWidgetItem(parent, QStringList{folder->name}); - item->setData(0, itemKindRole, folderItemKind); - item->setData(0, stableIdRole, folder->id); - item->setFlags(Qt::ItemIsEnabled); - item->setIcon(0, style()->standardIcon(QStyle::SP_DirIcon)); - item->setToolTip(0, plainTooltip(organization.folderPath(folder->id))); - item->setExpanded(folder->expanded); - folderItems.insert(folderId, item); - return item; - }; - for (const auto& folder : organization.folders()) { - if (neededFolders.contains(folder.id)) - ensureFolder(folder.id); - } - - for (const auto& presentation : renderedThreads) { - if (presentation.archived != archived) - continue; - QTreeWidgetItem* parent = sectionItem; - const QString folderId = organization.folderForThread(presentation.id); - if (!folderId.isEmpty() && neededFolders.contains(folderId)) - parent = ensureFolder(folderId); - auto* item = new QTreeWidgetItem(parent); - item->setFlags(Qt::ItemIsEnabled); - item->setSizeHint(0, QSize(0, 64)); - auto* row = new ThreadRow(presentation.id, - presentation.title, - presentation.details, - presentation.color, - presentation.actions, - presentation.running, - presentation.attention, - presentation.archived, - threadTree); - renderedThreadRows.insert(presentation.id, row); - row->setSelected(presentation.id == renderedSelection); - row->setInteractionEnabled(threadInteractionEnabled); - row->clicked = [this](ThreadRow* selected) { emit threadSelected(selected->id()); }; - row->contextRequested = [this](ThreadRow* source, const QPoint& globalPosition) { - source->setContextOpen(true); - auto* menu = new QMenu(this); - const QString stableId = source->id(); - const QPointer guardedSource(source); - const ThreadActionAvailability available = source->availability(); - const bool running = source->isRunning(); - const bool archived = source->isArchived(); - const auto add = [menu, this, stableId](const QString& text, - ThreadAction action, - bool enabled = true) { - QAction* actionItem = menu->addAction(text); - actionItem->setEnabled(enabled); - connect(actionItem, &QAction::triggered, this, [this, stableId, action] { - emit threadActionRequested(stableId, action); - }); - return actionItem; - }; - add(QStringLiteral("Open"), ThreadAction::Open, available.open); - add(QStringLiteral("Rename…"), ThreadAction::Rename, available.rename); - add(QStringLiteral("Fork…"), ThreadAction::Fork, available.fork); - - QMenu* moveMenu = menu->addMenu(QStringLiteral("Move to folder")); - moveMenu->setEnabled(organizationWritable); - const QString currentFolder = organization.folderForThread(stableId); - QAction* rootAction = moveMenu->addAction(QStringLiteral("Threads root")); - rootAction->setCheckable(true); - rootAction->setChecked(currentFolder.isEmpty()); - connect(rootAction, &QAction::triggered, this, - [this, stableId] { moveThread(stableId, {}); }); - if (!organization.folders().empty()) - moveMenu->addSeparator(); - for (const auto& folder : organization.folders()) { - QAction* folderAction = moveMenu->addAction(menuLabel(organization.folderPath(folder.id))); - folderAction->setCheckable(true); - folderAction->setChecked(currentFolder == folder.id); - connect(folderAction, &QAction::triggered, this, - [this, stableId, folderId = folder.id] { - moveThread(stableId, folderId); - }); - } - - menu->addSeparator(); - if (running) - add(QStringLiteral("Interrupt"), ThreadAction::Interrupt, available.interrupt); - else - add(QStringLiteral("Resume with options…\tadvanced"), - ThreadAction::ResumeWithOptions, - available.resumeWithOptions); - menu->addSeparator(); - if (archived) - add(QStringLiteral("Unarchive"), ThreadAction::Unarchive, available.unarchive); - else - add(running ? QStringLiteral("Archive\trunning") : QStringLiteral("Archive"), - ThreadAction::Archive, - available.archive); - QAction* remove = add(running ? QStringLiteral("Delete…\trunning") - : QStringLiteral("Delete…"), - ThreadAction::Delete, - available.remove); - QPixmap destructiveIcon = style()->standardIcon(QStyle::SP_TrashIcon).pixmap(16, 16); - if (!destructiveIcon.isNull()) { - QPainter painter(&destructiveIcon); - painter.setCompositionMode(QPainter::CompositionMode_SourceIn); - painter.fillRect(destructiveIcon.rect(), QColor(QStringLiteral("#b83a3a"))); - } - remove->setIcon(QIcon(destructiveIcon)); - menu->addSeparator(); - QMenu* moreMenu = menu->addMenu(QStringLiteral("More")); - QAction* copyId = moreMenu->addAction(QStringLiteral("Copy thread ID")); - connect(copyId, &QAction::triggered, this, [this, stableId] { - emit threadActionRequested(stableId, ThreadAction::CopyId); - }); - connect(menu, &QMenu::aboutToHide, this, [guardedSource, menu] { - if (guardedSource) - guardedSource->setContextOpen(false); - menu->deleteLater(); - }); - menu->popup(globalPosition); - }; - threadTree->setItemWidget(item, 0, row); - } - sectionItem->setExpanded(true); - }; - addGroup(QStringLiteral("ACTIVE"), false); - addGroup(QStringLiteral("ARCHIVED"), true); - if (threadTree->topLevelItemCount() == 0) { - auto* active = makeSection(QStringLiteral("ACTIVE")); - auto* empty = new QTreeWidgetItem(active, QStringList{QStringLiteral("No synchronized threads")}); - empty->setFlags(Qt::ItemIsEnabled); - empty->setForeground(0, QColor(QStringLiteral("#667085"))); - } - rebuildingTree = false; -} - -void SidebarWidget::persistOrganization() -{ - if (!organizationWritable) - return; - QSettings settings; - const bool encoded = organization.save(settings); - if (encoded) - settings.sync(); - if (encoded && settings.status() == QSettings::NoError) - return; - qWarning("CodexUI could not persist thread-folder organization"); - organizationWritable = false; - newFolder->setEnabled(false); - newFolder->setToolTip(QStringLiteral( - "Thread-folder changes are read-only because local settings could not be saved.")); - if (!organizationPersistenceFailureReported) { - organizationPersistenceFailureReported = true; - auto* message = new QMessageBox( - QMessageBox::Warning, - QStringLiteral("Thread folders not saved"), - QStringLiteral( - "CodexUI could not save the local thread-folder organization. The current in-memory arrangement may be lost when this window closes."), - QMessageBox::Ok, - this); - message->setTextFormat(Qt::PlainText); - message->setAttribute(Qt::WA_DeleteOnClose); - message->open(); - } -} - -void SidebarWidget::createFolder(const QString& parentFolderId) -{ - if (!organizationWritable) - return; - auto* dialog = new QInputDialog(this); - dialog->setObjectName(QStringLiteral("threadFolderNameDialog")); - dialog->setAttribute(Qt::WA_DeleteOnClose); - dialog->setWindowTitle(parentFolderId.isEmpty() ? QStringLiteral("New folder") - : QStringLiteral("New subfolder")); - dialog->setLabelText(QStringLiteral("Folder name")); - dialog->setInputMode(QInputDialog::TextInput); - connect(dialog, &QInputDialog::textValueSelected, this, - [this, parentFolderId](const QString& name) { - if (organization.createFolder(name, parentFolderId).isEmpty()) { - auto* message = new QMessageBox(QMessageBox::Warning, - QStringLiteral("Folder not created"), - QStringLiteral("Use a non-empty name that is unique in this folder."), - QMessageBox::Ok, - this); - message->setAttribute(Qt::WA_DeleteOnClose); - message->open(); - return; - } - persistOrganization(); - renderThreadTree(); - }); - dialog->open(); -} - -void SidebarWidget::renameFolder(const QString& folderId) -{ - if (!organizationWritable) - return; - const auto* folder = organization.folder(folderId); - if (!folder) - return; - auto* dialog = new QInputDialog(this); - dialog->setObjectName(QStringLiteral("threadFolderNameDialog")); - dialog->setAttribute(Qt::WA_DeleteOnClose); - dialog->setWindowTitle(QStringLiteral("Rename folder")); - dialog->setLabelText(QStringLiteral("Folder name")); - dialog->setInputMode(QInputDialog::TextInput); - dialog->setTextValue(folder->name); - connect(dialog, &QInputDialog::textValueSelected, this, - [this, folderId](const QString& name) { - const auto* current = organization.folder(folderId); - if (!current || current->name == name.trimmed()) - return; - if (!organization.renameFolder(folderId, name)) { - auto* message = new QMessageBox(QMessageBox::Warning, - QStringLiteral("Folder not renamed"), - QStringLiteral("Use a non-empty name that is unique in this folder."), - QMessageBox::Ok, - this); - message->setAttribute(Qt::WA_DeleteOnClose); - message->open(); - return; - } - persistOrganization(); - renderThreadTree(); - }); - dialog->open(); -} - -void SidebarWidget::moveFolder(const QString& folderId, const QString& parentFolderId) -{ - if (!organizationWritable) - return; - if (!organization.moveFolder(folderId, parentFolderId)) - return; - persistOrganization(); - renderThreadTree(); -} - -void SidebarWidget::deleteFolder(const QString& folderId) -{ - if (!organizationWritable) - return; - const auto* folder = organization.folder(folderId); - if (!folder) - return; - const QString destination = folder->parentId.isEmpty() - ? QStringLiteral("the threads root") - : organization.folderPath(folder->parentId); - auto* message = new QMessageBox(QMessageBox::Warning, - QStringLiteral("Delete folder?"), - QStringLiteral("Delete \"%1\"? Its threads and subfolders will move to %2. No threads will be deleted.") - .arg(folder->name, destination), - QMessageBox::Yes | QMessageBox::Cancel, - this); - message->setObjectName(QStringLiteral("deleteThreadFolderDialog")); - message->setTextFormat(Qt::PlainText); - message->setAttribute(Qt::WA_DeleteOnClose); - message->setDefaultButton(QMessageBox::Cancel); - connect(message, &QMessageBox::finished, this, [this, folderId](int result) { - if (result != QMessageBox::Yes || !organization.removeFolderAndPromoteContents(folderId)) - return; - persistOrganization(); - renderThreadTree(); - }); - message->open(); -} - -void SidebarWidget::moveThread(const QString& threadId, const QString& folderId) -{ - if (!organizationWritable) - return; - if (!organization.moveThread(threadId, folderId)) - return; - persistOrganization(); - renderThreadTree(); -} - -void SidebarWidget::showFolderContextMenu(QTreeWidgetItem* item, const QPoint& globalPosition) -{ - if (!organizationWritable) - return; - const QString folderId = item ? item->data(0, stableIdRole).toString() : QString{}; - const auto* folder = organization.folder(folderId); - if (!folder) - return; - auto* menu = new QMenu(this); - QAction* create = menu->addAction(QStringLiteral("New subfolder…")); - connect(create, &QAction::triggered, this, [this, folderId] { createFolder(folderId); }); - QAction* rename = menu->addAction(QStringLiteral("Rename…")); - connect(rename, &QAction::triggered, this, [this, folderId] { renameFolder(folderId); }); - - QMenu* moveMenu = menu->addMenu(QStringLiteral("Move folder to")); - const QSet movableParents = organization.movableFolderParents(folderId); - QAction* root = moveMenu->addAction(QStringLiteral("Threads root")); - root->setCheckable(true); - root->setChecked(folder->parentId.isEmpty()); - root->setEnabled(folder->parentId.isEmpty() || movableParents.contains(QString{})); - connect(root, &QAction::triggered, this, [this, folderId] { moveFolder(folderId, {}); }); - if (!organization.folders().empty()) - moveMenu->addSeparator(); - for (const auto& candidate : organization.folders()) { - if (candidate.id == folderId) - continue; - QAction* target = moveMenu->addAction(menuLabel(organization.folderPath(candidate.id))); - target->setCheckable(true); - target->setChecked(folder->parentId == candidate.id); - target->setEnabled(folder->parentId == candidate.id - || movableParents.contains(candidate.id)); - connect(target, &QAction::triggered, this, - [this, folderId, parentId = candidate.id] { moveFolder(folderId, parentId); }); - } - - menu->addSeparator(); - QAction* remove = menu->addAction(style()->standardIcon(QStyle::SP_TrashIcon), - QStringLiteral("Delete folder…")); - connect(remove, &QAction::triggered, this, [this, folderId] { deleteFolder(folderId); }); - connect(menu, &QMenu::aboutToHide, menu, &QObject::deleteLater); - menu->popup(globalPosition); -} - -void SidebarWidget::setConnectionStatus(const QString& title, const QString& connectionDetail, const QString& color) -{ - serverTitle->setText(title); - serverDetail->setText(connectionDetail); - serverDot->setStyleSheet(QStringLiteral("background:%1;border-radius:4px;").arg(color)); -} - -void SidebarWidget::setNewThreadEnabled(bool enabled) -{ - newThread->setEnabled(enabled); -} - -void SidebarWidget::setThreadInteractionEnabled(bool enabled) -{ - if (threadInteractionEnabled == enabled) - return; - threadInteractionEnabled = enabled; - QTreeWidgetItemIterator iterator(threadTree); - while (*iterator) { - auto* item = *iterator; - ++iterator; - if (auto* row = dynamic_cast(threadTree->itemWidget(item, 0))) - row->setInteractionEnabled(enabled); - } -} - -} // namespace codexui diff --git a/src/ui/SidebarWidget.h b/src/ui/SidebarWidget.h deleted file mode 100644 index 6d139a0..0000000 --- a/src/ui/SidebarWidget.h +++ /dev/null @@ -1,195 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#ifndef CODEXUI_UI_SIDEBARWIDGET_H -#define CODEXUI_UI_SIDEBARWIDGET_H - -#include -#include -#include -#include -#include - -#include - -class QFrame; -class QLabel; -class QLockFile; -class QPushButton; -class QSettings; -class QTreeWidget; -class QTreeWidgetItem; - -namespace ai::openai::codex::frontend::client { -class State; -struct ThreadState; -} - -namespace codexui { - -enum class ThreadAction { - Open, - Rename, - Fork, - Interrupt, - ResumeWithOptions, - Archive, - Unarchive, - Delete, - CopyId, -}; - -struct ThreadActionAvailability { - bool open = true; - bool rename = true; - bool fork = true; - bool interrupt = false; - bool resumeWithOptions = true; - bool archive = false; - bool unarchive = false; - bool remove = true; - - bool operator==(const ThreadActionAvailability&) const = default; -}; - -namespace detail { -struct ThreadUiStatus { - ThreadActionAvailability actions; - bool running = false; - bool awaitingResponse = false; - bool archived = false; - - bool operator==(const ThreadUiStatus&) const = default; -}; - -[[nodiscard]] ThreadUiStatus -threadUiStatus(const ai::openai::codex::frontend::client::State& state, - const ai::openai::codex::frontend::client::ThreadState& thread, - bool awaitingResponse = false); - -[[nodiscard]] ThreadActionAvailability -threadActionAvailability(const ai::openai::codex::frontend::client::State& state, - const ai::openai::codex::frontend::client::ThreadState& thread); - -struct ThreadFolder { - QString id; - QString name; - QString parentId; - bool expanded = true; - - bool operator==(const ThreadFolder&) const = default; -}; - -class ThreadOrganization -{ -public: - void load(QSettings& settings); - [[nodiscard]] bool save(QSettings& settings) const; - - [[nodiscard]] const std::vector& folders() const noexcept; - [[nodiscard]] const ThreadFolder* folder(const QString& folderId) const noexcept; - [[nodiscard]] QString folderForThread(const QString& threadId) const; - [[nodiscard]] QString folderPath(const QString& folderId) const; - [[nodiscard]] quint64 revision() const noexcept; - - [[nodiscard]] QString createFolder(const QString& name, const QString& parentId = {}); - [[nodiscard]] bool renameFolder(const QString& folderId, const QString& name); - [[nodiscard]] bool moveFolder(const QString& folderId, const QString& parentId); - [[nodiscard]] bool removeFolderAndPromoteContents(const QString& folderId); - [[nodiscard]] bool moveThread(const QString& threadId, const QString& folderId); - [[nodiscard]] bool retainThreadAssignments(const QSet& threadIds); - [[nodiscard]] bool setFolderExpanded(const QString& folderId, bool expanded); - [[nodiscard]] QSet movableFolderParents(const QString& folderId) const; - [[nodiscard]] bool canMoveFolder(const QString& folderId, const QString& parentId) const; - -private: - [[nodiscard]] bool validName(const QString& name, - const QString& parentId, - const QString& excludedFolderId = {}) const; - [[nodiscard]] bool isDescendantOf(const QString& folderId, - const QString& possibleAncestorId) const; - void normalize(); - - std::vector storedFolders; - QHash threadFolders; - qsizetype currentStorageBytes = 0; - quint64 currentRevision = 0; -}; -} - -class SidebarWidget : public QWidget -{ - Q_OBJECT - -public: - explicit SidebarWidget(QWidget* parent = nullptr); - ~SidebarWidget() override; - void setThreads(const ai::openai::codex::frontend::client::State& state, - const QString& selectedThreadId, - bool allThreadDiscoveryComplete); - void updateThreads(const ai::openai::codex::frontend::client::State& state, - const QString& selectedThreadId, - bool allThreadDiscoveryComplete, - const QStringList& affectedThreadIds); - void setConnectionStatus(const QString& title, const QString& detail, const QString& color); - void setNewThreadEnabled(bool enabled); - void setThreadInteractionEnabled(bool enabled); - -signals: - void hideRequested(); - void newThreadRequested(); - void threadSelected(const QString& threadId); - void threadActionRequested(const QString& threadId, codexui::ThreadAction action); - -private: - struct ThreadPresentation { - QString id; - QString title; - QString details; - QString color; - ThreadActionAvailability actions; - bool running = false; - bool attention = false; - bool archived = false; - - bool operator==(const ThreadPresentation&) const = default; - }; - - void renderThreadTree(); - [[nodiscard]] ThreadPresentation threadPresentation( - const ai::openai::codex::frontend::client::State& state, - const ai::openai::codex::frontend::client::ThreadState& thread, - bool awaitingResponse) const; - void rebuildRenderedThreadIndex(); - void updateRenderedRows(const QSet& threadIds); - void tryAcquireOrganizationLock(); - void persistOrganization(); - void createFolder(const QString& parentFolderId = {}); - void renameFolder(const QString& folderId); - void moveFolder(const QString& folderId, const QString& parentFolderId); - void deleteFolder(const QString& folderId); - void moveThread(const QString& threadId, const QString& folderId); - void showFolderContextMenu(QTreeWidgetItem* item, const QPoint& globalPosition); - - QTreeWidget* threadTree = nullptr; - QFrame* serverDot = nullptr; - QLabel* serverTitle = nullptr; - QLabel* serverDetail = nullptr; - QPushButton* newThread = nullptr; - QPushButton* newFolder = nullptr; - std::vector renderedThreads; - QHash renderedThreadIndexes; - QHash renderedThreadRows; - QString renderedSelection; - detail::ThreadOrganization organization; - quint64 renderedOrganizationRevision = 0; - bool threadsRendered = false; - bool threadInteractionEnabled = false; - bool rebuildingTree = false; - QLockFile* organizationLock = nullptr; - bool organizationWritable = false; - bool organizationPersistenceFailureReported = false; -}; - -} // namespace codexui - -#endif // CODEXUI_UI_SIDEBARWIDGET_H diff --git a/src/ui/ThreadSetupDialog.cpp b/src/ui/ThreadSetupDialog.cpp deleted file mode 100644 index 1125e82..0000000 --- a/src/ui/ThreadSetupDialog.cpp +++ /dev/null @@ -1,417 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "ui/ThreadSetupDialog.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace codexui { -namespace { - -QLabel* plainLabel(const QString& value, const char* kind = nullptr) -{ - auto* result = new QLabel(value); - result->setTextFormat(Qt::PlainText); - if (kind) - result->setProperty("kind", kind); - return result; -} - -QLabel* wrappedPlainLabel(const QString& value, const char* kind = nullptr) -{ - auto* result = plainLabel(value, kind); - result->setWordWrap(true); - return result; -} - -QString dialogTitle(ThreadSetupDialog::Mode mode) -{ - switch (mode) { - case ThreadSetupDialog::Mode::NewThread: - return QStringLiteral("New Thread"); - case ThreadSetupDialog::Mode::ForkThread: - return QStringLiteral("Fork Thread"); - case ThreadSetupDialog::Mode::ResumeWithOptions: - return QStringLiteral("Resume with options"); - } - return {}; -} - -QString dialogDescription(ThreadSetupDialog::Mode mode) -{ - switch (mode) { - case ThreadSetupDialog::Mode::NewThread: - return QStringLiteral("Set the thread's foundational context. Execution settings belong to the upcoming turn."); - case ThreadSetupDialog::Mode::ForkThread: - return QStringLiteral("Create a new thread from the selected history. Blank instruction fields keep the inherited context."); - case ThreadSetupDialog::Mode::ResumeWithOptions: - return QStringLiteral("Resume the selected thread with explicit foundational-instruction overrides."); - } - return {}; -} - -QString submitText(ThreadSetupDialog::Mode mode) -{ - switch (mode) { - case ThreadSetupDialog::Mode::NewThread: - return QStringLiteral("Create thread"); - case ThreadSetupDialog::Mode::ForkThread: - return QStringLiteral("Fork thread"); - case ThreadSetupDialog::Mode::ResumeWithOptions: - return QStringLiteral("Resume thread"); - } - return {}; -} - -FoundationalInstructionsEditor::Context editorContext(ThreadSetupDialog::Mode mode) -{ - switch (mode) { - case ThreadSetupDialog::Mode::NewThread: - return FoundationalInstructionsEditor::Context::NewThread; - case ThreadSetupDialog::Mode::ForkThread: - return FoundationalInstructionsEditor::Context::ForkThread; - case ThreadSetupDialog::Mode::ResumeWithOptions: - return FoundationalInstructionsEditor::Context::ResumeWithOptions; - } - return FoundationalInstructionsEditor::Context::NewThread; -} - -QString localStyleSheet() -{ - return QStringLiteral(R"QSS( - QDialog#threadSetupDialog { - background: #ffffff; - color: #1d2633; - } - QDialog#threadSetupDialog QWidget { - color: #1d2633; - font-size: 12px; - } - QDialog#threadSetupDialog QLabel { - background: transparent; - color: #1d2633; - font-weight: 400; - } - QDialog#threadSetupDialog QLabel[kind="dialogTitle"] { - color: #1d2633; - font-size: 20px; - font-weight: 600; - } - QDialog#threadSetupDialog QLabel[kind="description"] { - color: #667085; - font-size: 12px; - } - QDialog#threadSetupDialog QLabel[kind="fieldLabel"] { - color: #344054; - font-size: 11px; - font-weight: 600; - } - QDialog#threadSetupDialog QLabel[kind="fieldHelp"] { - color: #667085; - font-size: 10px; - } - QDialog#threadSetupDialog QLineEdit, - QDialog#threadSetupDialog QPlainTextEdit { - background: #ffffff; - color: #1d2633; - border: 2px solid #b9c4d2; - border-radius: 7px; - selection-background-color: #dce8ff; - selection-color: #1d2633; - } - QDialog#threadSetupDialog QLineEdit { - min-height: 36px; - padding: 0 10px; - } - QDialog#threadSetupDialog QPlainTextEdit { - padding: 9px 10px; - } - QDialog#threadSetupDialog QLineEdit:focus, - QDialog#threadSetupDialog QPlainTextEdit:focus { - border: 2px solid #2f6feb; - } - QDialog#threadSetupDialog QCheckBox { - color: #1d2633; - spacing: 9px; - font-size: 12px; - font-weight: 600; - } - QDialog#threadSetupDialog QFrame#temporaryThreadPanel { - background: #f8fafc; - border: 1px solid #d7dee8; - border-radius: 8px; - } - QDialog#threadSetupDialog QFrame#resumeOptionsWarning { - background: #fff8e8; - border: 1px solid #eccb86; - border-radius: 8px; - } - QDialog#threadSetupDialog QLabel[kind="warningTitle"] { - color: #855600; - font-size: 11px; - font-weight: 600; - } - QDialog#threadSetupDialog QLabel[kind="warningBody"] { - color: #765a24; - font-size: 10px; - } - QDialog#threadSetupDialog QPushButton, - QDialog#threadSetupDialog QToolButton { - min-height: 36px; - border: 1px solid #b9c4d2; - border-radius: 7px; - padding: 0 14px; - background: #ffffff; - color: #344054; - font-size: 11px; - font-weight: 600; - } - QDialog#threadSetupDialog QPushButton:hover, - QDialog#threadSetupDialog QToolButton:hover { - background: #f1f5fb; - } - QDialog#threadSetupDialog QPushButton[kind="primary"] { - color: #ffffff; - background: #2f6feb; - border-color: #2f6feb; - } - QDialog#threadSetupDialog QPushButton[kind="primary"]:hover { - background: #245fce; - border-color: #245fce; - } - QDialog#threadSetupDialog QToolButton#threadSetupClose { - min-width: 32px; - max-width: 32px; - padding: 0; - border-color: transparent; - font-size: 18px; - font-weight: 400; - } - )QSS"); -} - -} // namespace - -FoundationalInstructionsEditor::FoundationalInstructionsEditor(Context context, QWidget* parent) - : QWidget(parent) -{ - setObjectName(QStringLiteral("foundationalInstructionsEditor")); - - auto* root = new QVBoxLayout(this); - root->setContentsMargins(0, 0, 0, 0); - root->setSpacing(8); - - auto* baseLabel = plainLabel(QStringLiteral("Base Instructions"), "fieldLabel"); - root->addWidget(baseLabel); - baseInstructionsEdit = new QPlainTextEdit; - baseInstructionsEdit->setObjectName(QStringLiteral("baseInstructionsEdit")); - baseInstructionsEdit->setAccessibleName(QStringLiteral("Base Instructions")); - baseInstructionsEdit->setMinimumHeight(102); - baseInstructionsEdit->setMaximumHeight(132); - root->addWidget(baseInstructionsEdit); - - auto* baseHelp = wrappedPlainLabel( - QStringLiteral("Fundamental Codex behavior for this thread. Leave blank to avoid an override."), - "fieldHelp"); - root->addWidget(baseHelp); - root->addSpacing(4); - - auto* developerLabel = plainLabel(QStringLiteral("Developer Instructions"), "fieldLabel"); - root->addWidget(developerLabel); - developerInstructionsEdit = new QPlainTextEdit; - developerInstructionsEdit->setObjectName(QStringLiteral("developerInstructionsEdit")); - developerInstructionsEdit->setAccessibleName(QStringLiteral("Developer Instructions")); - developerInstructionsEdit->setMinimumHeight(102); - developerInstructionsEdit->setMaximumHeight(132); - root->addWidget(developerInstructionsEdit); - - setFocusProxy(baseInstructionsEdit); - - auto* developerHelp = wrappedPlainLabel( - QStringLiteral("Project, workflow, architecture, and testing constraints. Leave blank to avoid an override."), - "fieldHelp"); - root->addWidget(developerHelp); - - switch (context) { - case Context::NewThread: - baseInstructionsEdit->setPlaceholderText(QStringLiteral("Use the Codex default when empty")); - developerInstructionsEdit->setPlaceholderText(QStringLiteral("Use the Codex default when empty")); - break; - case Context::ForkThread: - baseInstructionsEdit->setPlaceholderText(QStringLiteral("Keep inherited Base Instructions when empty")); - developerInstructionsEdit->setPlaceholderText(QStringLiteral("Keep inherited Developer Instructions when empty")); - break; - case Context::ResumeWithOptions: - baseInstructionsEdit->setPlaceholderText(QStringLiteral("Keep current Base Instructions when empty")); - developerInstructionsEdit->setPlaceholderText(QStringLiteral("Keep current Developer Instructions when empty")); - break; - } -} - -FoundationalInstructionsDraft FoundationalInstructionsEditor::value() const -{ - return {baseInstructionsEdit->toPlainText(), developerInstructionsEdit->toPlainText()}; -} - -void FoundationalInstructionsEditor::clear() -{ - baseInstructionsEdit->clear(); - developerInstructionsEdit->clear(); -} - -ThreadSetupDialog::ThreadSetupDialog(Mode mode, QWidget* parent) - : QDialog(parent) - , currentMode(mode) -{ - setObjectName(QStringLiteral("threadSetupDialog")); - setProperty("mode", static_cast(mode)); - setWindowTitle(dialogTitle(mode)); - setWindowModality(Qt::WindowModal); - setModal(true); - setSizeGripEnabled(false); - setMinimumWidth(520); - resize(664, mode == Mode::ResumeWithOptions ? 590 : 680); - setStyleSheet(localStyleSheet()); - - auto* root = new QVBoxLayout(this); - root->setContentsMargins(28, 24, 28, 24); - root->setSpacing(18); - - auto* header = new QHBoxLayout; - header->setSpacing(12); - auto* heading = new QVBoxLayout; - heading->setSpacing(5); - auto* title = plainLabel(dialogTitle(mode), "dialogTitle"); - title->setObjectName(QStringLiteral("threadSetupTitle")); - heading->addWidget(title); - auto* description = wrappedPlainLabel(dialogDescription(mode), "description"); - description->setObjectName(QStringLiteral("threadSetupSubtitle")); - heading->addWidget(description); - header->addLayout(heading, 1); - - auto* closeButton = new QToolButton; - closeButton->setObjectName(QStringLiteral("threadSetupClose")); - closeButton->setText(QString(QChar(0x00d7))); - closeButton->setAccessibleName(QStringLiteral("Close")); - connect(closeButton, &QToolButton::clicked, this, &QDialog::reject); - header->addWidget(closeButton, 0, Qt::AlignTop); - root->addLayout(header); - - auto* bodyScroll = new QScrollArea; - bodyScroll->setObjectName(QStringLiteral("threadSetupBodyScroll")); - bodyScroll->setWidgetResizable(true); - bodyScroll->setFrameShape(QFrame::NoFrame); - bodyScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - auto* body = new QWidget; - auto* bodyLayout = new QVBoxLayout(body); - bodyLayout->setContentsMargins(0, 0, 0, 0); - bodyLayout->setSpacing(18); - bodyLayout->setSizeConstraint(QLayout::SetMinimumSize); - bodyScroll->setWidget(body); - root->addWidget(bodyScroll, 1); - - if (mode == Mode::ResumeWithOptions) { - auto* warning = new QFrame; - warning->setObjectName(QStringLiteral("resumeOptionsWarning")); - auto* warningLayout = new QVBoxLayout(warning); - warningLayout->setContentsMargins(12, 10, 12, 10); - warningLayout->setSpacing(3); - warningLayout->addWidget(plainLabel(QStringLiteral("Expert operation"), "warningTitle")); - warningLayout->addWidget(wrappedPlainLabel( - QStringLiteral("This resumes the same historical thread while changing its foundational context. Normal opening resumes automatically without this dialog."), - "warningBody")); - bodyLayout->addWidget(warning); - } - - if (mode != Mode::ResumeWithOptions) { - auto* nameGroup = new QVBoxLayout; - nameGroup->setSpacing(7); - nameGroup->addWidget(plainLabel(QStringLiteral("Thread name (optional)"), "fieldLabel")); - nameEdit = new QLineEdit; - nameEdit->setObjectName(QStringLiteral("threadNameEdit")); - nameEdit->setAccessibleName(QStringLiteral("Thread name")); - nameEdit->setPlaceholderText(QStringLiteral("Derived from the first turn when empty")); - nameGroup->addWidget(nameEdit); - bodyLayout->addLayout(nameGroup); - } - - instructionsEditor = new FoundationalInstructionsEditor(editorContext(mode)); - bodyLayout->addWidget(instructionsEditor); - - if (mode != Mode::ResumeWithOptions) { - auto* temporaryPanel = new QFrame; - temporaryPanel->setObjectName(QStringLiteral("temporaryThreadPanel")); - auto* temporaryLayout = new QVBoxLayout(temporaryPanel); - temporaryLayout->setContentsMargins(12, 10, 12, 10); - temporaryLayout->setSpacing(3); - temporaryCheckBox = new QCheckBox(QStringLiteral("Temporary thread")); - temporaryCheckBox->setObjectName(QStringLiteral("temporaryThreadCheckBox")); - temporaryCheckBox->setAccessibleName(QStringLiteral("Temporary thread")); - temporaryLayout->addWidget(temporaryCheckBox); - temporaryLayout->addWidget(wrappedPlainLabel( - QStringLiteral("Not persisted to normal thread history."), "fieldHelp")); - bodyLayout->addWidget(temporaryPanel); - } - bodyLayout->addStretch(); - - auto* buttons = new QHBoxLayout; - buttons->setSpacing(10); - buttons->addStretch(); - auto* cancelButton = new QPushButton(QStringLiteral("Cancel")); - cancelButton->setObjectName(QStringLiteral("threadSetupCancel")); - connect(cancelButton, &QPushButton::clicked, this, &QDialog::reject); - buttons->addWidget(cancelButton); - submitButton = new QPushButton(submitText(mode)); - submitButton->setObjectName(QStringLiteral("threadSetupSubmit")); - submitButton->setProperty("kind", "primary"); - submitButton->setDefault(true); - connect(submitButton, &QPushButton::clicked, this, &QDialog::accept); - buttons->addWidget(submitButton); - root->addLayout(buttons); - - if (nameEdit) - nameEdit->setFocus(Qt::OtherFocusReason); - else - instructionsEditor->setFocus(Qt::OtherFocusReason); -} - -ThreadSetupDialog::Mode ThreadSetupDialog::mode() const noexcept -{ - return currentMode; -} - -ThreadSetupResult ThreadSetupDialog::result() const -{ - const FoundationalInstructionsDraft instructions = instructionsEditor->value(); - switch (currentMode) { - case Mode::NewThread: - return NewThreadSetup{nameEdit->text(), instructions, temporaryCheckBox->isChecked()}; - case Mode::ForkThread: - return ForkThreadSetup{nameEdit->text(), instructions, temporaryCheckBox->isChecked()}; - case Mode::ResumeWithOptions: - return ResumeWithOptionsSetup{instructions}; - } - return ResumeWithOptionsSetup{}; -} - -void ThreadSetupDialog::setSuggestedThreadName(const QString& name) -{ - if (nameEdit) - nameEdit->setText(name); -} - -void ThreadSetupDialog::setTemporary(bool temporary) -{ - if (temporaryCheckBox) - temporaryCheckBox->setChecked(temporary); -} - -} // namespace codexui diff --git a/src/ui/ThreadSetupDialog.h b/src/ui/ThreadSetupDialog.h deleted file mode 100644 index fad8e6b..0000000 --- a/src/ui/ThreadSetupDialog.h +++ /dev/null @@ -1,88 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#ifndef CODEXUI_UI_THREADSETUPDIALOG_H -#define CODEXUI_UI_THREADSETUPDIALOG_H - -#include -#include -#include - -#include - -class QCheckBox; -class QLineEdit; -class QPlainTextEdit; -class QPushButton; - -namespace codexui { - -struct FoundationalInstructionsDraft { - QString baseInstructions; - QString developerInstructions; - - bool operator==(const FoundationalInstructionsDraft&) const = default; -}; - -struct NewThreadSetup { - QString name; - FoundationalInstructionsDraft instructions; - bool temporary = false; - - bool operator==(const NewThreadSetup&) const = default; -}; - -struct ForkThreadSetup { - QString name; - FoundationalInstructionsDraft instructions; - bool temporary = false; - - bool operator==(const ForkThreadSetup&) const = default; -}; - -struct ResumeWithOptionsSetup { - FoundationalInstructionsDraft instructions; - - bool operator==(const ResumeWithOptionsSetup&) const = default; -}; - -using ThreadSetupResult = std::variant; - -class FoundationalInstructionsEditor final : public QWidget -{ -public: - enum class Context { NewThread, ForkThread, ResumeWithOptions }; - - explicit FoundationalInstructionsEditor(Context context, QWidget* parent = nullptr); - - [[nodiscard]] FoundationalInstructionsDraft value() const; - void clear(); - -private: - QPlainTextEdit* baseInstructionsEdit = nullptr; - QPlainTextEdit* developerInstructionsEdit = nullptr; -}; - -class ThreadSetupDialog final : public QDialog -{ -public: - enum class Mode { NewThread, ForkThread, ResumeWithOptions }; - - explicit ThreadSetupDialog(Mode mode, QWidget* parent = nullptr); - - [[nodiscard]] Mode mode() const noexcept; - [[nodiscard]] ThreadSetupResult result() const; - - void setSuggestedThreadName(const QString& name); - void setTemporary(bool temporary); - -private: - Mode currentMode; - QLineEdit* nameEdit = nullptr; - FoundationalInstructionsEditor* instructionsEditor = nullptr; - QCheckBox* temporaryCheckBox = nullptr; - QPushButton* submitButton = nullptr; -}; - -} // namespace codexui - -#endif // CODEXUI_UI_THREADSETUPDIALOG_H diff --git a/src/ui/UiStyle.cpp b/src/ui/UiStyle.cpp deleted file mode 100644 index decab27..0000000 --- a/src/ui/UiStyle.cpp +++ /dev/null @@ -1,138 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "ui/UiStyle.h" - -namespace codexui::UiStyle { - -QString applicationStyleSheet() -{ - return QStringLiteral(R"QSS( - * { - color: #1d2633; - font-family: "Inter", "Noto Sans", "DejaVu Sans", sans-serif; - font-size: 12px; - } - QMainWindow, QWidget#workbench { background: #f6f8fb; } - QLabel { background: transparent; font-weight: 400; } - QLabel[kind="muted"] { color: #667085; } - QLabel[kind="section"] { - color: #667085; - font-size: 10px; - font-weight: 600; - } - QLabel[kind="attentionSection"] { - color: #a76812; - font-size: 9px; - font-weight: 600; - } - QLabel[kind="heading"] { font-size: 18px; font-weight: 600; } - QLabel[kind="title"] { font-size: 13px; font-weight: 600; } - QLabel[kind="body"] { font-size: 13px; } - QLabel[kind="meta"] { color: #667085; font-size: 10px; } - QLabel[kind="small"] { color: #667085; font-size: 9px; } - QPushButton, QToolButton { - background: #ffffff; - border: 1px solid #d7dee8; - border-radius: 7px; - padding: 0 12px; - font-size: 11px; - font-weight: 600; - } - QPushButton:hover, QToolButton:hover { background: #f1f5fb; border-color: #b9c4d2; } - QPushButton:pressed, QToolButton:pressed { background: #e5eeff; border-color: #bfd3f9; } - QPushButton:focus, QToolButton:focus { border: 2px solid #2f6feb; } - QPushButton:disabled, QToolButton:disabled { color: #98a2b3; background: #f6f8fb; border-color: #d7dee8; } - QPushButton[kind="primary"] { background: #2f6feb; border-color: #2f6feb; color: white; } - QPushButton[kind="primary"]:hover { background: #285fca; border-color: #285fca; } - QPushButton[kind="subtle"] { color: #667085; background: transparent; border-color: transparent; } - QPushButton[kind="agentLink"] { background: #e5eeff; border-color: #bfd3f9; color: #2f6feb; text-align: left; } - QPushButton[kind="stop"] { background: #ffffff; border-color: #b83a3a; color: #b83a3a; } - QPushButton[kind="stop"]:hover { background: #fff1f1; } - QFrame[kind="panel"] { background: #ffffff; } - QFrame[kind="raised"] { background: #ffffff; border: 1px solid #d7dee8; border-radius: 10px; } - QFrame[kind="summary"] { background: #f8fafc; border: 1px solid #d7dee8; border-radius: 7px; } - QFrame[kind="greenBadge"] { background: #e9f7f0; border-radius: 6px; } - QFrame[kind="blueBadge"] { background: #e5eeff; border-radius: 5px; } - QFrame[kind="amberBadge"] { background: #fff6df; border: 1px solid #e5c77d; border-radius: 7px; } - QFrame[kind="composer"] { background: #ffffff; border: 1px solid #d7dee8; border-radius: 10px; } - QFrame[kind="composer"][focused="true"] { border: 2px solid #2f6feb; } - QPlainTextEdit { - background: transparent; - border: 0; - color: #1d2633; - font-size: 13px; - padding: 0; - selection-background-color: #e5eeff; - selection-color: #1d2633; - } - QPlainTextEdit[empty="true"] { color: #98a2b3; } - QLineEdit { - background: #ffffff; - border: 1px solid #d7dee8; - border-radius: 7px; - min-height: 32px; - padding: 0 9px; - selection-background-color: #e5eeff; - selection-color: #1d2633; - } - QLineEdit:focus { border-color: #2f6feb; } - QLineEdit:disabled { color: #98a2b3; background: #f6f8fb; } - QComboBox { - background: #ffffff; - border: 1px solid #d7dee8; - border-radius: 7px; - min-height: 30px; - padding: 0 24px 0 9px; - } - QComboBox:hover { border-color: #b9c4d2; } - QComboBox:focus { border-color: #2f6feb; } - QComboBox:disabled { color: #98a2b3; background: #f6f8fb; } - QComboBox QLineEdit { - background: transparent; - border: 0; - border-radius: 0; - min-height: 0; - padding: 0; - } - QComboBox::drop-down { border: 0; width: 20px; } - QComboBox[codexChevron="true"]::down-arrow { image: none; } - QComboBox QAbstractItemView { - background: #ffffff; - color: #1d2633; - border: 1px solid #d7dee8; - selection-background-color: #e5eeff; - selection-color: #1d2633; - } - QCheckBox, QRadioButton { spacing: 8px; } - QDialog { background: #ffffff; } - QScrollArea { background: transparent; border: 0; } - QScrollArea > QWidget > QWidget { background: transparent; } - QScrollBar:vertical { background: transparent; width: 8px; margin: 2px; } - QScrollBar::handle:vertical { background: #b9c4d2; min-height: 28px; border-radius: 3px; } - QScrollBar::handle:vertical:hover { background: #98a2b3; } - QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; } - QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: transparent; } - QSplitter::handle { background: #d7dee8; } - QSplitter::handle:horizontal { width: 8px; } - QTabBar { background: transparent; } - QTabBar::tab { - background: transparent; - color: #667085; - min-width: 62px; - height: 30px; - border-radius: 7px; - font-size: 11px; - } - QTabBar::tab:selected { background: #e5eeff; color: #1d2633; font-weight: 600; } - QTabBar::tab:hover:!selected { background: #f1f5fb; color: #1d2633; } - QTabBar::tab:focus { border: 1px solid #2f6feb; } - QMenu { background: #ffffff; color: #1d2633; border: 1px solid #d7dee8; padding: 5px; } - QMenu::item { padding: 7px 28px 7px 10px; border-radius: 4px; } - QMenu::item:selected { background: #e5eeff; color: #1d2633; } - QMenu::item:disabled { color: #98a2b3; } - QMenu::separator { height: 1px; background: #d7dee8; margin: 5px 8px; } - QToolTip { background: #ffffff; color: #1d2633; border: 1px solid #b9c4d2; padding: 5px; } - )QSS"); -} - -} // namespace codexui::UiStyle diff --git a/src/ui/UpcomingTurnDock.cpp b/src/ui/UpcomingTurnDock.cpp deleted file mode 100644 index 9a0b707..0000000 --- a/src/ui/UpcomingTurnDock.cpp +++ /dev/null @@ -1,1728 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "ui/UpcomingTurnDock.h" - -#include "ui/ExpandingPromptEditor.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -namespace codexui -{ -namespace -{ -namespace sdk = ai::openai::codex::frontend::client; -namespace typed = ai::openai::codex::typed; - -class CompactComboBox final : public QComboBox -{ -protected: - void paintEvent(QPaintEvent* event) override - { - QComboBox::paintEvent(event); - - QStyleOptionComboBox option; - initStyleOption(&option); - const QRect indicator = style()->subControlRect( - QStyle::CC_ComboBox, &option, QStyle::SC_ComboBoxArrow, this); - if (!indicator.isValid() || indicator.isEmpty()) - return; - - const QPointF center = indicator.center(); - QPainterPath chevron; - chevron.moveTo(center.x() - 3.5, center.y() - 1.5); - chevron.lineTo(center.x(), center.y() + 2.0); - chevron.lineTo(center.x() + 3.5, center.y() - 1.5); - - QColor color(QStringLiteral("#667085")); - if (!(option.state & QStyle::State_Enabled)) - color = QColor(QStringLiteral("#98a2b3")); - else if (option.state & (QStyle::State_MouseOver | QStyle::State_HasFocus)) - color = QColor(QStringLiteral("#1d2633")); - - QPainter painter(this); - painter.setRenderHint(QPainter::Antialiasing, true); - painter.setPen(QPen(color, 1.4, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); - painter.setBrush(Qt::NoBrush); - painter.drawPath(chevron); - } -}; - -QString fromUtf8(const std::string& value) -{ - return QString::fromUtf8(value.data(), static_cast(value.size())); -} - -std::string toUtf8(const QString& value) -{ - const QByteArray encoded = value.toUtf8(); - return std::string(encoded.constData(), static_cast(encoded.size())); -} - -QLabel* plainLabel(const QString& text, const char* objectName = nullptr) -{ - auto* result = new QLabel(text); - result->setTextFormat(Qt::PlainText); - if (objectName) - result->setObjectName(QString::fromLatin1(objectName)); - return result; -} - -QWidget* labelledControl(const QString& label, QWidget* control) -{ - auto* result = new QFrame; - result->setObjectName(QStringLiteral("turnSettingChip")); - result->setProperty("changed", false); - result->setStyleSheet(QStringLiteral( - "QFrame#turnSettingChip{background:transparent;border:0;}" - "QFrame#turnSettingChip[changed=\"true\"]{background:#e5eeff;border:0;border-radius:7px;}")); - auto* layout = new QVBoxLayout(result); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(2); - auto* caption = plainLabel(label); - caption->setStyleSheet(QStringLiteral("color:#667085;font-size:10px;font-weight:600;")); - caption->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); - caption->setFixedHeight(13); - caption->setBuddy(control); - control->setAccessibleName(label); - layout->addWidget(caption, 0, Qt::AlignLeft | Qt::AlignTop); - layout->addWidget(control, 0); - result->setFixedHeight(52); - return result; -} - -void addChoice(QComboBox* combo, const QString& text, const QString& key) -{ - if (combo->findData(key) < 0) - combo->addItem(text, key); -} - -void selectKey(QComboBox* combo, - const QString& key, - const QString& fallbackText = {}, - bool fallbackSelectable = true) -{ - int index = combo->findData(key); - if (index < 0) - { - combo->addItem(fallbackText.isEmpty() ? key : fallbackText, key); - index = combo->count() - 1; - if (!fallbackSelectable) { - if (auto* model = qobject_cast(combo->model())) { - if (QStandardItem* item = model->item(index)) - item->setFlags(item->flags() & ~Qt::ItemIsEnabled & ~Qt::ItemIsSelectable); - } - } - } - combo->setCurrentIndex(index); -} - -QString effortKey(const typed::OptionalNullable& value) -{ - return value.hasValue() ? fromUtf8(value->value) : QStringLiteral("default"); -} - -QString personalityKey(const typed::OptionalNullable& value) -{ - return value.hasValue() ? fromUtf8(value->value) : QStringLiteral("default"); -} - -QString summaryKey(const typed::OptionalNullable& value) -{ - return value.hasValue() ? fromUtf8(value->value) : QStringLiteral("default"); -} - -QString sandboxKey(const typed::SandboxPolicy& value) -{ - return std::visit( - [](const auto& item) -> QString { - using T = std::decay_t; - if constexpr (std::is_same_v) - return QStringLiteral("danger-full-access"); - if constexpr (std::is_same_v) - return QStringLiteral("read-only"); - if constexpr (std::is_same_v) - return QStringLiteral("workspace-write"); - if constexpr (std::is_same_v) - return QStringLiteral("external"); - if constexpr (std::is_same_v) - return item.type ? fromUtf8(*item.type) : QStringLiteral("unknown"); - }, - value); -} - -QString networkKey(const typed::SandboxPolicy& value) -{ - return std::visit( - [](const auto& item) -> QString { - using T = std::decay_t; - if constexpr (std::is_same_v) - return QStringLiteral("enabled"); - if constexpr (std::is_same_v - || std::is_same_v) - return item.networkAccessOrDefault() ? QStringLiteral("enabled") - : QStringLiteral("restricted"); - if constexpr (std::is_same_v) - return fromUtf8(item.networkAccessOrDefault().value); - if constexpr (std::is_same_v) - return QStringLiteral("unavailable"); - }, - value); -} - -QString approvalKey(const typed::AskForApproval& value) -{ - return std::visit( - [](const auto& item) -> QString { - using T = std::decay_t; - if constexpr (std::is_same_v) - return fromUtf8(item.value); - if constexpr (std::is_same_v) - return QStringLiteral("granular"); - if constexpr (std::is_same_v) - return item.discriminator ? fromUtf8(*item.discriminator) : QStringLiteral("unknown"); - }, - value); -} - -QString collaborationKey(const typed::CollaborationMode& value) -{ - return fromUtf8(value.mode.value); -} - -std::optional sandboxForKey(const QString& key) -{ - if (key == QStringLiteral("danger-full-access")) - return typed::DangerFullAccessSandboxPolicy{}; - if (key == QStringLiteral("read-only")) - return typed::ReadOnlySandboxPolicy{}; - if (key == QStringLiteral("external")) - return typed::ExternalSandboxPolicy{}; - if (key == QStringLiteral("workspace-write")) - return typed::WorkspaceWriteSandboxPolicy{}; - return std::nullopt; -} - -bool applyNetworkChoice(typed::SandboxPolicy& policy, const QString& network) -{ - return std::visit( - [&network](auto& item) { - using T = std::decay_t; - if constexpr (std::is_same_v) - return network == QStringLiteral("enabled"); - if constexpr (std::is_same_v - || std::is_same_v) { - if (network != QStringLiteral("restricted") - && network != QStringLiteral("enabled")) - return false; - item.networkAccess = network == QStringLiteral("enabled"); - return true; - } - if constexpr (std::is_same_v) { - const typed::NetworkAccess value{toUtf8(network)}; - if (!value.isKnown()) - return false; - item.networkAccess = value; - return true; - } - if constexpr (std::is_same_v) - return false; - }, - policy); -} - -std::optional approvalForKey(const QString& key) -{ - const typed::ApprovalPolicy value{toUtf8(key)}; - if (!value.isKnown()) - return std::nullopt; - return typed::AskForApproval{value}; -} - -bool sandboxIsEditable(const typed::SandboxPolicy& value) -{ - return !std::holds_alternative(value); -} - -bool approvalIsEditable(const typed::AskForApproval& value) -{ - const auto* policy = std::get_if(&value); - return policy && policy->isKnown(); -} - -QString defaultSettingLabel() -{ - return QStringLiteral("Codex default"); -} - -QString unavailableSettingLabel() -{ - return QStringLiteral("Unavailable"); -} - -void resetSandboxChoices(QComboBox* combo) -{ - combo->clear(); - addChoice(combo, defaultSettingLabel(), QStringLiteral("default")); - addChoice(combo, QStringLiteral("Workspace"), QStringLiteral("workspace-write")); - addChoice(combo, QStringLiteral("Read only"), QStringLiteral("read-only")); - addChoice(combo, QStringLiteral("Full access"), QStringLiteral("danger-full-access")); - addChoice(combo, QStringLiteral("External"), QStringLiteral("external")); -} - -void resetNetworkChoices(QComboBox* combo, const QString& access) -{ - combo->clear(); - if (access == QStringLiteral("default")) { - addChoice(combo, defaultSettingLabel(), QStringLiteral("default")); - return; - } - if (access == QStringLiteral("danger-full-access")) { - addChoice(combo, QStringLiteral("Included"), QStringLiteral("enabled")); - return; - } - if (access == QStringLiteral("workspace-write") - || access == QStringLiteral("read-only") - || access == QStringLiteral("external")) { - addChoice(combo, QStringLiteral("Restricted"), QStringLiteral("restricted")); - addChoice(combo, QStringLiteral("Enabled"), QStringLiteral("enabled")); - return; - } - addChoice(combo, unavailableSettingLabel(), QStringLiteral("unavailable")); -} - -bool networkIsEditable(const QString& access) -{ - return access == QStringLiteral("workspace-write") - || access == QStringLiteral("read-only") - || access == QStringLiteral("external"); -} - -bool networkChoiceIsRepresentable(const QString& access, const QString& network) -{ - if (access == QStringLiteral("workspace-write") - || access == QStringLiteral("read-only")) - return network == QStringLiteral("restricted") || network == QStringLiteral("enabled"); - if (access == QStringLiteral("external")) - return typed::NetworkAccess{toUtf8(network)}.isKnown(); - return true; -} - -void resetApprovalChoices(QComboBox* combo) -{ - combo->clear(); - addChoice(combo, defaultSettingLabel(), QStringLiteral("default")); - addChoice(combo, QStringLiteral("On request"), QStringLiteral("on-request")); - addChoice(combo, QStringLiteral("Untrusted"), QStringLiteral("untrusted")); - addChoice(combo, QStringLiteral("Never"), QStringLiteral("never")); -} - -void resetPersonalityChoices(QComboBox* combo) -{ - combo->clear(); - addChoice(combo, defaultSettingLabel(), QStringLiteral("default")); - addChoice(combo, QStringLiteral("None"), QStringLiteral("none")); - addChoice(combo, QStringLiteral("Friendly"), QStringLiteral("friendly")); - addChoice(combo, QStringLiteral("Pragmatic"), QStringLiteral("pragmatic")); -} - -void resetReviewerChoices(QComboBox* combo) -{ - combo->clear(); - addChoice(combo, QStringLiteral("User"), QStringLiteral("user")); - addChoice(combo, defaultSettingLabel(), QStringLiteral("default")); - addChoice(combo, QStringLiteral("Auto review"), QStringLiteral("auto_review")); - addChoice(combo, QStringLiteral("Guardian"), QStringLiteral("guardian_subagent")); -} - -void resetSummaryChoices(QComboBox* combo) -{ - combo->clear(); - addChoice(combo, defaultSettingLabel(), QStringLiteral("default")); - addChoice(combo, QStringLiteral("Auto"), QStringLiteral("auto")); - addChoice(combo, QStringLiteral("Concise"), QStringLiteral("concise")); - addChoice(combo, QStringLiteral("Detailed"), QStringLiteral("detailed")); - addChoice(combo, QStringLiteral("None"), QStringLiteral("none")); -} - -void resetCollaborationChoices(QComboBox* combo) -{ - combo->clear(); - addChoice(combo, QStringLiteral("Code"), QStringLiteral("default")); - addChoice(combo, QStringLiteral("Plan"), QStringLiteral("plan")); -} - -typed::CollaborationMode collaborationForKey( - const QString& key, - const QString& selectedModel, - const QString& selectedEffort, - const std::optional& canonical) -{ - typed::CollaborationMode result; - result.mode.value = toUtf8(key); - // Developer instructions belong to the selected collaboration mode. An - // explicit null lets app-server install that mode's built-in instructions - // instead of retaining the previous mode's prompt. - result.settings.developerInstructions = - typed::OptionalNullable::explicitNull(); - const QString effectiveModel = !selectedModel.isEmpty() - ? selectedModel - : canonical ? fromUtf8(canonical->model.value) : QString{}; - result.settings.model = typed::ModelId{toUtf8(effectiveModel)}; - if (selectedEffort == QStringLiteral("default")) - result.settings.reasoningEffort = typed::OptionalNullable::explicitNull(); - else if (!selectedEffort.isEmpty() && selectedEffort != QStringLiteral("unavailable")) - result.settings.reasoningEffort = typed::ReasoningEffort{toUtf8(selectedEffort)}; - else - result.settings.reasoningEffort = - typed::OptionalNullable{}; - return result; -} - -QString friendlyValue(QString value) -{ - value.replace(QLatin1Char('-'), QLatin1Char(' ')); - value.replace(QLatin1Char('_'), QLatin1Char(' ')); - if (!value.isEmpty()) - value[0] = value.at(0).toUpper(); - return value; -} - -QComboBox* compactCombo(const char* name) -{ - auto* result = new CompactComboBox; - result->setObjectName(QString::fromLatin1(name)); - result->setProperty("codexChevron", true); - result->setFixedHeight(34); - result->setSizeAdjustPolicy(QComboBox::AdjustToMinimumContentsLengthWithIcon); - result->setMinimumContentsLength(3); - return result; -} - -} // namespace - -bool UpcomingTurnDraft::empty() const noexcept -{ - return model.isOmitted() && effort.isOmitted() && personality.isOmitted() && sandboxPolicy.isOmitted() - && approvalPolicy.isOmitted() && approvalsReviewer.isOmitted() && cwd.isOmitted() && serviceTier.isOmitted() - && summary.isOmitted() && collaborationMode.isOmitted(); -} - -UpcomingTurnDock::UpcomingTurnDock(QWidget* parent) - : QWidget(parent) -{ - setObjectName(QStringLiteral("upcomingTurnDock")); - setAttribute(Qt::WA_StyledBackground, true); - setStyleSheet(QStringLiteral("#upcomingTurnDock{background:#ffffff;border-top:1px solid #d7dee8;}")); - setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - - auto* root = new QVBoxLayout(this); - root->setContentsMargins(16, 10, 16, 12); - root->setSpacing(8); - - auto* headingRow = new QHBoxLayout; - headingRow->setContentsMargins(0, 0, 0, 0); - headingRow->setSpacing(8); - auto* heading = plainLabel(QStringLiteral("UPCOMING TURN"), "upcomingTurnHeading"); - heading->setStyleSheet(QStringLiteral("color:#667085;font-size:10px;font-weight:700;letter-spacing:.5px;")); - headingRow->addWidget(heading); - headingRow->addStretch(); - root->addLayout(headingRow); - - settingsSurface = new QFrame; - settingsSurface->setObjectName(QStringLiteral("upcomingTurnSettings")); - settingsSurface->setStyleSheet(QStringLiteral( - "#upcomingTurnSettings{background:#ffffff;border:1px solid #d7dee8;border-radius:10px;}")); - auto* settingsLayout = new QVBoxLayout(settingsSurface); - settingsLayout->setContentsMargins(10, 8, 10, 7); - settingsLayout->setSpacing(5); - - auto* settingsGrid = new QGridLayout; - settingsGrid->setContentsMargins(0, 0, 0, 0); - settingsGrid->setHorizontalSpacing(8); - settingsGrid->setVerticalSpacing(6); - - model = compactCombo("upcomingModel"); - model->setEditable(true); - effort = compactCombo("upcomingReasoning"); - personality = compactCombo("upcomingStyle"); - sandbox = compactCombo("upcomingAccess"); - network = compactCombo("upcomingNetwork"); - approval = compactCombo("upcomingApproval"); - fieldSurfaces[static_cast(Field::Model)] = labelledControl(QStringLiteral("Model"), model); - fieldSurfaces[static_cast(Field::Effort)] = labelledControl(QStringLiteral("Reasoning"), effort); - fieldSurfaces[static_cast(Field::Personality)] = labelledControl(QStringLiteral("Style"), personality); - fieldSurfaces[static_cast(Field::Sandbox)] = labelledControl(QStringLiteral("Access"), sandbox); - fieldSurfaces[static_cast(Field::Network)] = labelledControl(QStringLiteral("Network"), network); - fieldSurfaces[static_cast(Field::Approval)] = labelledControl(QStringLiteral("Approval"), approval); - cwd = new QLineEdit; - cwd->setObjectName(QStringLiteral("upcomingWorkspace")); - cwd->setPlaceholderText(QStringLiteral("Codex default workspace")); - cwd->setFixedHeight(34); - fieldSurfaces[static_cast(Field::Cwd)] = labelledControl(QStringLiteral("Workspace"), cwd); - more = new QPushButton(QStringLiteral("More…")); - more->setObjectName(QStringLiteral("upcomingMore")); - more->setFixedHeight(34); - more->setStyleSheet(QStringLiteral( - "QPushButton{background:#ffffff;color:#344054;border:1px solid #d7dee8;border-radius:7px;padding:2px 12px;font-weight:600;}" - "QPushButton:hover{background:#f1f5fb;}" - "QPushButton[changed=\"true\"]{background:#e5eeff;color:#2f6feb;border-color:#2f6feb;}")); - auto* moreSurface = labelledControl(QStringLiteral("Additional"), more); - - const std::array primaryFields{ - fieldSurfaces[static_cast(Field::Model)], - fieldSurfaces[static_cast(Field::Effort)], - fieldSurfaces[static_cast(Field::Sandbox)], - fieldSurfaces[static_cast(Field::Network)], - fieldSurfaces[static_cast(Field::Cwd)], - fieldSurfaces[static_cast(Field::Approval)], - fieldSurfaces[static_cast(Field::Personality)], - moreSurface, - }; - for (QWidget* field : primaryFields) - field->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed); - - settingsGrid->addWidget(primaryFields[0], 0, 0); - settingsGrid->addWidget(primaryFields[1], 0, 1); - settingsGrid->addWidget(primaryFields[2], 0, 2); - settingsGrid->addWidget(primaryFields[3], 0, 3); - settingsGrid->addWidget(primaryFields[4], 1, 0); - settingsGrid->addWidget(fieldSurfaces[static_cast(Field::Approval)], 1, 1); - settingsGrid->addWidget(fieldSurfaces[static_cast(Field::Personality)], 1, 2); - settingsGrid->addWidget(moreSurface, 1, 3); - for (int column = 0; column < 4; ++column) - settingsGrid->setColumnStretch(column, 1); - settingsLayout->addLayout(settingsGrid); - - settingsHint = plainLabel({}, "upcomingSettingsHint"); - settingsHint->setStyleSheet(QStringLiteral("color:#2f6feb;font-size:10px;font-weight:600;")); - // Reserve this row permanently so changing a setting never changes or - // clips the fixed base geometry of the anchored turn dock. - settingsHint->setFixedHeight(14); - settingsLayout->addWidget(settingsHint, 0, Qt::AlignLeft); - root->addWidget(settingsSurface); - - moreMenu = new QMenu(this); - moreMenu->setObjectName(QStringLiteral("upcomingMoreMenu")); - moreMenu->setStyleSheet(QStringLiteral( - "QMenu{background:#ffffff;border:1px solid #d7dee8;border-radius:10px;padding:0;}")); - auto* moreContents = new QWidget; - moreContents->setObjectName(QStringLiteral("upcomingMoreContents")); - moreContents->setMinimumWidth(430); - auto* moreLayout = new QGridLayout(moreContents); - moreLayout->setContentsMargins(16, 14, 16, 14); - moreLayout->setHorizontalSpacing(8); - moreLayout->setVerticalSpacing(8); - serviceTier = compactCombo("upcomingServiceTier"); - serviceTier->setEditable(true); - summary = compactCombo("upcomingReasoningSummary"); - collaboration = compactCombo("upcomingCollaborationMode"); - reviewer = compactCombo("upcomingApprovalReviewer"); - fieldSurfaces[static_cast(Field::ServiceTier)] = labelledControl( - QStringLiteral("Service tier"), serviceTier); - fieldSurfaces[static_cast(Field::Summary)] = labelledControl( - QStringLiteral("Reasoning summary"), summary); - fieldSurfaces[static_cast(Field::Collaboration)] = labelledControl( - QStringLiteral("Collaboration mode"), collaboration); - fieldSurfaces[static_cast(Field::Reviewer)] = labelledControl( - QStringLiteral("Approval reviewer"), reviewer); - moreLayout->addWidget(fieldSurfaces[static_cast(Field::ServiceTier)], 0, 0); - moreLayout->addWidget(fieldSurfaces[static_cast(Field::Summary)], 0, 1); - moreLayout->addWidget(fieldSurfaces[static_cast(Field::Collaboration)], 1, 0); - moreLayout->addWidget(fieldSurfaces[static_cast(Field::Reviewer)], 1, 1); - auto* moreAction = new QWidgetAction(moreMenu); - moreAction->setDefaultWidget(moreContents); - moreMenu->addAction(moreAction); - more->setMenu(moreMenu); - - composerSurface = new QFrame; - composerSurface->setObjectName(QStringLiteral("upcomingComposer")); - composerSurface->setStyleSheet(QStringLiteral( - "#upcomingComposer{background:#ffffff;border:2px solid #b9c4d2;border-radius:10px;}" - "#upcomingComposer[focused=\"true\"]{border:2px solid #2f6feb;}")); - composerSurface->setProperty("focused", false); - auto* composerLayout = new QHBoxLayout(composerSurface); - composerLayout->setContentsMargins(10, 8, 10, 8); - composerLayout->setSpacing(8); - attach = new QPushButton(QStringLiteral("Attach")); - attach->setObjectName(QStringLiteral("upcomingAttach")); - attach->setToolTip(QStringLiteral("Attach images or files")); - attach->setFixedSize(54, 28); - attach->setStyleSheet(QStringLiteral("color:#667085;background:transparent;border:0;padding:2px 4px;")); - composerLayout->addWidget(attach, 0, Qt::AlignBottom); - - attachmentSummary = new QPushButton; - attachmentSummary->setObjectName(QStringLiteral("upcomingAttachmentSummary")); - attachmentSummary->setMinimumSize(76, 28); - attachmentSummary->setMaximumWidth(112); - attachmentSummary->setStyleSheet(QStringLiteral( - "QPushButton{color:#2f6feb;background:#eef4ff;border:1px solid #c7d7f2;" - "border-radius:7px;padding:2px 7px;text-align:left;}" - "QPushButton:hover{background:#e3edff;}")); - attachmentSummary->hide(); - composerLayout->addWidget(attachmentSummary, 0, Qt::AlignBottom); - - editor = new ExpandingPromptEditor; - composerLayout->addWidget(editor, 1); - - status = plainLabel(QStringLiteral("Ctrl+Enter to send"), "upcomingTurnStatus"); - status->setStyleSheet(QStringLiteral("color:#667085;font-size:10px;")); - status->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); - status->setMinimumWidth(112); - status->setMaximumWidth(220); - status->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - composerLayout->addWidget(status, 0, Qt::AlignBottom); - send = new QPushButton(QStringLiteral("Send")); - send->setObjectName(QStringLiteral("upcomingSendButton")); - send->setMinimumSize(66, 28); - send->setStyleSheet(QStringLiteral( - "QPushButton{background:#2f6feb;color:#ffffff;border:0;border-radius:7px;font-weight:700;}" - "QPushButton:hover{background:#245fd1;}QPushButton:disabled{background:#d7dee8;color:#98a2b3;}")); - composerLayout->addWidget(send, 0, Qt::AlignBottom); - stop = new QPushButton(QStringLiteral("Stop")); - stop->setObjectName(QStringLiteral("upcomingStopButton")); - stop->setMinimumSize(66, 28); - stop->setStyleSheet(QStringLiteral( - "QPushButton{background:#fff4f2;color:#b83a3a;border:1px solid #f0c2bb;border-radius:7px;font-weight:700;}" - "QPushButton:hover{background:#ffe9e5;}QPushButton:disabled{color:#98a2b3;border-color:#d7dee8;}")); - composerLayout->addWidget(stop, 0, Qt::AlignBottom); - stop->hide(); - root->addWidget(composerSurface); - - addChoice(effort, defaultSettingLabel(), QStringLiteral("default")); - addChoice(effort, QStringLiteral("Minimal"), QStringLiteral("minimal")); - addChoice(effort, QStringLiteral("Low"), QStringLiteral("low")); - addChoice(effort, QStringLiteral("Medium"), QStringLiteral("medium")); - addChoice(effort, QStringLiteral("High"), QStringLiteral("high")); - addChoice(effort, QStringLiteral("XHigh"), QStringLiteral("xhigh")); - resetPersonalityChoices(personality); - resetSandboxChoices(sandbox); - resetNetworkChoices(network, QStringLiteral("unavailable")); - resetApprovalChoices(approval); - resetReviewerChoices(reviewer); - addChoice(serviceTier, defaultSettingLabel(), QStringLiteral("default")); - resetSummaryChoices(summary); - resetCollaborationChoices(collaboration); - - connect(model, &QComboBox::currentIndexChanged, this, [this] { - markComboChange(Field::Model, model); - refreshModelDependentControls(true); - }); - connect(model->lineEdit(), &QLineEdit::textEdited, this, [this] { - markComboChange(Field::Model, model); - refreshModelDependentControls(true); - }); - connect(effort, &QComboBox::currentIndexChanged, this, [this] { markComboChange(Field::Effort, effort); }); - connect(personality, &QComboBox::currentIndexChanged, this, - [this] { markComboChange(Field::Personality, personality); }); - connect(sandbox, &QComboBox::currentIndexChanged, this, [this] { - markComboChange(Field::Sandbox, sandbox); - refreshNetworkControl(true); - }); - connect(network, &QComboBox::currentIndexChanged, this, - [this] { markComboChange(Field::Network, network); }); - connect(approval, &QComboBox::currentIndexChanged, this, [this] { markComboChange(Field::Approval, approval); }); - connect(reviewer, &QComboBox::currentIndexChanged, this, [this] { markComboChange(Field::Reviewer, reviewer); }); - connect(serviceTier, &QComboBox::currentIndexChanged, this, - [this] { markComboChange(Field::ServiceTier, serviceTier); }); - connect(serviceTier->lineEdit(), &QLineEdit::textEdited, this, - [this] { markComboChange(Field::ServiceTier, serviceTier); }); - connect(summary, &QComboBox::currentIndexChanged, this, [this] { markComboChange(Field::Summary, summary); }); - connect(collaboration, &QComboBox::currentIndexChanged, this, - [this] { markComboChange(Field::Collaboration, collaboration); }); - connect(cwd, &QLineEdit::textEdited, this, [this] { markTextChange(Field::Cwd, cwd); }); - - connect(editor, &ExpandingPromptEditor::textChanged, this, [this] { - updateDraftTarget(); - updateSendEnabled(); - }); - connect(editor, &ExpandingPromptEditor::editorHeightChanged, - this, &UpcomingTurnDock::updatePromptHeight); - connect(editor, &ExpandingPromptEditor::focusStateChanged, this, [this](bool focused) { - composerSurface->setProperty("focused", focused); - composerSurface->style()->unpolish(composerSurface); - composerSurface->style()->polish(composerSurface); - }); - connect(editor, &ExpandingPromptEditor::submitRequested, this, [this] { - if (send->isEnabled()) - emit sendRequested(editor->toPlainText(), steeringMode); - }); - connect(attach, &QPushButton::clicked, this, &UpcomingTurnDock::chooseAttachments); - connect(send, &QPushButton::clicked, this, [this] { - const QString value = editor->toPlainText(); - if (send->isEnabled()) - emit sendRequested(value, steeringMode); - }); - connect(stop, &QPushButton::clicked, this, &UpcomingTurnDock::stopRequested); - - refreshControls(true); - setActionState(false, false, false, false, false, false); - root->activate(); - compactBaseHeight = std::max(1, root->sizeHint().height()); - setFixedHeight(compactBaseHeight); - updateGeometry(); -} - -void UpcomingTurnDock::setCanonicalConfiguration(const std::optional& configuration, - const QString& stableThreadIdentity, - bool useCodexDefaults) -{ - const bool threadChanged = threadIdentity != stableThreadIdentity; - const bool configurationChanged = canonicalConfiguration != configuration; - const bool defaultsContextChanged = codexDefaultsContext != useCodexDefaults; - threadIdentity = stableThreadIdentity; - canonicalConfiguration = configuration; - codexDefaultsContext = useCodexDefaults; - if (threadChanged) - touchedFields.fill(false); - if (threadChanged || configurationChanged || defaultsContextChanged) - refreshControls(threadChanged); - updateChangedPresentation(); - settingsSurface->setEnabled(controlsContextAllowed); - more->setEnabled(controlsContextAllowed); - settingsSurface->setToolTip( - canonicalConfiguration || codexDefaultsContext - ? QString{} - : QStringLiteral("Thread settings are not available in canonical state; only explicit changes will be submitted")); - const bool cwdAvailable = controlsContextAllowed; - fieldSurfaces[static_cast(Field::Cwd)]->setEnabled(cwdAvailable); - fieldSurfaces[static_cast(Field::Cwd)]->setToolTip( - cwdAvailable ? QString{} - : QStringLiteral("Workspace cannot be changed in the current thread state")); - cwd->setPlaceholderText( - codexDefaultsContext || (canonicalConfiguration && !canonicalConfiguration->cwd) - ? QStringLiteral("Codex default workspace") - : canonicalConfiguration - ? QStringLiteral("Workspace") - : unavailableSettingLabel()); -} - -void UpcomingTurnDock::setModelCatalog(const std::vector& catalog) -{ - std::vector next; - next.reserve(catalog.size()); - for (const auto& entry : catalog) { - if (entry.hidden || entry.model.value.empty()) - continue; - if (std::ranges::any_of(next, [&entry](const auto& choice) { - return choice.model.value == entry.model.value; - })) - continue; - next.push_back(entry); - } - if (modelCatalog == next) - return; - modelCatalog = std::move(next); - // A catalogue refresh may change display names/capabilities for a pending - // model selection, so refresh the model row even when that field is dirty. - refreshModelControl(); - refreshControls(false); - updateChangedPresentation(); -} - -UpcomingTurnDraft UpcomingTurnDock::draft() const -{ - UpcomingTurnDraft result; - result.threadIdentity = threadIdentity; - for (std::size_t index = 0; index < result.presentationKeys.size(); ++index) - result.presentationKeys[index] = currentFieldKey(static_cast(index)); - - const auto key = [](const QComboBox* combo) { return combo->currentData().toString(); }; - if (touched(Field::Model)) - { - const bool selectedCanonicalItem = model->currentIndex() >= 0 - && model->currentText() == model->itemText(model->currentIndex()); - const QString value = selectedCanonicalItem ? model->currentData().toString() : model->currentText().trimmed(); - if (value.isEmpty()) - result.model = typed::OptionalNullable::explicitNull(); - else if (value != QStringLiteral("unavailable")) - result.model = typed::ModelId{toUtf8(value)}; - } - if (touched(Field::Effort)) - { - const QString value = key(effort); - if (value == QStringLiteral("default")) - result.effort = typed::OptionalNullable::explicitNull(); - else if (value != QStringLiteral("unavailable")) - result.effort = typed::ReasoningEffort{toUtf8(value)}; - } - if (touched(Field::Personality)) - { - const QString value = key(personality); - if (value == QStringLiteral("default")) - result.personality = typed::OptionalNullable::explicitNull(); - else if (typed::Personality{toUtf8(value)}.isKnown()) - result.personality = typed::Personality{toUtf8(value)}; - } - if (touched(Field::Sandbox) || touched(Field::Network)) { - if (key(sandbox) == QStringLiteral("default")) - result.sandboxPolicy = typed::OptionalNullable::explicitNull(); - else { - std::optional value; - if (canonicalConfiguration - && sandboxKey(canonicalConfiguration->sandboxPolicy) == key(sandbox)) - value = canonicalConfiguration->sandboxPolicy; - else - value = sandboxForKey(key(sandbox)); - if (value && applyNetworkChoice(*value, key(network))) - result.sandboxPolicy = std::move(*value); - } - } - if (touched(Field::Approval)) { - if (key(approval) == QStringLiteral("default")) - result.approvalPolicy = typed::OptionalNullable::explicitNull(); - else if (const auto value = approvalForKey(key(approval))) - result.approvalPolicy = *value; - } - if (touched(Field::Reviewer)) { - const QString value = key(reviewer); - if (value == QStringLiteral("default")) - result.approvalsReviewer = typed::OptionalNullable::explicitNull(); - else if (typed::ApprovalsReviewer{toUtf8(value)}.isKnown()) - result.approvalsReviewer = typed::ApprovalsReviewer{toUtf8(value)}; - } - if (touched(Field::Cwd)) - { - const QString value = cwd->text().trimmed(); - result.cwd = value.isEmpty() ? typed::OptionalNullable::explicitNull() : toUtf8(value); - } - if (touched(Field::ServiceTier)) - { - const int index = serviceTier->currentIndex(); - const bool selectedCanonicalItem = index >= 0 - && serviceTier->currentText() == serviceTier->itemText(index); - const QString value = selectedCanonicalItem ? serviceTier->currentData().toString() - : serviceTier->currentText().trimmed(); - if (value.isEmpty() || value == QStringLiteral("default")) - result.serviceTier = typed::OptionalNullable::explicitNull(); - else if (value != QStringLiteral("unavailable")) - result.serviceTier = toUtf8(value); - } - if (touched(Field::Summary)) - { - const QString value = key(summary); - if (value == QStringLiteral("default")) - result.summary = typed::OptionalNullable::explicitNull(); - else if (typed::ReasoningSummary{toUtf8(value)}.isKnown()) - result.summary = typed::ReasoningSummary{toUtf8(value)}; - } - if (touched(Field::Collaboration)) { - const QString collaborationKey = key(collaboration); - const QString selectedModel = currentFieldKey(Field::Model); - const QString selectedEffort = currentFieldKey(Field::Effort); - if (typed::ModeKind{toUtf8(collaborationKey)}.isKnown() - && !selectedModel.isEmpty() && selectedModel != QStringLiteral("unavailable") - && !selectedEffort.isEmpty() && selectedEffort != QStringLiteral("unavailable")) { - result.collaborationMode = collaborationForKey( - collaborationKey, selectedModel, selectedEffort, canonicalConfiguration); - } - } - return result; -} - -bool UpcomingTurnDock::hasSettingsChanges() const noexcept -{ - return std::ranges::any_of(touchedFields, [](bool value) { return value; }); -} - -void UpcomingTurnDock::clearTouchedSettings() -{ - touchedFields.fill(false); - refreshControls(true); - updateChangedPresentation(); - emit settingsChanged(); -} - -void UpcomingTurnDock::acknowledgeSubmittedSettings(const UpcomingTurnDraft& submitted) -{ - if (submitted.threadIdentity != threadIdentity) - return; - resolveSubmittedSettings(submitted); -} - -void UpcomingTurnDock::resolveSubmittedSettings(const UpcomingTurnDraft& submitted) -{ - const auto fieldWasSubmitted = [&submitted](Field field) { - switch (field) { - case Field::Model: - return !submitted.model.isOmitted(); - case Field::Effort: - return !submitted.effort.isOmitted(); - case Field::Personality: - return !submitted.personality.isOmitted(); - case Field::Sandbox: - case Field::Network: - return !submitted.sandboxPolicy.isOmitted(); - case Field::Approval: - return !submitted.approvalPolicy.isOmitted(); - case Field::Reviewer: - return !submitted.approvalsReviewer.isOmitted(); - case Field::Cwd: - return !submitted.cwd.isOmitted(); - case Field::ServiceTier: - return !submitted.serviceTier.isOmitted(); - case Field::Summary: - return !submitted.summary.isOmitted(); - case Field::Collaboration: - return !submitted.collaborationMode.isOmitted(); - case Field::Count: - break; - } - return false; - }; - - bool clearedSubmittedField = false; - for (std::size_t index = 0; index < submitted.presentationKeys.size(); ++index) { - const Field field = static_cast(index); - if (!fieldWasSubmitted(field) - || currentFieldKey(field) != submitted.presentationKeys[index]) - continue; - setTouched(field, false); - clearedSubmittedField = true; - } - if (clearedSubmittedField) - refreshControls(false); -} - -QString UpcomingTurnDock::prompt() const -{ - return editor->toPlainText(); -} - -const QList& UpcomingTurnDock::attachments() const noexcept -{ - return selectedAttachments; -} - -QString UpcomingTurnDock::attachmentWorkspace() const -{ - return cwd->text().trimmed(); -} - -bool UpcomingTurnDock::addAttachmentPaths(const QStringList& paths, QString* errorMessage) -{ - QList additions; - QSet knownPaths; - for (const auto& attachment : selectedAttachments) - knownPaths.insert(attachment.sourcePath); - - for (const QString& path : paths) { - AttachmentInfo inspected; - QString error; - if (!AttachmentManager::inspectFile(path, &inspected, &error)) { - if (errorMessage) - *errorMessage = error; - return false; - } - if (knownPaths.contains(inspected.sourcePath)) - continue; - knownPaths.insert(inspected.sourcePath); - additions.append(std::move(inspected)); - } - constexpr qsizetype maximumAttachmentCount = 16; - if (selectedAttachments.size() + additions.size() > maximumAttachmentCount) { - if (errorMessage) - *errorMessage = QStringLiteral("A turn can contain at most %1 attachments.") - .arg(maximumAttachmentCount); - return false; - } - QList candidate = selectedAttachments; - candidate.append(additions); - if (AttachmentManager::totalSize(candidate) > AttachmentManager::MaximumTotalBytes) { - if (errorMessage) - *errorMessage = QStringLiteral("Attachments exceed the %1 total limit.") - .arg(AttachmentManager::formatSize( - AttachmentManager::MaximumTotalBytes)); - return false; - } - selectedAttachments = std::move(candidate); - updateDraftTarget(); - refreshAttachmentPresentation(); - updateSendEnabled(); - return true; -} - -void UpcomingTurnDock::clearPrompt() -{ - editor->clear(); -} - -void UpcomingTurnDock::clearPromptIfUnchanged(const QString& submittedPrompt) -{ - if (editor->toPlainText() == submittedPrompt) - editor->clear(); -} - -void UpcomingTurnDock::clearAttachmentsIfUnchanged( - const QList& submittedAttachments) -{ - if (selectedAttachments != submittedAttachments) - return; - selectedAttachments.clear(); - updateDraftTarget(); - refreshAttachmentPresentation(); - updateSendEnabled(); -} - -void UpcomingTurnDock::focusPrompt() -{ - editor->setFocus(Qt::OtherFocusReason); -} - -void UpcomingTurnDock::setActionState(bool primaryAllowed, - bool stopAllowed, - bool editorAllowed, - bool settingsAllowed, - bool stopVisible, - bool steerMode, - const QString& actionThreadIdentity, - const QString& activeTurnIdentity) -{ - const bool actionChanged = steeringMode != steerMode; - sendContextAllowed = primaryAllowed; - controlsContextAllowed = settingsAllowed; - steeringMode = steerMode; - currentPromptTarget = {actionThreadIdentity, - steerMode ? activeTurnIdentity : QString{}, - steerMode}; - editor->setEnabled(editorAllowed); - attach->setEnabled(editorAllowed); - attachmentSummary->setEnabled(editorAllowed); - settingsSurface->setEnabled(settingsAllowed); - more->setEnabled(settingsAllowed); - const bool cwdAvailable = settingsAllowed; - fieldSurfaces[static_cast(Field::Cwd)]->setEnabled(cwdAvailable); - stop->setEnabled(stopAllowed); - send->setText(steeringMode ? QStringLiteral("Steer") : QStringLiteral("Send")); - send->setVisible(true); - stop->setVisible(stopVisible); - if (actionChanged - && (status->text() == QStringLiteral("Ctrl+Enter to send") - || status->text() == QStringLiteral("Ctrl+Enter to steer"))) { - status->setText(steeringMode ? QStringLiteral("Ctrl+Enter to steer") - : QStringLiteral("Ctrl+Enter to send")); - } - updateSendEnabled(); -} - -void UpcomingTurnDock::setStatus(const QString& text, bool error) -{ - status->setProperty("draftTargetMismatch", false); - if (text.isEmpty()) - { - status->setText(steeringMode ? QStringLiteral("Ctrl+Enter to steer") - : QStringLiteral("Ctrl+Enter to send")); - status->setToolTip({}); - status->setStyleSheet(QStringLiteral("color:#667085;font-size:10px;")); - return; - } - QString visible = text; - constexpr qsizetype maximumVisibleCharacters = 100; - if (visible.size() > maximumVisibleCharacters) { - visible.truncate(maximumVisibleCharacters - 1); - visible.append(QChar(0x2026)); - } - status->setText(visible); - status->setToolTip(Qt::convertFromPlainText(text, Qt::WhiteSpaceNormal)); - status->setStyleSheet(QStringLiteral("color:%1;font-size:10px;").arg( - error ? QStringLiteral("#b83a3a") : QStringLiteral("#667085"))); -} - -int UpcomingTurnDock::baseHeight() const noexcept -{ - return compactBaseHeight; -} - -void UpcomingTurnDock::refreshControls(bool resetAll) -{ - const auto shouldRefresh = [this, resetAll](Field field) { return resetAll || !touched(field); }; - const auto remember = [this](Field field, const QString& key) { - canonicalKeys[static_cast(field)] = key; - }; - - const QString missingValue = codexDefaultsContext ? QStringLiteral("default") - : QStringLiteral("unavailable"); - const typed::Model* catalogDefault = codexDefaultsContext - ? defaultModelDefinition() - : nullptr; - const QString modelValue = canonicalConfiguration - ? fromUtf8(canonicalConfiguration->model.value) - : catalogDefault - ? fromUtf8(catalogDefault->model.value) - : codexDefaultsContext ? QString{} : missingValue; - const QString effortValue = canonicalConfiguration ? effortKey(canonicalConfiguration->effort) - : missingValue; - const QString personalityValue = canonicalConfiguration ? personalityKey(canonicalConfiguration->personality) - : missingValue; - const QString sandboxValue = canonicalConfiguration ? sandboxKey(canonicalConfiguration->sandboxPolicy) - : missingValue; - const QString networkValue = canonicalConfiguration ? networkKey(canonicalConfiguration->sandboxPolicy) - : missingValue; - const QString approvalValue = canonicalConfiguration ? approvalKey(canonicalConfiguration->approvalPolicy) - : missingValue; - const QString reviewerValue = canonicalConfiguration ? fromUtf8(canonicalConfiguration->approvalsReviewer.value) - : missingValue; - const QString cwdValue = canonicalConfiguration && canonicalConfiguration->cwd - ? fromUtf8(canonicalConfiguration->cwd->value) - : QString{}; - const QString serviceTierValue = canonicalConfiguration && canonicalConfiguration->serviceTier.hasValue() - ? fromUtf8(*canonicalConfiguration->serviceTier) - : canonicalConfiguration ? QStringLiteral("default") : missingValue; - const QString summaryValue = canonicalConfiguration ? summaryKey(canonicalConfiguration->summary) - : missingValue; - const QString collaborationValue = canonicalConfiguration ? collaborationKey(canonicalConfiguration->collaborationMode) - : missingValue; - const std::array(Field::Count)> nextCanonicalKeys{ - modelValue, - effortValue, - personalityValue, - sandboxValue, - networkValue, - approvalValue, - reviewerValue, - cwdValue, - serviceTierValue, - summaryValue, - collaborationValue, - }; - for (std::size_t index = 0; index < nextCanonicalKeys.size(); ++index) - { - const Field field = static_cast(index); - remember(field, nextCanonicalKeys[index]); - if (touched(field) && currentFieldKey(field) == nextCanonicalKeys[index]) - setTouched(field, false); - } - - const bool editableSandbox = !canonicalConfiguration - || sandboxIsEditable(canonicalConfiguration->sandboxPolicy); - const bool editableApproval = !canonicalConfiguration - || approvalIsEditable(canonicalConfiguration->approvalPolicy); - if (!editableSandbox) { - setTouched(Field::Sandbox, false); - setTouched(Field::Network, false); - } - if (!editableApproval) - setTouched(Field::Approval, false); - - if (shouldRefresh(Field::Model)) - refreshModelControl(); - if (shouldRefresh(Field::Effort)) - { - const QSignalBlocker blocker(effort); - selectKey(effort, effortValue, - effortValue == QStringLiteral("default") ? defaultSettingLabel() - : effortValue == QStringLiteral("unavailable") ? unavailableSettingLabel() - : friendlyValue(effortValue), - effortValue == QStringLiteral("default") - || typed::ReasoningEffort{toUtf8(effortValue)}.isKnown()); - } - if (shouldRefresh(Field::Personality)) - { - const QSignalBlocker blocker(personality); - resetPersonalityChoices(personality); - selectKey(personality, personalityValue, - personalityValue == QStringLiteral("default") ? defaultSettingLabel() - : personalityValue == QStringLiteral("unavailable") ? unavailableSettingLabel() - : friendlyValue(personalityValue), - personalityValue == QStringLiteral("default") - || typed::Personality{toUtf8(personalityValue)}.isKnown()); - } - if (shouldRefresh(Field::Sandbox)) - { - const QSignalBlocker blocker(sandbox); - resetSandboxChoices(sandbox); - selectKey(sandbox, sandboxValue, - sandboxValue == QStringLiteral("unavailable") ? unavailableSettingLabel() - : friendlyValue(sandboxValue), - sandboxValue == QStringLiteral("default") || sandboxForKey(sandboxValue).has_value()); - } - refreshNetworkControl(false); - if (shouldRefresh(Field::Approval)) - { - const QSignalBlocker blocker(approval); - resetApprovalChoices(approval); - selectKey(approval, approvalValue, - approvalValue == QStringLiteral("unavailable") ? unavailableSettingLabel() - : friendlyValue(approvalValue), - approvalValue == QStringLiteral("default") || approvalForKey(approvalValue).has_value()); - } - if (shouldRefresh(Field::Reviewer)) - { - const QSignalBlocker blocker(reviewer); - resetReviewerChoices(reviewer); - selectKey(reviewer, reviewerValue, - reviewerValue == QStringLiteral("default") ? defaultSettingLabel() - : reviewerValue == QStringLiteral("unavailable") ? unavailableSettingLabel() - : friendlyValue(reviewerValue), - reviewerValue == QStringLiteral("default") - || typed::ApprovalsReviewer{toUtf8(reviewerValue)}.isKnown()); - } - if (shouldRefresh(Field::Cwd)) - { - const QSignalBlocker blocker(cwd); - cwd->setText(cwdValue); - } - if (shouldRefresh(Field::ServiceTier)) - { - const QSignalBlocker blocker(serviceTier); - selectKey(serviceTier, - serviceTierValue, - serviceTierValue == QStringLiteral("default") ? defaultSettingLabel() - : serviceTierValue == QStringLiteral("unavailable") ? unavailableSettingLabel() - : serviceTierValue, - serviceTierValue != QStringLiteral("unavailable")); - } - if (shouldRefresh(Field::Summary)) - { - const QSignalBlocker blocker(summary); - resetSummaryChoices(summary); - selectKey(summary, - summaryValue, - summaryValue == QStringLiteral("default") ? defaultSettingLabel() - : summaryValue == QStringLiteral("unavailable") ? unavailableSettingLabel() - : friendlyValue(summaryValue), - summaryValue == QStringLiteral("default") - || typed::ReasoningSummary{toUtf8(summaryValue)}.isKnown()); - } - if (shouldRefresh(Field::Collaboration)) - { - const QSignalBlocker blocker(collaboration); - resetCollaborationChoices(collaboration); - selectKey(collaboration, collaborationValue, - collaborationValue == QStringLiteral("default") ? QStringLiteral("Code") - : collaborationValue == QStringLiteral("unavailable") ? unavailableSettingLabel() - : friendlyValue(collaborationValue), - typed::ModeKind{toUtf8(collaborationValue)}.isKnown()); - } - refreshModelDependentControls(false); - fieldSurfaces[static_cast(Field::Sandbox)]->setEnabled( - editableSandbox); - fieldSurfaces[static_cast(Field::Sandbox)]->setToolTip( - editableSandbox ? QString{} - : QStringLiteral("This projected access policy is read-only in CodexUI")); - fieldSurfaces[static_cast(Field::Approval)]->setEnabled( - editableApproval); - fieldSurfaces[static_cast(Field::Approval)]->setToolTip( - editableApproval ? QString{} - : QStringLiteral("This projected approval policy is read-only in CodexUI")); -} - -void UpcomingTurnDock::refreshNetworkControl(bool accessChangedByUser) -{ - const QString access = currentFieldKey(Field::Sandbox); - const QString previous = currentFieldKey(Field::Network); - QString target; - - if (networkIsEditable(access)) { - const bool canonicalAccessSelected = canonicalConfiguration - && sandboxKey(canonicalConfiguration->sandboxPolicy) == access; - const QString canonicalNetwork = canonicalAccessSelected - ? networkKey(canonicalConfiguration->sandboxPolicy) - : QString{}; - const bool previousIsChoice = previous == QStringLiteral("restricted") - || previous == QStringLiteral("enabled"); - if (canonicalAccessSelected - && !networkChoiceIsRepresentable(access, canonicalNetwork)) - target = canonicalNetwork; - else if (previousIsChoice && (touched(Field::Network) || accessChangedByUser)) - target = previous; - else if (canonicalAccessSelected) - target = canonicalNetwork; - else - target = QStringLiteral("restricted"); - } else if (access == QStringLiteral("danger-full-access")) { - target = QStringLiteral("enabled"); - } else if (access == QStringLiteral("default")) { - target = QStringLiteral("default"); - } else { - target = QStringLiteral("unavailable"); - } - - const bool representable = networkChoiceIsRepresentable(access, target); - - { - const QSignalBlocker blocker(network); - resetNetworkChoices(network, access); - selectKey(network, target, - representable - ? friendlyValue(target) - : QStringLiteral("Unsupported (%1)").arg(target), - representable); - } - - const bool editable = networkIsEditable(access) && representable; - fieldSurfaces[static_cast(Field::Network)]->setEnabled(editable); - QString tooltip; - if (!representable) - tooltip = QStringLiteral("This network-access value cannot be represented or changed by CodexUI and will be left unchanged"); - else if (access == QStringLiteral("danger-full-access")) - tooltip = QStringLiteral("Full access includes network access; Codex does not provide a separate network override for this mode"); - else if (access == QStringLiteral("default")) - tooltip = QStringLiteral("Network access follows the Codex default access policy"); - else if (!editable) - tooltip = QStringLiteral("Network access is unavailable for this access policy"); - fieldSurfaces[static_cast(Field::Network)]->setToolTip(tooltip); - - if (accessChangedByUser) - setTouched(Field::Network, - target != canonicalKeys[static_cast(Field::Network)]); -} - -void UpcomingTurnDock::refreshModelControl() -{ - const QString selectedKey = currentFieldKey(Field::Model); - const int selectedIndex = model->currentIndex(); - const bool selectedCatalogEntry = selectedIndex >= 0 - && model->currentText() == model->itemText(selectedIndex); - const QString canonicalKey = canonicalKeys[static_cast(Field::Model)]; - const QString targetKey = touched(Field::Model) ? selectedKey : canonicalKey; - - const QSignalBlocker blocker(model); - model->clear(); - for (const auto& choice : modelCatalog) { - const QString key = fromUtf8(choice.model.value); - QString display = choice.displayName.empty() ? key : fromUtf8(choice.displayName); - model->addItem(display, key); - } - int targetIndex = model->findData(targetKey); - if (targetIndex < 0) { - const QString display = targetKey == QStringLiteral("unavailable") - ? unavailableSettingLabel() - : targetKey.isEmpty() ? defaultSettingLabel() : targetKey; - model->addItem(display, targetKey); - targetIndex = model->count() - 1; - if (targetKey == QStringLiteral("unavailable")) { - if (auto* itemModel = qobject_cast(model->model())) { - if (QStandardItem* item = itemModel->item(targetIndex)) - item->setFlags(item->flags() & ~Qt::ItemIsEnabled & ~Qt::ItemIsSelectable); - } - } - } - model->setCurrentIndex(targetIndex); - const typed::Model* advertisedDefault = defaultModelDefinition(); - const bool showingAdvertisedDefault = codexDefaultsContext - && advertisedDefault - && model->currentData().toString() == fromUtf8(advertisedDefault->model.value); - model->setToolTip(showingAdvertisedDefault - ? QStringLiteral("Codex default model") - : QString{}); - if (touched(Field::Model) && !selectedCatalogEntry && !selectedKey.isEmpty()) { - model->setEditText(selectedKey); - model->lineEdit()->setCursorPosition(static_cast(selectedKey.size())); - } else { - model->lineEdit()->setCursorPosition(0); - model->lineEdit()->deselect(); - } -} - -const typed::Model* UpcomingTurnDock::defaultModelDefinition() const -{ - const auto match = std::ranges::find_if(modelCatalog, [](const auto& candidate) { - return candidate.isDefault; - }); - return match == modelCatalog.end() ? nullptr : &*match; -} - -const typed::Model* UpcomingTurnDock::selectedModelDefinition() const -{ - const std::string selected = toUtf8(currentFieldKey(Field::Model)); - const auto match = std::ranges::find_if(modelCatalog, [&selected](const auto& candidate) { - return candidate.model.value == selected; - }); - return match == modelCatalog.end() ? nullptr : &*match; -} - -void UpcomingTurnDock::refreshModelDependentControls(bool modelChangedByUser) -{ - const typed::Model* definition = selectedModelDefinition(); - QString defaultEffortText = defaultSettingLabel(); - if (definition && !definition->defaultReasoningEffort.value.empty()) - defaultEffortText = friendlyValue(fromUtf8(definition->defaultReasoningEffort.value)) - + QStringLiteral(" · default"); - - QString effortTarget = currentFieldKey(Field::Effort); - const bool constrainedEfforts = definition && !definition->supportedReasoningEfforts.empty(); - const auto supportsEffort = [definition](const QString& key) { - return definition && std::ranges::any_of( - definition->supportedReasoningEfforts, - [&key](const auto& option) { - return fromUtf8(option.reasoningEffort.value) == key; - }); - }; - if (modelChangedByUser && effortTarget == QStringLiteral("unavailable")) { - effortTarget = definition && !definition->defaultReasoningEffort.value.empty() - ? fromUtf8(definition->defaultReasoningEffort.value) - : QStringLiteral("default"); - } - if (modelChangedByUser && constrainedEfforts - && effortTarget != QStringLiteral("default") && !supportsEffort(effortTarget)) { - effortTarget = fromUtf8(definition->defaultReasoningEffort.value); - } - { - const QSignalBlocker blocker(effort); - effort->clear(); - addChoice(effort, defaultEffortText, QStringLiteral("default")); - if (constrainedEfforts) { - for (const auto& option : definition->supportedReasoningEfforts) { - const QString key = fromUtf8(option.reasoningEffort.value); - addChoice(effort, friendlyValue(key), key); - } - } else { - addChoice(effort, QStringLiteral("Minimal"), QStringLiteral("minimal")); - addChoice(effort, QStringLiteral("Low"), QStringLiteral("low")); - addChoice(effort, QStringLiteral("Medium"), QStringLiteral("medium")); - addChoice(effort, QStringLiteral("High"), QStringLiteral("high")); - addChoice(effort, QStringLiteral("XHigh"), QStringLiteral("xhigh")); - } - selectKey(effort, effortTarget, - effortTarget == QStringLiteral("unavailable") ? unavailableSettingLabel() - : friendlyValue(effortTarget), - effortTarget == QStringLiteral("default") || supportsEffort(effortTarget) - || typed::ReasoningEffort{toUtf8(effortTarget)}.isKnown()); - } - if (modelChangedByUser) - setTouched(Field::Effort, - effortTarget != canonicalKeys[static_cast(Field::Effort)]); - - QString tierTarget = currentFieldKey(Field::ServiceTier); - std::vector> tiers; - if (definition) { - tiers.reserve(definition->serviceTiers.size() + definition->additionalSpeedTiers.size()); - for (const auto& tier : definition->serviceTiers) { - const QString key = fromUtf8(tier.id.value); - const QString display = tier.name.empty() ? friendlyValue(key) : fromUtf8(tier.name); - if (!std::ranges::any_of(tiers, [&key](const auto& item) { return item.second == key; })) - tiers.emplace_back(display, key); - } - for (const auto& tier : definition->additionalSpeedTiers) { - const QString key = fromUtf8(tier.value); - if (!std::ranges::any_of(tiers, [&key](const auto& item) { return item.second == key; })) - tiers.emplace_back(friendlyValue(key), key); - } - } - const bool constrainedTiers = !tiers.empty(); - const auto supportsTier = [&tiers](const QString& key) { - return std::ranges::any_of(tiers, [&key](const auto& item) { return item.second == key; }); - }; - if (modelChangedByUser && constrainedTiers - && tierTarget != QStringLiteral("default") && !supportsTier(tierTarget)) { - tierTarget = definition->defaultServiceTier.hasValue() - ? fromUtf8(definition->defaultServiceTier->value) - : QStringLiteral("default"); - } - { - const QSignalBlocker blocker(serviceTier); - serviceTier->clear(); - QString defaultTierText = defaultSettingLabel(); - if (definition && definition->defaultServiceTier.hasValue()) - defaultTierText = friendlyValue(fromUtf8(definition->defaultServiceTier->value)) - + QStringLiteral(" · default"); - addChoice(serviceTier, defaultTierText, QStringLiteral("default")); - for (const auto& [display, key] : tiers) - addChoice(serviceTier, display, key); - selectKey(serviceTier, - tierTarget, - tierTarget == QStringLiteral("default") ? defaultTierText - : tierTarget == QStringLiteral("unavailable") ? unavailableSettingLabel() - : friendlyValue(tierTarget), - tierTarget != QStringLiteral("unavailable")); - serviceTier->lineEdit()->setReadOnly(constrainedTiers); - } - if (modelChangedByUser) - setTouched(Field::ServiceTier, - tierTarget != canonicalKeys[static_cast(Field::ServiceTier)]); - - const bool personalityUnsupported = definition && !definition->supportsPersonality; - if (modelChangedByUser && personalityUnsupported) { - const QSignalBlocker blocker(personality); - selectKey(personality, QStringLiteral("default"), defaultSettingLabel()); - setTouched(Field::Personality, - canonicalKeys[static_cast(Field::Personality)] - != QStringLiteral("default")); - } - fieldSurfaces[static_cast(Field::Personality)]->setEnabled( - !personalityUnsupported); - fieldSurfaces[static_cast(Field::Personality)]->setToolTip( - personalityUnsupported - ? QStringLiteral("The selected model does not support personality settings") - : QString{}); - - const QString selectedModel = currentFieldKey(Field::Model); - const QString selectedEffort = currentFieldKey(Field::Effort); - const bool collaborationAvailable = !selectedModel.isEmpty() - && selectedModel != QStringLiteral("unavailable") - && !selectedEffort.isEmpty() && selectedEffort != QStringLiteral("unavailable") - && (canonicalConfiguration || codexDefaultsContext || touched(Field::Model)); - if (!collaborationAvailable) - setTouched(Field::Collaboration, false); - fieldSurfaces[static_cast(Field::Collaboration)]->setEnabled( - collaborationAvailable); - fieldSurfaces[static_cast(Field::Collaboration)]->setToolTip( - collaborationAvailable - ? QString{} - : QStringLiteral("Choose a model and reasoning effort before changing collaboration mode")); - updateSendEnabled(); -} - -void UpcomingTurnDock::updatePromptHeight(int editorHeight) -{ - const int wantedDockHeight = compactBaseHeight + editorHeight - - ExpandingPromptEditor::compactHeight(); - if (wantedDockHeight == height()) - return; - setFixedHeight(wantedDockHeight); - updateGeometry(); - emit dockHeightChanged(wantedDockHeight); -} - -void UpcomingTurnDock::updateSendEnabled() -{ - const bool hasPrompt = !editor->toPlainText().trimmed().isEmpty(); - const bool hasAttachments = !selectedAttachments.isEmpty(); - const bool hasUnsupportedImages = std::ranges::any_of( - selectedAttachments, - [](const AttachmentInfo& attachment) { - return attachment.kind == AttachmentInfo::Kind::Image; - }) && !selectedModelSupportsImages(); - const bool actionMatchesDraft = !draftPromptTarget || *draftPromptTarget == currentPromptTarget; - send->setEnabled(sendContextAllowed && editor->isEnabled() - && (hasPrompt || hasAttachments) && actionMatchesDraft - && !hasUnsupportedImages); - const QString problem = !actionMatchesDraft - ? QStringLiteral("The turn state changed while this draft was open. Edit the draft or attachments to confirm its new action.") - : hasUnsupportedImages - ? QStringLiteral("The selected model does not accept image input.") - : QString{}; - send->setToolTip(problem); - editor->setToolTip(problem); - if (!actionMatchesDraft) { - const QString retained = QStringLiteral("Draft retained for a previous active turn; edit it to retarget"); - status->setProperty("draftTargetMismatch", true); - status->setText(retained); - status->setToolTip(Qt::convertFromPlainText(retained, Qt::WhiteSpaceNormal)); - status->setStyleSheet(QStringLiteral("color:#667085;font-size:10px;")); - } else if (status->property("draftTargetMismatch").toBool()) { - setStatus({}); - } -} - -void UpcomingTurnDock::chooseAttachments() -{ - const QStringList paths = QFileDialog::getOpenFileNames( - this, - QStringLiteral("Attach files"), - attachmentWorkspace(), - QStringLiteral("All files (*)")); - if (paths.isEmpty()) - return; - QString error; - if (!addAttachmentPaths(paths, &error)) - setStatus(error, true); - else - setStatus({}); -} - -void UpcomingTurnDock::refreshAttachmentPresentation() -{ - if (QMenu* previous = attachmentSummary->menu()) { - attachmentSummary->setMenu(nullptr); - previous->deleteLater(); - } - attachmentSummary->setVisible(!selectedAttachments.isEmpty()); - if (selectedAttachments.isEmpty()) { - attachmentSummary->setText({}); - attachmentSummary->setToolTip({}); - return; - } - - attachmentSummary->setText(selectedAttachments.size() == 1 - ? QStringLiteral("1 attached") - : QStringLiteral("%1 attached").arg(selectedAttachments.size())); - QStringList details; - auto* menu = new QMenu(attachmentSummary); - menu->setObjectName(QStringLiteral("upcomingAttachmentMenu")); - for (qsizetype index = 0; index < selectedAttachments.size(); ++index) { - const AttachmentInfo& attachment = selectedAttachments.at(index); - details.append(QStringLiteral("%1 (%2)") - .arg(attachment.displayName, - AttachmentManager::formatSize(attachment.sizeBytes))); - QIcon icon = style()->standardIcon(QStyle::SP_FileIcon); - if (attachment.kind == AttachmentInfo::Kind::Image) { - QImageReader reader(attachment.sourcePath); - reader.setAutoTransform(true); - const QSize sourceSize = reader.size(); - constexpr qint64 maximumThumbnailSourcePixels = 100'000'000; - if (sourceSize.isValid() && sourceSize.width() <= 32'768 - && sourceSize.height() <= 32'768 - && static_cast(sourceSize.width()) * sourceSize.height() - <= maximumThumbnailSourcePixels) { - reader.setScaledSize(sourceSize.scaled( - QSize(48, 48), Qt::KeepAspectRatio)); - const QImage thumbnail = reader.read(); - if (!thumbnail.isNull()) - icon = QIcon(QPixmap::fromImage(thumbnail)); - } - } - QAction* remove = menu->addAction( - icon, QStringLiteral("Remove %1").arg(attachment.displayName)); - connect(remove, &QAction::triggered, this, [this, path = attachment.sourcePath] { - selectedAttachments.removeIf([&path](const AttachmentInfo& value) { - return value.sourcePath == path; - }); - updateDraftTarget(); - refreshAttachmentPresentation(); - updateSendEnabled(); - }); - } - menu->addSeparator(); - QAction* clear = menu->addAction(QStringLiteral("Remove all attachments")); - connect(clear, &QAction::triggered, this, [this] { - selectedAttachments.clear(); - updateDraftTarget(); - refreshAttachmentPresentation(); - updateSendEnabled(); - }); - attachmentSummary->setToolTip(details.join(QLatin1Char('\n'))); - attachmentSummary->setMenu(menu); -} - -bool UpcomingTurnDock::selectedModelSupportsImages() const -{ - const typed::Model* definition = selectedModelDefinition(); - return !definition || std::ranges::any_of( - definition->inputModalities, - [](const typed::InputModality& modality) { - return modality == typed::InputModality::image(); - }); -} - -void UpcomingTurnDock::updateDraftTarget() -{ - if (editor->toPlainText().trimmed().isEmpty() && selectedAttachments.isEmpty()) - draftPromptTarget.reset(); - else - draftPromptTarget = currentPromptTarget; -} - -void UpcomingTurnDock::updateChangedPresentation() -{ - const auto applyChanged = [](QWidget* widget, bool changed) { - if (!widget || widget->property("changed").toBool() == changed) - return; - widget->setProperty("changed", changed); - widget->style()->unpolish(widget); - widget->style()->polish(widget); - widget->update(); - }; - for (std::size_t index = 0; index < fieldSurfaces.size(); ++index) - applyChanged(fieldSurfaces[index], touchedFields[index]); - - const bool advancedChanged = touched(Field::Reviewer) || touched(Field::ServiceTier) - || touched(Field::Summary) || touched(Field::Collaboration); - applyChanged(more, advancedChanged); - settingsHint->setText(hasSettingsChanges() - ? QStringLiteral("Changed values apply to this and subsequent turns") - : QString{}); -} - -void UpcomingTurnDock::markComboChange(Field field, QComboBox* combo) -{ - QString current = combo->currentData().toString(); - if (combo->isEditable()) - { - const int index = combo->currentIndex(); - const bool matchesSelectedItem = index >= 0 && combo->currentText() == combo->itemText(index); - if (!matchesSelectedItem) - current = combo->currentText().trimmed(); - } - setTouched(field, current != canonicalKeys[static_cast(field)]); -} - -void UpcomingTurnDock::markTextChange(Field field, QLineEdit* edit) -{ - setTouched(field, edit->text().trimmed() != canonicalKeys[static_cast(field)]); -} - -QString UpcomingTurnDock::currentFieldKey(Field field) const -{ - const auto comboKey = [](const QComboBox* combo) { - if (combo->isEditable()) - { - const int index = combo->currentIndex(); - const bool matchesSelectedItem = index >= 0 && combo->currentText() == combo->itemText(index); - if (!matchesSelectedItem) - return combo->currentText().trimmed(); - } - return combo->currentData().toString(); - }; - switch (field) - { - case Field::Model: - return comboKey(model); - case Field::Effort: - return comboKey(effort); - case Field::Personality: - return comboKey(personality); - case Field::Sandbox: - return comboKey(sandbox); - case Field::Network: - return comboKey(network); - case Field::Approval: - return comboKey(approval); - case Field::Reviewer: - return comboKey(reviewer); - case Field::Cwd: - return cwd->text().trimmed(); - case Field::ServiceTier: - return comboKey(serviceTier); - case Field::Summary: - return comboKey(summary); - case Field::Collaboration: - return comboKey(collaboration); - case Field::Count: - break; - } - return {}; -} - -bool UpcomingTurnDock::touched(Field field) const noexcept -{ - return touchedFields[static_cast(field)]; -} - -void UpcomingTurnDock::setTouched(Field field, bool value) -{ - const auto index = static_cast(field); - if (touchedFields[index] == value) - return; - touchedFields[index] = value; - updateChangedPresentation(); - emit settingsChanged(); -} - -} // namespace codexui diff --git a/src/ui/UpcomingTurnDock.h b/src/ui/UpcomingTurnDock.h deleted file mode 100644 index 6c9b5e1..0000000 --- a/src/ui/UpcomingTurnDock.h +++ /dev/null @@ -1,182 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#ifndef CODEXUI_UI_UPCOMINGTURNDOCK_H -#define CODEXUI_UI_UPCOMINGTURNDOCK_H - -#include "app/AttachmentManager.h" - -#include -#include -#include - -#include - -#include -#include -#include -#include - -class QComboBox; -class QFrame; -class QLabel; -class QLineEdit; -class QMenu; -class QPushButton; - -namespace codexui { - -class ExpandingPromptEditor; - -// A non-authoritative, write-only description of the settings that the user -// changed for the upcoming turn. Untouched fields stay omitted so callers can -// pass the draft to the typed AISuite turn/start surface without replacing -// canonical thread settings with UI defaults. -struct UpcomingTurnDraft -{ - QString threadIdentity; - std::array presentationKeys{}; - ai::openai::codex::typed::OptionalNullable model; - ai::openai::codex::typed::OptionalNullable effort; - ai::openai::codex::typed::OptionalNullable personality; - ai::openai::codex::typed::OptionalNullable sandboxPolicy; - ai::openai::codex::typed::OptionalNullable approvalPolicy; - ai::openai::codex::typed::OptionalNullable approvalsReviewer; - ai::openai::codex::typed::OptionalNullable cwd; - ai::openai::codex::typed::OptionalNullable serviceTier; - ai::openai::codex::typed::OptionalNullable summary; - ai::openai::codex::typed::OptionalNullable collaborationMode; - - [[nodiscard]] bool empty() const noexcept; -}; - -class UpcomingTurnDock final : public QWidget -{ - Q_OBJECT - -public: - explicit UpcomingTurnDock(QWidget* parent = nullptr); - - // Rebase untouched controls from immutable AISuite State. User changes are - // kept while the same thread receives unrelated streaming state updates. - void setCanonicalConfiguration( - const std::optional& configuration, - const QString& stableThreadIdentity, - bool useCodexDefaults = false); - void setModelCatalog(const std::vector& catalog); - [[nodiscard]] UpcomingTurnDraft draft() const; - [[nodiscard]] bool hasSettingsChanges() const noexcept; - void clearTouchedSettings(); - void acknowledgeSubmittedSettings(const UpcomingTurnDraft& submitted); - - [[nodiscard]] QString prompt() const; - [[nodiscard]] const QList& attachments() const noexcept; - [[nodiscard]] QString attachmentWorkspace() const; - [[nodiscard]] bool addAttachmentPaths(const QStringList& paths, - QString* errorMessage = nullptr); - void clearPrompt(); - void clearPromptIfUnchanged(const QString& submittedPrompt); - void clearAttachmentsIfUnchanged(const QList& submittedAttachments); - void focusPrompt(); - void setActionState(bool primaryAllowed, - bool stopAllowed, - bool editorAllowed, - bool settingsAllowed, - bool stopVisible, - bool steerMode, - const QString& actionThreadIdentity = {}, - const QString& activeTurnIdentity = {}); - void setStatus(const QString& text, bool error = false); - [[nodiscard]] int baseHeight() const noexcept; - -signals: - void sendRequested(const QString& prompt, bool steerRequested); - void stopRequested(); - void settingsChanged(); - void dockHeightChanged(int height); - -private: - struct PromptDraftTarget { - QString threadIdentity; - QString turnIdentity; - bool steering = false; - - bool operator==(const PromptDraftTarget&) const = default; - }; - - enum class Field : std::size_t { - Model, - Effort, - Personality, - Sandbox, - Network, - Approval, - Reviewer, - Cwd, - ServiceTier, - Summary, - Collaboration, - Count - }; - - void refreshControls(bool resetAll); - void refreshNetworkControl(bool accessChangedByUser); - void refreshModelControl(); - void refreshModelDependentControls(bool modelChangedByUser); - [[nodiscard]] const ai::openai::codex::typed::Model* defaultModelDefinition() const; - [[nodiscard]] const ai::openai::codex::typed::Model* selectedModelDefinition() const; - void resolveSubmittedSettings(const UpcomingTurnDraft& submitted); - void chooseAttachments(); - void refreshAttachmentPresentation(); - [[nodiscard]] bool selectedModelSupportsImages() const; - void updateDraftTarget(); - void updatePromptHeight(int editorHeight); - void updateSendEnabled(); - void updateChangedPresentation(); - void markComboChange(Field field, QComboBox* combo); - void markTextChange(Field field, QLineEdit* edit); - [[nodiscard]] QString currentFieldKey(Field field) const; - [[nodiscard]] bool touched(Field field) const noexcept; - void setTouched(Field field, bool value); - - std::optional canonicalConfiguration; - QString threadIdentity; - std::array(Field::Count)> touchedFields{}; - std::array(Field::Count)> canonicalKeys{}; - std::array(Field::Count)> fieldSurfaces{}; - std::vector modelCatalog; - bool codexDefaultsContext = false; - - QFrame* settingsSurface = nullptr; - QComboBox* model = nullptr; - QComboBox* effort = nullptr; - QComboBox* personality = nullptr; - QComboBox* sandbox = nullptr; - QComboBox* network = nullptr; - QComboBox* approval = nullptr; - QLineEdit* cwd = nullptr; - QPushButton* more = nullptr; - QMenu* moreMenu = nullptr; - QComboBox* reviewer = nullptr; - QComboBox* serviceTier = nullptr; - QComboBox* summary = nullptr; - QComboBox* collaboration = nullptr; - QFrame* composerSurface = nullptr; - QPushButton* attach = nullptr; - QPushButton* attachmentSummary = nullptr; - ExpandingPromptEditor* editor = nullptr; - QLabel* settingsHint = nullptr; - QLabel* status = nullptr; - QPushButton* send = nullptr; - QPushButton* stop = nullptr; - bool sendContextAllowed = false; - bool controlsContextAllowed = false; - bool steeringMode = false; - PromptDraftTarget currentPromptTarget; - std::optional draftPromptTarget; - QList selectedAttachments; - int compactBaseHeight = 0; -}; - -} // namespace codexui - -#endif // CODEXUI_UI_UPCOMINGTURNDOCK_H diff --git a/src/ui/WorkbenchWidget.cpp b/src/ui/WorkbenchWidget.cpp deleted file mode 100644 index 581498f..0000000 --- a/src/ui/WorkbenchWidget.cpp +++ /dev/null @@ -1,2391 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "ui/WorkbenchWidget.h" - -#include "app/FrontendSession.h" -#include "ui/ConversationWidget.h" -#include "ui/InspectorWidget.h" -#include "ui/InteractiveRequestDialog.h" -#include "ui/PresentationRefreshAccumulator.h" -#include "ui/SidebarWidget.h" -#include "ui/ThreadSetupDialog.h" -#include "ui/UpcomingTurnDock.h" - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -namespace codexui { -namespace { - -void setStyleSheetIfChanged(QWidget* widget, const QString& styleSheet) -{ - if (widget->styleSheet() != styleSheet) - widget->setStyleSheet(styleSheet); -} - -std::optional interactiveResponseValidationError( - const ai::openai::codex::frontend::client::State& state, - const InteractiveRequestResponse& response) -{ - const auto source = detail::interactiveRequestSource(state, response.requestId); - if (!source) - return QStringLiteral("This request is no longer pending"); - if (source->request.kind != response.kind) - return QStringLiteral("This request changed; review it and retry"); - if (*source != response.source) - return QStringLiteral("This request changed; review it and retry"); - const auto safety = detail::interactiveRequestResponseSafety(*source); - if (safety == InteractiveRequestResponseSafety::Disabled - || (safety == InteractiveRequestResponseSafety::NegativeOnly - && !detail::interactiveResponseIsNegative(response))) - return QStringLiteral("This request is incomplete and cannot be safely answered"); - return std::nullopt; -} - -QLabel* label(const QString& text, const char* kind = nullptr) -{ - auto* result = new QLabel(text); - result->setTextFormat(Qt::PlainText); - if (kind) - result->setProperty("kind", kind); - return result; -} - -QString plainTooltip(const QString& text) -{ - return Qt::convertFromPlainText(text, Qt::WhiteSpaceNormal); -} - -std::string toUtf8(const QString& value) -{ - const QByteArray encoded = value.toUtf8(); - return std::string(encoded.constData(), static_cast(encoded.size())); -} - -QFrame* dot(const QString& color, int size = 7) -{ - auto* result = new QFrame; - result->setFixedSize(size, size); - result->setStyleSheet(QStringLiteral("background:%1;border-radius:%2px;").arg(color).arg(size / 2)); - return result; -} - -QWidget* makeTopBar(QPushButton*& restoreLeft, - QPushButton*& restoreRight, - QLabel*& workspace, - QPushButton*& attention, - QPushButton*& reconnect) -{ - auto* bar = new QFrame; - bar->setObjectName(QStringLiteral("topBar")); - bar->setStyleSheet(QStringLiteral( - "QFrame#topBar{background:#ffffff;border-bottom:1px solid #d7dee8;}")); - bar->setFixedHeight(56); - auto* row = new QHBoxLayout(bar); - row->setContentsMargins(20, 0, 18, 0); - row->setSpacing(12); - - auto* brand = label(QStringLiteral("CODEX WORKBENCH"), "title"); - brand->setStyleSheet(QStringLiteral("font-size:13px;font-weight:600;")); - row->addWidget(brand); - - restoreLeft = new QPushButton(QStringLiteral("Show threads")); - restoreLeft->setProperty("kind", "subtle"); - restoreLeft->setFixedHeight(32); - restoreLeft->hide(); - row->addSpacing(12); - row->addWidget(restoreLeft); - row->addSpacing(18); - - workspace = label(QStringLiteral("No workspace"), "muted"); - workspace->setObjectName(QStringLiteral("workspaceBreadcrumb")); - workspace->setStyleSheet(QStringLiteral("color:#667085;font-size:12px;font-weight:500;")); - row->addWidget(workspace); - row->addStretch(); - - reconnect = new QPushButton(QStringLiteral("Reconnect")); - reconnect->setProperty("kind", "subtle"); - reconnect->setFixedHeight(32); - reconnect->hide(); - row->addWidget(reconnect); - - attention = new QPushButton(QStringLiteral("0 requests")); - attention->setFixedSize(106, 32); - row->addWidget(attention); - - restoreRight = new QPushButton(QStringLiteral("Show inspector")); - restoreRight->setProperty("kind", "subtle"); - restoreRight->setFixedHeight(32); - restoreRight->hide(); - row->addWidget(restoreRight); - return bar; -} - -QWidget* makeStatusBar(QFrame*& codexStatusDot, - QLabel*& threadContextStatus, - QLabel*& agentActivityStatus, - QLabel*& synchronizationStatus, - QLabel*& controllerStatus, - QLabel*& attentionStatus) -{ - auto* bar = new QFrame; - bar->setObjectName(QStringLiteral("customStatusBar")); - bar->setStyleSheet(QStringLiteral( - "QFrame#customStatusBar{background:#f8fafc;border-top:1px solid #d7dee8;}")); - bar->setFixedHeight(40); - auto* row = new QHBoxLayout(bar); - row->setContentsMargins(18, 0, 80, 0); - row->setSpacing(8); - - codexStatusDot = dot(QStringLiteral("#98a2b3")); - row->addWidget(codexStatusDot); - row->addWidget(label(QStringLiteral("Codex"), "meta")); - row->addSpacing(48); - threadContextStatus = label(QStringLiteral("No thread context"), "meta"); - row->addWidget(threadContextStatus); - row->addSpacing(62); - agentActivityStatus = label(QStringLiteral("No agent activity"), "meta"); - row->addWidget(agentActivityStatus); - row->addSpacing(24); - synchronizationStatus = label(QStringLiteral("Disconnected"), "meta"); - synchronizationStatus->setStyleSheet(QStringLiteral("color:#667085;font-size:10px;font-weight:600;")); - row->addWidget(synchronizationStatus); - row->addSpacing(18); - controllerStatus = label(QStringLiteral("Observer"), "meta"); - controllerStatus->setStyleSheet(QStringLiteral("color:#667085;font-size:10px;font-weight:600;")); - row->addWidget(controllerStatus); - row->addStretch(); - attentionStatus = label(QStringLiteral("0 requests"), "meta"); - attentionStatus->setStyleSheet(QStringLiteral("color:#667085;font-size:10px;font-weight:600;")); - row->addWidget(attentionStatus); - return bar; -} - -const ai::openai::codex::frontend::client::TurnState* -activeTurn(const ai::openai::codex::frontend::client::State& state, - const ai::openai::codex::frontend::client::ThreadState* thread) -{ - if (!thread) - return nullptr; - for (auto iterator = thread->orderedTurns.rbegin(); iterator != thread->orderedTurns.rend(); ++iterator) { - const auto* turn = state.turn(thread->id, *iterator); - if (turn && turn->active && !turn->terminal) - return turn; - } - return nullptr; -} - -const ai::openai::codex::frontend::client::TurnState* -latestTurn(const ai::openai::codex::frontend::client::State& state, - const ai::openai::codex::frontend::client::ThreadState* thread) -{ - if (!thread) - return nullptr; - for (auto iterator = thread->orderedTurns.rbegin(); iterator != thread->orderedTurns.rend(); ++iterator) { - if (const auto* turn = state.turn(thread->id, *iterator)) - return turn; - } - return nullptr; -} - -} // namespace - -WorkbenchWidget::WorkbenchWidget(FrontendSession& session, QWidget* parent) - : QWidget(parent) - , frontendSession(session) -{ - setObjectName(QStringLiteral("workbench")); - auto* layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); - layout->addWidget(makeTopBar(restoreSidebar, - restoreInspector, - workspaceBreadcrumb, - attentionButton, - reconnectButton)); - - splitter = new QSplitter(Qt::Horizontal); - splitter->setChildrenCollapsible(false); - splitter->setHandleWidth(8); - sidebar = new SidebarWidget; - conversation = new ConversationWidget; - inspector = new InspectorWidget; - splitter->addWidget(sidebar); - splitter->addWidget(conversation); - splitter->addWidget(inspector); - splitter->setStretchFactor(0, 0); - splitter->setStretchFactor(1, 1); - splitter->setStretchFactor(2, 0); - splitter->setSizes({282, 834, 404}); - layout->addWidget(splitter, 1); - layout->addWidget(makeStatusBar(codexStatusDot, threadContextStatus, agentActivityStatus, - synchronizationStatus, controllerStatus, attentionStatus)); - - interactiveRequestDialog = new InteractiveRequestDialog( - [this]() -> const ai::openai::codex::frontend::client::State& { return frontendSession.state(); }, - [this](InteractiveRequestResponse response) { submitInteractiveResponse(std::move(response)); }, - this); - - connect(sidebar, &SidebarWidget::hideRequested, this, [this] { setSidebarVisible(false); }); - connect(inspector, &InspectorWidget::hideRequested, this, [this] { setInspectorVisible(false); }); - connect(inspector, &InspectorWidget::historicalTurnCloseRequested, this, [this] { - selectedInspectorTurnId.clear(); - inspector->render(frontendSession.state(), - selectedThreadId, - frontendSession.lifecycle() == FrontendSession::Lifecycle::Ready, - frontendSession.statusText()); - }); - connect(restoreSidebar, &QPushButton::clicked, this, [this] { setSidebarVisible(true); }); - connect(restoreInspector, &QPushButton::clicked, this, [this] { setInspectorVisible(true); }); - connect(attentionButton, &QPushButton::clicked, interactiveRequestDialog, &InteractiveRequestDialog::present); - connect(reconnectButton, &QPushButton::clicked, &frontendSession, &FrontendSession::reconnectToBackend); - connect(sidebar, &SidebarWidget::newThreadRequested, this, &WorkbenchWidget::beginNewThread); - connect(sidebar, &SidebarWidget::threadSelected, this, &WorkbenchWidget::selectThread); - connect(sidebar, &SidebarWidget::threadActionRequested, - this, &WorkbenchWidget::handleThreadAction); - connect(inspector, &InspectorWidget::selectionChanged, this, [this] { refreshState(); }); - connect(inspector, &InspectorWidget::threadOpenRequested, this, &WorkbenchWidget::selectProjectedAgentThread); - connect(conversation, &ConversationWidget::sendRequested, this, &WorkbenchWidget::sendPrompt); - connect(conversation, &ConversationWidget::stopRequested, this, &WorkbenchWidget::stopActiveTurn); - connect(conversation, &ConversationWidget::turnDetailsRequested, this, - [this](const QString& turnId) { - selectedInspectorTurnId = turnId; - inspector->render(frontendSession.state(), - selectedThreadId, - frontendSession.lifecycle() == FrontendSession::Lifecycle::Ready, - frontendSession.statusText(), - selectedInspectorTurnId); - inspector->showInfo(); - }); - connect(conversation, &ConversationWidget::latestPresentationRequested, this, - [this] { refreshState(true, false, false); }); - connect(&frontendSession, &FrontendSession::lifecycleChanged, this, &WorkbenchWidget::refreshLifecycle); - connect(&frontendSession, &FrontendSession::statusChanged, this, &WorkbenchWidget::refreshLifecycle); - connect(&frontendSession, &FrontendSession::stateChanged, this, &WorkbenchWidget::scheduleStateRefresh); - connect(&frontendSession, &FrontendSession::modelCatalogChanged, this, [this] { - conversation->setModelCatalog(frontendSession.modelCatalog()); - }); - - conversation->setModelCatalog(frontendSession.modelCatalog()); - recoverAttachmentStaging(); - refreshLifecycle(); - refreshState(); -} - -WorkbenchWidget::~WorkbenchWidget() -{ - cancelAttachmentPreparation(); -} - -void WorkbenchWidget::scheduleStateRefresh(const detail::StateUpdateScope& scope) -{ - const bool selectedThreadExactlyRemoved = - scope.removedThreadIds.contains(selectedThreadId); - if (selectedThreadExactlyRemoved) - authoritativelyRemovedSelectedThreadId = selectedThreadId; - else if (authoritativelyRemovedSelectedThreadId == selectedThreadId - && (!selectedThreadId.isEmpty() - && (scope.affectedThreadIds.contains(selectedThreadId) - || frontendSession.state().thread( - selectedThreadId.toStdString())))) { - // Match mailbox ordering across the separate 16 ms presentation - // window: newer exact presence supersedes an already delivered - // tombstone before a later bounded omission can become ambiguous. - authoritativelyRemovedSelectedThreadId.clear(); - } - // If a pathological removal burst exceeded the bounded GUI mailbox, ask - // the backend for the one identity the presentation actually needs. An - // absent result publishes one exact tombstone; an omitted result restores - // the retained thread without guessing from global capacity provenance. - if (scope.removedThreadIdsOverflowed && !selectedThreadExactlyRemoved - && !selectedThreadId.isEmpty() - && frontendSession.state().thread(selectedThreadId.toStdString()) == nullptr) - frontendSession.loadThread(selectedThreadId, true); - const bool currentSelectionAffected = scope.affectedThreadIds.contains(selectedThreadId); - const bool awaitedSelectionAffected = !newThreadIdAwaitingState.isEmpty() - && scope.affectedThreadIds.contains(newThreadIdAwaitingState); - const bool selectedAffected = scope.allThreadsAffected || currentSelectionAffected - || awaitedSelectionAffected; - const bool submittedTurnAffected = !turnThreadIdAwaitingState.isEmpty() - && (scope.allThreadsAffected - || scope.affectedThreadIds.contains( - turnThreadIdAwaitingState)); - const bool automaticResumeAffected = !automaticResumeThreadId.isEmpty() - && (scope.allThreadsAffected - || scope.affectedThreadIds.contains( - automaticResumeThreadId)); - const bool selectedInspectorAffected = scope.allInspectorsAffected - || std::ranges::any_of(scope.affectedInspectorThreadIds, - [this](const QString& threadId) { - return inspector->dependsOnThread(threadId) - || (!newThreadIdAwaitingState.isEmpty() - && threadId == newThreadIdAwaitingState); - }); - // Keep the factual State revision current without invoking the expensive - // Inspector projections when none of their selected semantics changed. - if (!selectedInspectorAffected) - inspector->updateStateRevision(frontendSession.state().revision()); - if (!selectedAffected && !selectedInspectorAffected && !scope.sidebarAffected - && !submittedTurnAffected && !automaticResumeAffected) - return; - if (selectedAffected) - detail::mergeSelectedPresentationRefresh( - selectedPresentationRefresh, - scope, - selectedThreadId, - awaitedSelectionAffected); - inspectorRefreshPending = inspectorRefreshPending || selectedInspectorAffected; - sidebarRefreshPending = sidebarRefreshPending || scope.sidebarAffected; - if (scope.sidebarAffected) { - if (scope.allSidebarThreadsAffected) { - sidebarFullRefreshPending = true; - sidebarThreadRefreshPending.clear(); - sidebarThreadRefreshPendingSet.clear(); - } else if (!sidebarFullRefreshPending) { - for (const QString& threadId : scope.affectedSidebarThreadIds) { - if (detail::appendUniqueSidebarThread( - sidebarThreadRefreshPending, - sidebarThreadRefreshPendingSet, - threadId) - == detail::BoundedMergeResult::CapacityExceeded) { - sidebarFullRefreshPending = true; - sidebarThreadRefreshPending.clear(); - sidebarThreadRefreshPendingSet.clear(); - break; - } - } - if (scope.affectedSidebarThreadIds.isEmpty()) { - sidebarFullRefreshPending = true; - sidebarThreadRefreshPending.clear(); - sidebarThreadRefreshPendingSet.clear(); - } - } - } - if (stateRefreshPending) - return; - stateRefreshPending = true; - // Let adjacent bounded socket batches collapse into one presentation pass - // while still updating live output at approximately one frame cadence. - QTimer::singleShot(16, this, [this] { - if (!stateRefreshPending) - return; - stateRefreshPending = false; - const bool refreshSelectedPresentation = - selectedPresentationRefresh.refreshPending; - const bool refreshInspector = inspectorRefreshPending; - const bool refreshSidebar = sidebarRefreshPending; - const bool refreshFullSidebar = sidebarFullRefreshPending; - QStringList sidebarThreadChanges = std::move(sidebarThreadRefreshPending); - const bool exactContentAvailable = refreshSelectedPresentation - && !selectedPresentationRefresh.fullRefreshPending - && !selectedPresentationRefresh.contentChanges.empty(); - const bool structuralReconciliation = refreshSelectedPresentation - && !selectedPresentationRefresh.fullRefreshPending - && selectedPresentationRefresh - .structuralReconciliationPending; - const bool exactContentOnly = exactContentAvailable - && !structuralReconciliation; - ConversationContentUpdates exactContentChanges = - std::move(selectedPresentationRefresh.contentChanges); - selectedPresentationRefresh.clear(); - inspectorRefreshPending = false; - sidebarRefreshPending = false; - sidebarFullRefreshPending = false; - sidebarThreadRefreshPending.clear(); - sidebarThreadRefreshPendingSet.clear(); - if (exactContentOnly && !refreshInspector && !refreshSidebar - && turnThreadIdAwaitingState.isEmpty() && automaticResumeThreadId.isEmpty() - && conversation->updateExactMessageContent( - frontendSession.state(), selectedThreadId, exactContentChanges)) - return; - refreshState(refreshSelectedPresentation, - refreshInspector, - refreshSidebar, - exactContentAvailable ? &exactContentChanges : nullptr, - refreshSidebar && !refreshFullSidebar && !sidebarThreadChanges.isEmpty() - ? &sidebarThreadChanges - : nullptr, - structuralReconciliation); - }); -} - -void WorkbenchWidget::refreshLifecycle() -{ - using Lifecycle = FrontendSession::Lifecycle; - const bool ready = frontendSession.lifecycle() == Lifecycle::Ready; - const bool becameReady = ready && !frontendWasReady; - frontendWasReady = ready; - QString color = QStringLiteral("#667085"); - QString title = QStringLiteral("App server disconnected"); - QString detail = frontendSession.statusText(); - - switch (frontendSession.lifecycle()) { - case Lifecycle::Connecting: - case Lifecycle::Authenticating: - case Lifecycle::Synchronizing: - color = QStringLiteral("#2f6feb"); - title = QStringLiteral("Connecting to app server"); - break; - case Lifecycle::Ready: - color = QStringLiteral("#23845a"); - title = QStringLiteral("App server connected"); - detail = QStringLiteral("Local · Unix · synchronized"); - break; - case Lifecycle::Failed: - color = QStringLiteral("#a76812"); - title = QStringLiteral("App server unavailable"); - break; - case Lifecycle::Disconnected: - break; - } - - const bool reconnectAvailable = frontendSession.lifecycle() == Lifecycle::Disconnected - || frontendSession.lifecycle() == Lifecycle::Failed; - reconnectButton->setVisible(reconnectAvailable); - reconnectButton->setEnabled(reconnectAvailable); - - sidebar->setConnectionStatus(title, detail, color); - setStyleSheetIfChanged(codexStatusDot, QStringLiteral("background:%1;border-radius:3px;").arg(color)); - synchronizationStatus->setText(frontendSession.statusText()); - synchronizationStatus->setToolTip(plainTooltip(frontendSession.statusText())); - setStyleSheetIfChanged(synchronizationStatus, - QStringLiteral("color:%1;font-size:10px;font-weight:600;").arg(color)); - if (frontendSession.lifecycle() != Lifecycle::Ready) { - threadContextStatus->setText(QStringLiteral("No thread context")); - threadContextStatus->setToolTip({}); - retainedAgentActivityThreadId.clear(); - retainedAgentActivityItemIds.clear(); - agentActivityStatus->setText(QStringLiteral("No agent activity")); - inspector->render(frontendSession.state(), {}, false, frontendSession.statusText()); - } - - if (frontendSession.lifecycle() != Lifecycle::Ready) { - const bool writeWasPending = pendingAction != PendingAction::None || controllerAcquireInFlight - || threadStartInFlight || threadResumeInFlight || turnStartInFlight - || turnSteerInFlight || attachmentPreparationInFlight - || interruptInFlight || threadMutationInFlight; - clearWriteTransients(); - if (writeWasPending) - showWriteError(QStringLiteral("Backend disconnected before the write completed")); - if (requestControllerAcquireInFlight || requestResponseInFlight || pendingInteractiveResponse) - clearInteractiveTransients(QStringLiteral("Backend disconnected before the response completed")); - } - refreshControllerStatus(); - refreshControls(); - // Inspector-projected selections deliberately survive reconnects. The - // worker clears its bounded read ownership on disconnect, so one explicit - // retry at the next Ready boundary restores that selection even when no - // later presentation event happens to arrive. - if (detail::shouldRetryProjectedSelectionAfterReady( - becameReady, selectedThreadId, projectedAgentThreadId)) - frontendSession.loadThread(selectedThreadId, true); -} - -void WorkbenchWidget::refreshState(bool refreshSelectedPresentation, - bool refreshInspector, - bool refreshSidebar, - const ConversationContentUpdates* exactContentChanges, - const QStringList* sidebarThreadChanges, - bool requiresStructuralReconciliation) -{ - stateRefreshPending = false; - selectedPresentationRefresh.clear(); - inspectorRefreshPending = false; - sidebarRefreshPending = false; - sidebarFullRefreshPending = false; - sidebarThreadRefreshPending.clear(); - sidebarThreadRefreshPendingSet.clear(); - const auto& state = frontendSession.state(); - const auto threads = state.threads(); - const bool ready = frontendSession.lifecycle() == FrontendSession::Lifecycle::Ready; - const bool threadListComplete = state.threadList().value && state.threadList().value->complete; - const bool threadDiscoveryComplete = threadListComplete - && frontendSession.archivedThreadDiscoveryComplete(); - const bool threadDiscoveryTerminal = threadListComplete - && frontendSession.archivedThreadDiscoveryTerminal(); - const auto capacityProvenance = state.capacityProvenance(); - const std::size_t omittedThreads = capacityProvenance - ? capacityProvenance->omittedThreads - : 0; - const QString previousThreadId = selectedThreadId; - const bool selectedAuthoritativelyRemoved = - authoritativelyRemovedSelectedThreadId == selectedThreadId; - authoritativelyRemovedSelectedThreadId.clear(); - - if (!newThreadIdAwaitingState.isEmpty() - && state.thread(newThreadIdAwaitingState.toStdString()) != nullptr) { - selectedThreadId = newThreadIdAwaitingState; - selectedInspectorTurnId.clear(); - newThreadIdAwaitingState.clear(); - } - const bool awaitingSelectedThread = !newThreadIdAwaitingState.isEmpty() - && selectedThreadId == newThreadIdAwaitingState; - if (!selectedThreadId.isEmpty() - && state.thread(selectedThreadId.toStdString()) == nullptr - && detail::shouldClearMissingSelectedThread( - ready, - threadDiscoveryTerminal, - awaitingSelectedThread, - omittedThreads, - selectedAuthoritativelyRemoved)) - selectedThreadId.clear(); - if (selectedThreadId.isEmpty() && !threads.empty() && newThreadIdAwaitingState.isEmpty()) - selectedThreadId = QString::fromStdString(threads.front().id.value); - const bool selectionChanged = previousThreadId != selectedThreadId; - if (selectionChanged) { - selectedInspectorTurnId.clear(); - conversation->clearPrompt(); - } - refreshSelectedPresentation = refreshSelectedPresentation || selectionChanged; - refreshInspector = refreshInspector || selectionChanged; - refreshSidebar = refreshSidebar || selectionChanged; - requiresStructuralReconciliation = requiresStructuralReconciliation - && !selectionChanged; - - if (refreshSidebar) { - if (!selectionChanged && sidebarThreadChanges && !sidebarThreadChanges->isEmpty()) - sidebar->updateThreads( - state, selectedThreadId, threadDiscoveryComplete, *sidebarThreadChanges); - else - sidebar->setThreads(state, selectedThreadId, threadDiscoveryComplete); - } - - // ConversationWidget resolves the stable selection against this exact - // immutable State and never retains backend object addresses. - if (refreshSelectedPresentation) { - conversation->render(state, - selectedThreadId, - false, - selectionChanged ? nullptr : exactContentChanges, - requiresStructuralReconciliation); - } - reconcileAttachmentStaging(); - reconcileSubmittedTurnSettings(); - - if (refreshInspector) - inspector->render(state, selectedThreadId, - ready, frontendSession.statusText(), selectedInspectorTurnId); - - const auto* selected = !selectedThreadId.isEmpty() - ? state.thread(selectedThreadId.toStdString()) - : nullptr; - const bool selectedMissingFromBoundedState = !selected - && !selectedThreadId.isEmpty() - && !awaitingSelectedThread - && (omittedThreads > 0 - || (state.threadList().value - && !state.threadList().value->complete)); - if (refreshSelectedPresentation && !requiresStructuralReconciliation) { - const QString context = ready && selected && selected->cwd - ? QString::fromStdString(selected->cwd->value) - : QStringLiteral("No thread context"); - const QString workspaceName = ready && selected && selected->cwd - ? QFileInfo(context).fileName() - : QString{}; - workspaceBreadcrumb->setText(workspaceName.isEmpty() - ? QStringLiteral("No workspace") - : QStringLiteral("Workspace / %1").arg(workspaceName)); - workspaceBreadcrumb->setToolTip(workspaceName.isEmpty() ? QString{} : plainTooltip(context)); - threadContextStatus->setText(context.size() > 44 - ? context.left(20) + QChar(0x2026) + context.right(20) - : context); - threadContextStatus->setToolTip(ready && selected && selected->cwd ? plainTooltip(context) : QString{}); - } - - if (refreshSelectedPresentation) { - QSet projectedAgentActivityItemIds; - if (ready && selected) { - for (const auto& turnId : selected->orderedTurns) { - const auto* turn = state.turn(selected->id, turnId); - if (!turn) - continue; - for (const auto& itemId : turn->orderedItems) { - const auto* item = state.item(selected->id, turn->id, itemId); - if (!item) - continue; - const auto semantic = ai::openai::codex::frontend::client::itemSemanticView(*item); - if (semantic - && (std::holds_alternative< - ai::openai::codex::frontend::client::SubAgentActivitySemanticView>(semantic->details) - || std::holds_alternative< - ai::openai::codex::frontend::client::CollabAgentToolCallSemanticView>(semantic->details))) - projectedAgentActivityItemIds.insert( - QString::fromStdString(item->id.value)); - } - } - } - if (retainedAgentActivityThreadId != selectedThreadId) { - retainedAgentActivityThreadId = selectedThreadId; - retainedAgentActivityItemIds.clear(); - } - if (selected) { - if (selected->fullyLoaded) - retainedAgentActivityItemIds = projectedAgentActivityItemIds; - else - retainedAgentActivityItemIds.unite(projectedAgentActivityItemIds); - } else if (!selectedMissingFromBoundedState) { - retainedAgentActivityItemIds.clear(); - } - const qsizetype agentActivities = retainedAgentActivityItemIds.size(); - agentActivityStatus->setText(agentActivities == 0 - ? QStringLiteral("No agent activity") - : QStringLiteral("%1 agent activit%2") - .arg(agentActivities) - .arg(agentActivities == 1 ? QStringLiteral("y") - : QStringLiteral("ies"))); - } - - const std::size_t attentionCount = state.hasPendingRequestProjection() ? state.pendingRequests().size() : 0; - const QString attentionText = attentionCount == 0 - ? QStringLiteral("No attention") - : QStringLiteral("%1 request%2") - .arg(attentionCount) - .arg(attentionCount == 1 ? QString{} : QStringLiteral("s")); - attentionButton->setText(attentionText); - attentionStatus->setText(attentionText); - const bool needsAttention = attentionCount > 0; - setStyleSheetIfChanged( - attentionButton, - needsAttention - ? QStringLiteral("background:#fff6df;color:#a76812;border:1px solid #e5c77d;border-radius:8px;font-size:11px;font-weight:600;") - : QStringLiteral("background:#f1f5fb;color:#667085;border:1px solid #d7dee8;border-radius:8px;font-size:11px;font-weight:600;")); - setStyleSheetIfChanged( - attentionStatus, - QStringLiteral("color:%1;font-size:10px;font-weight:600;") - .arg(needsAttention ? QStringLiteral("#a76812") : QStringLiteral("#667085"))); - attentionButton->setToolTip(needsAttention ? QStringLiteral("Open pending Codex requests") - : QStringLiteral("Codex has no pending requests")); - interactiveRequestDialog->synchronize(state); - - if ((selected && !selected->fullyLoaded) || selectedMissingFromBoundedState) - frontendSession.loadThread(selectedThreadId); - - reconcileAutomaticResumeState(); - controllerUnavailable = controllerUnavailable && !frontendSession.ownsController(); - refreshControllerStatus(); - maybeResumeSelectedThread(); - if (pendingAction != PendingAction::None && frontendSession.ownsController()) - executePendingAction(); - if (pendingInteractiveResponse && !requestControllerAcquireInFlight && !requestResponseInFlight - && frontendSession.ownsController()) - performInteractiveResponse(); - refreshControls(); -} - -void WorkbenchWidget::selectThread(const QString& threadId) -{ - const bool semanticSelectionChanged = selectedThreadId != threadId - || !projectedAgentThreadId.isEmpty(); - if (semanticSelectionChanged) { - cancelAttachmentPreparation(); - ++selectionGeneration; - } - if (automaticResumeThreadId != threadId) - automaticResumeAttemptedThreadIds.remove(threadId); - if (!newThreadIdAwaitingState.isEmpty() && threadId != newThreadIdAwaitingState) - newThreadIdAwaitingState.clear(); - if (selectedThreadId != threadId) - conversation->clearPrompt(); - if (selectedThreadId != threadId) - selectedInspectorTurnId.clear(); - selectedThreadId = threadId; - projectedAgentThreadId.clear(); - frontendSession.loadThread(selectedThreadId, true); - if (semanticSelectionChanged) - conversation->setWriteStatus({}); - refreshState(); -} - -void WorkbenchWidget::selectProjectedAgentThread(const QString& threadId) -{ - const bool semanticSelectionChanged = selectedThreadId != threadId - || projectedAgentThreadId != threadId; - if (semanticSelectionChanged) { - cancelAttachmentPreparation(); - ++selectionGeneration; - } - if (automaticResumeThreadId != threadId) - automaticResumeAttemptedThreadIds.remove(threadId); - if (!newThreadIdAwaitingState.isEmpty() && threadId != newThreadIdAwaitingState) - newThreadIdAwaitingState.clear(); - if (selectedThreadId != threadId) - conversation->clearPrompt(); - if (selectedThreadId != threadId) - selectedInspectorTurnId.clear(); - selectedThreadId = threadId; - projectedAgentThreadId = threadId; - frontendSession.loadThread(selectedThreadId, true); - if (semanticSelectionChanged) - conversation->setWriteStatus({}); - refreshState(); -} - -void WorkbenchWidget::refreshControls() -{ - const bool ready = frontendSession.lifecycle() == FrontendSession::Lifecycle::Ready; - const auto& state = frontendSession.state(); - const auto* selected = !selectedThreadId.isEmpty() - ? state.thread(selectedThreadId.toStdString()) - : nullptr; - const auto* active = activeTurn(state, selected); - const bool pendingControllerWrite = controllerAcquireInFlight || pendingAction != PendingAction::None - || requestControllerAcquireInFlight || requestResponseInFlight - || threadMutationInFlight; - const bool promptSubmissionInFlight = attachmentPreparationInFlight - || threadStartInFlight || threadResumeInFlight - || turnStartInFlight || turnSteerInFlight; - const bool selectedWritable = selected && selected->fullyLoaded - && !selected->archived.value_or(false); - - sidebar->setNewThreadEnabled(ready && !promptSubmissionInFlight && !pendingControllerWrite); - sidebar->setThreadInteractionEnabled(ready); - const bool idleComposerAvailable = ready && selectedWritable && active == nullptr - && !promptSubmissionInFlight && !pendingControllerWrite; - const bool steerComposerAvailable = ready && selectedWritable && active != nullptr - && !promptSubmissionInFlight && !pendingControllerWrite; - conversation->setActionState( - idleComposerAvailable || steerComposerAvailable, - ready && active != nullptr && !interruptInFlight - && !promptSubmissionInFlight && !pendingControllerWrite, - idleComposerAvailable || steerComposerAvailable, - idleComposerAvailable, - ready && active != nullptr, - active != nullptr, - selectedThreadId, - active ? QString::fromStdString(active->id.value) : QString{}); -} - -bool WorkbenchWidget::writeOperationBusy() const noexcept -{ - return pendingAction != PendingAction::None || controllerAcquireInFlight - || threadStartInFlight || threadResumeInFlight || turnStartInFlight - || turnSteerInFlight || attachmentPreparationInFlight - || interruptInFlight || threadMutationInFlight - || requestControllerAcquireInFlight || requestResponseInFlight; -} - -void WorkbenchWidget::refreshControllerStatus() -{ - QString text = QStringLiteral("Observer"); - QString color = QStringLiteral("#667085"); - QString tooltip = QStringLiteral("Writes acquire controller ownership when needed"); - if (frontendSession.lifecycle() != FrontendSession::Lifecycle::Ready) { - tooltip = QStringLiteral("Controller state is unavailable while disconnected"); - } else if (frontendSession.ownsController()) { - text = QStringLiteral("Controller"); - color = QStringLiteral("#23845a"); - tooltip = QStringLiteral("This frontend owns controller"); - } else if (controllerUnavailable) { - text = QStringLiteral("Controller unavailable"); - color = QStringLiteral("#a76812"); - tooltip = QStringLiteral("The last controller acquisition was rejected"); - } else { - const auto& projection = frontendSession.state().controller(); - if (projection.value && projection.value->present) - tooltip = QStringLiteral("Another frontend currently owns controller"); - } - controllerStatus->setText(text); - controllerStatus->setToolTip(plainTooltip(tooltip)); - setStyleSheetIfChanged(controllerStatus, - QStringLiteral("color:%1;font-size:10px;font-weight:600;").arg(color)); -} - -void WorkbenchWidget::submitInteractiveResponse(InteractiveRequestResponse response) -{ - const std::string id = response.requestId.value; - if (const auto error = interactiveResponseValidationError(frontendSession.state(), response)) { - interactiveRequestDialog->responseFailed(id, *error); - return; - } - if (requestControllerAcquireInFlight || requestResponseInFlight || pendingInteractiveResponse) { - interactiveRequestDialog->responseFailed(id, QStringLiteral("Another response is already being submitted")); - return; - } - if (controllerAcquireInFlight || pendingAction != PendingAction::None) { - interactiveRequestDialog->responseFailed(id, QStringLiteral("Another write is acquiring controller; retry shortly")); - return; - } - - pendingInteractiveResponse = std::move(response); - activeInteractiveRequestId = id; - ensureInteractiveController(); -} - -void WorkbenchWidget::ensureInteractiveController() -{ - if (!pendingInteractiveResponse) - return; - if (frontendSession.ownsController()) { - performInteractiveResponse(); - return; - } - - requestControllerAcquireInFlight = true; - controllerUnavailable = false; - const std::string requestId = pendingInteractiveResponse->requestId.value; - interactiveRequestDialog->setSubmitting(requestId, QStringLiteral("Acquiring controller…")); - refreshControllerStatus(); - refreshControls(); - const QPointer self(this); - const auto immediateError = frontendSession.acquireController([self, requestId](const QString& error) { - if (!self) - return; - self->requestControllerAcquireInFlight = false; - if (self->frontendSession.ownsController()) { - self->controllerUnavailable = false; - self->performInteractiveResponse(); - } else if (!error.isEmpty()) { - self->pendingInteractiveResponse.reset(); - self->activeInteractiveRequestId.clear(); - self->controllerUnavailable = true; - self->interactiveRequestDialog->responseFailed(requestId, error); - } else { - self->interactiveRequestDialog->setSubmitting(requestId, QStringLiteral("Waiting for controller state…")); - } - self->refreshControllerStatus(); - self->refreshControls(); - }); - if (immediateError) { - requestControllerAcquireInFlight = false; - pendingInteractiveResponse.reset(); - activeInteractiveRequestId.clear(); - controllerUnavailable = true; - interactiveRequestDialog->responseFailed(requestId, *immediateError); - refreshControllerStatus(); - refreshControls(); - } -} - -void WorkbenchWidget::performInteractiveResponse() -{ - if (!pendingInteractiveResponse || !frontendSession.ownsController() || requestResponseInFlight) - return; - - InteractiveRequestResponse response = std::move(*pendingInteractiveResponse); - pendingInteractiveResponse.reset(); - const std::string requestId = response.requestId.value; - if (const auto error = interactiveResponseValidationError(frontendSession.state(), response)) { - activeInteractiveRequestId.clear(); - interactiveRequestDialog->responseFailed(requestId, *error); - return; - } - - requestResponseInFlight = true; - interactiveRequestDialog->setSubmitting(requestId, QStringLiteral("Submitting response…")); - refreshControls(); - const QPointer self(this); - const auto completion = [self, requestId](const QString& error) { - if (!self) - return; - self->requestResponseInFlight = false; - if (error.isEmpty()) - self->interactiveRequestDialog->responseAccepted(requestId); - else - self->interactiveRequestDialog->responseFailed(requestId, error); - self->activeInteractiveRequestId.clear(); - self->refreshControls(); - }; - - std::optional immediateError; - if (auto* approval = std::get_if(&response.value)) { - immediateError = frontendSession.respondApproval(response.requestId, std::move(*approval), completion); - } else if (auto* patch = std::get_if(&response.value)) { - immediateError = frontendSession.respondApplyPatchApproval(response.requestId, std::move(*patch), completion); - } else if (auto* command = std::get_if(&response.value)) { - immediateError = frontendSession.respondExecCommandApproval(response.requestId, std::move(*command), completion); - } else if (auto* answers = std::get_if>(&response.value)) { - immediateError = frontendSession.respondUserInput(response.requestId, std::move(*answers), completion); - } - if (immediateError) { - requestResponseInFlight = false; - activeInteractiveRequestId.clear(); - interactiveRequestDialog->responseFailed(requestId, *immediateError); - refreshControls(); - } -} - -void WorkbenchWidget::clearInteractiveTransients(const QString& error) -{ - const std::string requestId = activeInteractiveRequestId; - pendingInteractiveResponse.reset(); - activeInteractiveRequestId.clear(); - requestControllerAcquireInFlight = false; - requestResponseInFlight = false; - if (!requestId.empty()) - interactiveRequestDialog->responseFailed(requestId, error); -} - -void WorkbenchWidget::beginNewThread() -{ - if (frontendSession.lifecycle() != FrontendSession::Lifecycle::Ready || writeOperationBusy()) - return; - - auto* dialog = new ThreadSetupDialog(ThreadSetupDialog::Mode::NewThread, this); - dialog->setAttribute(Qt::WA_DeleteOnClose); - const QPointer guarded(dialog); - connect(dialog, &QDialog::accepted, this, [this, guarded] { - if (!guarded) - return; - if (writeOperationBusy()) { - showWriteError(QStringLiteral("Another write operation is already in progress")); - return; - } - const auto result = guarded->result(); - const auto* setup = std::get_if(&result); - if (!setup) - return; - pendingNewThreadSetup = *setup; - pendingAction = PendingAction::CreateThread; - pendingSelectionGeneration = selectionGeneration; - pendingThreadId.clear(); - conversation->setWriteStatus(QStringLiteral("Preparing new thread…")); - ensureController(); - }); - dialog->open(); -} - -void WorkbenchWidget::handleThreadAction(const QString& threadId, ThreadAction action) -{ - const auto& state = frontendSession.state(); - const auto* thread = state.thread(threadId.toStdString()); - if (!thread) { - showWriteError(QStringLiteral("The selected thread is no longer available")); - return; - } - const ThreadActionAvailability available = detail::threadActionAvailability(state, *thread); - switch (action) { - case ThreadAction::Open: - if (available.open) - selectThread(threadId); - return; - case ThreadAction::CopyId: - QApplication::clipboard()->setText(threadId); - return; - case ThreadAction::Rename: - if (writeOperationBusy()) - return; - if (available.rename) - showRenameThreadDialog(threadId); - return; - case ThreadAction::Fork: - if (writeOperationBusy()) - return; - if (available.fork) - showForkThreadDialog(threadId); - return; - case ThreadAction::ResumeWithOptions: - if (writeOperationBusy()) - return; - if (available.resumeWithOptions) - showResumeWithOptionsDialog(threadId); - return; - case ThreadAction::Delete: - if (writeOperationBusy()) - return; - if (available.remove) - showDeleteThreadConfirmation(threadId); - return; - case ThreadAction::Interrupt: - { - if (writeOperationBusy()) - return; - if (!available.interrupt) - return; - const auto* turn = activeTurn(state, thread); - if (!turn) - return; - pendingAction = PendingAction::InterruptTurn; - pendingThreadId = threadId; - pendingTurnId = QString::fromStdString(turn->id.value); - conversation->setWriteStatus(QStringLiteral("Preparing interrupt…")); - ensureController(); - return; - } - case ThreadAction::Archive: - case ThreadAction::Unarchive: - { - if (writeOperationBusy()) - return; - const bool allowed = action == ThreadAction::Archive ? available.archive : available.unarchive; - if (!allowed) - return; - pendingAction = action == ThreadAction::Archive ? PendingAction::ArchiveThread - : PendingAction::UnarchiveThread; - pendingThreadId = threadId; - conversation->setWriteStatus(action == ThreadAction::Archive - ? QStringLiteral("Preparing archive…") - : QStringLiteral("Preparing unarchive…")); - ensureController(); - return; - } - } -} - -void WorkbenchWidget::showRenameThreadDialog(const QString& threadId) -{ - const auto* thread = frontendSession.state().thread(threadId.toStdString()); - if (!thread) - return; - auto* dialog = new QInputDialog(this); - dialog->setAttribute(Qt::WA_DeleteOnClose); - dialog->setWindowTitle(QStringLiteral("Rename thread")); - dialog->setLabelText(QStringLiteral("Thread name")); - dialog->setTextValue(thread->title ? QString::fromStdString(*thread->title) : QString{}); - dialog->setOkButtonText(QStringLiteral("Rename")); - const QPointer guarded(dialog); - connect(dialog, &QDialog::accepted, this, [this, guarded, threadId] { - if (!guarded || guarded->textValue().trimmed().isEmpty()) - return; - if (writeOperationBusy()) { - showWriteError(QStringLiteral("Another write operation is already in progress")); - return; - } - pendingAction = PendingAction::RenameThread; - pendingThreadId = threadId; - pendingThreadValue = guarded->textValue().trimmed(); - conversation->setWriteStatus(QStringLiteral("Preparing rename…")); - ensureController(); - }); - dialog->open(); -} - -void WorkbenchWidget::showForkThreadDialog(const QString& threadId) -{ - const auto* thread = frontendSession.state().thread(threadId.toStdString()); - if (!thread) - return; - auto* dialog = new ThreadSetupDialog(ThreadSetupDialog::Mode::ForkThread, this); - dialog->setAttribute(Qt::WA_DeleteOnClose); - if (thread->title) - dialog->setSuggestedThreadName(QStringLiteral("%1 (fork)").arg(QString::fromStdString(*thread->title))); - const QPointer guarded(dialog); - connect(dialog, &QDialog::accepted, this, [this, guarded, threadId] { - if (!guarded) - return; - if (writeOperationBusy()) { - showWriteError(QStringLiteral("Another write operation is already in progress")); - return; - } - const auto result = guarded->result(); - const auto* setup = std::get_if(&result); - if (!setup) - return; - pendingForkThreadSetup = *setup; - pendingAction = PendingAction::ForkThread; - pendingSelectionGeneration = selectionGeneration; - pendingThreadId = threadId; - conversation->setWriteStatus(QStringLiteral("Preparing fork…")); - ensureController(); - }); - dialog->open(); -} - -void WorkbenchWidget::showResumeWithOptionsDialog(const QString& threadId) -{ - auto* dialog = new ThreadSetupDialog(ThreadSetupDialog::Mode::ResumeWithOptions, this); - dialog->setAttribute(Qt::WA_DeleteOnClose); - const QPointer guarded(dialog); - connect(dialog, &QDialog::accepted, this, [this, guarded, threadId] { - if (!guarded) - return; - if (writeOperationBusy()) { - showWriteError(QStringLiteral("Another write operation is already in progress")); - return; - } - const auto result = guarded->result(); - const auto* setup = std::get_if(&result); - if (!setup) - return; - pendingResumeSetup = *setup; - pendingAction = PendingAction::ResumeWithOptions; - pendingSelectionGeneration = selectionGeneration; - pendingThreadId = threadId; - conversation->setWriteStatus(QStringLiteral("Preparing resume…")); - ensureController(); - }); - dialog->open(); -} - -void WorkbenchWidget::showDeleteThreadConfirmation(const QString& threadId) -{ - const auto* thread = frontendSession.state().thread(threadId.toStdString()); - if (!thread) - return; - const QString name = thread->title && !thread->title->empty() - ? QString::fromStdString(*thread->title) - : threadId; - auto* dialog = new QMessageBox(QMessageBox::Warning, - QStringLiteral("Delete thread"), - QStringLiteral("Delete “%1”? This cannot be undone.").arg(name), - QMessageBox::Cancel | QMessageBox::Ok, - this); - dialog->setTextFormat(Qt::PlainText); - dialog->setAttribute(Qt::WA_DeleteOnClose); - dialog->button(QMessageBox::Ok)->setText(QStringLiteral("Delete")); - connect(dialog, &QMessageBox::finished, this, [this, threadId](int result) { - if (result != QMessageBox::Ok) - return; - if (writeOperationBusy()) { - showWriteError(QStringLiteral("Another write operation is already in progress")); - return; - } - pendingAction = PendingAction::DeleteThread; - pendingThreadId = threadId; - conversation->setWriteStatus(QStringLiteral("Preparing delete…")); - ensureController(); - }); - dialog->open(); -} - -void WorkbenchWidget::sendPrompt(const QString& prompt, bool steerRequested) -{ - const QList attachments = conversation->attachments(); - if (frontendSession.lifecycle() != FrontendSession::Lifecycle::Ready - || (prompt.trimmed().isEmpty() && attachments.isEmpty()) || writeOperationBusy()) - return; - if (const auto error = FrontendSession::promptValidationError(prompt)) { - showWriteError(*error); - return; - } - - const auto& state = frontendSession.state(); - const auto* selected = selectedThreadId.isEmpty() ? nullptr : state.thread(selectedThreadId.toStdString()); - if (!selected || !selected->fullyLoaded || selected->archived.value_or(false)) - return; - - if (const auto* active = activeTurn(state, selected)) { - if (!steerRequested) { - showWriteError(QStringLiteral( - "A turn started before this prompt was submitted. Edit the draft to send it as a steer.")); - refreshControls(); - return; - } - pendingAction = PendingAction::SteerActiveTurn; - pendingPrompt = prompt; - pendingAttachments = attachments; - pendingAttachmentWorkspace = conversation->attachmentWorkspace(); - pendingThreadId = selectedThreadId; - pendingTurnId = QString::fromStdString(active->id.value); - pendingTurnDraft = {}; - pendingSelectionGeneration = selectionGeneration; - conversation->setWriteStatus(QStringLiteral("Preparing steer…")); - ensureController(); - return; - } - - if (steerRequested) { - showWriteError(QStringLiteral( - "The active turn ended before this steer was submitted. Edit the draft to use it for a new turn.")); - refreshControls(); - return; - } - - const UpcomingTurnDraft settings = conversation->upcomingTurnDraft(); - if (settings.threadIdentity != selectedThreadId) { - showWriteError(QStringLiteral("Upcoming-turn settings no longer match the selected thread")); - return; - } - - pendingAction = PendingAction::SendExistingThread; - pendingPrompt = prompt; - pendingAttachments = attachments; - pendingAttachmentWorkspace = conversation->attachmentWorkspace(); - pendingThreadId = selectedThreadId; - pendingTurnId.clear(); - pendingTurnDraft = settings; - pendingSelectionGeneration = selectionGeneration; - conversation->setWriteStatus(QStringLiteral("Preparing write…")); - ensureController(); -} - -void WorkbenchWidget::stopActiveTurn() -{ - if (frontendSession.lifecycle() != FrontendSession::Lifecycle::Ready || writeOperationBusy() - || selectedThreadId.isEmpty()) - return; - const auto& state = frontendSession.state(); - const auto* selected = state.thread(selectedThreadId.toStdString()); - const auto* turn = activeTurn(state, selected); - if (!turn) - return; - - pendingAction = PendingAction::InterruptTurn; - pendingThreadId = selectedThreadId; - pendingTurnId = QString::fromStdString(turn->id.value); - pendingPrompt.clear(); - conversation->setWriteStatus(QStringLiteral("Preparing interrupt…")); - ensureController(); -} - -void WorkbenchWidget::maybeResumeSelectedThread() -{ - if (frontendSession.lifecycle() != FrontendSession::Lifecycle::Ready - || selectedThreadId.isEmpty() || writeOperationBusy() - || automaticResumeAttemptedThreadIds.contains(selectedThreadId)) - return; - - const auto& state = frontendSession.state(); - const auto* thread = state.thread(selectedThreadId.toStdString()); - if (!thread || !thread->fullyLoaded || thread->archived.value_or(false) - || activeTurn(state, thread) - || ai::openai::codex::frontend::client::threadIsIdle(*thread)) - return; - - automaticResumeAttemptedThreadIds.insert(selectedThreadId); - pendingAction = PendingAction::OpenThread; - pendingThreadId = selectedThreadId; - pendingSelectionGeneration = selectionGeneration; - conversation->setWriteStatus(QStringLiteral("Resuming thread…")); - ensureController(); -} - -void WorkbenchWidget::reconcileAutomaticResumeState() -{ - if (automaticResumeThreadId.isEmpty()) - return; - - const auto& state = frontendSession.state(); - const auto* thread = state.thread(automaticResumeThreadId.toStdString()); - const bool threadDiscoveryTerminal = state.threadList().value - && state.threadList().value->complete - && frontendSession.archivedThreadDiscoveryTerminal(); - const bool canonicalResumeObserved = thread - && (thread->archived.value_or(false) || activeTurn(state, thread) - || ai::openai::codex::frontend::client::threadIsIdle(*thread)); - if (!canonicalResumeObserved && (thread || !threadDiscoveryTerminal)) - return; - - const QString completedThreadId = automaticResumeThreadId; - automaticResumeThreadId.clear(); - automaticResumeAttemptedThreadIds.remove(completedThreadId); - threadResumeInFlight = false; - const bool composerReady = thread && !thread->archived.value_or(false) - && !activeTurn(state, thread) - && ai::openai::codex::frontend::client::threadIsIdle(*thread); - if (selectedThreadId == completedThreadId) { - conversation->setWriteStatus({}); - if (composerReady) - conversation->focusComposer(); - } -} - -void WorkbenchWidget::ensureController() -{ - if (frontendSession.ownsController()) { - executePendingAction(); - return; - } - if (controllerAcquireInFlight) - return; - - controllerAcquireInFlight = true; - controllerUnavailable = false; - conversation->setWriteStatus(QStringLiteral("Acquiring controller…")); - refreshControllerStatus(); - refreshControls(); - const QPointer self(this); - const auto immediateError = frontendSession.acquireController([self](const QString& error) { - if (!self) - return; - self->controllerAcquireInFlight = false; - if (self->frontendSession.ownsController()) { - self->controllerUnavailable = false; - self->executePendingAction(); - } else if (!error.isEmpty()) { - self->pendingAction = PendingAction::None; - self->pendingPrompt.clear(); - self->pendingAttachments.clear(); - self->pendingAttachmentWorkspace.clear(); - self->pendingThreadId.clear(); - self->pendingTurnId.clear(); - self->pendingThreadValue.clear(); - self->pendingNewThreadSetup.reset(); - self->pendingForkThreadSetup.reset(); - self->pendingResumeSetup.reset(); - self->pendingTurnDraft = {}; - self->pendingSelectionGeneration = 0; - self->controllerUnavailable = true; - self->showWriteError(error); - } else { - self->conversation->setWriteStatus(QStringLiteral("Waiting for controller state…")); - } - self->refreshControllerStatus(); - self->refreshControls(); - }); - if (immediateError) { - controllerAcquireInFlight = false; - pendingAction = PendingAction::None; - pendingPrompt.clear(); - pendingAttachments.clear(); - pendingAttachmentWorkspace.clear(); - pendingThreadId.clear(); - pendingTurnId.clear(); - pendingThreadValue.clear(); - pendingNewThreadSetup.reset(); - pendingForkThreadSetup.reset(); - pendingResumeSetup.reset(); - pendingTurnDraft = {}; - pendingSelectionGeneration = 0; - controllerUnavailable = true; - showWriteError(*immediateError); - refreshControllerStatus(); - refreshControls(); - } -} - -void WorkbenchWidget::executePendingAction() -{ - if (!frontendSession.ownsController() || pendingAction == PendingAction::None) - return; - - const PendingAction action = pendingAction; - const QString prompt = pendingPrompt; - const QList attachments = pendingAttachments; - const QString attachmentWorkspace = pendingAttachmentWorkspace; - const QString threadId = pendingThreadId; - const QString turnId = pendingTurnId; - const QString value = pendingThreadValue; - const auto newThreadSetup = pendingNewThreadSetup; - const auto forkSetup = pendingForkThreadSetup; - const auto resumeSetup = pendingResumeSetup; - const UpcomingTurnDraft turnSettings = pendingTurnDraft; - const std::uint64_t expectedSelectionGeneration = pendingSelectionGeneration; - pendingAction = PendingAction::None; - pendingPrompt.clear(); - pendingAttachments.clear(); - pendingAttachmentWorkspace.clear(); - pendingThreadId.clear(); - pendingTurnId.clear(); - pendingThreadValue.clear(); - pendingNewThreadSetup.reset(); - pendingForkThreadSetup.reset(); - pendingResumeSetup.reset(); - pendingTurnDraft = {}; - pendingSelectionGeneration = 0; - - switch (action) { - case PendingAction::OpenThread: - { - const auto& state = frontendSession.state(); - const auto* thread = state.thread(threadId.toStdString()); - if (selectionGeneration != expectedSelectionGeneration - || selectedThreadId != threadId) { - automaticResumeAttemptedThreadIds.remove(threadId); - refreshState(); - break; - } - if (!thread || !thread->fullyLoaded || thread->archived.value_or(false) - || activeTurn(state, thread)) { - automaticResumeAttemptedThreadIds.remove(threadId); - showWriteError(QStringLiteral("The selected thread changed before it could be resumed")); - refreshControls(); - break; - } - if (ai::openai::codex::frontend::client::threadIsIdle(*thread)) { - conversation->setWriteStatus({}); - refreshControls(); - break; - } - resumeThreadForOpen(threadId, expectedSelectionGeneration); - break; - } - case PendingAction::SendExistingThread: - { - const auto& state = frontendSession.state(); - const auto* thread = state.thread(threadId.toStdString()); - if (selectionGeneration != expectedSelectionGeneration - || selectedThreadId != threadId || !thread || !thread->fullyLoaded - || thread->archived.value_or(false) - || activeTurn(state, thread) || turnSettings.threadIdentity != threadId) { - showWriteError(QStringLiteral("The target thread changed before the prompt could be sent")); - refreshControls(); - break; - } - beginTurnSubmissionPreparation({action, - threadId, - {}, - prompt, - attachments, - attachmentWorkspace, - turnSettings, - expectedSelectionGeneration}); - break; - } - case PendingAction::SteerActiveTurn: - { - const auto& state = frontendSession.state(); - const auto* thread = state.thread(threadId.toStdString()); - const auto* turn = activeTurn(state, thread); - if (selectionGeneration != expectedSelectionGeneration - || selectedThreadId != threadId || !thread || !thread->fullyLoaded - || thread->archived.value_or(false) - || !turn || QString::fromStdString(turn->id.value) != turnId) { - showWriteError(QStringLiteral("The target turn changed before it could be steered")); - refreshControls(); - break; - } - beginTurnSubmissionPreparation({action, - threadId, - turnId, - prompt, - attachments, - attachmentWorkspace, - {}, - expectedSelectionGeneration}); - break; - } - case PendingAction::InterruptTurn: - { - const auto& state = frontendSession.state(); - const auto* thread = state.thread(threadId.toStdString()); - const auto* turn = activeTurn(state, thread); - if (!thread || !turn || QString::fromStdString(turn->id.value) != turnId - || !detail::threadActionAvailability(state, *thread).interrupt) { - showWriteError(QStringLiteral("The target turn is no longer interruptible")); - refreshControls(); - break; - } - interruptTurn(threadId, turnId); - break; - } - case PendingAction::CreateThread: - if (newThreadSetup) - startNewThread(*newThreadSetup, expectedSelectionGeneration); - break; - case PendingAction::RenameThread: - case PendingAction::ArchiveThread: - case PendingAction::UnarchiveThread: - case PendingAction::DeleteThread: - mutateThread(action, threadId, value); - break; - case PendingAction::ForkThread: - if (forkSetup) { - const auto& state = frontendSession.state(); - const auto* thread = state.thread(threadId.toStdString()); - if (!thread || !detail::threadActionAvailability(state, *thread).fork) { - showWriteError(QStringLiteral("The target thread can no longer be forked")); - refreshControls(); - break; - } - forkThread(threadId, *forkSetup, expectedSelectionGeneration); - } - break; - case PendingAction::ResumeWithOptions: - if (resumeSetup) { - const auto& state = frontendSession.state(); - const auto* thread = state.thread(threadId.toStdString()); - if (!thread || !detail::threadActionAvailability(state, *thread).resumeWithOptions) { - showWriteError(QStringLiteral("The target thread can no longer be resumed with options")); - refreshControls(); - break; - } - resumeThreadWithOptions(threadId, *resumeSetup, expectedSelectionGeneration); - } - break; - case PendingAction::None: - break; - } -} - -void WorkbenchWidget::startNewThread(const NewThreadSetup& setup, - std::uint64_t expectedSelectionGeneration) -{ - threadStartInFlight = true; - conversation->setWriteStatus(QStringLiteral("Creating thread…")); - refreshControls(); - ai::openai::codex::typed::ThreadStartParams parameters; - if (!setup.instructions.baseInstructions.isEmpty()) - parameters.baseInstructions = toUtf8(setup.instructions.baseInstructions); - if (!setup.instructions.developerInstructions.isEmpty()) - parameters.developerInstructions = toUtf8(setup.instructions.developerInstructions); - parameters.ephemeral = setup.temporary; - const QPointer self(this); - const auto immediateError = frontendSession.startThread( - std::move(parameters), - [self, name = setup.name.trimmed(), expectedSelectionGeneration](const QString& threadId, - const QString& error) { - if (!self) - return; - self->threadStartInFlight = false; - if (!error.isEmpty()) { - self->showWriteError(error); - self->refreshControls(); - return; - } - const bool keepAutomaticSelection = self->selectionGeneration == expectedSelectionGeneration; - if (keepAutomaticSelection) { - self->newThreadIdAwaitingState = threadId; - self->selectedThreadId = threadId; - self->projectedAgentThreadId.clear(); - self->conversation->clearPrompt(); - self->conversation->clearUpcomingTurnSettings(); - self->conversation->setWriteStatus(QStringLiteral("Thread created")); - } - if (!name.isEmpty()) { - self->threadMutationInFlight = true; - self->refreshControls(); - const auto renameError = self->frontendSession.renameThread( - threadId, - name, - [self, threadId](const QString& renameFailure) { - if (!self) - return; - self->threadMutationInFlight = false; - if (!renameFailure.isEmpty()) - self->showWriteError(renameFailure); - else if (self->selectedThreadId == threadId) - self->conversation->setWriteStatus({}); - self->refreshState(); - }); - if (renameError) { - self->threadMutationInFlight = false; - self->showWriteError(*renameError); - } - } else if (keepAutomaticSelection) { - self->conversation->setWriteStatus({}); - } - self->refreshState(); - if (keepAutomaticSelection) - self->conversation->focusComposer(); - }); - if (immediateError) { - threadStartInFlight = false; - showWriteError(*immediateError); - refreshControls(); - } -} - -void WorkbenchWidget::beginTurnSubmissionPreparation( - TurnSubmissionPreparationRequest request) -{ - cancelAttachmentPreparation(); - const bool copiesGenericFiles = std::ranges::any_of( - request.attachments, - [](const AttachmentInfo& attachment) { - return attachment.kind == AttachmentInfo::Kind::File; - }); - const std::uint64_t generation = attachmentPreparationGeneration; - if (!copiesGenericFiles) { - TurnSubmissionPreparationOutcome outcome; - outcome.success = AttachmentManager::prepare(request.attachments, - request.workspace, - request.threadId, - &outcome.preparation, - &outcome.error); - finishTurnSubmissionPreparation(std::move(request), std::move(outcome), generation); - return; - } - - attachmentPreparationInFlight = true; - auto cancellation = std::make_shared(false); - attachmentPreparationCancellation = cancellation; - conversation->setWriteStatus(QStringLiteral("Preparing attachments…")); - refreshControls(); - QList workerAttachments = request.attachments; - QString workerWorkspace = request.workspace; - QString workerThreadId = request.threadId; - auto* watcher = new QFutureWatcher(this); - connect(watcher, - &QFutureWatcher::finished, - this, - [this, watcher, request = std::move(request), generation]() mutable { - TurnSubmissionPreparationOutcome outcome = watcher->result(); - watcher->deleteLater(); - finishTurnSubmissionPreparation( - std::move(request), std::move(outcome), generation); - }); - watcher->setFuture(QtConcurrent::run( - [attachments = std::move(workerAttachments), - workspace = std::move(workerWorkspace), - threadId = std::move(workerThreadId), - cancellation = std::move(cancellation)]() mutable { - TurnSubmissionPreparationOutcome outcome; - outcome.success = AttachmentManager::prepare(attachments, - workspace, - threadId, - &outcome.preparation, - &outcome.error, - [cancellation] { - return cancellation->load( - std::memory_order_relaxed); - }); - return outcome; - })); -} - -void WorkbenchWidget::cancelAttachmentPreparation() noexcept -{ - if (attachmentPreparationCancellation) - attachmentPreparationCancellation->store(true, std::memory_order_relaxed); - attachmentPreparationCancellation.reset(); - attachmentPreparationInFlight = false; - ++attachmentPreparationGeneration; -} - -void WorkbenchWidget::finishTurnSubmissionPreparation( - TurnSubmissionPreparationRequest request, - TurnSubmissionPreparationOutcome outcome, - std::uint64_t preparationGeneration) -{ - if (preparationGeneration != attachmentPreparationGeneration) - return; - attachmentPreparationCancellation.reset(); - attachmentPreparationInFlight = false; - if (!outcome.success) { - showWriteError(outcome.error.isEmpty() - ? QStringLiteral("Unable to prepare attachments") - : outcome.error); - refreshControls(); - return; - } - if (frontendSession.lifecycle() != FrontendSession::Lifecycle::Ready - || selectionGeneration != request.expectedSelectionGeneration - || selectedThreadId != request.threadId) { - showWriteError(QStringLiteral( - "The target thread changed while attachments were being prepared")); - refreshControls(); - return; - } - - auto submission = preparedTurnSubmission( - request.prompt, request.attachments, std::move(outcome.preparation)); - if (!submission) { - refreshControls(); - return; - } - - const auto& state = frontendSession.state(); - const auto* thread = state.thread(request.threadId.toStdString()); - if (request.action == PendingAction::SendExistingThread) { - if (!thread || !thread->fullyLoaded || thread->archived.value_or(false) - || activeTurn(state, thread) - || request.settings.threadIdentity != request.threadId) { - showWriteError(QStringLiteral( - "The target thread changed while attachments were being prepared")); - refreshControls(); - return; - } - // Resuming an already attached thread can replay its existing item projection. - if (ai::openai::codex::frontend::client::threadIsIdle(*thread)) - startTurn(request.threadId, *submission, request.settings); - else - resumeThread(request.threadId, *submission, request.settings); - return; - } - if (request.action == PendingAction::SteerActiveTurn) { - if (!thread || !thread->fullyLoaded || thread->archived.value_or(false)) { - showWriteError(QStringLiteral( - "The target turn changed while attachments were being prepared")); - refreshControls(); - return; - } - const auto* turn = activeTurn(state, thread); - if (!turn || QString::fromStdString(turn->id.value) != request.turnId) { - showWriteError(QStringLiteral( - "The target turn changed while attachments were being prepared")); - refreshControls(); - return; - } - steerTurn(request.threadId, request.turnId, *submission); - return; - } - - showWriteError(QStringLiteral("The pending write changed while attachments were being prepared")); - refreshControls(); -} - -std::optional -WorkbenchWidget::preparedTurnSubmission(const QString& prompt, - const QList& attachments, - AttachmentPreparation preparation) -{ - const QString effectivePrompt = AttachmentManager::composePrompt(prompt, preparation); - if (const auto validationError = FrontendSession::promptValidationError(effectivePrompt)) { - if (preparation.stagingLease) - (void)preparation.stagingLease->cleanup(); - showWriteError(*validationError); - return std::nullopt; - } - if (effectivePrompt.trimmed().isEmpty() && preparation.imagePaths.isEmpty()) { - if (preparation.stagingLease) - (void)preparation.stagingLease->cleanup(); - showWriteError(QStringLiteral("A turn requires a prompt or attachment")); - return std::nullopt; - } - return PreparedTurnSubmission{ - prompt, effectivePrompt, preparation.imagePaths, attachments, - std::move(preparation.stagingLease)}; -} - -void WorkbenchWidget::startTurn(const QString& threadId, - const PreparedTurnSubmission& submission, - const UpcomingTurnDraft& settings) -{ - if (settings.threadIdentity != threadId) { - if (submission.stagingLease) - (void)submission.stagingLease->cleanup(); - showWriteError(QStringLiteral("Upcoming-turn settings no longer match the target thread")); - return; - } - QString stagingError; - if (!retainAttachmentStaging(threadId, {}, submission.stagingLease, &stagingError)) { - if (submission.stagingLease) - (void)submission.stagingLease->cleanup(); - showWriteError(stagingError); - return; - } - turnStartInFlight = true; - turnThreadIdAwaitingState.clear(); - turnIdAwaitingState.clear(); - submittedTurnSettings.reset(); - if (selectedThreadId == threadId) - conversation->setWriteStatus(QStringLiteral("Starting turn…")); - refreshControls(); - ai::openai::codex::typed::TurnStartParams parameters; - parameters.threadId = ai::openai::codex::typed::ThreadId{threadId.toStdString()}; - parameters.model = settings.model; - parameters.effort = settings.effort; - parameters.personality = settings.personality; - parameters.sandboxPolicy = settings.sandboxPolicy; - parameters.approvalPolicy = settings.approvalPolicy; - parameters.approvalsReviewer = settings.approvalsReviewer; - parameters.cwd = settings.cwd; - parameters.serviceTier = settings.serviceTier; - parameters.summary = settings.summary; - parameters.collaborationMode = settings.collaborationMode; - const QPointer self(this); - const auto immediateError = frontendSession.startTurn( - std::move(parameters), - submission.effectivePrompt, - submission.imagePaths, - [self, - targetThreadId = threadId, - submittedPrompt = submission.userPrompt, - submittedAttachments = submission.attachments, - stagingLease = submission.stagingLease, - submittedSettings = settings](const QString& acceptedTurnId, const QString& error) { - if (!self) - return; - if (!error.isEmpty() || acceptedTurnId.isEmpty()) { - // Once submitted, a lost correlated result is ambiguous: the - // backend may still be using the staged workspace paths. Do - // not clean them without authoritative terminal turn state. - self->turnStartInFlight = false; - self->turnThreadIdAwaitingState.clear(); - self->turnIdAwaitingState.clear(); - const QString failure = !error.isEmpty() - ? error - : QStringLiteral("The backend accepted the turn without returning its identity"); - if (self->selectedThreadId == targetThreadId) - self->showWriteError(failure); - else - self->showWriteError( - QStringLiteral("Turn in %1 failed: %2").arg(targetThreadId, failure)); - } else { - QString stagingError; - if (!self->retainAttachmentStaging( - targetThreadId, acceptedTurnId, stagingLease, &stagingError)) { - self->showWriteError(QStringLiteral( - "The turn started, but attachment cleanup ownership could not be updated: %1") - .arg(stagingError)); - } - self->turnThreadIdAwaitingState = targetThreadId; - self->turnIdAwaitingState = acceptedTurnId; - self->submittedTurnSettings = SubmittedTurnSettings{ - targetThreadId, acceptedTurnId, submittedSettings}; - if (self->selectedThreadId == targetThreadId) { - self->conversation->clearPromptIfUnchanged(submittedPrompt); - self->conversation->clearAttachmentsIfUnchanged(submittedAttachments); - self->conversation->setWriteStatus(QStringLiteral("Waiting for canonical turn state…")); - } - } - self->refreshState(); - }); - if (immediateError) { - releaseAttachmentStaging(submission.stagingLease); - turnStartInFlight = false; - turnThreadIdAwaitingState.clear(); - turnIdAwaitingState.clear(); - showWriteError(*immediateError); - refreshState(); - return; - } - - if (selectedThreadId == threadId) - conversation->setWriteStatus(QStringLiteral("Prompt submitted")); - refreshControls(); -} - -void WorkbenchWidget::steerTurn(const QString& threadId, - const QString& turnId, - const PreparedTurnSubmission& submission) -{ - QString stagingError; - if (!retainAttachmentStaging( - threadId, turnId, submission.stagingLease, &stagingError)) { - if (submission.stagingLease) - (void)submission.stagingLease->cleanup(); - showWriteError(stagingError); - return; - } - turnSteerInFlight = true; - if (selectedThreadId == threadId) - conversation->setWriteStatus(QStringLiteral("Steering turn…")); - refreshControls(); - - const QPointer self(this); - const auto immediateError = frontendSession.steerTurn( - threadId, - turnId, - submission.effectivePrompt, - submission.imagePaths, - [self, - targetThreadId = threadId, - targetTurnId = turnId, - submittedPrompt = submission.userPrompt, - submittedAttachments = submission.attachments, - stagingLease = submission.stagingLease](const QString& error) { - if (!self) - return; - self->turnSteerInFlight = false; - if (!error.isEmpty()) { - // A transport/result failure after dispatch does not prove - // that the backend rejected the steering input. - if (self->selectedThreadId == targetThreadId) - self->showWriteError(error); - else - self->showWriteError( - QStringLiteral("Turn in %1 could not be steered: %2") - .arg(targetThreadId, error)); - } else { - QString stagingError; - if (!self->retainAttachmentStaging( - targetThreadId, targetTurnId, stagingLease, &stagingError)) { - self->showWriteError(QStringLiteral( - "The steering input was accepted, but attachment cleanup ownership could not be updated: %1") - .arg(stagingError)); - } - if (self->selectedThreadId == targetThreadId) { - self->conversation->clearPromptIfUnchanged(submittedPrompt); - self->conversation->clearAttachmentsIfUnchanged(submittedAttachments); - self->conversation->setWriteStatus({}); - } - } - self->refreshState(); - }); - if (immediateError) { - releaseAttachmentStaging(submission.stagingLease); - turnSteerInFlight = false; - showWriteError(*immediateError); - refreshControls(); - } -} - -void WorkbenchWidget::recoverAttachmentStaging() -{ - QSettings settings; - QList recovered; - if (!AttachmentManager::recoverDispatchedStaging(settings, &recovered)) - return; - for (auto& entry : recovered) { - retainedAttachmentStaging.append(RetainedAttachmentStaging{ - std::move(entry.registryId), std::move(entry.threadId), - std::move(entry.turnId), std::move(entry.stagingLease)}); - } -} - -bool WorkbenchWidget::retainAttachmentStaging(const QString& threadId, - const QString& turnId, - const AttachmentStagingLeasePtr& lease, - QString* errorMessage) -{ - if (!lease) - return true; - const auto retained = std::ranges::find_if( - retainedAttachmentStaging, - [&lease](const auto& entry) { return entry.lease == lease; }); - const QString registryId = retained == retainedAttachmentStaging.end() - ? AttachmentManager::createStagingRegistryId() : retained->registryId; - const QString retainedThreadId = retained == retainedAttachmentStaging.end() - || !threadId.isEmpty() ? threadId : retained->threadId; - const QString retainedTurnId = retained == retainedAttachmentStaging.end() - || !turnId.isEmpty() ? turnId : retained->turnId; - if (retained != retainedAttachmentStaging.end()) { - if (!threadId.isEmpty()) - retained->threadId = threadId; - if (!turnId.isEmpty()) - retained->turnId = turnId; - } - QSettings settings; - if (!AttachmentManager::persistDispatchedStaging( - settings, registryId, retainedThreadId, retainedTurnId, lease, errorMessage)) { - return false; - } - if (retained == retainedAttachmentStaging.end()) { - retainedAttachmentStaging.append( - RetainedAttachmentStaging{registryId, threadId, turnId, lease}); - return true; - } - return true; -} - -void WorkbenchWidget::releaseAttachmentStaging(const AttachmentStagingLeasePtr& lease) -{ - if (!lease) - return; - lease->cancelDispatch(); - if (!lease->cleanup()) - return; - - QSettings settings; - auto iterator = retainedAttachmentStaging.begin(); - while (iterator != retainedAttachmentStaging.end()) { - if (iterator->lease != lease) { - ++iterator; - continue; - } - if (!AttachmentManager::forgetDispatchedStaging(settings, iterator->registryId)) { - ++iterator; - continue; - } - iterator = retainedAttachmentStaging.erase(iterator); - } -} - -void WorkbenchWidget::reconcileAttachmentStaging() -{ - const auto& state = frontendSession.state(); - auto iterator = retainedAttachmentStaging.begin(); - while (iterator != retainedAttachmentStaging.end()) { - if (iterator->turnId.isEmpty()) { - ++iterator; - continue; - } - const auto* thread = state.thread(iterator->threadId.toStdString()); - if (!thread) { - ++iterator; - continue; - } - const auto turnId = std::ranges::find_if( - thread->orderedTurns, - [&iterator](const auto& id) { - return QString::fromStdString(id.value) == iterator->turnId; - }); - const auto* turn = turnId == thread->orderedTurns.end() - ? nullptr : state.turn(thread->id, *turnId); - if (!turn || !turn->terminal) { - ++iterator; - continue; - } - if (!iterator->lease || !iterator->lease->cleanup()) { - ++iterator; - continue; - } - QSettings settings; - if (!AttachmentManager::forgetDispatchedStaging(settings, iterator->registryId)) { - ++iterator; - continue; - } - iterator = retainedAttachmentStaging.erase(iterator); - } -} - -void WorkbenchWidget::reconcileSubmittedTurnSettings() -{ - const auto& state = frontendSession.state(); - if (!turnThreadIdAwaitingState.isEmpty() && !turnIdAwaitingState.isEmpty()) { - const auto* awaitingThread = state.thread(turnThreadIdAwaitingState.toStdString()); - if (awaitingThread) { - const auto acceptedTurn = std::ranges::find_if( - awaitingThread->orderedTurns, - [this](const auto& id) { - return QString::fromStdString(id.value) == turnIdAwaitingState; - }); - if (acceptedTurn != awaitingThread->orderedTurns.end()) { - const auto* turn = state.turn(awaitingThread->id, *acceptedTurn); - if (turn) { - const QString completedThreadId = turnThreadIdAwaitingState; - turnStartInFlight = false; - turnThreadIdAwaitingState.clear(); - turnIdAwaitingState.clear(); - if (selectedThreadId == completedThreadId) - conversation->setWriteStatus({}); - } - } - } - } - - if (!submittedTurnSettings) - return; - const auto* thread = state.thread(submittedTurnSettings->threadId.toStdString()); - if (!thread) - return; - const auto acceptedTurn = std::ranges::find_if( - thread->orderedTurns, - [this](const auto& id) { - return QString::fromStdString(id.value) == submittedTurnSettings->turnId; - }); - if (acceptedTurn == thread->orderedTurns.end()) - return; - const auto* turn = state.turn(thread->id, *acceptedTurn); - if (!turn - || !thread->executionConfiguration || !turn->effectiveExecutionConfiguration - || *thread->executionConfiguration != *turn->effectiveExecutionConfiguration) - return; - - conversation->acknowledgeSubmittedSettings(submittedTurnSettings->draft); - submittedTurnSettings.reset(); -} - -void WorkbenchWidget::resumeThread(const QString& threadId, - const PreparedTurnSubmission& submission, - const UpcomingTurnDraft& settings) -{ - threadResumeInFlight = true; - if (selectedThreadId == threadId) - conversation->setWriteStatus(QStringLiteral("Attaching thread…")); - refreshControls(); - const QPointer self(this); - const auto immediateError = frontendSession.resumeThread( - threadId, - [self, submission, settings, targetThreadId = threadId](const QString& resumedThreadId, - const QString& error) { - if (!self) - return; - self->threadResumeInFlight = false; - if (!error.isEmpty()) { - // Resume itself does not receive the staged paths. They are - // referenced only by the subsequent turn/start operation. - if (submission.stagingLease) - (void)submission.stagingLease->cleanup(); - if (self->selectedThreadId == targetThreadId) - self->showWriteError(error); - else - self->showWriteError( - QStringLiteral("Thread %1 could not be resumed: %2") - .arg(targetThreadId, error)); - self->refreshControls(); - return; - } - self->startTurn(resumedThreadId, submission, settings); - }); - if (immediateError) { - if (submission.stagingLease) - (void)submission.stagingLease->cleanup(); - threadResumeInFlight = false; - showWriteError(*immediateError); - refreshControls(); - } -} - -void WorkbenchWidget::resumeThreadForOpen(const QString& threadId, - std::uint64_t expectedSelectionGeneration) -{ - threadResumeInFlight = true; - automaticResumeThreadId = threadId; - if (selectedThreadId == threadId) - conversation->setWriteStatus(QStringLiteral("Resuming thread…")); - refreshControls(); - const QPointer self(this); - const auto immediateError = frontendSession.resumeThread( - threadId, - [self, targetThreadId = threadId, expectedSelectionGeneration](const QString&, - const QString& error) { - if (!self) - return; - // State projection may reach the coalesced UI before the operation - // completion. In that case canonical reconciliation already - // finished this resume and the late completion must be a no-op. - if (self->automaticResumeThreadId != targetThreadId) { - self->refreshState(); - return; - } - if (!error.isEmpty()) { - self->threadResumeInFlight = false; - self->automaticResumeThreadId.clear(); - if (self->selectedThreadId == targetThreadId - && self->selectionGeneration == expectedSelectionGeneration) - self->showWriteError(error); - else - self->showWriteError( - QStringLiteral("Thread %1 could not be resumed: %2") - .arg(targetThreadId, error)); - } else if (self->selectedThreadId == targetThreadId - && self->selectionGeneration == expectedSelectionGeneration) { - self->conversation->setWriteStatus( - QStringLiteral("Waiting for canonical thread state…")); - } - self->refreshState(); - }); - if (immediateError) { - threadResumeInFlight = false; - automaticResumeThreadId.clear(); - showWriteError(*immediateError); - refreshControls(); - } -} - -void WorkbenchWidget::forkThread(const QString& threadId, - const ForkThreadSetup& setup, - std::uint64_t expectedSelectionGeneration) -{ - threadMutationInFlight = true; - conversation->setWriteStatus(QStringLiteral("Forking thread…")); - refreshControls(); - ai::openai::codex::typed::ThreadForkParams parameters; - parameters.threadId = ai::openai::codex::typed::ThreadId{threadId.toStdString()}; - if (!setup.instructions.baseInstructions.isEmpty()) - parameters.baseInstructions = toUtf8(setup.instructions.baseInstructions); - if (!setup.instructions.developerInstructions.isEmpty()) - parameters.developerInstructions = toUtf8(setup.instructions.developerInstructions); - parameters.ephemeral = setup.temporary; - const QPointer self(this); - const auto immediateError = frontendSession.forkThread( - std::move(parameters), - [self, name = setup.name.trimmed(), expectedSelectionGeneration](const QString& forkedThreadId, - const QString& error) { - if (!self) - return; - self->threadMutationInFlight = false; - if (!error.isEmpty()) { - self->showWriteError(error); - self->refreshControls(); - return; - } - const bool keepAutomaticSelection = self->selectionGeneration == expectedSelectionGeneration; - if (keepAutomaticSelection) { - self->newThreadIdAwaitingState = forkedThreadId; - self->selectedThreadId = forkedThreadId; - self->projectedAgentThreadId.clear(); - self->conversation->clearPrompt(); - self->conversation->setWriteStatus(QStringLiteral("Thread forked")); - } - if (!name.isEmpty()) { - self->threadMutationInFlight = true; - self->refreshControls(); - const auto renameError = self->frontendSession.renameThread( - forkedThreadId, - name, - [self, forkedThreadId](const QString& renameFailure) { - if (!self) - return; - self->threadMutationInFlight = false; - if (!renameFailure.isEmpty()) - self->showWriteError(renameFailure); - else if (self->selectedThreadId == forkedThreadId) - self->conversation->setWriteStatus({}); - self->refreshState(); - }); - if (renameError) { - self->threadMutationInFlight = false; - self->showWriteError(*renameError); - } - } else if (keepAutomaticSelection) { - self->conversation->setWriteStatus({}); - } - self->refreshState(); - }); - if (immediateError) { - threadMutationInFlight = false; - showWriteError(*immediateError); - refreshControls(); - } -} - -void WorkbenchWidget::resumeThreadWithOptions(const QString& threadId, - const ResumeWithOptionsSetup& setup, - std::uint64_t expectedSelectionGeneration) -{ - threadMutationInFlight = true; - conversation->setWriteStatus(QStringLiteral("Resuming thread…")); - refreshControls(); - ai::openai::codex::typed::ThreadResumeParams parameters; - parameters.threadId = ai::openai::codex::typed::ThreadId{threadId.toStdString()}; - if (!setup.instructions.baseInstructions.isEmpty()) - parameters.baseInstructions = toUtf8(setup.instructions.baseInstructions); - if (!setup.instructions.developerInstructions.isEmpty()) - parameters.developerInstructions = toUtf8(setup.instructions.developerInstructions); - const QPointer self(this); - const auto immediateError = frontendSession.resumeThread( - std::move(parameters), - [self, expectedSelectionGeneration](const QString& resumedThreadId, const QString& error) { - if (!self) - return; - self->threadMutationInFlight = false; - if (!error.isEmpty()) { - self->showWriteError(error); - } else if (self->selectionGeneration == expectedSelectionGeneration) { - self->selectedThreadId = resumedThreadId; - self->projectedAgentThreadId.clear(); - self->conversation->setWriteStatus({}); - self->conversation->focusComposer(); - } else { - self->conversation->setWriteStatus({}); - } - self->refreshState(); - }); - if (immediateError) { - threadMutationInFlight = false; - showWriteError(*immediateError); - refreshControls(); - } -} - -void WorkbenchWidget::mutateThread(PendingAction action, - const QString& threadId, - const QString& value) -{ - const auto& state = frontendSession.state(); - const auto* thread = state.thread(threadId.toStdString()); - if (!thread) { - showWriteError(QStringLiteral("The selected thread is no longer available")); - return; - } - const ThreadActionAvailability available = detail::threadActionAvailability(state, *thread); - if ((action == PendingAction::ArchiveThread && !available.archive) - || (action == PendingAction::UnarchiveThread && !available.unarchive) - || (action == PendingAction::DeleteThread && !available.remove) - || (action == PendingAction::RenameThread && !available.rename)) { - showWriteError(QStringLiteral("That thread action is no longer available")); - return; - } - - threadMutationInFlight = true; - refreshControls(); - const QPointer self(this); - const auto completion = [self, action, threadId](const QString& error) { - if (!self) - return; - self->threadMutationInFlight = false; - if (!error.isEmpty()) { - self->showWriteError(error); - } else { - self->conversation->setWriteStatus({}); - } - self->refreshState(); - }; - - std::optional immediateError; - switch (action) { - case PendingAction::RenameThread: - immediateError = frontendSession.renameThread(threadId, value, completion); - break; - case PendingAction::ArchiveThread: - immediateError = frontendSession.archiveThread(threadId, completion); - break; - case PendingAction::UnarchiveThread: - immediateError = frontendSession.unarchiveThread(threadId, completion); - break; - case PendingAction::DeleteThread: - immediateError = frontendSession.deleteThread(threadId, completion); - break; - default: - threadMutationInFlight = false; - return; - } - if (immediateError) { - threadMutationInFlight = false; - showWriteError(*immediateError); - refreshControls(); - } -} - -void WorkbenchWidget::interruptTurn(const QString& threadId, const QString& turnId) -{ - interruptInFlight = true; - conversation->setWriteStatus(QStringLiteral("Stopping turn…")); - refreshControls(); - const QPointer self(this); - const auto immediateError = frontendSession.interruptTurn(threadId, turnId, [self](const QString& error) { - if (!self) - return; - self->interruptInFlight = false; - if (!error.isEmpty()) - self->showWriteError(error); - else - self->conversation->setWriteStatus({}); - self->refreshState(); - }); - if (immediateError) { - interruptInFlight = false; - showWriteError(*immediateError); - refreshControls(); - } -} - -void WorkbenchWidget::showWriteError(const QString& error) -{ - conversation->setWriteStatus(error.isEmpty() ? QStringLiteral("Write operation failed") : error, true); -} - -void WorkbenchWidget::clearWriteTransients() -{ - pendingAction = PendingAction::None; - pendingPrompt.clear(); - pendingAttachments.clear(); - pendingAttachmentWorkspace.clear(); - pendingThreadId.clear(); - pendingTurnId.clear(); - pendingThreadValue.clear(); - pendingNewThreadSetup.reset(); - pendingForkThreadSetup.reset(); - pendingResumeSetup.reset(); - pendingTurnDraft = {}; - submittedTurnSettings.reset(); - turnThreadIdAwaitingState.clear(); - turnIdAwaitingState.clear(); - automaticResumeThreadId.clear(); - automaticResumeAttemptedThreadIds.clear(); - pendingSelectionGeneration = 0; - newThreadIdAwaitingState.clear(); - controllerAcquireInFlight = false; - threadStartInFlight = false; - threadResumeInFlight = false; - turnStartInFlight = false; - turnSteerInFlight = false; - cancelAttachmentPreparation(); - interruptInFlight = false; - threadMutationInFlight = false; - controllerUnavailable = false; -} - -void WorkbenchWidget::setSidebarVisible(bool visible) -{ - sidebar->setVisible(visible); - restoreSidebar->setVisible(!visible); - if (visible) - splitter->setSizes({282, qMax(500, splitter->width() - 694), 404}); -} - -void WorkbenchWidget::setInspectorVisible(bool visible) -{ - inspector->setVisible(visible); - restoreInspector->setVisible(!visible); - if (visible) - splitter->setSizes({282, qMax(500, splitter->width() - 694), 404}); -} - -} // namespace codexui diff --git a/src/ui/WorkbenchWidget.h b/src/ui/WorkbenchWidget.h deleted file mode 100644 index 7b56e15..0000000 --- a/src/ui/WorkbenchWidget.h +++ /dev/null @@ -1,268 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#ifndef CODEXUI_UI_WORKBENCHWIDGET_H -#define CODEXUI_UI_WORKBENCHWIDGET_H - -#include "ui/InteractiveRequestDialog.h" -#include "ui/ConversationWidget.h" -#include "ui/PresentationRefreshAccumulator.h" -#include "ui/ThreadSetupDialog.h" -#include "ui/UpcomingTurnDock.h" - -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -class QFrame; -class QLabel; -class QPushButton; -class QSplitter; - -namespace codexui { - -namespace detail { -struct StateUpdateScope; - -[[nodiscard]] constexpr bool shouldClearMissingSelectedThread( - bool ready, - bool threadDiscoveryTerminal, - bool awaitingSelectedThread, - std::size_t omittedThreads, - bool authoritativelyRemoved) noexcept -{ - return authoritativelyRemoved - || (ready && threadDiscoveryTerminal && !awaitingSelectedThread - && omittedThreads == 0); -} - -[[nodiscard]] inline bool shouldRetryProjectedSelectionAfterReady( - bool becameReady, - const QString& selectedThreadId, - const QString& projectedAgentThreadId) noexcept -{ - return becameReady && !selectedThreadId.isEmpty() - && selectedThreadId == projectedAgentThreadId; -} -} - -class ConversationWidget; -class FrontendSession; -class InspectorWidget; -class SidebarWidget; -enum class ThreadAction; - -class WorkbenchWidget : public QWidget -{ -public: - explicit WorkbenchWidget(FrontendSession& frontendSession, QWidget* parent = nullptr); - ~WorkbenchWidget() override; - -private: - enum class PendingAction { - None, - OpenThread, - SendExistingThread, - SteerActiveTurn, - InterruptTurn, - CreateThread, - RenameThread, - ForkThread, - ResumeWithOptions, - ArchiveThread, - UnarchiveThread, - DeleteThread, - }; - - struct SubmittedTurnSettings { - QString threadId; - QString turnId; - UpcomingTurnDraft draft; - }; - - struct PreparedTurnSubmission { - QString userPrompt; - QString effectivePrompt; - QStringList imagePaths; - QList attachments; - AttachmentStagingLeasePtr stagingLease; - }; - - struct RetainedAttachmentStaging { - QString registryId; - QString threadId; - QString turnId; - AttachmentStagingLeasePtr lease; - }; - - struct TurnSubmissionPreparationRequest { - PendingAction action = PendingAction::None; - QString threadId; - QString turnId; - QString prompt; - QList attachments; - QString workspace; - UpcomingTurnDraft settings; - std::uint64_t expectedSelectionGeneration = 0; - }; - - struct TurnSubmissionPreparationOutcome { - AttachmentPreparation preparation; - QString error; - bool success = false; - }; - - void refreshLifecycle(); - void scheduleStateRefresh(const detail::StateUpdateScope& scope); - void refreshState(bool refreshSelectedPresentation = true, - bool refreshInspector = true, - bool refreshSidebar = true, - const ConversationContentUpdates* exactContentChanges = nullptr, - const QStringList* sidebarThreadChanges = nullptr, - bool requiresStructuralReconciliation = false); - void refreshControls(); - void refreshControllerStatus(); - [[nodiscard]] bool writeOperationBusy() const noexcept; - void selectThread(const QString& threadId); - void selectProjectedAgentThread(const QString& threadId); - void beginNewThread(); - void handleThreadAction(const QString& threadId, ThreadAction action); - void showRenameThreadDialog(const QString& threadId); - void showForkThreadDialog(const QString& threadId); - void showResumeWithOptionsDialog(const QString& threadId); - void showDeleteThreadConfirmation(const QString& threadId); - void sendPrompt(const QString& prompt, bool steerRequested); - void stopActiveTurn(); - void maybeResumeSelectedThread(); - void reconcileAutomaticResumeState(); - void ensureController(); - void executePendingAction(); - void startNewThread(const NewThreadSetup& setup, std::uint64_t expectedSelectionGeneration); - void forkThread(const QString& threadId, - const ForkThreadSetup& setup, - std::uint64_t expectedSelectionGeneration); - void resumeThreadWithOptions(const QString& threadId, - const ResumeWithOptionsSetup& setup, - std::uint64_t expectedSelectionGeneration); - void mutateThread(PendingAction action, const QString& threadId, const QString& value = {}); - void beginTurnSubmissionPreparation(TurnSubmissionPreparationRequest request); - void cancelAttachmentPreparation() noexcept; - void finishTurnSubmissionPreparation(TurnSubmissionPreparationRequest request, - TurnSubmissionPreparationOutcome outcome, - std::uint64_t preparationGeneration); - [[nodiscard]] std::optional preparedTurnSubmission( - const QString& prompt, - const QList& attachments, - AttachmentPreparation preparation); - void resumeThread(const QString& threadId, - const PreparedTurnSubmission& submission, - const UpcomingTurnDraft& settings); - void resumeThreadForOpen(const QString& threadId, std::uint64_t expectedSelectionGeneration); - void startTurn(const QString& threadId, - const PreparedTurnSubmission& submission, - const UpcomingTurnDraft& settings); - void steerTurn(const QString& threadId, - const QString& turnId, - const PreparedTurnSubmission& submission); - void recoverAttachmentStaging(); - [[nodiscard]] bool retainAttachmentStaging(const QString& threadId, - const QString& turnId, - const AttachmentStagingLeasePtr& lease, - QString* errorMessage = nullptr); - void releaseAttachmentStaging(const AttachmentStagingLeasePtr& lease); - void reconcileAttachmentStaging(); - void reconcileSubmittedTurnSettings(); - void interruptTurn(const QString& threadId, const QString& turnId); - void showWriteError(const QString& error); - void clearWriteTransients(); - void submitInteractiveResponse(InteractiveRequestResponse response); - void ensureInteractiveController(); - void performInteractiveResponse(); - void clearInteractiveTransients(const QString& error = {}); - void setSidebarVisible(bool visible); - void setInspectorVisible(bool visible); - - FrontendSession& frontendSession; - QSplitter* splitter = nullptr; - SidebarWidget* sidebar = nullptr; - ConversationWidget* conversation = nullptr; - InspectorWidget* inspector = nullptr; - QPushButton* restoreSidebar = nullptr; - QPushButton* restoreInspector = nullptr; - QLabel* workspaceBreadcrumb = nullptr; - QFrame* codexStatusDot = nullptr; - QLabel* threadContextStatus = nullptr; - QLabel* agentActivityStatus = nullptr; - QLabel* synchronizationStatus = nullptr; - QLabel* controllerStatus = nullptr; - QLabel* attentionStatus = nullptr; - QPushButton* attentionButton = nullptr; - QPushButton* reconnectButton = nullptr; - InteractiveRequestDialog* interactiveRequestDialog = nullptr; - QString selectedThreadId; - // Only the current selection matters to presentation reconciliation. One - // retained identity keeps the 16 ms GUI coalescing window strictly - // bounded even during a pathological removal burst. - QString authoritativelyRemovedSelectedThreadId; - QString selectedInspectorTurnId; - QString projectedAgentThreadId; - QString retainedAgentActivityThreadId; - QSet retainedAgentActivityItemIds; - QString newThreadIdAwaitingState; - QString pendingPrompt; - QList pendingAttachments; - QString pendingAttachmentWorkspace; - QString pendingThreadId; - QString pendingTurnId; - QString pendingThreadValue; - std::optional pendingNewThreadSetup; - std::optional pendingForkThreadSetup; - std::optional pendingResumeSetup; - UpcomingTurnDraft pendingTurnDraft; - std::optional submittedTurnSettings; - // Kept across frontend reconnects. Canonical terminal turn state is the - // only authority that triggers cleanup. Lease destruction itself retains - // files so closing the UI cannot break a backend turn that remains active. - QList retainedAttachmentStaging; - QString turnThreadIdAwaitingState; - QString turnIdAwaitingState; - QString automaticResumeThreadId; - QSet automaticResumeAttemptedThreadIds; - PendingAction pendingAction = PendingAction::None; - std::uint64_t selectionGeneration = 0; - std::uint64_t pendingSelectionGeneration = 0; - bool controllerAcquireInFlight = false; - bool threadStartInFlight = false; - bool threadResumeInFlight = false; - bool turnStartInFlight = false; - bool turnSteerInFlight = false; - bool attachmentPreparationInFlight = false; - std::uint64_t attachmentPreparationGeneration = 0; - std::shared_ptr attachmentPreparationCancellation; - bool interruptInFlight = false; - bool threadMutationInFlight = false; - bool controllerUnavailable = false; - std::optional pendingInteractiveResponse; - std::string activeInteractiveRequestId; - bool requestControllerAcquireInFlight = false; - bool requestResponseInFlight = false; - bool stateRefreshPending = false; - detail::SelectedPresentationRefreshAccumulator selectedPresentationRefresh; - bool inspectorRefreshPending = false; - bool sidebarRefreshPending = false; - bool sidebarFullRefreshPending = false; - bool frontendWasReady = false; - QStringList sidebarThreadRefreshPending; - QSet sidebarThreadRefreshPendingSet; -}; - -} // namespace codexui - -#endif // CODEXUI_UI_WORKBENCHWIDGET_H diff --git a/tests/AttachmentManagerTest.cpp b/tests/AttachmentManagerTest.cpp deleted file mode 100644 index c623dea..0000000 --- a/tests/AttachmentManagerTest.cpp +++ /dev/null @@ -1,398 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "app/AttachmentManager.h" - -#include -#include -#include -#include -#include -#include - -#include - -namespace { - -bool expect(bool condition, const char* message) -{ - if (!condition) - std::cerr << message << '\n'; - return condition; -} - -QString writeFile(const QString& path, const QByteArray& data) -{ - QDir().mkpath(QFileInfo(path).absolutePath()); - QFile file(path); - if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size()) - return {}; - file.close(); - return QFileInfo(path).canonicalFilePath(); -} - -codexui::AttachmentInfo inspect(const QString& path) -{ - codexui::AttachmentInfo result; - QString error; - if (!codexui::AttachmentManager::inspectFile(path, &result, &error)) - std::cerr << error.toStdString() << '\n'; - return result; -} - -bool hasOnlyOwnerPermissions(const QString& path, bool directory) -{ - const QFileDevice::Permissions permissions = QFileInfo(path).permissions(); - const QFileDevice::Permissions required = QFileDevice::ReadOwner | QFileDevice::WriteOwner - | (directory ? QFileDevice::ExeOwner : QFileDevice::Permissions{}); - const QFileDevice::Permissions forbidden = QFileDevice::ReadGroup | QFileDevice::WriteGroup - | QFileDevice::ExeGroup | QFileDevice::ReadOther | QFileDevice::WriteOther - | QFileDevice::ExeOther; - return (permissions & required) == required - && (permissions & forbidden) == QFileDevice::Permissions{}; -} - -} // namespace - -int main(int argc, char** argv) -{ - QCoreApplication application(argc, argv); - QTemporaryDir source; - QTemporaryDir workspace; - if (!expect(source.isValid() && workspace.isValid(), - "temporary attachment directories must be available")) - return 1; - - QByteArray archiveBytes("archive bytes stay untouched"); - archiveBytes.append('\0'); - archiveBytes.append("more"); - const QString archivePath = writeFile( - QDir(source.path()).filePath(QStringLiteral("one/code.tar.gz")), archiveBytes); - const QString duplicatePath = writeFile( - QDir(source.path()).filePath(QStringLiteral("two/code.tar.gz")), "second"); - const QString imagePath = writeFile( - QDir(source.path()).filePath(QStringLiteral("screen.png")), "fake png"); - const QString preexistingMetadata = - QDir(workspace.path()).filePath(QStringLiteral(".codex-ui")); - const QString preexistingAttachments = - QDir(preexistingMetadata).filePath(QStringLiteral("attachments")); - QDir().mkpath(preexistingAttachments); - const QString ignorePath = QDir(preexistingAttachments).filePath(QStringLiteral(".gitignore")); - writeFile(ignorePath, "# Preserve an existing rule\n!keep-me\n"); - const QFileDevice::Permissions permissive = QFileDevice::ReadOwner | QFileDevice::WriteOwner - | QFileDevice::ExeOwner | QFileDevice::ReadGroup | QFileDevice::WriteGroup - | QFileDevice::ExeGroup | QFileDevice::ReadOther | QFileDevice::WriteOther - | QFileDevice::ExeOther; - (void)QFile::setPermissions(preexistingMetadata, permissive); - (void)QFile::setPermissions(preexistingAttachments, permissive); - - const codexui::AttachmentInfo archive = inspect(archivePath); - const codexui::AttachmentInfo duplicate = inspect(duplicatePath); - const codexui::AttachmentInfo image = inspect(imagePath); - bool passed = true; - passed &= expect(archive.kind == codexui::AttachmentInfo::Kind::File - && duplicate.kind == codexui::AttachmentInfo::Kind::File - && image.kind == codexui::AttachmentInfo::Kind::Image, - "inspection must distinguish generic files from supported local images"); - - codexui::AttachmentPreparation preparation; - QString error; - passed &= expect(codexui::AttachmentManager::prepare( - {archive, duplicate, image}, workspace.path(), - QStringLiteral("thread-123"), &preparation, &error), - qPrintable(error)); - passed &= expect(preparation.items.size() == 3 - && preparation.imagePaths == QStringList{imagePath} - && preparation.genericFilePrompt.contains( - QStringLiteral("not unpacked automatically")), - "preparation must keep images typed and describe staged generic files"); - passed &= expect(preparation.items.at(0).staged - && preparation.items.at(1).staged - && preparation.items.at(0).effectivePath - != preparation.items.at(1).effectivePath, - "same-name generic files must receive distinct staged paths"); - const QString metadataPath = QDir(workspace.path()).filePath(QStringLiteral(".codex-ui")); - const QString attachmentsPath = QDir(metadataPath).filePath(QStringLiteral("attachments")); - passed &= expect(preparation.stagingLease - && preparation.stagingLease->directory() == preparation.stagingDirectory - && hasOnlyOwnerPermissions(metadataPath, true) - && hasOnlyOwnerPermissions(attachmentsPath, true) - && hasOnlyOwnerPermissions(preparation.stagingDirectory, true), - "attachment metadata, storage, and per-submission directories must be owner-only"); - QFile stagedArchive(preparation.items.at(0).effectivePath); - passed &= expect(stagedArchive.open(QIODevice::ReadOnly) - && stagedArchive.readAll() == archiveBytes, - "generic attachment staging must preserve exact bytes"); - stagedArchive.close(); - passed &= expect(hasOnlyOwnerPermissions(preparation.items.at(0).effectivePath, false) - && hasOnlyOwnerPermissions(preparation.items.at(1).effectivePath, false), - "staged attachment copies must be readable and writable only by their owner"); - QFile ignoreFile(ignorePath); - passed &= expect(ignoreFile.open(QIODevice::ReadOnly | QIODevice::Text) - && ignoreFile.readAll().endsWith( - "# Transient files staged by CodexUI\n*\n") - && hasOnlyOwnerPermissions(ignorePath, false), - "the dedicated private attachment ignore file must preserve existing rules and ignore all contents"); - const QString composed = codexui::AttachmentManager::composePrompt( - QStringLiteral("Inspect these."), preparation); - passed &= expect(composed.startsWith(QStringLiteral("Inspect these.")) - && composed.contains(preparation.items.at(0).workspaceRelativePath), - "the submitted prompt must reference each staged workspace path"); - const QString markdownPrompt = QStringLiteral(" # Keep Markdown spacing\n\n"); - passed &= expect(codexui::AttachmentManager::composePrompt(markdownPrompt, {}) - == markdownPrompt, - "prompt composition must preserve the exact user-authored Markdown source"); - - const QString leasedDirectory = preparation.stagingDirectory; - codexui::AttachmentStagingLeasePtr inFlightLease = preparation.stagingLease; - preparation = {}; - passed &= expect(QFileInfo::exists(leasedDirectory), - "a staged directory must survive while an in-flight turn retains its lease"); - passed &= expect(inFlightLease->cleanup() && !QFileInfo::exists(leasedDirectory), - "explicit terminal cleanup must remove exact staged files and their empty directory"); - inFlightLease.reset(); - - const QString cancellablePath = writeFile( - QDir(source.path()).filePath(QStringLiteral("cancellable.bin")), - QByteArray(2 * 1024 * 1024, 'x')); - const codexui::AttachmentInfo cancellable = inspect(cancellablePath); - codexui::AttachmentPreparation cancelledPreparation; - error.clear(); - int cancellationChecks = 0; - passed &= expect(!codexui::AttachmentManager::prepare( - {cancellable}, workspace.path(), QStringLiteral("thread-cancelled"), - &cancelledPreparation, &error, - [&cancellationChecks] { return ++cancellationChecks >= 5; }) - && error.contains(QStringLiteral("cancelled"), Qt::CaseInsensitive) - && cancelledPreparation.stagingDirectory.isEmpty() - && QDir(attachmentsPath).entryList( - QDir::NoDotAndDotDot | QDir::Dirs).isEmpty(), - "cooperative cancellation during a chunked copy must remove partial staging"); - - QTemporaryDir registryDirectory; - passed &= expect(registryDirectory.isValid(), - "an isolated staging registry must be available"); - const QString registryPath = - QDir(registryDirectory.path()).filePath(QStringLiteral("attachments.ini")); - QSettings registry(registryPath, QSettings::IniFormat); - codexui::AttachmentPreparation restartPreparation; - error.clear(); - passed &= expect(codexui::AttachmentManager::prepare( - {archive}, workspace.path(), QStringLiteral("thread-restart"), - &restartPreparation, &error), - qPrintable(error)); - const QString restartDirectory = restartPreparation.stagingDirectory; - const QString restartFile = restartPreparation.items.constFirst().effectivePath; - const QString restartRegistryId = - codexui::AttachmentManager::createStagingRegistryId(); - passed &= expect(codexui::AttachmentManager::persistDispatchedStaging( - registry, restartRegistryId, QStringLiteral("thread-restart"), {}, - restartPreparation.stagingLease, &error) - && hasOnlyOwnerPermissions(registryPath, false), - "dispatched attachment ownership must persist privately before correlation"); - restartPreparation = {}; - passed &= expect(QFileInfo::exists(restartDirectory) && QFileInfo::exists(restartFile), - "closing the originating frontend must retain dispatched attachment files"); - - QList recovered; - QSettings restartedRegistry(registryPath, QSettings::IniFormat); - passed &= expect(codexui::AttachmentManager::recoverDispatchedStaging( - restartedRegistry, &recovered, &error) - && recovered.size() == 1 - && recovered.constFirst().registryId == restartRegistryId - && recovered.constFirst().threadId == QStringLiteral("thread-restart") - && recovered.constFirst().turnId.isEmpty(), - "restart recovery must preserve ambiguous dispatched ownership without guessing a turn"); - passed &= expect(codexui::AttachmentManager::persistDispatchedStaging( - restartedRegistry, restartRegistryId, - QStringLiteral("thread-restart"), QStringLiteral("turn-authoritative"), - recovered.constFirst().stagingLease, &error), - "the correlated authoritative turn identity must update the existing lease record"); - recovered.clear(); - passed &= expect(QFileInfo::exists(restartDirectory), - "dropping a recovered lease must not clean an active backend turn"); - passed &= expect(codexui::AttachmentManager::recoverDispatchedStaging( - restartedRegistry, &recovered, &error) - && recovered.size() == 1 - && recovered.constFirst().turnId == QStringLiteral("turn-authoritative"), - "a later frontend must recover the authoritative thread and turn ownership"); - passed &= expect(recovered.constFirst().stagingLease->cleanup() - && codexui::AttachmentManager::forgetDispatchedStaging( - restartedRegistry, restartRegistryId, &error) - && !QFileInfo::exists(restartDirectory), - "simulated canonical terminal state must safely clean and forget recovered staging"); - recovered.clear(); - passed &= expect(codexui::AttachmentManager::recoverDispatchedStaging( - restartedRegistry, &recovered, &error) - && recovered.isEmpty(), - "terminal cleanup must not recover on another restart"); - - codexui::AttachmentPreparation stalePreparation; - error.clear(); - passed &= expect(codexui::AttachmentManager::prepare( - {archive}, workspace.path(), QStringLiteral("thread-stale"), - &stalePreparation, &error), - qPrintable(error)); - const QString staleRegistryId = - codexui::AttachmentManager::createStagingRegistryId(); - passed &= expect(codexui::AttachmentManager::persistDispatchedStaging( - restartedRegistry, staleRegistryId, - QStringLiteral("thread-stale"), QStringLiteral("turn-stale"), - stalePreparation.stagingLease, &error), - qPrintable(error)); - stalePreparation.stagingLease->cancelDispatch(); - passed &= expect(stalePreparation.stagingLease->cleanup(), - "the stale-record fixture must remove its exact staged data"); - stalePreparation = {}; - recovered.clear(); - passed &= expect(codexui::AttachmentManager::recoverDispatchedStaging( - restartedRegistry, &recovered, &error) - && recovered.isEmpty(), - "restart recovery must retire a registry record whose staged data is already absent"); - restartedRegistry.beginGroup(QStringLiteral("attachmentStaging/v1")); - passed &= expect(!restartedRegistry.childGroups().contains(staleRegistryId), - "retiring absent staging must persistently remove its registry record"); - restartedRegistry.endGroup(); - - codexui::AttachmentPreparation rejectedPreparation; - error.clear(); - passed &= expect(codexui::AttachmentManager::prepare( - {archive}, workspace.path(), QStringLiteral("thread-rejected"), - &rejectedPreparation, &error), - qPrintable(error)); - const QString rejectedDirectory = rejectedPreparation.stagingDirectory; - const QString rejectedRegistryId = - codexui::AttachmentManager::createStagingRegistryId(); - passed &= expect(codexui::AttachmentManager::persistDispatchedStaging( - restartedRegistry, rejectedRegistryId, - QStringLiteral("thread-rejected"), {}, - rejectedPreparation.stagingLease, &error), - qPrintable(error)); - rejectedPreparation.stagingLease->cancelDispatch(); - passed &= expect(rejectedPreparation.stagingLease->cleanup() - && codexui::AttachmentManager::forgetDispatchedStaging( - restartedRegistry, rejectedRegistryId, &error) - && !QFileInfo::exists(rejectedDirectory), - "an immediate rejection must clean exact files before forgetting its registry entry"); - rejectedPreparation = {}; - - codexui::AttachmentPreparation tamperedPreparation; - error.clear(); - passed &= expect(codexui::AttachmentManager::prepare( - {archive}, workspace.path(), QStringLiteral("thread-tampered"), - &tamperedPreparation, &error), - qPrintable(error)); - const QString tamperedDirectory = tamperedPreparation.stagingDirectory; - const QString tamperedFile = tamperedPreparation.items.constFirst().effectivePath; - const QString outsideFile = writeFile( - QDir(workspace.path()).filePath(QStringLiteral("must-not-delete.txt")), "outside"); - const QString tamperedRegistryId = - codexui::AttachmentManager::createStagingRegistryId(); - passed &= expect(codexui::AttachmentManager::persistDispatchedStaging( - restartedRegistry, tamperedRegistryId, - QStringLiteral("thread-tampered"), QStringLiteral("turn-tampered"), - tamperedPreparation.stagingLease, &error), - qPrintable(error)); - restartedRegistry.beginGroup(QStringLiteral("attachmentStaging/v1")); - restartedRegistry.beginGroup(tamperedRegistryId); - restartedRegistry.setValue(QStringLiteral("files"), QStringList{outsideFile}); - restartedRegistry.endGroup(); - restartedRegistry.endGroup(); - restartedRegistry.sync(); - tamperedPreparation = {}; - recovered.clear(); - passed &= expect(codexui::AttachmentManager::recoverDispatchedStaging( - restartedRegistry, &recovered, &error) - && recovered.isEmpty() && QFileInfo::exists(outsideFile) - && QFileInfo::exists(tamperedFile), - "recovery must reject paths outside the exact staging directory without deleting them"); - passed &= expect(codexui::AttachmentManager::forgetDispatchedStaging( - restartedRegistry, tamperedRegistryId, &error) - && QFile::remove(tamperedFile) && QDir().rmdir(tamperedDirectory) - && QFile::remove(outsideFile), - "the tampered recovery fixture must remain removable without recursive deletion"); - - codexui::AttachmentPreparation localFailure; - error.clear(); - passed &= expect(codexui::AttachmentManager::prepare( - {archive}, workspace.path(), QStringLiteral("thread-local-failure"), - &localFailure, &error), - qPrintable(error)); - const QString localFailureDirectory = localFailure.stagingDirectory; - localFailure = {}; - passed &= expect(!QFileInfo::exists(localFailureDirectory), - "destroying a prepared-but-unsubmitted lease must clean local failure staging"); - - codexui::AttachmentPreparation unresolved; - error.clear(); - passed &= expect(codexui::AttachmentManager::prepare( - {archive}, workspace.path(), QStringLiteral("thread-unresolved"), - &unresolved, &error), - qPrintable(error)); - const QString unresolvedDirectory = unresolved.stagingDirectory; - const QString unresolvedFile = unresolved.items.constFirst().effectivePath; - unresolved.stagingLease->markDispatched(); - unresolved = {}; - passed &= expect(QFileInfo::exists(unresolvedDirectory) && QFileInfo::exists(unresolvedFile), - "destroying an unresolved lease must not remove files an accepted backend turn may still use"); - passed &= expect(QFile::remove(unresolvedFile) && QDir().rmdir(unresolvedDirectory), - "the unresolved-lifetime fixture must be removable without recursive deletion"); - - codexui::AttachmentPreparation guardedCleanup; - error.clear(); - passed &= expect(codexui::AttachmentManager::prepare( - {archive}, workspace.path(), QStringLiteral("thread-guarded"), - &guardedCleanup, &error), - qPrintable(error)); - const QString unexpectedPath = writeFile( - QDir(guardedCleanup.stagingDirectory).filePath(QStringLiteral("backend-output.txt")), - "must survive"); - const QString guardedStagedFile = guardedCleanup.items.constFirst().effectivePath; - passed &= expect(!guardedCleanup.stagingLease->cleanup() - && !QFileInfo::exists(guardedStagedFile) - && QFileInfo::exists(unexpectedPath) - && QFileInfo::exists(guardedCleanup.stagingDirectory), - "cleanup must remove only tracked staged files and leave unexpected directory contents untouched"); - passed &= expect(QFile::remove(unexpectedPath) - && QDir().rmdir(guardedCleanup.stagingDirectory), - "the guarded-cleanup fixture must remain manually removable"); - guardedCleanup = {}; - - codexui::AttachmentPreparation retryCleanup; - error.clear(); - passed &= expect(codexui::AttachmentManager::prepare( - {archive}, workspace.path(), QStringLiteral("thread-retry"), - &retryCleanup, &error), - qPrintable(error)); - const QString replacedStagedPath = retryCleanup.items.constFirst().effectivePath; - passed &= expect(QFile::remove(replacedStagedPath) - && QDir().mkpath(replacedStagedPath) - && !retryCleanup.stagingLease->cleanup() - && QFileInfo(replacedStagedPath).isDir(), - "cleanup must not recursively remove a directory that replaced a tracked file"); - passed &= expect(QDir().rmdir(replacedStagedPath) - && retryCleanup.stagingLease->cleanup() - && !QFileInfo::exists(retryCleanup.stagingDirectory), - "failed tracked paths must remain available for a safe cleanup retry"); - retryCleanup = {}; - - codexui::AttachmentInfo missing = archive; - missing.sourcePath = QDir(source.path()).filePath(QStringLiteral("removed.tar.gz")); - codexui::AttachmentPreparation failedCopy; - error.clear(); - passed &= expect(!codexui::AttachmentManager::prepare( - {archive, missing}, workspace.path(), QStringLiteral("thread-123"), - &failedCopy, &error) - && failedCopy.stagingDirectory.isEmpty() - && QDir(attachmentsPath).entryList( - QDir::NoDotAndDotDot | QDir::Dirs).isEmpty(), - "failed preparation must release its partially created staging directory"); - - codexui::AttachmentPreparation invalid; - error.clear(); - passed &= expect(!codexui::AttachmentManager::prepare( - {archive}, QStringLiteral("/definitely/not/a/workspace"), - QStringLiteral("thread"), &invalid, &error) - && error.contains(QStringLiteral("workspace"), Qt::CaseInsensitive), - "generic files must fail clearly when no writable workspace exists"); - return passed ? 0 : 1; -} diff --git a/tests/ConversationLayoutTest.cpp b/tests/ConversationLayoutTest.cpp deleted file mode 100644 index 0404f90..0000000 --- a/tests/ConversationLayoutTest.cpp +++ /dev/null @@ -1,3223 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "ui/ConversationWidget.h" -#include "ui/InspectorWidget.h" -#include "ui/PresentationRefreshAccumulator.h" - -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -namespace codexui { - -struct ConversationWidgetTestAccess -{ - static bool primeViewportAnchor( - ConversationWidget& conversation, QWidget* anchor, int anchorY) - { - auto* scroll = conversation.scrollArea->verticalScrollBar(); - conversation.followingLatest = false; - conversation.pinLatestDuringLayout = true; - conversation.layoutSettleTimer->start(60'000); - conversation.pendingViewportAnchor = anchor; - conversation.pendingViewportAnchorY = anchorY; - return scroll->maximum() - scroll->value() > 72; - } - - static QWidget* viewportAnchor(const ConversationWidget& conversation) - { - return conversation.pendingViewportAnchor.data(); - } - - static int viewportAnchorY(const ConversationWidget& conversation) - { - return conversation.pendingViewportAnchorY; - } - - static bool tracksSegment( - const ConversationWidget& conversation, - const QString& turnId, - const QString& segmentId) - { - return conversation.renderedSegmentWidgets.contains( - turnId + QChar(0x1f) + segmentId); - } - - static void finishAnchoredReconciliation(ConversationWidget& conversation) - { - conversation.layoutSettleTimer->stop(); - conversation.layoutSettleTimer->setInterval(16); - conversation.pinLatestDuringLayout = false; - conversation.pendingViewportAnchor.clear(); - conversation.pendingFollowLatest = false; - conversation.pendingThreadChanged = false; - conversation.pendingTimelineShrink = false; - conversation.scrollArea->viewport()->setUpdatesEnabled(true); - } - - static void settleAnchoredReconciliation(ConversationWidget& conversation) - { - conversation.layoutSettleTimer->stop(); - conversation.layoutSettleTimer->setInterval(16); - conversation.pinLatestDuringLayout = false; - conversation.settleTimelineLayout(); - } -}; - -} // namespace codexui - -namespace { - -namespace frontend = ai::openai::codex::frontend; -namespace client = frontend::client; - -struct MessageFixture -{ - std::string id; - frontend::ThreadItemKind kind = frontend::ThreadItemKind::AgentMessage; - std::string text; - std::string status = "completed"; - bool contentTruncated = false; - bool textTruncated = false; - bool genericItemTruncatedOnly = false; - std::string command; - std::string reasoningSummary; - std::string agentPath; - std::string agentThreadId; - std::string agentKind; -}; - -struct TurnFixture -{ - std::string id; - std::vector messages; - std::optional plan; - std::string status = "completed"; - bool active = false; - bool terminal = true; - bool connectionInvalidated = false; -}; - -struct ThreadFixture -{ - std::string id; - std::vector turns; - bool fullyLoaded = true; -}; - -bool expect(bool condition, const char* message) -{ - if (!condition) - std::cerr << message << '\n'; - return condition; -} - -bool expectAtLeast(int actual, int required, const char* message) -{ - if (actual >= required) - return true; - std::cerr << message << " (actual " << actual << ", required " << required << ")\n"; - return false; -} - -class ActivityTopLevelShowMonitor final : public QObject -{ -public: - [[nodiscard]] bool empty() const noexcept - { - return unexpectedObjectNames.isEmpty(); - } - -protected: - bool eventFilter(QObject* watched, QEvent* event) override - { - if (event->type() == QEvent::Show) { - const auto* widget = qobject_cast(watched); - if (widget && widget->isWindow() - && widget->objectName().startsWith(QStringLiteral("conversationActivity"))) - unexpectedObjectNames.append(widget->objectName()); - } - return QObject::eventFilter(watched, event); - } - -private: - QStringList unexpectedObjectNames; -}; - -void settleEvents(int passes = 3, int delayMs = 25) -{ - for (int pass = 0; pass < passes; ++pass) { - QEventLoop loop; - QTimer::singleShot(delayMs, &loop, &QEventLoop::quit); - loop.exec(); - QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); - QCoreApplication::processEvents(); - } -} - -void settleTimeline() -{ - settleEvents(4, 75); -} - -frontend::Json messageJson(const std::string& threadId, - const std::string& turnId, - const MessageFixture& fixture) -{ - constexpr std::size_t initialIncrementalContentBytes = 12U * 1024U; - frontend::Json data = frontend::Json::object(); - if (fixture.kind == frontend::ThreadItemKind::UserMessage) { - data = frontend::Json{{"clientId", nullptr}, - {"contentTruncated", fixture.contentTruncated}, - {"text", fixture.text}, - {"textTruncated", fixture.textTruncated}, - {"originalContentBytes", - fixture.text.size() + (fixture.contentTruncated ? 1U : 0U)}, - {"retainedContentBytes", fixture.text.size()}, - {"originalContentItems", fixture.contentTruncated ? 2 : 1}, - {"retainedContentItems", 1}}; - } else if (fixture.kind == frontend::ThreadItemKind::CommandExecution) { - data = frontend::Json{{"command", fixture.command.empty() ? "bash -lc test-command" : fixture.command}, - {"cwd", "/workspace/test"}, - {"status", fixture.status}, - {"durationMs", 42}}; - if (fixture.status == "completed") - data["exitCode"] = 0; - } else if (fixture.kind == frontend::ThreadItemKind::SubAgentActivity) { - data = frontend::Json{{"agentPath", fixture.agentPath}, - {"agentThreadId", fixture.agentThreadId}, - {"kind", fixture.agentKind}}; - } - const bool carriesCommandOutput = fixture.kind == frontend::ThreadItemKind::CommandExecution - || fixture.kind == frontend::ThreadItemKind::FileChange; - const std::string initialAgentText = fixture.kind == frontend::ThreadItemKind::AgentMessage - ? fixture.text.substr( - 0, - std::min( - fixture.text.size(), - initialIncrementalContentBytes)) - : std::string{}; - const std::string summary = fixture.kind == frontend::ThreadItemKind::UserMessage - ? std::string{} - : carriesCommandOutput - ? fixture.text.substr(0, std::min(fixture.text.size(), 500)) - : fixture.kind == frontend::ThreadItemKind::AgentMessage - ? initialAgentText - : fixture.text; - const std::string initialCommandOutput = carriesCommandOutput - ? fixture.text.substr( - 0, - std::min( - fixture.text.size(), - initialIncrementalContentBytes)) - : std::string{}; - return frontend::Json{{"id", fixture.id}, - {"type", frontend::toString(fixture.kind)}, - {"threadId", threadId}, - {"turnId", turnId}, - {"status", fixture.status}, - {"summary", summary}, - {"agentText", initialAgentText}, - {"reasoningText", - fixture.kind == frontend::ThreadItemKind::Reasoning - ? fixture.text - : std::string{}}, - {"reasoningSummary", fixture.reasoningSummary}, - {"commandOutput", initialCommandOutput}, - {"droppedContentBytes", 0}, - {"contentTruncated", - fixture.contentTruncated || fixture.genericItemTruncatedOnly}, - {"data", std::move(data)}, - {"extensions", frontend::Json::object()}}; -} - -client::State makeState(const std::vector& fixtures, - std::size_t omittedThreads = 0) -{ - client::ClientOptions options; - options.requestedCapabilities = {frontend::FrontendCapability::CompleteThreadItems}; - options.credentialProvider = [] { - return client::AuthenticationContext{frontend::NoCredential{}, std::string{"conversation-layout-test"}}; - }; - client::Client sdk(std::move(options)); - auto connection = sdk.openConnection({ - [](client::OutboundMessage) { - return client::SendResult{client::SendStatus::Accepted, std::nullopt}; - }, - [](std::string) {}, - }); - connection.transportConnected(); - const frontend::CapabilityAdvertisement capabilities{ - {frontend::FrontendCapability::CompleteThreadItems}, - {frontend::FrontendCapability::CompleteThreadItems}, - {frontend::FrontendCapability::CompleteThreadItems}, - frontend::Json::object()}; - if (!connection - .receive(frontend::ServerMessage{frontend::Welcome{ - "fixture-session", - frontend::SessionRole::Observer, - frontend::SequenceNumber{0}, - frontend::SyncMode::Snapshot, - frontend::Json{{"projection", - frontend::Json{{"itemContentUpdateMode", "append-v2"}}}}, - capabilities}}) - .accepted) - return {}; - - const frontend::Json executionConfiguration{ - {"approvalPolicy", "on-request"}, - {"approvalsReviewer", "user"}, - {"collaborationMode", - {{"mode", "plan"}, - {"settings", {{"model", "gpt-test"}, {"reasoningEffort", "high"}}}}}, - {"cwd", "/workspace/test"}, - {"effort", "high"}, - {"model", "gpt-test"}, - {"modelProvider", "openai"}, - {"personality", "pragmatic"}, - {"sandboxPolicy", {{"type", "workspaceWrite"}, {"networkAccess", false}}}, - {"serviceTier", "flex"}, - {"summary", "detailed"}, - }; - frontend::Json threads = frontend::Json::array(); - for (const ThreadFixture& threadFixture : fixtures) { - frontend::Json turns = frontend::Json::array(); - for (const TurnFixture& turnFixture : threadFixture.turns) { - frontend::Json items = frontend::Json::array(); - for (const MessageFixture& message : turnFixture.messages) - items.push_back(messageJson(threadFixture.id, turnFixture.id, message)); - frontend::Json turn{{"id", turnFixture.id}, - {"threadId", threadFixture.id}, - {"status", turnFixture.status}, - {"active", turnFixture.active}, - {"terminal", turnFixture.terminal}, - {"connectionInvalidated", turnFixture.connectionInvalidated}, - {"effectiveExecutionConfiguration", executionConfiguration}, - {"effectiveExecutionConfigurationProvenance", "turn_start_accepted"}, - {"items", std::move(items)}, - {"extensions", frontend::Json::object()}}; - if (turnFixture.plan) - turn["plan"] = *turnFixture.plan; - turns.push_back(std::move(turn)); - } - threads.push_back(frontend::Json{{"id", threadFixture.id}, - {"title", threadFixture.id}, - {"status", "idle"}, - {"fullyLoaded", threadFixture.fullyLoaded}, - {"executionConfiguration", executionConfiguration}, - {"turns", std::move(turns)}, - {"extensions", frontend::Json::object()}}); - } - - frontend::Json state{{"backendRevision", 1}, - {"lifecycle", "ready"}, - {"diagnostics", {{"received", 0}, {"recent", frontend::Json::array()}}}, - {"sessions", frontend::Json::array()}, - {"threadList", {{"hasLoadedPage", true}, {"complete", true}, {"pagesLoaded", 1}}}, - {"threads", std::move(threads)}, - {"pendingRequests", frontend::Json::array()}, - {"codexExtensions", frontend::Json::array()}, - {"omittedCodexExtensions", 0}, - {"journal", {{"oldestReplayableAfter", 0}, {"currentSequence", 0}}}, - {"sequenceExhausted", false}}; - if (omittedThreads > 0) { - state["capacityProvenance"] = { - {"omittedThreads", omittedThreads}, - {"truncated", true}, - }; - } - if (!connection - .receive(frontend::ServerMessage{ - frontend::Snapshot{frontend::SequenceNumber{0}, std::move(state)}}) - .accepted) - return {}; - if (!connection.receive(frontend::ServerMessage{frontend::SyncComplete{frontend::SequenceNumber{0}}}).accepted) - return {}; - - // Exercise the public negotiated append-v2 path instead of putting an - // over-capacity scalar into a synthetic Snapshot. This mirrors how the - // real backend restores complete retained message and command content - // incrementally. - constexpr std::size_t initialIncrementalContentBytes = 12U * 1024U; - constexpr std::size_t incrementalContentDeltaBytes = 12U * 1024U; - std::uint64_t sequence = 0; - for (const ThreadFixture& thread : fixtures) { - for (const TurnFixture& turn : thread.turns) { - for (const MessageFixture& item : turn.messages) { - const bool carriesCommandOutput = - item.kind == frontend::ThreadItemKind::CommandExecution - || item.kind == frontend::ThreadItemKind::FileChange; - const bool carriesAgentText = - item.kind == frontend::ThreadItemKind::AgentMessage; - if (!carriesCommandOutput && !carriesAgentText) - continue; - std::size_t retained = std::min( - item.text.size(), initialIncrementalContentBytes); - while (retained < item.text.size()) { - const std::size_t deltaBytes = - std::min( - incrementalContentDeltaBytes, - item.text.size() - retained); - frontend::FrontendEvent event{ - frontend::SequenceNumber{++sequence}, - "item.content.updated", - frontend::Json{{"threadId", thread.id}, - {"turnId", turn.id}, - {"itemId", item.id}, - {"channel", - carriesCommandOutput - ? "commandOutput" - : "agentText"}, - {"content", ""}, - {"contentDelta", item.text.substr(retained, deltaBytes)}, - {"baseContentBytes", retained}, - {"contentTruncated", false}, - {"droppedContentBytes", 0}}, - frontend::Json::object()}; - if (!connection - .receive(frontend::ServerMessage{frontend::EventBatch{ - event.sequence, event.sequence, {std::move(event)}}}) - .accepted) - return {}; - retained += deltaBytes; - } - } - } - } - return sdk.state(); -} - -codexui::ConversationContentUpdates replacementUpdate( - QString turnId, - QString itemId, - client::ItemContentChannel channel) -{ - return {{std::move(turnId), std::move(itemId), channel, std::nullopt}}; -} - -codexui::ConversationContentUpdates appendUpdate( - QString turnId, - QString itemId, - client::ItemContentChannel channel, - std::uint64_t baseContentBytes, - QString delta, - std::uint64_t discardPrefixBytes = 0) -{ - const std::uint64_t deltaBytes = static_cast(delta.toUtf8().size()); - return {{std::move(turnId), - std::move(itemId), - channel, - codexui::ConversationContentAppend{ - baseContentBytes, - discardPrefixBytes, - deltaBytes, - std::move(delta)}}}; -} - -ThreadFixture sequentialTurns(std::string threadId, int turnCount) -{ - ThreadFixture result{std::move(threadId), {}}; - for (int index = 0; index < turnCount; ++index) { - const std::string suffix = std::to_string(index); - result.turns.push_back({"turn-" + result.id + "-" + suffix, - {{"item-" + result.id + "-" + suffix, - frontend::ThreadItemKind::AgentMessage, - "message " + result.id + " " + suffix}}}); - } - return result; -} - -ThreadFixture singleTurn(std::string threadId, int messageCount) -{ - ThreadFixture result{std::move(threadId), {}}; - result.turns.push_back({"turn-" + result.id, {}}); - for (int index = 0; index < messageCount; ++index) { - const std::string suffix = std::to_string(index); - result.turns.front().messages.push_back( - {"item-" + result.id + "-" + suffix, - index % 2 == 0 ? frontend::ThreadItemKind::UserMessage : frontend::ThreadItemKind::AgentMessage, - "message " + result.id + " " + suffix}); - } - return result; -} - -ThreadFixture activityTurn(std::string threadId, int itemCount) -{ - ThreadFixture result{std::move(threadId), {}}; - result.turns.push_back({"turn-" + result.id, {}}); - for (int index = 0; index < itemCount; ++index) { - const std::string suffix = std::to_string(index); - result.turns.front().messages.push_back( - {"item-" + result.id + "-" + suffix, - frontend::ThreadItemKind::Reasoning, - "activity " + result.id + " " + suffix}); - } - return result; -} - -QWidget* timeline(codexui::ConversationWidget& conversation) -{ - return conversation.findChild(QStringLiteral("conversationTimeline")); -} - -QFrame* windowNotice(codexui::ConversationWidget& conversation) -{ - return conversation.findChild(QStringLiteral("conversationWindowNotice")); -} - -QWidget* segment(codexui::ConversationWidget& conversation, const QString& id) -{ - for (QWidget* candidate : conversation.findChildren(QStringLiteral("conversationSegment"))) { - if (candidate->property("segmentId").toString() == id) - return candidate; - } - return nullptr; -} - -QLabel* messageLabel(QWidget* messageSegment, const QString& objectName) -{ - return messageSegment ? messageSegment->findChild(objectName) : nullptr; -} - -QWidget* messageContent(QWidget* messageSegment) -{ - return messageSegment - ? messageSegment->findChild( - QStringLiteral("conversationMessageContent")) - : nullptr; -} - -QString messageSourceText(const QLabel* label) -{ - return label ? label->property("sourceText").toString() : QString{}; -} - -QString messageSourceText(const QWidget* widget) -{ - if (const auto* label = qobject_cast(widget)) - return messageSourceText(label); - if (const auto* editor = qobject_cast(widget)) - return editor->toPlainText(); - if (const auto* editor = qobject_cast(widget)) - return editor->toPlainText(); - return {}; -} - -bool segmentHasLabel(QWidget* messageSegment, const QString& text) -{ - if (!messageSegment) - return false; - for (QLabel* label : messageSegment->findChildren()) { - if (label->text() == text || messageSourceText(label) == text) - return true; - } - for (QTextEdit* editor : messageSegment->findChildren()) { - if (editor->toPlainText() == text) - return true; - } - return false; -} - -bool hasLabel(codexui::ConversationWidget& conversation, const QString& text) -{ - for (QLabel* label : conversation.findChildren()) { - if (label->text() == text || messageSourceText(label) == text) - return true; - } - for (QTextEdit* editor : conversation.findChildren()) { - if (editor->toPlainText() == text) - return true; - } - return false; -} - -bool hasLabelContaining(codexui::ConversationWidget& conversation, - const QString& text) -{ - return std::ranges::any_of( - conversation.findChildren(), - [&text](const QLabel* label) { - return label->text().contains(text); - }); -} - -QStringList renderedTurnIds(codexui::ConversationWidget& conversation) -{ - QStringList result; - QWidget* host = timeline(conversation); - if (!host || !host->layout()) - return result; - for (int index = 0; index < host->layout()->count(); ++index) { - QWidget* turn = host->layout()->itemAt(index)->widget(); - if (turn && turn->objectName() == QStringLiteral("conversationTurn")) - result.append(turn->property("turnId").toString()); - } - return result; -} - -QWidget* renderedTurn(codexui::ConversationWidget& conversation, const QString& id) -{ - for (QWidget* turn : conversation.findChildren(QStringLiteral("conversationTurn"))) { - if (turn->property("turnId").toString() == id) - return turn; - } - return nullptr; -} - -QString turnHeading(QWidget* turn) -{ - if (!turn) - return {}; - for (QLabel* label : turn->findChildren()) { - if (label->text().startsWith(QStringLiteral("TURN "))) - return label->text(); - } - return {}; -} - -int requiredHeight(QWidget& host) -{ - QLayout* layout = host.layout(); - if (!layout) - return 0; - const int width = host.contentsRect().width(); - return width > 0 && layout->hasHeightForWidth() ? layout->heightForWidth(width) : layout->sizeHint().height(); -} - -QLabel* emptyStateDetail(codexui::ConversationWidget& conversation) -{ - for (QLabel* label : conversation.findChildren()) { - if (label->text() == QStringLiteral("Choose a synchronized thread from the sidebar.")) - return label; - } - return nullptr; -} - -bool testTurnWindow() -{ - const client::State state = makeState({sequentialTurns("many-turns", 40)}); - codexui::ConversationWidget conversation; - conversation.resize(900, 700); - conversation.show(); - conversation.render(state, QStringLiteral("many-turns")); - settleTimeline(); - - QWidget* host = timeline(conversation); - QFrame* notice = windowNotice(conversation); - const QStringList turns = renderedTurnIds(conversation); - bool passed = true; - passed &= expect(state.thread("many-turns") != nullptr, "the long-turn fixture must produce public AISuite State"); - passed &= expect(host && turns.size() == 40, - "a fully loaded conversation must materialize every retained turn"); - passed &= expect(!turns.isEmpty() && turns.front() == QStringLiteral("turn-many-turns-0") - && turns.back() == QStringLiteral("turn-many-turns-39"), - "the timeline must preserve complete canonical order and original identities"); - passed &= expect(notice && !notice->isVisible(), - "a fully loaded timeline must not claim that retained entries are omitted"); - passed &= expect(hasLabel(conversation, QStringLiteral("message many-turns 39")), - "the newest retained turn must remain visible"); - passed &= expect(hasLabel(conversation, QStringLiteral("message many-turns 0")), - "the earliest retained turn must remain available after reconstruction"); - return passed; -} - -bool testSameThreadPrefixExpansion() -{ - const ThreadFixture completeFixture = sequentialTurns("prefix", 40); - ThreadFixture partialFixture = completeFixture; - partialFixture.turns.erase(partialFixture.turns.begin(), partialFixture.turns.begin() + 8); - - const client::State partialState = makeState({partialFixture}); - const client::State completeState = makeState({completeFixture}); - codexui::ConversationWidget conversation; - conversation.resize(900, 700); - conversation.show(); - conversation.render(partialState, QStringLiteral("prefix")); - settleTimeline(); - - QPointer firstTailTurn = renderedTurn(conversation, QStringLiteral("turn-prefix-8")); - QWidget* preservedAddress = firstTailTurn.data(); - const QString initialHeading = turnHeading(firstTailTurn); - conversation.render(completeState, QStringLiteral("prefix")); - settleTimeline(); - - return expect(firstTailTurn && firstTailTurn.data() == preservedAddress - && firstTailTurn.data() - == renderedTurn(conversation, QStringLiteral("turn-prefix-8")) - && initialHeading == QStringLiteral("TURN 1") - && turnHeading(firstTailTurn) == QStringLiteral("TURN 9"), - "same-thread history expansion must preserve tail widgets and refresh canonical turn ordinals"); -} - -bool testHotTurnWindow() -{ - const client::State state = makeState({singleTurn("hot", 300)}); - codexui::ConversationWidget conversation; - conversation.resize(900, 700); - conversation.show(); - conversation.render(state, QStringLiteral("hot")); - settleTimeline(); - - QWidget* host = timeline(conversation); - const auto segments = conversation.findChildren(QStringLiteral("conversationSegment")); - qsizetype renderedItems = 0; - for (QWidget* item : segments) - renderedItems += item->property("timelineItemCount").toLongLong(); - bool passed = true; - passed &= expect(state.thread("hot") != nullptr, "the hot-turn fixture must produce public AISuite State"); - passed &= expect(host && renderedItems == 300 - && host->property("renderedTimelineItems").toLongLong() == 300 - && host->property("retainedTimelineItems").toLongLong() == 300, - "one oversized turn must materialize its complete retained history"); - passed &= expect(segment(conversation, QStringLiteral("message:item-hot-0")) != nullptr, - "the oversized turn must retain its earliest message after reconstruction"); - passed &= expect(segment(conversation, QStringLiteral("message:item-hot-299")) != nullptr - && hasLabel(conversation, QStringLiteral("message hot 299")), - "the oversized turn must retain its exact newest message"); - passed &= expect(windowNotice(conversation) && !windowNotice(conversation)->isVisible(), - "complete retained history must not show a presentation-window notice"); - passed &= expect(windowNotice(conversation) - && windowNotice(conversation)->styleSheet().contains( - QStringLiteral("QFrame#conversationWindowNotice")), - "the presentation-window border must be scoped and never leak to its text"); - - const client::State activityState = makeState({activityTurn("activity", 300)}); - codexui::ConversationWidget activityConversation; - activityConversation.resize(900, 700); - activityConversation.show(); - activityConversation.render(activityState, QStringLiteral("activity")); - settleTimeline(); - QWidget* activityHost = timeline(activityConversation); - qsizetype renderedActivities = 0; - bool boundedCards = true; - for (QWidget* card : activityConversation.findChildren(QStringLiteral("conversationSegment"))) { - const qsizetype cardItems = card->property("timelineItemCount").toLongLong(); - renderedActivities += cardItems; - boundedCards = boundedCards && cardItems <= 16; - } - passed &= expect(activityState.thread("activity") != nullptr && boundedCards && activityHost - && renderedActivities == 300 - && renderedActivities == activityHost->property("renderedTimelineItems").toLongLong() - && activityHost->property("retainedTimelineItems").toLongLong() - == renderedActivities, - "a contiguous activity run must retain every canonical row in bounded-size cards"); - QWidget* newestActivityRow = nullptr; - for (QWidget* row : activityConversation.findChildren( - QStringLiteral("conversationActivityRow"))) - { - if (row->property("itemId").toString() == QStringLiteral("item-activity-299")) - { - newestActivityRow = row; - break; - } - } - auto* newestActivityDetails = newestActivityRow - ? newestActivityRow->findChild( - QStringLiteral("conversationActivityDetails")) - : nullptr; - auto* newestActivityDisclosure = newestActivityRow - ? newestActivityRow->findChild( - QStringLiteral("activityDisclosure")) - : nullptr; - passed &= expect(newestActivityRow && newestActivityDetails - && newestActivityDetails->isHidden() - && !newestActivityRow->findChild( - QStringLiteral("conversationActivityDetail")) - && newestActivityDetails->property("detailMaterializationCount").toULongLong() == 0 - && newestActivityDetails->property("deferredDetailBytes").toULongLong() - == std::string_view("activity activity 299").size(), - "the complete activity timeline must retain its newest detail without materializing collapsed text"); - if (newestActivityDisclosure) - newestActivityDisclosure->click(); - settleTimeline(); - auto* newestActivityDetail = newestActivityRow - ? newestActivityRow->findChild( - QStringLiteral("conversationActivityDetail")) - : nullptr; - passed &= expect(newestActivityDetail - && newestActivityDetail->toPlainText() - == QStringLiteral("activity activity 299") - && newestActivityDetails - && newestActivityDetails->property("detailMaterializationCount").toULongLong() == 1, - "expanding the newest activity must materialize its exact retained detail once"); - const auto activityCards = activityConversation.findChildren( - QStringLiteral("conversationActivityCard")); - passed &= expect(!activityCards.isEmpty() - && std::ranges::all_of(activityCards, [](const QFrame* card) { - return card->styleSheet().contains( - QStringLiteral("QFrame#conversationActivityCard")) - && !card->styleSheet().contains(QStringLiteral("QFrame{")); - }), - "activity-card borders must be scoped to the card and never leak to child labels"); - return passed; -} - -bool testActivityDisclosureAndFullOutput() -{ - std::string output = "initial command output beginning\n" - + std::string(70 * 1024, 'i') - + "\ninitial command output final sentinel"; - ThreadFixture fixture{"activity-detail", - {{"turn-activity-detail", - {{"command-activity-detail", - frontend::ThreadItemKind::CommandExecution, - output, - "in_progress"}}, - frontend::Json{{"explanation", "Use the canonical typed plan."}, - {"steps", {"Inspect", "Verify"}}, - {"statuses", {"completed", "inProgress"}}, - {"totalSteps", 2}, - {"truncated", false}}}}}; - const std::string fullCommand = "bash -lc '" + std::string(400, 'x') + " --final-command-sentinel'"; - fixture.turns.front().messages.front().command = fullCommand; - - codexui::ConversationWidget conversation; - conversation.resize(900, 700); - conversation.show(); - ActivityTopLevelShowMonitor topLevelShowMonitor; - qApp->installEventFilter(&topLevelShowMonitor); - conversation.render(makeState({fixture}), QStringLiteral("activity-detail")); - qApp->removeEventFilter(&topLevelShowMonitor); - settleTimeline(); - - auto* card = conversation.findChild(QStringLiteral("conversationActivityCard")); - auto* body = card ? card->findChild(QStringLiteral("conversationActivityBody")) : nullptr; - auto* row = card ? card->findChild(QStringLiteral("conversationActivityRow")) : nullptr; - auto* details = row ? row->findChild(QStringLiteral("conversationActivityDetails")) : nullptr; - auto* detailDisclosure = row ? row->findChild(QStringLiteral("activityDisclosure")) : nullptr; - auto* planAvailable = card - ? card->findChild(QStringLiteral("conversationActivityPlanAvailable")) - : nullptr; - QPointer outputView; - QToolButton* groupDisclosure = nullptr; - if (card && body) { - for (auto* candidate : card->findChildren(QStringLiteral("activityDisclosure"))) { - if (!body->isAncestorOf(candidate)) { - groupDisclosure = candidate; - break; - } - } - } - - bool passed = true; - passed &= expect(topLevelShowMonitor.empty(), - "rendering activity content must never show temporary top-level widgets"); - passed &= expect(card && body && !body->isHidden() && row && details && details->isHidden(), - "an activity group must start expanded while each activity starts collapsed"); - passed &= expect(row && details && detailDisclosure && detailDisclosure->isCheckable() - && !row->findChild(QStringLiteral("conversationActivityOutput")) - && details->property("outputMaterializationCount").toULongLong() == 0 - && details->property("deferredOutputBytes").toULongLong() == output.size(), - "a collapsed activity must not materialize a potentially large output document"); - passed &= expect(groupDisclosure - && !groupDisclosure->styleSheet().contains( - QStringLiteral("activityDisclosure:checked")), - "expanded activity disclosures must remain transparent rather than painted as square buttons"); - passed &= expect(detailDisclosure - && detailDisclosure->accessibleName().contains(QStringLiteral("bash -lc")), - "each activity disclosure must identify its activity in its accessible name"); - passed &= expect(planAvailable && planAvailable->isVisible(), - "an activity card must advertise an authoritative typed turn plan on initial render"); - - QWidget* const rowAddress = row; - fixture.turns.front().plan.reset(); - conversation.render(makeState({fixture}), QStringLiteral("activity-detail")); - settleTimeline(); - passed &= expect(row == rowAddress && planAvailable && !planAvailable->isVisible(), - "removing a typed turn plan must update the existing activity-card indicator"); - fixture.turns.front().plan = frontend::Json{ - {"explanation", "The canonical typed plan returned."}, - {"steps", {"Inspect", "Verify"}}, - {"statuses", {"completed", "inProgress"}}, - {"totalSteps", 2}, - {"truncated", false}}; - conversation.render(makeState({fixture}), QStringLiteral("activity-detail")); - settleTimeline(); - passed &= expect(row == rowAddress && planAvailable && planAvailable->isVisible(), - "adding a typed turn plan must update the existing activity-card indicator"); - - output = "canonical collapsed-update beginning\n" - + std::string(72 * 1024, 'c') - + "\ncanonical collapsed-update final sentinel"; - fixture.turns.front().messages.front().text = output; - const auto exactOutputChange = replacementUpdate( - QStringLiteral("turn-activity-detail"), - QStringLiteral("command-activity-detail"), - client::ItemContentChannel::CommandOutput); - conversation.render( - makeState({fixture}), QStringLiteral("activity-detail"), false, &exactOutputChange); - settleTimeline(); - passed &= expect(row && row == rowAddress && details && details->isHidden() - && !row->findChild(QStringLiteral("conversationActivityOutput")) - && details->property("outputMaterializationCount").toULongLong() == 0 - && details->property("deferredOutputBytes").toULongLong() == output.size(), - "a canonical output update must keep a collapsed activity lazy until expansion"); - const int collapsedActivityHeight = timeline(conversation) - ? timeline(conversation)->height() - : 0; - if (detailDisclosure) - detailDisclosure->click(); - settleTimeline(); - outputView = row ? row->findChild(QStringLiteral("conversationActivityOutput")) : nullptr; - auto* detailView = row ? row->findChild(QStringLiteral("conversationActivityDetail")) : nullptr; - auto* outputHeading = row - ? row->findChild(QStringLiteral("conversationActivityOutputHeading")) - : nullptr; - passed &= expect(details && !details->isHidden() && outputView && outputView->isVisible() - && details->property("outputMaterializationCount").toULongLong() == 1 - && details->property("deferredOutputBytes").toULongLong() == output.size(), - "an individual activity disclosure must materialize and reveal its complete output"); - passed &= expect(outputView - && outputView->styleSheet().contains( - QStringLiteral("QScrollBar:vertical")) - && outputView->styleSheet().contains( - QStringLiteral("QScrollBar:horizontal")) - && outputView->styleSheet().contains( - QStringLiteral("QAbstractScrollArea::corner")), - "activity output must use the application scrollbar vocabulary on both axes"); - passed &= expect(detailDisclosure && detailDisclosure->isChecked() && outputHeading - && outputHeading->text() == QStringLiteral("Output"), - "expanded activity details must expose accessible checked state and label their output"); - const QString renderedOutput = outputView ? outputView->toPlainText() : QString{}; - passed &= expect( - output.size() > 64 * 1024 - && renderedOutput == QString::fromStdString(output) - && renderedOutput.startsWith(QStringLiteral("canonical collapsed-update beginning")) - && renderedOutput.endsWith(QStringLiteral("canonical collapsed-update final sentinel")), - "expanding after a collapsed update must reveal the complete >64 KiB canonical output, including its beginning and end"); - passed &= expect(detailView && detailView->text().contains(QString::fromStdString(fullCommand)), - "expanded command details must preserve commands longer than the collapsed summary"); - passed &= expect(timeline(conversation) - && timeline(conversation)->height() > collapsedActivityHeight, - "expanding an activity must grow the fixed timeline host and its scroll range"); - - QPlainTextEdit* const outputAddress = outputView.data(); - const std::uint64_t previousOutputBytes = output.size(); - const QString outputDelta = QStringLiteral("\nstreamed continuation"); - output += outputDelta.toStdString(); - fixture.turns.front().messages.front().text = output; - const auto exactOutputAppend = appendUpdate( - QStringLiteral("turn-activity-detail"), - QStringLiteral("command-activity-detail"), - client::ItemContentChannel::CommandOutput, - previousOutputBytes, - outputDelta); - conversation.render( - makeState({fixture}), QStringLiteral("activity-detail"), false, &exactOutputAppend); - settleTimeline(); - passed &= expect(row && row == rowAddress && outputView && outputView.data() == outputAddress - && outputView->toPlainText() == QString::fromStdString(output) - && outputView->property("streamAppendCount").toULongLong() == 1 - && details - && details->property("outputMaterializationCount").toULongLong() == 1 - && details->property("deferredOutputBytes").toULongLong() == output.size() - && !details->isHidden(), - "streaming command output must update the expanded activity through the cursor append path"); - fixture.turns.front().messages.front().status = "completed"; - conversation.render(makeState({fixture}), QStringLiteral("activity-detail")); - settleTimeline(); - auto* statusSymbol = row ? row->findChild(QStringLiteral("conversationActivitySymbol")) : nullptr; - passed &= expect(row == rowAddress && outputView && outputView.data() == outputAddress - && statusSymbol && statusSymbol->text() == QStringLiteral("✓"), - "the terminal item update must reconcile status without replacing streamed output"); - - const int longActivityHeight = timeline(conversation) ? timeline(conversation)->height() : 0; - fixture.turns.front().messages.front().command = "true"; - conversation.render(makeState({fixture}), QStringLiteral("activity-detail")); - settleTimeline(); - passed &= expect(timeline(conversation) - && timeline(conversation)->height() < longActivityHeight, - "shortening expanded activity detail must shrink the fixed timeline host without blank space"); - - fixture.turns.front().plan.reset(); - fixture.turns.front().messages.push_back({"command-activity-appended", - frontend::ThreadItemKind::Plan, - "verify the focused implementation", - "completed"}); - conversation.render(makeState({fixture}), QStringLiteral("activity-detail")); - settleTimeline(); - const auto appendedRows = card - ? card->findChildren(QStringLiteral("conversationActivityRow")) - : QList{}; - passed &= expect(card == conversation.findChild(QStringLiteral("conversationActivityCard")) - && appendedRows.size() == 2 && appendedRows.front() == rowAddress - && outputView && outputView.data() == outputAddress && !details->isHidden() - && planAvailable && planAvailable->isVisible(), - "appending a plan activity must preserve expanded output and update the card indicator"); - - const std::string fileOutput = "file-change output beginning\n" - + std::string(4'096, 'f') - + "\nfile-change output sentinel"; - fixture.turns.front().messages.push_back({"file-change-output", - frontend::ThreadItemKind::FileChange, - fileOutput, - "in_progress"}); - conversation.render(makeState({fixture}), QStringLiteral("activity-detail")); - settleTimeline(); - QWidget* fileRow = nullptr; - if (card) { - for (auto* candidate : card->findChildren(QStringLiteral("conversationActivityRow"))) { - if (candidate->property("itemId").toString() == QStringLiteral("file-change-output")) { - fileRow = candidate; - break; - } - } - } - auto* fileDisclosure = fileRow - ? fileRow->findChild(QStringLiteral("activityDisclosure")) - : nullptr; - if (fileDisclosure) - fileDisclosure->click(); - settleEvents(); - auto* fileOutputView = fileRow - ? fileRow->findChild(QStringLiteral("conversationActivityOutput")) - : nullptr; - passed &= expect(fileOutputView - && fileOutputView->toPlainText() == QString::fromStdString(fileOutput), - "any typed activity carrying canonical command output must disclose its complete text"); - - const int expandedGroupHeight = timeline(conversation) ? timeline(conversation)->height() : 0; - if (groupDisclosure) - groupDisclosure->click(); - settleTimeline(); - passed &= expect(groupDisclosure && groupDisclosure->isCheckable() - && !groupDisclosure->isChecked() && body && body->isHidden(), - "the activity group disclosure must collapse the complete activity region"); - passed &= expect(timeline(conversation) - && timeline(conversation)->height() < expandedGroupHeight, - "collapsing an activity group must shrink the fixed timeline host and its scroll range"); - fixture.turns.front().messages.back().status = "completed"; - fixture.turns.front().messages.back().text += "\npost-collapse update"; - conversation.render(makeState({fixture}), QStringLiteral("activity-detail")); - settleTimeline(); - passed &= expect(card == conversation.findChild(QStringLiteral("conversationActivityCard")) - && body->isHidden() && groupDisclosure && !groupDisclosure->isChecked(), - "a canonical activity update must preserve a collapsed group without rebuilding it"); - if (groupDisclosure) - groupDisclosure->click(); - settleTimeline(); - passed &= expect(body && !body->isHidden() && details && !details->isHidden(), - "reopening a group must preserve individual activity expansion state"); - passed &= expect(timeline(conversation) - && timeline(conversation)->height() > collapsedActivityHeight, - "reopening an activity group must restore the fixed timeline host geometry"); - return passed; -} - -bool testPointerPreservingAppend() -{ - ThreadFixture beforeFixture = singleTurn("append", 256); - const client::State before = makeState({beforeFixture}); - codexui::ConversationWidget conversation; - conversation.resize(900, 700); - conversation.show(); - conversation.render(before, QStringLiteral("append")); - settleTimeline(); - - QPointer retainedHead = segment(conversation, QStringLiteral("message:item-append-0")); - QPointer survivor = segment(conversation, QStringLiteral("message:item-append-2")); - QPointer readingAnchor = segment(conversation, QStringLiteral("message:item-append-10")); - QWidget* survivorAddress = survivor.data(); - QScrollArea* scroll = conversation.findChild(); - if (scroll && readingAnchor) - { - const int currentY = scroll->viewport()->mapFromGlobal(readingAnchor->mapToGlobal(QPoint{})).y(); - auto* bar = scroll->verticalScrollBar(); - bar->setValue(qBound(0, bar->value() + currentY - 120, bar->maximum())); - settleEvents(); - } - const int anchorYBefore = scroll && readingAnchor - ? scroll->viewport()->mapFromGlobal(readingAnchor->mapToGlobal(QPoint{})).y() - : 0; - const bool readingHistory = scroll - && scroll->verticalScrollBar()->maximum() - - scroll->verticalScrollBar()->value() > 72; - - beforeFixture.turns.front().messages.push_back( - {"item-append-256", frontend::ThreadItemKind::UserMessage, "reflected prompt"}); - beforeFixture.turns.front().messages.push_back( - {"item-append-257", frontend::ThreadItemKind::AgentMessage, "final answer"}); - const client::State after = makeState({beforeFixture}); - ThreadFixture latestFixture = beforeFixture; - latestFixture.turns.front().messages.back().text = "updated final answer"; - const client::State latest = makeState({latestFixture}); - int latestPresentationRequests = 0; - QObject::connect(&conversation, - &codexui::ConversationWidget::latestPresentationRequested, - &conversation, - [&latestPresentationRequests] { ++latestPresentationRequests; }); - conversation.render(after, QStringLiteral("append")); - settleTimeline(); - conversation.render(latest, QStringLiteral("append")); - settleTimeline(); - - QWidget* host = timeline(conversation); - const int frozenAnchorY = scroll && readingAnchor - ? scroll->viewport()->mapFromGlobal(readingAnchor->mapToGlobal(QPoint{})).y() - : 0; - bool passed = true; - passed &= expect(readingHistory && retainedHead && survivor - && survivor.data() == survivorAddress - && qAbs(frozenAnchorY - anchorYBefore) <= 2 - && !segment(conversation, QStringLiteral("message:item-append-256")) - && !hasLabel(conversation, QStringLiteral("updated final answer")), - "an off-bottom reader must see a completely frozen presentation across repeated canonical updates"); - if (scroll) - scroll->verticalScrollBar()->setValue(scroll->verticalScrollBar()->maximum()); - settleEvents(); - passed &= expect(latestPresentationRequests == 1, - "returning to the tail must request exactly one latest authoritative presentation"); - conversation.render(latest, QStringLiteral("append")); - settleTimeline(); - - passed &= expect(retainedHead && survivor && survivor.data() == survivorAddress - && survivor.data() == segment(conversation, QStringLiteral("message:item-append-2")), - "appending past the former window boundary must preserve the retained head and overlapping widgets"); - passed &= expect(segment(conversation, QStringLiteral("message:item-append-256")) != nullptr - && segment(conversation, QStringLiteral("message:item-append-257")) != nullptr - && hasLabel(conversation, QStringLiteral("reflected prompt")) - && hasLabel(conversation, QStringLiteral("updated final answer")), - "the reflected prompt and final answer must append at the timeline tail"); - passed &= expect(host && host->property("renderedTimelineItems").toLongLong() == 258 - && host->property("retainedTimelineItems").toLongLong() == 258, - "appending at the former rolling boundary must retain every canonical item"); - - codexui::ConversationWidget followingConversation; - followingConversation.resize(900, 700); - followingConversation.show(); - followingConversation.render(before, QStringLiteral("append")); - settleTimeline(); - QPointer followedSurvivor = segment( - followingConversation, QStringLiteral("message:item-append-10")); - QWidget* const followedSurvivorAddress = followedSurvivor.data(); - followingConversation.render(after, QStringLiteral("append")); - followingConversation.render(latest, QStringLiteral("append")); - settleTimeline(); - QScrollArea* followingScroll = followingConversation.findChild(); - passed &= expect(followingScroll - && followingScroll->verticalScrollBar()->value() - == followingScroll->verticalScrollBar()->maximum() - && followedSurvivor - && followedSurvivor.data() == followedSurvivorAddress - && followedSurvivor.data() - == segment(followingConversation, - QStringLiteral("message:item-append-10")) - && hasLabel(followingConversation, - QStringLiteral("updated final answer")), - "rapid followed updates must retain widgets and settle at the newest timeline content"); - return passed; -} - -bool testKeyedSegmentInsertion() -{ - ThreadFixture activityFixture{ - "keyed-activity-growth", - {{"turn-keyed-activity-growth", - {{"activity-keyed-0", - frontend::ThreadItemKind::Reasoning, - "first activity"}, - {"activity-keyed-1", - frontend::ThreadItemKind::Reasoning, - "second activity"}, - {"message-keyed-tail", - frontend::ThreadItemKind::UserMessage, - "stable tail"}}}}}; - activityFixture.turns.front().status = "inProgress"; - activityFixture.turns.front().active = true; - activityFixture.turns.front().terminal = false; - for (int index = 0; index < 18; ++index) - { - activityFixture.turns.front().messages.push_back( - {"message-keyed-padding-" + std::to_string(index), - frontend::ThreadItemKind::UserMessage, - "padding row " + std::to_string(index)}); - } - codexui::ConversationWidget activityConversation; - activityConversation.resize(900, 420); - activityConversation.show(); - activityConversation.render( - makeState({activityFixture}), - QStringLiteral("keyed-activity-growth")); - settleTimeline(); - - QPointer activity = segment( - activityConversation, - QStringLiteral("activities:activity-keyed-0")); - QPointer activityTail = segment( - activityConversation, - QStringLiteral("message:message-keyed-tail")); - QWidget* const activityAddress = activity.data(); - QWidget* const activityTailAddress = activityTail.data(); - - const auto activityTailFixturePosition = std::find_if( - activityFixture.turns.front().messages.begin(), - activityFixture.turns.front().messages.end(), - [](const MessageFixture& message) - { - return message.id == "message-keyed-tail"; - }); - activityFixture.turns.front().messages.insert( - activityTailFixturePosition, - {"activity-keyed-2", - frontend::ThreadItemKind::Reasoning, - "third activity"}); - activityConversation.render( - makeState({activityFixture}), - QStringLiteral("keyed-activity-growth")); - settleTimeline(); - - bool passed = expect( - activity && activity.data() == activityAddress - && activity.data() - == segment( - activityConversation, - QStringLiteral("activities:activity-keyed-0")) - && activity->findChildren( - QStringLiteral("conversationActivityRow")) - .size() - == 3 - && activityTail && activityTail.data() == activityTailAddress - && activityTail.data() - == segment( - activityConversation, - QStringLiteral("message:message-keyed-tail")), - "an activity bucket that gains a row must retain its segment and every unchanged trailing widget"); - - QHash> unchangedActivitySegments; - unchangedActivitySegments.insert( - QStringLiteral("activities:activity-keyed-0"), activity); - unchangedActivitySegments.insert( - QStringLiteral("message:message-keyed-tail"), activityTail); - for (int index = 0; index < 18; ++index) - { - const QString segmentId = QStringLiteral("message:message-keyed-padding-%1") - .arg(index); - unchangedActivitySegments.insert( - segmentId, segment(activityConversation, segmentId)); - } - - auto& activityMessages = activityFixture.turns.front().messages; - std::rotate( - activityMessages.begin(), - activityMessages.begin() + 3, - activityMessages.begin() + 4); - activityConversation.render( - makeState({activityFixture}), - QStringLiteral("keyed-activity-growth")); - settleTimeline(); - - QLayout* activityLayout = activityTail && activityTail->parentWidget() - ? activityTail->parentWidget()->layout() - : nullptr; - bool allReorderedSegmentsRetained = true; - for (auto retained = unchangedActivitySegments.cbegin(); - retained != unchangedActivitySegments.cend(); - ++retained) - { - allReorderedSegmentsRetained = - allReorderedSegmentsRetained && retained.value() - && retained.value().data() - == segment(activityConversation, retained.key()); - } - passed &= expect( - allReorderedSegmentsRetained - && activity && activity.data() == activityAddress - && activity.data() - == segment( - activityConversation, - QStringLiteral("activities:activity-keyed-0")) - && activityTail && activityTail.data() == activityTailAddress - && activityTail.data() - == segment( - activityConversation, - QStringLiteral("message:message-keyed-tail")) - && activityLayout && activityLayout->indexOf(activityTail.data()) == 0 - && activityLayout->indexOf(activity.data()) == 1, - "reordering unchanged segment keys must move the original QWidgets into the new order"); - - QScrollArea* activityScroll = activityConversation.findChild(); - if (activityScroll && activity) - { - const int currentY = activityScroll->viewport()->mapFromGlobal( - activity->mapToGlobal(QPoint{})).y(); - auto* bar = activityScroll->verticalScrollBar(); - bar->setValue(qBound(0, bar->value() + currentY - 96, bar->maximum())); - settleEvents(); - } - const int anchoredY = activityScroll && activity - ? activityScroll->viewport()->mapFromGlobal( - activity->mapToGlobal(QPoint{})).y() - : 0; - const bool anchorPathAvailable = - codexui::ConversationWidgetTestAccess::primeViewportAnchor( - activityConversation, activity.data(), anchoredY); - std::rotate( - activityMessages.begin(), - activityMessages.begin() + 1, - activityMessages.begin() + 4); - activityConversation.render( - makeState({activityFixture}), - QStringLiteral("keyed-activity-growth")); - const bool survivingAnchorTracked = - anchorPathAvailable - && codexui::ConversationWidgetTestAccess::viewportAnchor( - activityConversation) - == activity.data() - && codexui::ConversationWidgetTestAccess::viewportAnchorY( - activityConversation) - == anchoredY; - codexui::ConversationWidgetTestAccess::settleAnchoredReconciliation( - activityConversation); - const int settledAnchorY = activityScroll && activity - ? activityScroll->viewport()->mapFromGlobal( - activity->mapToGlobal(QPoint{})).y() - : 0; - passed &= expect( - survivingAnchorTracked && activity - && qAbs(settledAnchorY - anchoredY) <= 2 - && !codexui::ConversationWidgetTestAccess::viewportAnchor( - activityConversation), - "reordering a surviving segment must preserve its widget and observable viewport position"); - - codexui::ConversationWidgetTestAccess::primeViewportAnchor( - activityConversation, activity.data(), anchoredY); - const auto tailPosition = std::find_if( - activityMessages.begin(), - activityMessages.end(), - [](const MessageFixture& message) - { - return message.id == "message-keyed-tail"; - }); - activityMessages.erase(tailPosition); - activityConversation.render( - makeState({activityFixture}), - QStringLiteral("keyed-activity-growth")); - const bool removedTailUntracked = - !codexui::ConversationWidgetTestAccess::tracksSegment( - activityConversation, - QStringLiteral("turn-keyed-activity-growth"), - QStringLiteral("message:message-keyed-tail")); - QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); - QCoreApplication::processEvents(); - passed &= expect( - removedTailUntracked && !activityTail - && activity && activity.data() == activityAddress - && codexui::ConversationWidgetTestAccess::viewportAnchor( - activityConversation) - == activity.data() - && codexui::ConversationWidgetTestAccess::viewportAnchorY( - activityConversation) - == anchoredY, - "removing a different segment must destroy only that widget and retain the surviving anchor"); - - codexui::ConversationWidgetTestAccess::primeViewportAnchor( - activityConversation, activity.data(), anchoredY); - activityMessages.erase( - std::remove_if( - activityMessages.begin(), - activityMessages.end(), - [](const MessageFixture& message) - { - return message.id.starts_with("activity-keyed-"); - }), - activityMessages.end()); - activityConversation.render( - makeState({activityFixture}), - QStringLiteral("keyed-activity-growth")); - const bool removedAnchorCleared = - !codexui::ConversationWidgetTestAccess::viewportAnchor( - activityConversation); - const bool removedAnchorUntracked = - !codexui::ConversationWidgetTestAccess::tracksSegment( - activityConversation, - QStringLiteral("turn-keyed-activity-growth"), - QStringLiteral("activities:activity-keyed-0")); - QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); - QCoreApplication::processEvents(); - passed &= expect( - removedAnchorCleared && removedAnchorUntracked && !activity, - "destroying the anchor segment must clear its viewport anchor and delete the widget"); - codexui::ConversationWidgetTestAccess::finishAnchoredReconciliation( - activityConversation); - - ThreadFixture insertionFixture{ - "keyed-middle-insertion", - {{"turn-keyed-middle-insertion", - {{"message-keyed-left", - frontend::ThreadItemKind::UserMessage, - "left"}, - {"message-keyed-right", - frontend::ThreadItemKind::UserMessage, - "right"}}}}}; - insertionFixture.turns.front().status = "inProgress"; - insertionFixture.turns.front().active = true; - insertionFixture.turns.front().terminal = false; - codexui::ConversationWidget insertionConversation; - insertionConversation.resize(900, 700); - insertionConversation.show(); - insertionConversation.render( - makeState({insertionFixture}), - QStringLiteral("keyed-middle-insertion")); - settleTimeline(); - - QPointer left = segment( - insertionConversation, - QStringLiteral("message:message-keyed-left")); - QPointer right = segment( - insertionConversation, - QStringLiteral("message:message-keyed-right")); - QWidget* const leftAddress = left.data(); - QWidget* const rightAddress = right.data(); - - insertionFixture.turns.front().messages.insert( - insertionFixture.turns.front().messages.begin() + 1, - {"message-keyed-middle", - frontend::ThreadItemKind::UserMessage, - "middle"}); - insertionConversation.render( - makeState({insertionFixture}), - QStringLiteral("keyed-middle-insertion")); - settleTimeline(); - - QWidget* const middle = segment( - insertionConversation, - QStringLiteral("message:message-keyed-middle")); - QLayout* const orderedLayout = middle && middle->parentWidget() - ? middle->parentWidget()->layout() - : nullptr; - passed &= expect( - left && left.data() == leftAddress - && left.data() - == segment( - insertionConversation, - QStringLiteral("message:message-keyed-left")) - && right && right.data() == rightAddress - && right.data() - == segment( - insertionConversation, - QStringLiteral("message:message-keyed-right")), - "inserting a segment in the middle must preserve every unchanged keyed QWidget"); - passed &= expect( - orderedLayout && middle - && orderedLayout->indexOf(left.data()) == 0 - && orderedLayout->indexOf(middle) == 1 - && orderedLayout->indexOf(right.data()) == 2, - "a middle insertion must place the new segment between its surviving neighbors"); - return passed; -} - -bool runStructuralItemUpsertReconciliation( - bool exactFirst, bool verifyDeletion) -{ - ThreadFixture fixture{ - "structural-item-upserts", - {{"turn-structural-item-upserts", - {{"structural-stream", - frontend::ThreadItemKind::AgentMessage, - "streaming prefix", - "started"}, - {"structural-stable", - frontend::ThreadItemKind::UserMessage, - "stable prompt"}}}}}; - fixture.turns.front().status = "inProgress"; - fixture.turns.front().active = true; - fixture.turns.front().terminal = false; - - codexui::ConversationWidget conversation; - conversation.resize(900, 700); - conversation.show(); - conversation.render( - makeState({fixture}), QStringLiteral("structural-item-upserts")); - settleTimeline(); - - QPointer stream = segment( - conversation, QStringLiteral("message:structural-stream")); - QPointer stable = segment( - conversation, QStringLiteral("message:structural-stable")); - QPointer streamContent = messageContent(stream); - QPointer streamStatus = messageLabel( - stream, QStringLiteral("conversationMessageStatus")); - QWidget* const streamAddress = stream.data(); - QWidget* const stableAddress = stable.data(); - QWidget* const streamContentAddress = streamContent.data(); - const qulonglong appendCountBefore = streamContent - ? streamContent - ->property("streamAppendCount") - .toULongLong() - : 0; - const qulonglong materializationsBefore = - streamContent - ? streamContent->property("sourceMaterializationCount").toULongLong() - : 0; - const qulonglong replacementsBefore = - streamContent - ? streamContent->property("fullReplacementCount").toULongLong() - : 0; - - const QString delta = QStringLiteral(" plus exact delta"); - fixture.turns.front().messages.front().text += delta.toStdString(); - fixture.turns.front().messages.front().status = "completed"; - for (int index = 0; index < 50; ++index) - { - fixture.turns.front().messages.push_back( - {"structural-added-" + std::to_string(index), - frontend::ThreadItemKind::UserMessage, - "new prompt " + std::to_string(index)}); - } - codexui::detail::StateUpdateScope structuralScope; - structuralScope.affectedThreadIds.push_back( - QStringLiteral("structural-item-upserts")); - structuralScope.structurallyAffectedThreadIds.push_back( - QStringLiteral("structural-item-upserts")); - codexui::detail::StateUpdateScope exactScope; - exactScope.affectedThreadIds.push_back( - QStringLiteral("structural-item-upserts")); - exactScope.affectedItemContents.push_back({ - QStringLiteral("structural-item-upserts"), - QStringLiteral("turn-structural-item-upserts"), - QStringLiteral("structural-stream"), - client::ItemContentChannel::AgentText, - codexui::detail::StateUpdateScope::ItemContentAppend{ - std::string_view("streaming prefix").size(), - 0, - delta.toUtf8(), - }, - }); - exactScope.coalescedContentDeltaBytes = - static_cast(delta.toUtf8().size()); - codexui::detail::SelectedPresentationRefreshAccumulator accumulator; - const auto accumulate = [&accumulator]( - const codexui::detail::StateUpdateScope& scope) - { - codexui::detail::mergeSelectedPresentationRefresh( - accumulator, - scope, - QStringLiteral("structural-item-upserts"), - false); - }; - if (exactFirst) - { - accumulate(exactScope); - accumulate(structuralScope); - } - else - { - accumulate(structuralScope); - accumulate(exactScope); - } - conversation.render( - makeState({fixture}), - QStringLiteral("structural-item-upserts"), - false, - &accumulator.contentChanges, - accumulator.structuralReconciliationPending); - settleTimeline(); - - bool everyAdditionRendered = true; - for (int index = 0; index < 50; ++index) - { - everyAdditionRendered = everyAdditionRendered - && segment( - conversation, - QStringLiteral("message:structural-added-%1") - .arg(index)); - } - bool passed = expect( - accumulator.refreshPending && !accumulator.fullRefreshPending - && accumulator.structuralReconciliationPending - && accumulator.contentChanges.size() == 1 - && everyAdditionRendered && stream && stream.data() == streamAddress - && stable && stable.data() == stableAddress - && streamContent && streamContent.data() == streamContentAddress, - "a structural batch of 50 item upserts must materialize every new segment while preserving every unchanged QWidget"); - passed &= expect( - streamContent - && messageSourceText(streamContent) - == QStringLiteral("streaming prefix plus exact delta") - && streamContent->property("streamAppendCount").toULongLong() - == appendCountBefore + 1 - && streamContent->property("sourceMaterializationCount").toULongLong() - == materializationsBefore - && streamContent->property("fullReplacementCount").toULongLong() - == replacementsBefore - && streamStatus - && streamStatus->text() == QStringLiteral("Completed"), - "a coalesced exact append and structural batch must apply the delta once, refresh metadata, and never replace or rematerialize canonical content"); - - if (!verifyDeletion) - return passed; - - const auto removedPosition = std::find_if( - fixture.turns.front().messages.begin(), - fixture.turns.front().messages.end(), - [](const MessageFixture& message) - { - return message.id == "structural-added-24"; - }); - QPointer removed = segment( - conversation, QStringLiteral("message:structural-added-24")); - fixture.turns.front().messages.erase(removedPosition); - conversation.render( - makeState({fixture}), QStringLiteral("structural-item-upserts")); - const bool removedUntracked = - !codexui::ConversationWidgetTestAccess::tracksSegment( - conversation, - QStringLiteral("turn-structural-item-upserts"), - QStringLiteral("message:structural-added-24")); - settleTimeline(); - passed &= expect( - removedUntracked - && !segment(conversation, QStringLiteral("message:structural-added-24")) - && !removed && stream && stream.data() == streamAddress, - "a deletion-capable reconciliation must untrack and destroy the removed segment without replacing survivors"); - return passed; -} - -bool testStructuralItemUpsertReconciliation() -{ - bool passed = runStructuralItemUpsertReconciliation(true, true); - passed &= runStructuralItemUpsertReconciliation(false, false); - return passed; -} - -bool testInPlaceMessageReplacement() -{ - ThreadFixture agentFixture{"in-place-agent", - {{"turn-in-place-agent", - {{"item-in-place-agent", - frontend::ThreadItemKind::AgentMessage, - "streamed prefix", - "in_progress"}}}}}; - codexui::ConversationWidget agentConversation; - agentConversation.resize(900, 700); - agentConversation.show(); - agentConversation.render(makeState({agentFixture}), QStringLiteral("in-place-agent")); - settleTimeline(); - - QPointer agentSegment = - segment(agentConversation, QStringLiteral("message:item-in-place-agent")); - QPointer streamingContent = messageContent(agentSegment); - QPointer agentStatus = - messageLabel(agentSegment, QStringLiteral("conversationMessageStatus")); - QPointer agentTruncation = - messageLabel(agentSegment, QStringLiteral("conversationMessageTruncation")); - QWidget* const agentSegmentAddress = agentSegment.data(); - QWidget* const streamingContentAddress = streamingContent.data(); - QLabel* const agentStatusAddress = agentStatus.data(); - QLabel* const agentTruncationAddress = agentTruncation.data(); - - agentFixture.turns.front().messages.front().text = - "streamed prefix and canonical continuation"; - agentFixture.turns.front().messages.front().status = "completed"; - agentFixture.turns.front().messages.front().contentTruncated = true; - agentConversation.render(makeState({agentFixture}), QStringLiteral("in-place-agent")); - settleTimeline(); - - bool passed = true; - passed &= expect(agentSegment && agentSegment.data() == agentSegmentAddress - && agentSegment.data() - == segment(agentConversation, - QStringLiteral("message:item-in-place-agent")) - && !streamingContent - && agentStatus && agentStatus.data() == agentStatusAddress - && agentTruncation && agentTruncation.data() == agentTruncationAddress, - "a terminal agent-message update must preserve its segment metadata while replacing the streaming view"); - QPointer agentContent = - messageLabel(agentSegment, QStringLiteral("conversationMessageContent")); - QLabel* const agentContentAddress = agentContent.data(); - passed &= expect(streamingContentAddress && agentContent - && agentContent != streamingContentAddress - && messageSourceText(agentContent) - == QStringLiteral("streamed prefix and canonical continuation") - && agentStatus && agentStatus->text() == QStringLiteral("Completed") - && agentTruncation && agentTruncation->isVisible() - && agentTruncation->text().contains(QStringLiteral("truncated"), - Qt::CaseInsensitive) - && segmentHasLabel(agentSegment, QStringLiteral("CODEX")), - "the terminal agent-message widget must reflect canonical Markdown content, status and truncation"); - - agentFixture.turns.front().messages.front().text = "short canonical replacement"; - agentFixture.turns.front().messages.front().status = "failed"; - agentFixture.turns.front().messages.front().contentTruncated = false; - agentConversation.render(makeState({agentFixture}), QStringLiteral("in-place-agent")); - settleTimeline(); - passed &= expect(agentSegment && agentSegment.data() == agentSegmentAddress - && agentContent && agentContent.data() == agentContentAddress - && messageSourceText(agentContent) == QStringLiteral("short canonical replacement") - && agentStatus && agentStatus->text() == QStringLiteral("Failed") - && agentTruncation && !agentTruncation->isVisible(), - "a shorter canonical replacement must update the same agent-message widget and hide its marker"); - - ThreadFixture userFixture{"in-place-user", - {{"turn-in-place-user", - {{"item-in-place-user", - frontend::ThreadItemKind::UserMessage, - "draft prompt"}}}}}; - codexui::ConversationWidget userConversation; - userConversation.resize(900, 700); - userConversation.show(); - userConversation.render(makeState({userFixture}), QStringLiteral("in-place-user")); - settleTimeline(); - - QPointer userSegment = - segment(userConversation, QStringLiteral("message:item-in-place-user")); - QPointer userContent = - messageLabel(userSegment, QStringLiteral("conversationMessageContent")); - QPointer userTruncation = - messageLabel(userSegment, QStringLiteral("conversationMessageTruncation")); - QWidget* const userSegmentAddress = userSegment.data(); - QLabel* const userContentAddress = userContent.data(); - QLabel* const userTruncationAddress = userTruncation.data(); - - userFixture.turns.front().messages.front().text = "final prompt\n\nwith multipart text"; - userFixture.turns.front().messages.front().contentTruncated = true; - userFixture.turns.front().messages.front().textTruncated = true; - userConversation.render(makeState({userFixture}), QStringLiteral("in-place-user")); - settleTimeline(); - passed &= expect(userSegment && userSegment.data() == userSegmentAddress - && userContent && userContent.data() == userContentAddress - && userTruncation && userTruncation.data() == userTruncationAddress, - "canonical user-message replacement must preserve the segment and message labels"); - passed &= expect(userContent - && messageSourceText(userContent) - == QStringLiteral("final prompt\n\nwith multipart text"), - "the preserved user-message label must show exact canonical multipart text"); - passed &= expect(userTruncation && userTruncation->isVisible(), - "the preserved user-message truncation marker must reflect canonical semantics"); - passed &= expect(segmentHasLabel(userSegment, QStringLiteral("YOU")), - "the preserved user-message segment must retain its intended visual role"); - return passed; -} - -bool testIncompleteThreadPresentation() -{ - ThreadFixture fixture{"bounded-thread", - {{"bounded-turn", {}}}, - false}; - codexui::ConversationWidget conversation; - conversation.resize(900, 700); - conversation.show(); - conversation.render(makeState({fixture}), QStringLiteral("bounded-thread")); - settleTimeline(); - - bool passed = expect( - hasLabel(conversation, QStringLiteral("Conversation history incomplete")) - && hasLabel( - conversation, - QStringLiteral( - "Some turns or items are unavailable in the current synchronized view.")) - && !hasLabel(conversation, QStringLiteral("No items in this turn")), - "an incomplete bounded thread must distinguish its empty turn from a genuinely empty turn"); - - fixture.fullyLoaded = true; - conversation.render(makeState({fixture}), QStringLiteral("bounded-thread")); - settleTimeline(); - passed &= expect( - hasLabel(conversation, QStringLiteral("No items in this turn")) - && !hasLabel(conversation, QStringLiteral("Conversation history incomplete")), - "an authoritative full-thread replacement must restore the genuine empty-turn presentation"); - - ThreadFixture noTurns{"bounded-thread-without-turns", {}, false}; - conversation.render(makeState({noTurns}), - QStringLiteral("bounded-thread-without-turns")); - settleTimeline(); - passed &= expect( - hasLabel(conversation, QStringLiteral("Conversation history incomplete")) - && !hasLabel(conversation, QStringLiteral("Ready for the first turn")), - "an incomplete thread without retained turn shells must not masquerade as a new empty thread"); - - noTurns.fullyLoaded = true; - conversation.render(makeState({noTurns}), - QStringLiteral("bounded-thread-without-turns")); - settleTimeline(); - passed &= expect( - hasLabel(conversation, QStringLiteral("Ready for the first turn")) - && !hasLabel(conversation, QStringLiteral("Conversation history incomplete")), - "a same-thread authoritative replacement must refresh the no-turn presentation"); - - noTurns.fullyLoaded = false; - conversation.render(makeState({noTurns}), - QStringLiteral("bounded-thread-without-turns")); - settleTimeline(); - passed &= expect( - hasLabel(conversation, QStringLiteral("Conversation history incomplete")) - && !hasLabel(conversation, QStringLiteral("Ready for the first turn")), - "a same-thread bounded replacement must refresh the no-turn presentation"); - - conversation.render(makeState({}, 1), - QStringLiteral("bounded-thread-without-turns")); - settleTimeline(); - passed &= expect( - hasLabel(conversation, QStringLiteral("Conversation history incomplete")) - && hasLabel( - conversation, - QStringLiteral( - "This conversation is not available in the current synchronized view.")) - && !hasLabel(conversation, QStringLiteral("No synchronized thread")) - && !hasLabel(conversation, QStringLiteral("No thread selected")), - "an explicitly omitted selected thread must not masquerade as no selection"); - - conversation.render(makeState({}), - QStringLiteral("bounded-thread-without-turns")); - settleTimeline(); - passed &= expect( - hasLabel(conversation, QStringLiteral("No synchronized thread")) - && hasLabel(conversation, QStringLiteral("No thread selected")) - && !hasLabel(conversation, QStringLiteral("Conversation history incomplete")), - "removing omission provenance must refresh a same-ID missing-thread presentation"); - return passed; -} - -bool testIncompleteReplacementPreservesRenderedTimeline() -{ - ThreadFixture complete{ - "replacement-retention", - {{"replacement-retention-turn", - {{"replacement-retention-item", - frontend::ThreadItemKind::AgentMessage, - "retained answer"}}}}}; - codexui::ConversationWidget conversation; - conversation.resize(900, 700); - conversation.show(); - conversation.render(makeState({complete}), - QStringLiteral("replacement-retention")); - settleTimeline(); - - QPointer retainedSegment = segment( - conversation, QStringLiteral("message:replacement-retention-item")); - QPointer retainedContent = messageContent(retainedSegment); - QWidget* const retainedSegmentAddress = retainedSegment.data(); - QWidget* const retainedContentAddress = retainedContent.data(); - - conversation.render(makeState({}, 1), - QStringLiteral("replacement-retention")); - settleTimeline(); - bool passed = expect( - retainedSegment && retainedSegment.data() == retainedSegmentAddress - && retainedContent && retainedContent.data() == retainedContentAddress - && messageSourceText(retainedContent) == QStringLiteral("retained answer") - && hasLabelContaining(conversation, - QStringLiteral("History recovery pending")), - "an omitted same-thread replacement must retain the rendered timeline while recovery is pending"); - - ThreadFixture partialWithoutDescendants{ - "replacement-retention", {}, false}; - conversation.render(makeState({partialWithoutDescendants}), - QStringLiteral("replacement-retention")); - settleTimeline(); - passed &= expect( - retainedSegment && retainedSegment.data() == retainedSegmentAddress - && retainedContent && retainedContent.data() == retainedContentAddress - && messageSourceText(retainedContent) == QStringLiteral("retained answer"), - "an incomplete header-only replacement must not delete rendered descendants"); - - ThreadFixture merged = complete; - merged.fullyLoaded = false; - merged.turns.front().messages.push_back( - {"replacement-retention-new-item", - frontend::ThreadItemKind::AgentMessage, - "merged continuation"}); - conversation.render(makeState({merged}), - QStringLiteral("replacement-retention")); - settleTimeline(); - passed &= expect( - retainedSegment && retainedSegment.data() == retainedSegmentAddress - && retainedContent && retainedContent.data() == retainedContentAddress - && hasLabel(conversation, QStringLiteral("merged continuation")) - && !hasLabelContaining(conversation, - QStringLiteral("History recovery pending")), - "an incomplete requester-local Merge that accounts for rendered descendants must reconcile in place"); - - conversation.render(makeState({}), QString{}); - settleTimeline(); - passed &= expect( - !retainedSegment && !retainedContent - && hasLabel(conversation, QStringLiteral("No thread selected")) - && !hasLabel(conversation, QStringLiteral("retained answer")) - && !hasLabel(conversation, QStringLiteral("merged continuation")), - "an exact Absent transition must clear a populated rendered timeline"); - - conversation.render(makeState({complete}), - QStringLiteral("replacement-retention")); - settleTimeline(); - QPointer replaceAuthoritySegment = segment( - conversation, QStringLiteral("message:replacement-retention-item")); - ThreadFixture exactReplacement{ - "replacement-retention", {}, true}; - conversation.render(makeState({exactReplacement}), - QStringLiteral("replacement-retention")); - settleTimeline(); - passed &= expect( - !replaceAuthoritySegment - && hasLabel(conversation, QStringLiteral("Ready for the first turn")), - "a fully-loaded Replace remains authoritative to delete absent descendants"); - - ThreadFixture completeActivities{ - "grouped-activity-retention", - {{"grouped-activity-turn", - {{"grouped-activity-first", - frontend::ThreadItemKind::CommandExecution, - "first retained command output", - "completed", - false, - false, - false, - "printf first"}, - {"grouped-activity-second", - frontend::ThreadItemKind::CommandExecution, - "second retained command output", - "completed", - false, - false, - false, - "printf second"}}}}}; - codexui::ConversationWidget groupedConversation; - groupedConversation.resize(900, 700); - groupedConversation.show(); - groupedConversation.render( - makeState({completeActivities}), - QStringLiteral("grouped-activity-retention")); - settleTimeline(); - QPointer activitySegment = segment( - groupedConversation, - QStringLiteral("activities:grouped-activity-first")); - QPointer secondActivityRow; - if (activitySegment) - { - for (QWidget* row : activitySegment->findChildren( - QStringLiteral("conversationActivityRow"))) - { - if (row->property("itemId").toString() - == QStringLiteral("grouped-activity-second")) - secondActivityRow = row; - } - } - QWidget* const activitySegmentAddress = activitySegment.data(); - QWidget* const secondActivityRowAddress = secondActivityRow.data(); - - ThreadFixture partialActivities = completeActivities; - partialActivities.fullyLoaded = false; - partialActivities.turns.front().messages.pop_back(); - groupedConversation.render( - makeState({partialActivities}), - QStringLiteral("grouped-activity-retention"), - false, - nullptr, - true); - settleTimeline(); - passed &= expect( - activitySegment && activitySegment.data() == activitySegmentAddress - && secondActivityRow - && secondActivityRow.data() == secondActivityRowAddress - && hasLabelContaining(groupedConversation, - QStringLiteral("History recovery pending")), - "a structural command upsert from an incomplete projection must preserve every rendered shell row, not only the row that owns its segment identity"); - - ThreadFixture largePrefix{"bounded-recovery-prefix", {{"bounded-recovery-turn", {}}}}; - constexpr int largePrefixItems = 2'048; - largePrefix.turns.front().messages.reserve(largePrefixItems + 1); - for (int index = 0; index < largePrefixItems; ++index) - { - largePrefix.turns.front().messages.push_back( - {"bounded-recovery-item-" + std::to_string(index), - frontend::ThreadItemKind::AgentMessage, - "prefix " + std::to_string(index)}); - } - codexui::ConversationWidget boundedConversation; - boundedConversation.resize(900, 700); - boundedConversation.show(); - boundedConversation.render( - makeState({largePrefix}), QStringLiteral("bounded-recovery-prefix")); - settleTimeline(); - QPointer retainedTail = segment( - boundedConversation, - QStringLiteral("message:bounded-recovery-item-2047")); - QWidget* const retainedTailAddress = retainedTail.data(); - - largePrefix.fullyLoaded = false; - largePrefix.turns.front().messages.push_back( - {"bounded-recovery-appended", - frontend::ThreadItemKind::AgentMessage, - "bounded appended tail"}); - boundedConversation.render( - makeState({largePrefix}), QStringLiteral("bounded-recovery-prefix")); - settleTimeline(); - QWidget* boundedHost = timeline(boundedConversation); - const qlonglong maximumRecoveryScan = - boundedHost - ? boundedHost->property("recoveryInspectionItemBudget").toLongLong() - + 15 * boundedHost->property("recoveryInspectionTurnBudget").toLongLong() - : 0; - passed &= expect( - retainedTail && retainedTail.data() == retainedTailAddress - && !hasLabel(boundedConversation, - QStringLiteral("bounded appended tail")) - && hasLabelContaining(boundedConversation, - QStringLiteral("History recovery pending")) - && boundedHost - && boundedHost->property("recoveryInspectedTimelineItems").toLongLong() - <= maximumRecoveryScan, - "incomplete replacement recovery must freeze safely within its inspection budget when the retained timeline is large"); - largePrefix.fullyLoaded = true; - boundedConversation.render( - makeState({largePrefix}), QStringLiteral("bounded-recovery-prefix")); - settleTimeline(); - passed &= expect( - retainedTail && retainedTail.data() == retainedTailAddress - && hasLabel(boundedConversation, - QStringLiteral("bounded appended tail")), - "authoritative recovery must append new history without replacing retained widgets"); - return passed; -} - -bool testStreamingPlainTextAndTerminalMarkdown() -{ - ThreadFixture fixture{ - "streaming-markdown", - {{"turn-streaming-markdown", - {{"item-streaming-markdown", - frontend::ThreadItemKind::AgentMessage, - "**stream**", - "started"}}}}}; - codexui::ConversationWidget conversation; - conversation.resize(900, 700); - conversation.show(); - conversation.render(makeState({fixture}), QStringLiteral("streaming-markdown")); - settleTimeline(); - - QPointer message = - segment(conversation, QStringLiteral("message:item-streaming-markdown")); - QPointer content = messageContent(message); - QWidget* const messageAddress = message.data(); - QWidget* const contentAddress = content.data(); - - const QStringList streamedContent{ - QStringLiteral("**stream** [docs](https://example.com)"), - QStringLiteral("**stream** [docs](https://example.com)\n\n`code`"), - QStringLiteral("**stream** [docs](https://example.com)\n\n`code`\n\n![secret](file:///etc/passwd)")}; - bool passed = true; - QString previous = QStringLiteral("**stream**"); - const qulonglong sourceMaterializationsBeforeStreaming = - content ? content->property("sourceMaterializationCount").toULongLong() : 0; - for (const QString& update : streamedContent) { - fixture.turns.front().messages.front().text = update.toStdString(); - const QString delta = update.mid(previous.size()); - const auto exactChange = appendUpdate( - QStringLiteral("turn-streaming-markdown"), - QStringLiteral("item-streaming-markdown"), - client::ItemContentChannel::AgentText, - static_cast(previous.toUtf8().size()), - delta); - // Descriptor lookup verifies the authoritative retained byte boundary - // without materializing the canonical item or its lazy content chain. - const client::State updatedState = makeState({fixture}); - passed &= conversation.updateExactMessageContent( - updatedState, - QStringLiteral("streaming-markdown"), - exactChange); - passed &= expect(message && message.data() == messageAddress - && content && content.data() == contentAddress - && messageSourceText(content) == update - && content->property("markdownRenderMode").toString() - == QStringLiteral("streaming-plain"), - "streaming updates must append plain text in place without parsing Markdown"); - previous = update; - } - passed &= expect(content - && content->property("streamAppendCount").toULongLong() - == static_cast(streamedContent.size()), - "each verified streaming delta must use the cursor append path"); - passed &= expect( - content - && content->property("sourceMaterializationCount").toULongLong() - == sourceMaterializationsBeforeStreaming, - "verified streaming deltas must not materialize or transcode the accumulated message source"); - - const QString finalContent = streamedContent.back() - + QStringLiteral("\n\n_final answer_"); - fixture.turns.front().messages.front().text = finalContent.toStdString(); - fixture.turns.front().messages.front().status = "completed"; - conversation.render(makeState({fixture}), QStringLiteral("streaming-markdown")); - settleTimeline(); - QPointer finalContentWidget = messageContent(message); - auto* finalLabel = qobject_cast(finalContentWidget); - passed &= expect(message && message.data() == messageAddress - && finalContentWidget && finalContentWidget != contentAddress - && finalLabel && messageSourceText(finalLabel) == finalContent - && finalLabel->text().contains(QStringLiteral("final answer")) - && finalLabel->text().contains(QStringLiteral("https://example.com")) - && !finalLabel->text().contains(QStringLiteral("file:///etc/passwd")) - && !finalLabel->text().contains(QStringLiteral(" content = messageContent(message); - QWidget* const contentAddress = content.data(); - bool passed = expect( - content && content->property("kind").toString() == QStringLiteral("meta") - && messageSourceText(content) - == QStringLiteral("No retained message content"), - "an empty canonical agent message must initially show explanatory placeholder text"); - - const QString firstContent = QStringLiteral("first **streamed** content"); - fixture.turns.front().messages.front().text = firstContent.toStdString(); - const auto exactChange = appendUpdate( - QStringLiteral("turn-empty-streaming-message"), - QStringLiteral("item-empty-streaming-message"), - client::ItemContentChannel::AgentText, - 0, - firstContent); - passed &= conversation.updateExactMessageContent( - makeState({fixture}), QStringLiteral("empty-streaming-message"), exactChange); - settleEvents(); - - QWidget* const updatedContent = messageContent(message); - passed &= expect( - updatedContent && updatedContent == contentAddress - && updatedContent->property("kind").toString() == QStringLiteral("body") - && updatedContent->property("markdownRenderMode").toString() - == QStringLiteral("streaming-plain") - && messageSourceText(updatedContent) == firstContent - && updatedContent->property("streamAppendCount").toULongLong() == 1 - && updatedContent->property("sourceMaterializationCount").toULongLong() == 0, - "the first exact delta must replace only the placeholder and append without materializing the canonical source"); - return passed; -} - -bool testMaximumRetainedAgentMessageStaysIncremental() -{ - // AISuite's negotiated agentText append channel retains at most 32 KiB. - // CodexUI's large-message renderer threshold is 64 KiB, so a retained - // streaming agent message cannot validly cross it. - constexpr qsizetype maximumRetainedAgentText = 32 * 1024; - const QString initial(maximumRetainedAgentText - 1, QLatin1Char('a')); - ThreadFixture fixture{ - "streaming-threshold", - {{"turn-streaming-threshold", - {{"item-streaming-threshold", - frontend::ThreadItemKind::AgentMessage, - initial.toStdString(), - "started"}}}}}; - codexui::ConversationWidget conversation; - conversation.resize(900, 700); - conversation.show(); - conversation.render(makeState({fixture}), QStringLiteral("streaming-threshold")); - settleTimeline(); - - QWidget* message = segment( - conversation, QStringLiteral("message:item-streaming-threshold")); - QPointer streamingContent = messageContent(message); - QWidget* const streamingAddress = streamingContent.data(); - bool passed = expect( - streamingContent - && streamingContent->property("markdownRenderMode").toString() - == QStringLiteral("streaming-plain"), - "a near-maximum retained agent message must start in the incremental renderer"); - - QString current = initial + QLatin1Char('b'); - fixture.turns.front().messages.front().text = current.toStdString(); - passed &= conversation.updateExactMessageContent( - makeState({fixture}), - QStringLiteral("streaming-threshold"), - appendUpdate(QStringLiteral("turn-streaming-threshold"), - QStringLiteral("item-streaming-threshold"), - client::ItemContentChannel::AgentText, - static_cast(initial.toUtf8().size()), - QStringLiteral("b"))); - settleEvents(); - QWidget* const updatedContent = messageContent(message); - passed &= expect( - updatedContent && updatedContent == streamingAddress - && updatedContent->property("markdownRenderMode").toString() - == QStringLiteral("streaming-plain") - && messageSourceText(updatedContent) == current - && updatedContent->property("streamAppendCount").toULongLong() == 1 - && updatedContent->property("sourceMaterializationCount").toULongLong() == 0, - "the append reaching the retained agent-text maximum must stay incremental without materializing the accumulated source"); - return passed; -} - -bool testTerminalMarkdownAcceptsLateExactDelta() -{ - const QString initial = QStringLiteral("**finished** result"); - ThreadFixture fixture{ - "terminal-late-delta", - {{"turn-terminal-late-delta", - {{"item-terminal-late-delta", - frontend::ThreadItemKind::AgentMessage, - initial.toStdString(), - "completed"}}}}}; - codexui::ConversationWidget conversation; - conversation.resize(900, 700); - conversation.show(); - conversation.render(makeState({fixture}), QStringLiteral("terminal-late-delta")); - settleTimeline(); - - QWidget* message = segment( - conversation, QStringLiteral("message:item-terminal-late-delta")); - QPointer content = messageContent(message); - QWidget* const contentAddress = content.data(); - bool passed = expect( - content && qobject_cast(content) - && content->property("markdownRenderMode").toString() - == QStringLiteral("markdown"), - "a terminal agent message must start in the Markdown renderer"); - - const QString delta = QStringLiteral("\n\n_late terminal suffix_"); - const QString finalContent = initial + delta; - fixture.turns.front().messages.front().text = finalContent.toStdString(); - passed &= conversation.updateExactMessageContent( - makeState({fixture}), - QStringLiteral("terminal-late-delta"), - appendUpdate(QStringLiteral("turn-terminal-late-delta"), - QStringLiteral("item-terminal-late-delta"), - client::ItemContentChannel::AgentText, - static_cast(initial.toUtf8().size()), - delta)); - settleTimeline(); - - QWidget* const finalWidget = messageContent(message); - auto* finalLabel = qobject_cast(finalWidget); - passed &= expect( - finalWidget && finalWidget == contentAddress && finalLabel - && messageSourceText(finalLabel) == finalContent - && finalWidget->property("markdownRenderMode").toString() - == QStringLiteral("markdown") - && finalLabel->text().contains(QStringLiteral("late terminal suffix")), - "a late exact delta on a terminal item must remain in the final Markdown renderer"); - return passed; -} - -bool testCompletedAgentMessageStreamsBeforeTerminalMarkdown() -{ - ThreadFixture fixture{ - "completed-streaming-markdown", - {{"turn-completed-streaming-markdown", - {{"item-completed-streaming-markdown", - frontend::ThreadItemKind::AgentMessage, - "**partial", - "completed"}}}}}; - fixture.turns.front().status = "inProgress"; - fixture.turns.front().active = true; - fixture.turns.front().terminal = false; - codexui::ConversationWidget conversation; - conversation.resize(900, 700); - conversation.show(); - conversation.render(makeState({fixture}), - QStringLiteral("completed-streaming-markdown")); - settleTimeline(); - - QPointer message = segment( - conversation, QStringLiteral("message:item-completed-streaming-markdown")); - QPointer streamingContent = messageContent(message); - QWidget* const messageAddress = message.data(); - QWidget* const streamingContentAddress = streamingContent.data(); - bool passed = expect( - streamingContent - && streamingContent->property("markdownRenderMode").toString() - == QStringLiteral("streaming-plain"), - "an active turn must keep a completed-looking agent message plain while content can still arrive"); - - conversation.render(makeState({fixture}), - QStringLiteral("completed-streaming-markdown")); - settleEvents(); - passed &= expect( - messageContent(message) == streamingContentAddress, - "an unrelated full refresh during the active turn must preserve the streaming view"); - - auto* streamingEditor = qobject_cast(streamingContent.data()); - const qreal liveDocumentWidth = streamingEditor - ? streamingEditor->document()->textWidth() - : 0.0; - const int speculativeHeight = streamingContent - ? streamingContent->heightForWidth(54) - : 0; - passed &= expect( - streamingEditor && speculativeHeight > 0 - && streamingEditor->document()->textWidth() == liveDocumentWidth, - "speculative height-for-width measurement must not reflow the visible streaming document"); - - const qulonglong geometryInvalidationsBeforeGrowth = - streamingContent - ? streamingContent->property("geometryInvalidationCount").toULongLong() - : 0; - const QString fullyReconciledGrowth = - QStringLiteral("**partial via full reconciliation"); - fixture.turns.front().messages.front().text = - fullyReconciledGrowth.toStdString(); - conversation.render(makeState({fixture}), - QStringLiteral("completed-streaming-markdown")); - settleEvents(); - passed &= expect( - messageContent(message) == streamingContentAddress - && messageSourceText(streamingContent) == fullyReconciledGrowth - && streamingContent->property("streamAppendCount").toULongLong() == 1 - && streamingContent->property("fullReplacementCount").toULongLong() == 0, - "a full active-turn reconciliation with grown canonical text must cursor-append in the existing streaming view"); - passed &= expect( - streamingContent - && streamingContent->property("geometryInvalidationCount").toULongLong() - == geometryInvalidationsBeforeGrowth, - "a same-line streaming append must not invalidate unchanged message geometry"); - - const QStringList streamedContent{ - QStringLiteral("**partial via full reconciliation result"), - QStringLiteral("**partial via full reconciliation result**\n\n- one"), - QStringLiteral("**partial via full reconciliation result**\n\n- one\n- two")}; - QString previous = fullyReconciledGrowth; - for (const QString& update : streamedContent) - { - fixture.turns.front().messages.front().text = update.toStdString(); - const QString delta = update.mid(previous.size()); - const auto exactChange = appendUpdate( - QStringLiteral("turn-completed-streaming-markdown"), - QStringLiteral("item-completed-streaming-markdown"), - client::ItemContentChannel::AgentText, - static_cast(previous.toUtf8().size()), - delta); - const client::State updatedState = makeState({fixture}); - const bool handled = conversation.updateExactMessageContent( - updatedState, - QStringLiteral("completed-streaming-markdown"), - exactChange); - settleEvents(); - - QWidget* const currentContent = messageContent(message); - passed &= expect( - handled && message && message.data() == messageAddress - && currentContent && currentContent == streamingContentAddress - && messageSourceText(currentContent) == update - && currentContent->property("markdownRenderMode").toString() - == QStringLiteral("streaming-plain"), - "append-v2 deltas must keep a completed agent message in one plain streaming view"); - previous = update; - } - passed &= expect( - streamingContent - && streamingContent->property("streamAppendCount").toULongLong() - == static_cast(streamedContent.size() + 1) - && streamingContent->property("fullReplacementCount").toULongLong() == 0, - "completed agent-message deltas must use only the cursor append path"); - auto* conversationScroll = conversation.findChild(); - passed &= expect( - conversationScroll && conversationScroll->viewport()->updatesEnabled() - && conversationScroll->verticalScrollBar()->value() - == conversationScroll->verticalScrollBar()->maximum(), - "a height-changing stream batch must expose its settled geometry once and remain pinned at the tail"); - - fixture.turns.front().status = "completed"; - fixture.turns.front().active = false; - fixture.turns.front().terminal = true; - const client::State finalState = makeState({fixture}); - conversation.render(finalState, - QStringLiteral("completed-streaming-markdown")); - settleTimeline(); - - QPointer finalContent = messageContent(message); - auto* finalLabel = qobject_cast(finalContent); - QWidget* const finalContentAddress = finalContent.data(); - const QString finalRenderedText = finalLabel ? finalLabel->text() : QString{}; - passed &= expect( - message && message.data() == messageAddress && finalContent - && finalContent.data() != streamingContentAddress && finalLabel - && messageSourceText(finalLabel) == streamedContent.back() - && finalContent->property("markdownRenderMode").toString() - == QStringLiteral("markdown") - && finalRenderedText.contains(QStringLiteral("font-weight")) - && finalRenderedText.contains(QStringLiteral("two")), - "the first non-delta terminal publication must promote the stream to Markdown once"); - - conversation.render(finalState, - QStringLiteral("completed-streaming-markdown")); - settleEvents(); - QWidget* const repeatedContent = messageContent(message); - passed &= expect( - repeatedContent == finalContentAddress - && qobject_cast(repeatedContent) - && qobject_cast(repeatedContent)->text() == finalRenderedText, - "an unchanged terminal publication must preserve the final Markdown widget"); - return passed; -} - -bool testTerminalMarkdownPromotionResettlesFollowedTail() -{ - ThreadFixture fixture = sequentialTurns("terminal-markdown-tail", 9); - auto& finalTurn = fixture.turns.back(); - finalTurn.status = "inProgress"; - finalTurn.active = true; - finalTurn.terminal = false; - const QString source = QStringLiteral("[compact](https://example.invalid/") - + QString(6000, QLatin1Char('x')) - + QLatin1Char(')'); - finalTurn.messages.front().text = source.toStdString(); - finalTurn.messages.front().status = "completed"; - - codexui::ConversationWidget conversation; - conversation.resize(900, 500); - conversation.show(); - conversation.render(makeState({fixture}), QStringLiteral("terminal-markdown-tail")); - settleTimeline(); - - auto* scroll = conversation.findChild(); - QWidget* timelineHost = timeline(conversation); - QPointer message = segment( - conversation, QStringLiteral("message:item-terminal-markdown-tail-8")); - QPointer streamingContent = messageContent(message); - const int streamingPreferredHeight = streamingContent - ? streamingContent->heightForWidth( - streamingContent->width()) - : 0; - const int streamingTimelineHeight = timelineHost ? timelineHost->height() : 0; - const int streamingMaximum = scroll ? scroll->verticalScrollBar()->maximum() : 0; - bool passed = expect( - scroll && timelineHost && streamingContent - && streamingContent->property("markdownRenderMode").toString() - == QStringLiteral("streaming-plain") - && streamingMaximum > 0 - && scroll->verticalScrollBar()->value() == streamingMaximum, - "the tall streaming source must begin at a genuinely followed tail"); - - finalTurn.status = "completed"; - finalTurn.active = false; - finalTurn.terminal = true; - conversation.render(makeState({fixture}), QStringLiteral("terminal-markdown-tail")); - settleTimeline(); - - QPointer finalContent = messageContent(message); - auto* finalLabel = qobject_cast(finalContent.data()); - const int finalPreferredHeight = finalContent - ? finalContent->heightForWidth(finalContent->width()) - : 0; - const int finalMaximum = scroll ? scroll->verticalScrollBar()->maximum() : 0; - passed &= expect( - finalLabel && finalContent != streamingContent - && finalContent->property("markdownRenderMode").toString() - == QStringLiteral("markdown") - && finalPreferredHeight < streamingPreferredHeight, - "terminal Markdown must replace the tall source with its compact rendered presentation"); - passed &= expect( - timelineHost && timelineHost->height() < streamingTimelineHeight - && finalMaximum > 0 && finalMaximum < streamingMaximum, - "terminal renderer replacement must shrink the timeline and its retained scroll range"); - passed &= expect( - scroll && scroll->verticalScrollBar()->value() == finalMaximum, - "terminal Markdown promotion must settle at the new true tail"); - return passed; -} - -bool testCompleteAndLargeUserMessagePresentation() -{ - ThreadFixture completeFixture{ - "complete-user-message", - {{"turn-complete-user-message", - {{"item-complete-user-message", - frontend::ThreadItemKind::UserMessage, - "**complete** canonical prompt with [docs](https://example.com)\n\n```cpp\nint answer = 42;\n```\n\n![secret](file:///etc/passwd)\n", - "completed", - true, - false, - true}}}}}; - codexui::ConversationWidget completeConversation; - completeConversation.resize(900, 700); - completeConversation.show(); - completeConversation.render(makeState({completeFixture}), - QStringLiteral("complete-user-message")); - settleTimeline(); - - QWidget* completeSegment = - segment(completeConversation, QStringLiteral("message:item-complete-user-message")); - QLabel* completeContent = - messageLabel(completeSegment, QStringLiteral("conversationMessageContent")); - QLabel* completeMarker = - messageLabel(completeSegment, QStringLiteral("conversationMessageTruncation")); - const QString completeMarkdown = QStringLiteral( - "**complete** canonical prompt with [docs](https://example.com)\n\n```cpp\nint answer = 42;\n```\n\n![secret](file:///etc/passwd)\n"); - bool passed = expect(completeContent - && messageSourceText(completeContent) == completeMarkdown - && completeContent->textFormat() == Qt::RichText - && completeContent->text().contains(QStringLiteral("font-weight")) - && completeContent->text().contains(QStringLiteral("https://example.com")) - && completeContent->text().contains(QStringLiteral("int answer = 42")) - && !completeContent->text().contains(QStringLiteral("file:///etc/passwd")) - && !completeContent->text().contains(QStringLiteral("isVisible(), - "non-text omissions and generic item bounds must not mark complete typed user text as truncated"); - - std::string largeText(70U * 1024U, 'a'); - largeText.replace(16U, 2U, "\n\n"); - ThreadFixture largeFixture{ - "large-user-message", - {{"turn-large-user-message", - {{"item-large-user-message", - frontend::ThreadItemKind::UserMessage, - largeText}}}}}; - codexui::ConversationWidget largeConversation; - largeConversation.resize(900, 700); - largeConversation.show(); - largeConversation.render(makeState({largeFixture}), QStringLiteral("large-user-message")); - settleTimeline(); - - QPointer largeSegment = - segment(largeConversation, QStringLiteral("message:item-large-user-message")); - QPointer largeContent = largeSegment - ? largeSegment->findChild( - QStringLiteral("conversationMessageContent")) - : nullptr; - QPlainTextEdit* const largeContentAddress = largeContent.data(); - const QString expectedLargeText = QString::fromStdString(largeText); - passed &= expect(largeContent && largeContent->isReadOnly() - && largeContent->toPlainText() == expectedLargeText - && largeContent->height() == 240, - "large retained user text must remain complete in a bounded read-only editor"); - - largeText.replace(0U, 5U, "omega"); - largeFixture.turns.front().messages.front().text = largeText; - largeConversation.render(makeState({largeFixture}), QStringLiteral("large-user-message")); - settleTimeline(); - passed &= expect(largeSegment - && largeSegment.data() - == segment(largeConversation, - QStringLiteral("message:item-large-user-message")) - && largeContent && largeContent.data() == largeContentAddress - && largeContent->toPlainText() == QString::fromStdString(largeText), - "large canonical user-text replacement must update the existing editor in place"); - return passed; -} - -bool testExactContentInvalidation() -{ - ThreadFixture fixture{"exact-content", - {{"turn-exact-content", - {{"item-exact-first", - frontend::ThreadItemKind::AgentMessage, - "first prefix", - "in_progress"}, - {"item-exact-second", - frontend::ThreadItemKind::AgentMessage, - "second stable", - "completed"}, - {"item-exact-activity", - frontend::ThreadItemKind::CommandExecution, - "activity output", - "completed"}}}}}; - codexui::ConversationWidget conversation; - conversation.resize(900, 700); - conversation.show(); - conversation.render(makeState({fixture}), QStringLiteral("exact-content")); - settleTimeline(); - - QPointer first = segment(conversation, QStringLiteral("message:item-exact-first")); - QPointer second = segment(conversation, QStringLiteral("message:item-exact-second")); - QPointer firstContent = messageContent(first); - QPointer secondContent = - messageLabel(second, QStringLiteral("conversationMessageContent")); - QWidget* const firstAddress = first.data(); - QWidget* const secondAddress = second.data(); - - const QString firstDelta = QStringLiteral(" canonical continuation"); - fixture.turns.front().messages.front().text = "first prefix canonical continuation"; - const auto exactChanges = appendUpdate( - QStringLiteral("turn-exact-content"), - QStringLiteral("item-exact-first"), - client::ItemContentChannel::AgentText, - std::string_view("first prefix").size(), - firstDelta); - const auto updatedState = makeState({fixture}); - const bool exactApplied = conversation.updateExactMessageContent( - updatedState, QStringLiteral("exact-content"), exactChanges); - settleTimeline(); - - bool passed = true; - passed &= expect(exactApplied && first && first.data() == firstAddress && firstContent - && messageSourceText(firstContent) - == QStringLiteral("first prefix canonical continuation") - && firstContent->property("streamAppendCount").toULongLong() == 1, - "an exact content update must mutate its canonical message directly in place"); - passed &= expect(second && second.data() == secondAddress && secondContent - && messageSourceText(secondContent) == QStringLiteral("second stable"), - "an exact content update must preserve unaffected segment widgets"); - - const auto activityChanges = replacementUpdate( - QStringLiteral("turn-exact-content"), - QStringLiteral("item-exact-activity"), - client::ItemContentChannel::CommandOutput); - passed &= expect(!conversation.updateExactMessageContent( - updatedState, QStringLiteral("exact-content"), activityChanges), - "a non-message content update must retain the full activity-card reconciliation fallback"); - - fixture.turns.front().messages.at(1).text = "second structural fallback"; - conversation.render(makeState({fixture}), QStringLiteral("exact-content")); - settleTimeline(); - passed &= expect(second && second.data() == secondAddress && secondContent - && messageSourceText(secondContent) == QStringLiteral("second structural fallback"), - "a full reconciliation fallback must still refresh every changed canonical segment"); - return passed; -} - -bool testExactReasoningChannels() -{ - ThreadFixture fixture{ - "reasoning-channels", - {{"turn-reasoning-channels", - {{"reasoning-text", - frontend::ThreadItemKind::Reasoning, - "working", - "in_progress"}, - {"reasoning-summary", - frontend::ThreadItemKind::Reasoning, - "", - "in_progress"}}}}}; - fixture.turns.front().messages.at(1).reasoningSummary = "summary"; - - codexui::ConversationWidget conversation; - conversation.resize(900, 700); - conversation.show(); - conversation.render(makeState({fixture}), QStringLiteral("reasoning-channels")); - settleTimeline(); - - QWidget* textRow = nullptr; - QWidget* summaryRow = nullptr; - for (QWidget* row : conversation.findChildren( - QStringLiteral("conversationActivityRow"))) - { - if (row->property("itemId").toString() == QStringLiteral("reasoning-text")) - textRow = row; - else if (row->property("itemId").toString() == QStringLiteral("reasoning-summary")) - summaryRow = row; - } - auto* textDetails = textRow - ? textRow->findChild( - QStringLiteral("conversationActivityDetails")) - : nullptr; - auto* summaryDetails = summaryRow - ? summaryRow->findChild( - QStringLiteral("conversationActivityDetails")) - : nullptr; - auto* textDisclosure = textRow - ? textRow->findChild( - QStringLiteral("activityDisclosure")) - : nullptr; - auto* summaryDisclosure = summaryRow - ? summaryRow->findChild( - QStringLiteral("activityDisclosure")) - : nullptr; - const QString textDelta = QStringLiteral(" through evidence"); - fixture.turns.front().messages.at(0).text += textDelta.toStdString(); - const auto textChange = appendUpdate( - QStringLiteral("turn-reasoning-channels"), - QStringLiteral("reasoning-text"), - client::ItemContentChannel::ReasoningText, - std::string_view("working").size(), - textDelta); - bool passed = expect(textRow && summaryRow && textDetails && summaryDetails - && textDetails->isHidden() && summaryDetails->isHidden() - && !textRow->findChild( - QStringLiteral("conversationActivityDetail")) - && !summaryRow->findChild( - QStringLiteral("conversationActivityDetail")) - && textDetails->property("detailMaterializationCount").toULongLong() == 0 - && summaryDetails->property("detailMaterializationCount").toULongLong() == 0 - && textDetails->property("deferredDetailBytes").toULongLong() - == std::string_view("working").size() - && summaryDetails->property("deferredDetailBytes").toULongLong() - == std::string_view("summary").size(), - "collapsed reasoning channels must retain their source bytes without materializing text documents"); - passed &= conversation.updateExactMessageContent( - makeState({fixture}), QStringLiteral("reasoning-channels"), textChange); - - const QString summaryDelta = QStringLiteral(" complete"); - fixture.turns.front().messages.at(1).reasoningSummary += summaryDelta.toStdString(); - const auto summaryChange = appendUpdate( - QStringLiteral("turn-reasoning-channels"), - QStringLiteral("reasoning-summary"), - client::ItemContentChannel::ReasoningSummary, - std::string_view("summary").size(), - summaryDelta); - passed &= conversation.updateExactMessageContent( - makeState({fixture}), QStringLiteral("reasoning-channels"), summaryChange); - passed &= expect(textDetails && summaryDetails - && textDetails->isHidden() && summaryDetails->isHidden() - && !textRow->findChild( - QStringLiteral("conversationActivityDetail")) - && !summaryRow->findChild( - QStringLiteral("conversationActivityDetail")) - && textDetails->property("detailMaterializationCount").toULongLong() == 0 - && summaryDetails->property("detailMaterializationCount").toULongLong() == 0 - && textDetails->property("deferredDetailBytes").toULongLong() - == std::string_view("working through evidence").size() - && summaryDetails->property("deferredDetailBytes").toULongLong() - == std::string_view("summary complete").size(), - "exact reasoning updates must advance collapsed deferred sources without creating hidden documents"); - - if (textDisclosure) - textDisclosure->click(); - if (summaryDisclosure) - summaryDisclosure->click(); - settleTimeline(); - QPointer textDetail = textRow - ? textRow->findChild( - QStringLiteral("conversationActivityDetail")) - : nullptr; - QPointer summaryDetail = summaryRow - ? summaryRow->findChild( - QStringLiteral("conversationActivityDetail")) - : nullptr; - passed &= expect(textDetail && summaryDetail && textDetails && summaryDetails - && textDetail->toPlainText() - == QStringLiteral("working through evidence") - && summaryDetail->toPlainText() - == QStringLiteral("summary complete") - && textDetails->property("detailMaterializationCount").toULongLong() == 1 - && summaryDetails->property("detailMaterializationCount").toULongLong() == 1 - && textDetail->property("streamAppendCount").toULongLong() == 0 - && summaryDetail->property("streamAppendCount").toULongLong() == 0, - "expanding reasoning rows must materialize each latest channel exactly once"); - - const QString expandedTextDelta = QStringLiteral(" after expansion"); - const std::uint64_t expandedTextBase = - fixture.turns.front().messages.at(0).text.size(); - fixture.turns.front().messages.at(0).text += expandedTextDelta.toStdString(); - const auto expandedTextChange = appendUpdate( - QStringLiteral("turn-reasoning-channels"), - QStringLiteral("reasoning-text"), - client::ItemContentChannel::ReasoningText, - expandedTextBase, - expandedTextDelta); - passed &= conversation.updateExactMessageContent( - makeState({fixture}), QStringLiteral("reasoning-channels"), expandedTextChange); - - const QString expandedSummaryDelta = QStringLiteral(" after expansion"); - const std::uint64_t expandedSummaryBase = - fixture.turns.front().messages.at(1).reasoningSummary.size(); - fixture.turns.front().messages.at(1).reasoningSummary += - expandedSummaryDelta.toStdString(); - const auto expandedSummaryChange = appendUpdate( - QStringLiteral("turn-reasoning-channels"), - QStringLiteral("reasoning-summary"), - client::ItemContentChannel::ReasoningSummary, - expandedSummaryBase, - expandedSummaryDelta); - passed &= conversation.updateExactMessageContent( - makeState({fixture}), QStringLiteral("reasoning-channels"), expandedSummaryChange); - passed &= expect(textDetail - && textDetail->toPlainText() - == QStringLiteral("working through evidence after expansion") - && textDetail->property("streamAppendCount").toULongLong() == 1, - "an expanded reasoning-text delta must append through the text cursor"); - passed &= expect(summaryDetail - && summaryDetail->toPlainText() - == QStringLiteral("summary complete after expansion") - && summaryDetail->property("streamAppendCount").toULongLong() == 1, - "an expanded reasoning-summary delta must append independently through its text cursor"); - - const auto wrongBase = appendUpdate( - QStringLiteral("turn-reasoning-channels"), - QStringLiteral("reasoning-text"), - client::ItemContentChannel::ReasoningText, - 1, - QStringLiteral("invalid")); - passed &= expect(!conversation.updateExactMessageContent( - makeState({fixture}), QStringLiteral("reasoning-channels"), wrongBase) - && textDetail - && textDetail->toPlainText() - == QStringLiteral("working through evidence after expansion"), - "a mismatched reasoning base must decline the exact path without corrupting presentation"); - return passed; -} - -bool testSegmentReplacementShrink() -{ - ThreadFixture fixture = singleTurn("replacement", 1); - std::string longText; - for (int index = 0; index < 120; ++index) - longText += "A retained line that gives the replacement a measurable wrapped height.\n"; - fixture.turns.front().messages.front().text = std::move(longText); - const client::State tallState = makeState({fixture}); - - codexui::ConversationWidget conversation; - conversation.resize(900, 700); - conversation.show(); - conversation.render(tallState, QStringLiteral("replacement")); - settleTimeline(); - QWidget* host = timeline(conversation); - const int tallHeight = host ? host->height() : 0; - - fixture.turns.front().messages.front().text = "short replacement"; - const client::State shortState = makeState({fixture}); - conversation.render(shortState, QStringLiteral("replacement")); - settleTimeline(); - - return expect(host && host->height() < tallHeight, - "replacing a segment with shorter canonical content must release the previous fixed height"); -} - -bool testThreadSwitchWindow() -{ - const client::State state = makeState({singleTurn("switch-a", 300), singleTurn("switch-b", 2)}); - codexui::ConversationWidget conversation; - conversation.resize(900, 700); - conversation.show(); - bool passed = true; - - const auto verify = [&](const QString& selected, const QString& present, const QString& absent) - { - conversation.render(state, selected); - settleTimeline(); - QWidget* host = timeline(conversation); - QScrollArea* scroll = conversation.findChild(); - passed &= expect(segment(conversation, present) != nullptr && segment(conversation, absent) == nullptr, - "thread switching must retain only the selected canonical window"); - const qlonglong expectedItems = selected == QStringLiteral("switch-a") ? 300 : 2; - passed &= expect(host && host->property("renderedTimelineItems").toLongLong() - == expectedItems, - "every selected thread must reconstruct its complete retained timeline"); - passed &= expect(scroll && scroll->verticalScrollBar()->value() == scroll->verticalScrollBar()->maximum(), - "a selected historical thread must settle at its newest retained entry"); - return host ? host->height() : 0; - }; - - const int firstLongHeight = verify(QStringLiteral("switch-a"), QStringLiteral("message:item-switch-a-299"), - QStringLiteral("message:item-switch-b-1")); - const int shortHeight = verify(QStringLiteral("switch-b"), QStringLiteral("message:item-switch-b-1"), - QStringLiteral("message:item-switch-a-299")); - const int secondLongHeight = verify(QStringLiteral("switch-a"), QStringLiteral("message:item-switch-a-299"), - QStringLiteral("message:item-switch-b-1")); - passed &= expect(shortHeight > 0 && shortHeight < firstLongHeight && secondLongHeight > shortHeight, - "thread switching must release a previous long timeline height before laying out a short thread"); - conversation.render(state, QStringLiteral("switch-b")); - conversation.render(state, QStringLiteral("switch-a")); - settleTimeline(); - auto* rapidSwitchScroll = conversation.findChild(); - passed &= expect( - rapidSwitchScroll && rapidSwitchScroll->viewport()->updatesEnabled() - && segment(conversation, - QStringLiteral("message:item-switch-a-299")), - "two thread switches inside one settle interval must restore viewport updates for the newest generation"); - return passed; -} - -bool testInspectorRevisionOnlyUpdate() -{ - const client::State state = makeState({singleTurn("inspector-revision", 1)}); - codexui::InspectorWidget inspector; - inspector.resize(420, 700); - inspector.show(); - inspector.render(state, QStringLiteral("inspector-revision"), true, - QStringLiteral("State synced")); - settleEvents(); - - auto* revision = inspector.findChild(QStringLiteral("inspectorStateRevision")); - const auto detailCards = inspector.findChildren(QStringLiteral("inspectorDetailCard")); - const auto expensivePaneWidgets = inspector.findChildren(); - const std::uint64_t nextRevision = state.revision() + 7; - inspector.updateStateRevision(nextRevision); - QCoreApplication::processEvents(); - - return expect(!detailCards.isEmpty() - && std::ranges::all_of(detailCards, [](const QFrame* card) { - return card->styleSheet().contains( - QStringLiteral("QFrame#inspectorDetailCard")); - }), - "Inspector card borders must be scoped and never leak to child labels") - && expect(revision && revision->text() == QString::number(nextRevision), - "a revision-only update must refresh the Inspector's factual State revision") - && expect(revision - && revision - == inspector.findChild( - QStringLiteral("inspectorStateRevision")) - && expensivePaneWidgets == inspector.findChildren(), - "a revision-only update must preserve every existing Inspector pane widget"); -} - -bool testInspectorThreadDependencies() -{ - MessageFixture activity; - activity.id = "subagent-activity"; - activity.kind = frontend::ThreadItemKind::SubAgentActivity; - activity.text = "Delegated work"; - activity.agentPath = "agent/reviewer"; - activity.agentThreadId = "inspector-agent-child"; - activity.agentKind = "spawn"; - ThreadFixture parent{ - "inspector-parent", - {{"turn-inspector-parent-agents", {activity}, std::nullopt}, - {"turn-inspector-parent-latest", - {{"inspector-parent-latest-message", - frontend::ThreadItemKind::AgentMessage, - "A newer turn without agent activity"}}, - std::nullopt}}}; - const client::State state = makeState( - {parent, singleTurn("inspector-agent-child", 1)}); - - codexui::InspectorWidget inspector; - inspector.render(state, - QStringLiteral("inspector-parent"), - true, - QStringLiteral("State synced")); - bool passed = expect(inspector.dependsOnThread(QStringLiteral("inspector-parent")) - && inspector.dependsOnThread( - QStringLiteral("inspector-agent-child")) - && !inspector.dependsOnThread(QStringLiteral("unrelated")), - "Inspector reconstruction must retain earlier-turn agents and their linked thread dependencies"); - - QPointer retainedAgentRow; - for (QPushButton* button : inspector.findChildren()) { - if (button->toolTip() == QStringLiteral("agent/reviewer")) { - retainedAgentRow = button; - break; - } - } - ThreadFixture partialParent = singleTurn("inspector-parent", 1); - partialParent.fullyLoaded = false; - const client::State partialWithoutActivity = makeState( - {partialParent, singleTurn("inspector-agent-child", 1)}); - inspector.render(partialWithoutActivity, - QStringLiteral("inspector-parent"), - true, - QStringLiteral("State synced")); - settleEvents(); - const auto partialLabels = inspector.findChildren(); - passed &= expect(retainedAgentRow - && inspector.dependsOnThread( - QStringLiteral("inspector-agent-child")) - && std::ranges::none_of( - partialLabels, - [](const QLabel* label) { - return label->text() - == QStringLiteral("No agent activity"); - }), - "an incomplete latest-turn projection must retain the Agents row and its linked-thread dependency"); - - const client::State withoutActivity = makeState( - {singleTurn("inspector-parent", 1), singleTurn("inspector-agent-child", 1)}); - inspector.render(withoutActivity, - QStringLiteral("inspector-parent"), - true, - QStringLiteral("State synced")); - settleEvents(); - passed &= expect(inspector.dependsOnThread(QStringLiteral("inspector-parent")) - && !inspector.dependsOnThread( - QStringLiteral("inspector-agent-child")) - && !retainedAgentRow, - "removing subagent activity must discard its stale linked-thread dependency"); - return passed; -} - -bool testStructuredPlanPresentation() -{ - ThreadFixture fixture{"structured-plan", - {{"turn-structured-plan", - {{"message-structured-plan", - frontend::ThreadItemKind::AgentMessage, - "Working through the plan"}}, - frontend::Json{{"explanation", "Keep the implementation focused."}, - {"steps", - {"Inspect canonical state", - "Render typed plan", - "Validate behavior"}}, - {"statuses", {"completed", "inProgress", "pending"}}, - {"totalSteps", 3}, - {"truncated", false}}}}}; - const client::State state = makeState({fixture}); - codexui::InspectorWidget inspector; - inspector.resize(420, 700); - inspector.show(); - inspector.render(state, - QStringLiteral("structured-plan"), - true, - QStringLiteral("State synced")); - settleEvents(); - - auto steps = inspector.findChildren(QStringLiteral("inspectorPlanStepText")); - const auto hasStep = [&steps](const QString& text) { - return std::ranges::any_of(steps, [&text](const QLabel* label) { return label->text() == text; }); - }; - bool passed = expect(state.thread("structured-plan") != nullptr && steps.size() == 3 - && hasStep(QStringLiteral("Inspect canonical state")) - && hasStep(QStringLiteral("Render typed plan")) - && hasStep(QStringLiteral("Validate behavior")), - "the Plan tab must render the authoritative ordered typed turn plan"); - - fixture.turns.front().plan = frontend::Json{ - {"explanation", "The plan changed."}, - {"steps", - {"Inspect canonical state", "Render typed plan", "Validate behavior", "Publish result"}}, - {"statuses", {"completed", "completed", "inProgress", "pending"}}, - {"totalSteps", 5}, - {"truncated", true}}; - inspector.render(makeState({fixture}), - QStringLiteral("structured-plan"), - true, - QStringLiteral("State synced")); - settleEvents(); - steps = inspector.findChildren(QStringLiteral("inspectorPlanStepText")); - auto* truncation = inspector.findChild(QStringLiteral("inspectorPlanTruncation")); - passed &= expect(steps.size() == 4 && truncation && truncation->text().contains(QStringLiteral("4 of 5")), - "incremental plan replacement must refresh ordered steps and truthful truncation state"); - - fixture.turns.front().plan.reset(); - inspector.render(makeState({fixture}), - QStringLiteral("structured-plan"), - true, - QStringLiteral("State synced")); - settleEvents(); - passed &= expect(inspector.findChildren(QStringLiteral("inspectorPlanStepText")).isEmpty(), - "removing the canonical plan must clear stale structured Plan rows"); - return passed; -} - -bool testHistoricalTurnDetailsMode() -{ - const client::State state = makeState({sequentialTurns("turn-details", 2)}); - codexui::InspectorWidget inspector; - inspector.resize(420, 700); - inspector.show(); - inspector.render(state, - QStringLiteral("turn-details"), - true, - QStringLiteral("State synced"), - QStringLiteral("turn-turn-details-1")); - settleEvents(); - - auto* heading = inspector.findChild(QStringLiteral("inspectorHeading")); - auto* tabs = inspector.findChild(QStringLiteral("inspectorTabs")); - auto* back = inspector.findChild(QStringLiteral("historicalTurnBack")); - auto* title = inspector.findChild(QStringLiteral("historicalTurnConfigurationTitle")); - const auto hasText = [&inspector](const QString& text) { - return std::ranges::any_of(inspector.findChildren(), - [&text](const QLabel* label) { - return label->text() == text; - }); - }; - bool passed = expect(heading && heading->text() == QStringLiteral("TURN DETAILS") - && tabs && !tabs->isVisible() && tabs->currentIndex() == 3 - && back && back->isVisible(), - "selecting a historical turn must enter the dedicated Turn Details mode"); - passed &= expect(title && title->text() == QStringLiteral("Effective configuration · Turn 2") - && hasText(QStringLiteral("Read-only historical record")) - && hasText(QStringLiteral("gpt-test")) - && hasText(QStringLiteral("/workspace/test")), - "Turn Details must show the selected turn's authoritative effective configuration"); - passed &= expect(!inspector.findChild(QStringLiteral("inspectorStateRevision")), - "Turn Details must not mix generic synchronization diagnostics into the historical record"); - - bool closeRequested = false; - QObject::connect(&inspector, &codexui::InspectorWidget::historicalTurnCloseRequested, - &inspector, [&closeRequested] { closeRequested = true; }); - back->click(); - passed &= expect(closeRequested, - "Turn Details must expose an explicit route back to the normal Inspector"); - - inspector.render(state, - QStringLiteral("turn-details"), - true, - QStringLiteral("State synced")); - settleEvents(); - passed &= expect(heading && heading->text() == QStringLiteral("INSPECTOR") - && tabs && tabs->isVisible() && back && !back->isVisible() - && inspector.findChild(QStringLiteral("inspectorStateRevision")), - "leaving a historical selection must restore the normal Inspector mode"); - return passed; -} - -bool testScopedDuplicateTurnIdentity() -{ - ThreadFixture original{"duplicate-turn-original", - {{"shared-turn", - {{"original-item", - frontend::ThreadItemKind::UserMessage, - "original scoped message"}}}}}; - ThreadFixture fork{"duplicate-turn-fork", - {{"shared-turn", - {{"fork-item", - frontend::ThreadItemKind::AgentMessage, - "fork scoped message"}}}}}; - const client::State state = makeState({original, fork}); - const auto* originalTurn = state.turn( - ai::openai::codex::typed::ThreadId{"duplicate-turn-original"}, - ai::openai::codex::typed::TurnId{"shared-turn"}); - const auto* forkTurn = state.turn( - ai::openai::codex::typed::ThreadId{"duplicate-turn-fork"}, - ai::openai::codex::typed::TurnId{"shared-turn"}); - - bool passed = expect(originalTurn && forkTurn && originalTurn != forkTurn - && state.turn("shared-turn") == nullptr, - "the fixture must retain both scoped turns and reject ambiguous bare lookup"); - - codexui::ConversationWidget conversation; - conversation.resize(900, 700); - conversation.show(); - conversation.render(state, QStringLiteral("duplicate-turn-original")); - settleTimeline(); - passed &= expect(hasLabel(conversation, QStringLiteral("original scoped message")) - && !hasLabel(conversation, QStringLiteral("fork scoped message")), - "conversation rendering must resolve a shared turn ID under the selected original thread"); - - conversation.render(state, QStringLiteral("duplicate-turn-fork")); - settleTimeline(); - passed &= expect(hasLabel(conversation, QStringLiteral("fork scoped message")) - && !hasLabel(conversation, QStringLiteral("original scoped message")), - "conversation rendering must resolve a shared turn ID under the selected fork thread"); - - codexui::InspectorWidget inspector; - inspector.resize(420, 700); - inspector.show(); - inspector.render(state, - QStringLiteral("duplicate-turn-fork"), - true, - QStringLiteral("State synced"), - QStringLiteral("shared-turn")); - settleEvents(); - const auto hasInspectorText = [&inspector](const QString& text) { - return std::ranges::any_of(inspector.findChildren(), - [&text](const QLabel* label) { - return label->text() == text; - }); - }; - auto* title = inspector.findChild(QStringLiteral("historicalTurnConfigurationTitle")); - passed &= expect(title && title->text() == QStringLiteral("Effective configuration · Turn 1") - && hasInspectorText(QStringLiteral("Effective settings")) - && !hasInspectorText(QStringLiteral("Unavailable")), - "historical details must resolve a shared turn ID under the inspected thread"); - return passed; -} - -} // namespace - -int main(int argc, char** argv) -{ - QApplication application(argc, argv); - codexui::ConversationWidget conversation; - - QWidget* timeline = conversation.findChild(QStringLiteral("conversationTimeline")); - QLabel* detail = emptyStateDetail(conversation); - QFrame* emptyCard = conversation.findChild(QStringLiteral("conversationEmptyState")); - bool passed = true; - passed &= expect(timeline != nullptr, "the conversation timeline must be discoverable"); - passed &= expect(detail != nullptr, "the wrapped empty-state detail must be discoverable"); - passed &= expect(emptyCard - && emptyCard->styleSheet().contains( - QStringLiteral("QFrame#conversationEmptyState")), - "the empty-state border must be scoped and never leak to child labels"); - if (!timeline || !detail || !emptyCard) - return 1; - - QString longDetail; - for (int index = 0; index < 32; ++index) { - if (!longDetail.isEmpty()) - longDetail += QLatin1Char(' '); - longDetail += QStringLiteral("wrapped timeline content must retain its natural height"); - } - detail->setText(longDetail); - detail->updateGeometry(); - - conversation.resize(900, 700); - conversation.show(); - settleEvents(); - - passed &= expect(detail->sizePolicy().hasHeightForWidth(), - "a wrapping label must advertise height-for-width to its parent layouts"); - passed &= expect(timeline->layout()->hasHeightForWidth(), - "height-for-width must propagate through the timeline layout"); - passed &= expectAtLeast(timeline->height(), requiredHeight(*timeline), - "the wide timeline must not be shorter than its wrapped content"); - passed &= expectAtLeast(detail->height(), detail->heightForWidth(detail->width()), - "the wide wrapped label must receive its required height"); - const int wideHeight = timeline->height(); - - conversation.resize(520, 700); - settleEvents(); - passed &= expectAtLeast(timeline->height(), requiredHeight(*timeline), - "the narrow timeline must not be shorter than its wrapped content"); - passed &= expectAtLeast(detail->height(), detail->heightForWidth(detail->width()), - "the narrow wrapped label must receive its required height"); - const int narrowHeight = timeline->height(); - passed &= expect(narrowHeight > wideHeight, - "the timeline must grow when wrapped content receives less width"); - - conversation.resize(900, 700); - settleEvents(); - passed &= expectAtLeast(timeline->height(), requiredHeight(*timeline), - "the widened timeline must still contain its wrapped content"); - passed &= expect(timeline->height() < narrowHeight, - "the timeline must shrink again after wrapped content receives more width"); - - conversation.hide(); - passed &= testTurnWindow(); - passed &= testSameThreadPrefixExpansion(); - passed &= testHotTurnWindow(); - passed &= testActivityDisclosureAndFullOutput(); - passed &= testPointerPreservingAppend(); - passed &= testKeyedSegmentInsertion(); - passed &= testStructuralItemUpsertReconciliation(); - passed &= testInPlaceMessageReplacement(); - passed &= testIncompleteThreadPresentation(); - passed &= testIncompleteReplacementPreservesRenderedTimeline(); - passed &= testStreamingPlainTextAndTerminalMarkdown(); - passed &= testEmptyAgentMessageAcceptsFirstExactDelta(); - passed &= testMaximumRetainedAgentMessageStaysIncremental(); - passed &= testTerminalMarkdownAcceptsLateExactDelta(); - passed &= testCompletedAgentMessageStreamsBeforeTerminalMarkdown(); - passed &= testTerminalMarkdownPromotionResettlesFollowedTail(); - passed &= testCompleteAndLargeUserMessagePresentation(); - passed &= testExactContentInvalidation(); - passed &= testExactReasoningChannels(); - passed &= testSegmentReplacementShrink(); - passed &= testThreadSwitchWindow(); - passed &= testInspectorRevisionOnlyUpdate(); - passed &= testInspectorThreadDependencies(); - passed &= testStructuredPlanPresentation(); - passed &= testHistoricalTurnDetailsMode(); - passed &= testScopedDuplicateTurnIdentity(); - - return passed ? 0 : 1; -} diff --git a/tests/FrontendSessionTest.cpp b/tests/FrontendSessionTest.cpp deleted file mode 100644 index c885141..0000000 --- a/tests/FrontendSessionTest.cpp +++ /dev/null @@ -1,2882 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "app/FrontendSessionWorker.h" - -#include -#include - -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace codexui { - -struct FrontendSessionWorkerTestAccess -{ - static void setLifecycle(FrontendSessionWorker& session, FrontendSessionWorker::Lifecycle lifecycle, QString detail = {}) - { - session.setLifecycle(lifecycle, std::move(detail)); - } - - static void handleConnectionStateChange( - FrontendSessionWorker& session, - const ai::openai::codex::frontend::client::ConnectionStateChange& change) - { - session.handleConnectionStateChange(change); - } - - static void reportDiagnostic(FrontendSessionWorker& session, QString message) - { - session.reportDiagnostic(std::move(message)); - } - - static bool automaticReconnectEnabled(const FrontendSessionWorker& session) - { - return session.automaticReconnectEnabled; - } - - static int consecutivePreReadyDisconnects(const FrontendSessionWorker& session) - { - return session.consecutivePreReadyDisconnects; - } - - static int maximumConsecutivePreReadyDisconnects() - { - return FrontendSessionWorker::maximumConsecutivePreReadyDisconnects; - } - - static void markUnstableSynchronized(FrontendSessionWorker& session) - { - session.synchronizedCurrentConnection = true; - session.preReadyFailureRecordedCurrentConnection = false; - session.connectionStabilityTimer.start( - FrontendSessionWorker::stableConnectionDwellMs); - } - - static std::size_t maximumFrameBytes(const FrontendSessionWorker& session) - { - return session.maximumFrameBytes; - } - - static void resetReconnectPolicy(FrontendSessionWorker& session) - { - session.resetReconnectPolicy(); - } - - static void setInbound(FrontendSessionWorker& session, QByteArray bytes, qsizetype offset) - { - session.inboundBuffer = std::move(bytes); - session.inboundOffset = offset; - session.inboundScanOffset = offset; - } - - static void appendInbound(FrontendSessionWorker& session, const QByteArray& bytes) - { - session.inboundBuffer.append(bytes); - } - - static qsizetype inboundScanOffset(const FrontendSessionWorker& session) - { - return session.inboundScanOffset; - } - - static void compactInbound(FrontendSessionWorker& session) - { - session.compactInbound(); - } - - static QByteArray inboundBytes(const FrontendSessionWorker& session) - { - return session.inboundBuffer; - } - - static qsizetype inboundOffset(const FrontendSessionWorker& session) - { - return session.inboundOffset; - } - - static bool hasCompleteInboundFrame(const FrontendSessionWorker& session) - { - return session.hasCompleteInboundFrame(); - } - - static void prepareReconnectReset(FrontendSessionWorker& session) - { - session.automaticReconnectEnabled = false; - session.reconnectDelayMs = FrontendSessionWorker::maximumReconnectDelayMs; - session.reconnectTimer.start(60'000); - } - - static void installConnectionWithTerminalClose(FrontendSessionWorker& session, bool& closeObserved) - { - session.connection = session.client->openConnection({ - [](FrontendSessionWorker::OutboundMessage) { - return FrontendSessionWorker::SendResult{ - ai::openai::codex::frontend::client::SendStatus::Accepted, - std::nullopt}; - }, - [&session, &closeObserved](std::string) { - closeObserved = true; - ai::openai::codex::frontend::client::Error terminalError; - terminalError.message = "terminal close callback"; - terminalError.retryable = false; - session.handleConnectionStateChange( - {ai::openai::codex::frontend::client::ConnectionState::Connecting, - ai::openai::codex::frontend::client::ConnectionState::Closed, - terminalError}); - }, - }); - } - - static ai::openai::codex::frontend::client::SendResult - acceptOutbound(FrontendSessionWorker& session, - std::string compactJson, - qint64 socketBufferedBytes, - const std::function& writer) - { - const std::size_t serializedBytes = compactJson.size(); - return session.acceptOutbound( - {ai::openai::codex::frontend::client::OutboundKind::Command, - std::move(compactJson), - serializedBytes, - false}, - socketBufferedBytes, - writer); - } - - static ai::openai::codex::frontend::client::SendResult - sendToTransport(FrontendSessionWorker& session, - std::string compactJson, - bool transportConnected, - qint64 socketBufferedBytes, - const std::function& writer) - { - const std::size_t serializedBytes = compactJson.size(); - ai::openai::codex::frontend::client::OutboundMessage message{ - ai::openai::codex::frontend::client::OutboundKind::Command, - std::move(compactJson), - serializedBytes, - false}; - return session.sendToTransport( - std::move(message), transportConnected, socketBufferedBytes, writer); - } - - static bool drainOutbound(FrontendSessionWorker& session, - const std::function& writer) - { - const FrontendSessionWorker::DrainResult result = session.drainOutbound(writer); - return result != FrontendSessionWorker::DrainResult::Failed - && result != FrontendSessionWorker::DrainResult::Reset; - } - - static std::string pendingWire(const FrontendSessionWorker& session) - { - std::string wire; - for (const FrontendSessionWorker::PendingWrite& pending : session.pendingWrites) { - const qint64 frameSize = static_cast(pending.frame.size()); - if (pending.offset < 0 || pending.offset > frameSize) - return ""; - const std::size_t offset = static_cast(pending.offset); - wire.append(pending.frame.data() + offset, pending.frame.size() - offset); - } - return wire; - } - - static qint64 pendingWriteBytes(const FrontendSessionWorker& session) - { - return session.pendingWriteBytes; - } - - static qint64 maximumBufferedOutboundBytes() - { - return FrontendSessionWorker::maximumBufferedOutboundBytes; - } - - static void clearOutbound(FrontendSessionWorker& session) - { - session.clearOutbound(); - } - - static bool outboundDrainIsScheduled(const FrontendSessionWorker& session) - { - return session.outboundDrainTimer.isActive(); - } - - static ai::openai::codex::frontend::client::SendResult - send(FrontendSessionWorker& session, ai::openai::codex::frontend::client::OutboundMessage& message) - { - return session.send(std::move(message)); - } - - static bool outboundClearIsDeferred(const FrontendSessionWorker& session) - { - return session.outboundClearPending && session.pendingWriteBytes > 0; - } - - static void disconnectTransport(FrontendSessionWorker& session) - { - session.preReadyFailureRecordedCurrentConnection = false; - session.socketDisconnected(); - session.reconnectTimer.stop(); - } - - static void failTransport(FrontendSessionWorker& session, bool newConnectionAttempt = true) - { - if (newConnectionAttempt) - session.preReadyFailureRecordedCurrentConnection = false; - session.socketFailed(QLocalSocket::ConnectionRefusedError); - session.reconnectTimer.stop(); - } - - static bool synchronizeWithCapturedTransport( - FrontendSessionWorker& session, - std::vector& messages, - ai::openai::codex::frontend::Json threads = - ai::openai::codex::frontend::Json::array(), - std::size_t omittedThreads = 0) - { - namespace frontend = ai::openai::codex::frontend; - namespace sdk = frontend::client; - - session.connection = session.client->openConnection({ - [&messages](FrontendSessionWorker::OutboundMessage message) { - messages.push_back(std::move(message)); - return FrontendSessionWorker::SendResult{sdk::SendStatus::Accepted, std::nullopt}; - }, - [](std::string) {}, - }); - session.connection.transportConnected(); - - // ThreadReadStateEffects is a required observed mechanism. It must not - // be mixed into Hello's representation-capability request list. - if (messages.empty()) - return false; - const auto decodedHello = frontend::Codec::decodeClient( - std::string_view(messages.front().compactJson)); - const auto* hello = decodedHello - ? std::get_if(&decodedHello.value()) - : nullptr; - if (!hello || !hello->capabilities - || std::ranges::find(*hello->capabilities, - frontend::FrontendCapability::ThreadReadStateEffects) - != hello->capabilities->end()) - return false; - - const frontend::Json state{ - {"backendRevision", std::uint64_t{1}}, - {"lifecycle", "ready"}, - {"diagnostics", - {{"received", std::uint64_t{0}}, {"recent", frontend::Json::array()}}}, - {"sessions", frontend::Json::array()}, - {"threadList", - {{"hasLoadedPage", true}, - {"complete", true}, - {"pagesLoaded", std::uint64_t{1}}}}, - {"threads", std::move(threads)}, - {"pendingRequests", frontend::Json::array()}, - {"codexExtensions", frontend::Json::array()}, - {"omittedCodexExtensions", std::uint64_t{0}}, - {"capacityProvenance", - {{"omittedThreads", omittedThreads}, - {"truncated", omittedThreads > 0}}}, - {"journal", - {{"oldestReplayableAfter", std::uint64_t{0}}, - {"currentSequence", std::uint64_t{0}}}}, - {"sequenceExhausted", false}, - }; - const frontend::FrontendCapability threadReadStateEffects = - frontend::FrontendCapability::ThreadReadStateEffects; - return session.connection - .receive(frontend::ServerMessage{frontend::Welcome{ - "archived-refresh-test", - frontend::SessionRole::Observer, - frontend::SequenceNumber{0}, - frontend::SyncMode::Snapshot, - frontend::Json::object(), - frontend::CapabilityAdvertisement{ - {threadReadStateEffects}, - {threadReadStateEffects}, - {threadReadStateEffects}, - frontend::Json::object()}}}) - .accepted - && session.connection - .receive(frontend::ServerMessage{ - frontend::Snapshot{frontend::SequenceNumber{0}, state}}) - .accepted - && session.connection - .receive(frontend::ServerMessage{ - frontend::SyncComplete{frontend::SequenceNumber{0}}}) - .accepted; - } - - static bool rejectsMissingThreadReadStateEffects( - FrontendSessionWorker& session, - std::vector& messages) - { - namespace frontend = ai::openai::codex::frontend; - namespace sdk = frontend::client; - - session.connection = session.client->openConnection({ - [&messages](FrontendSessionWorker::OutboundMessage message) { - messages.push_back(std::move(message)); - return FrontendSessionWorker::SendResult{ - sdk::SendStatus::Accepted, std::nullopt}; - }, - [](std::string) {}, - }); - session.connection.transportConnected(); - const auto result = session.connection.receive( - frontend::ServerMessage{frontend::Welcome{ - "missing-thread-read-effects", - frontend::SessionRole::Observer, - frontend::SequenceNumber{0}, - frontend::SyncMode::Snapshot, - frontend::Json::object(), - frontend::CapabilityAdvertisement{ - {}, {}, {}, frontend::Json::object()}}}); - return !result.accepted - && session.currentLifecycle == FrontendSessionWorker::Lifecycle::Failed - && !session.automaticReconnectEnabled; - } - - static bool receive(FrontendSessionWorker& session, - ai::openai::codex::frontend::ServerMessage message) - { - return session.connection.receive(std::move(message)).accepted; - } - - static void publishStateUpdate( - FrontendSessionWorker& session, - const ai::openai::codex::frontend::client::StateUpdate& update) - { - session.handleStateUpdate(update); - } - - static void receiveWire(FrontendSessionWorker& session, QByteArray wire) - { - session.inboundBuffer = std::move(wire); - session.inboundOffset = 0; - session.socketReadyRead(); - } - - static void beginArchivedThreadRefresh(FrontendSessionWorker& session) - { - session.beginArchivedThreadRefresh(); - } - - static bool archivedThreadListInFlight(const FrontendSessionWorker& session) - { - return session.archivedThreadListInFlight; - } - - static std::size_t archivedThreadCursorCount(const FrontendSessionWorker& session) - { - return session.archivedThreadListCursors.size(); - } -}; - -struct FrontendSessionFacadeTestAccess -{ - static void enqueueState(FrontendSession& session, - std::uint64_t generation, - detail::StateUpdateScope scope) - { - session.enqueueStateForTest(generation, std::move(scope)); - } - - static void enqueueStatus(FrontendSession& session, - std::uint64_t generation, - QString status) - { - session.enqueueStatusForTest(generation, std::move(status)); - } - - static void enqueueStatus(FrontendSession& session, - std::uint64_t generation, - FrontendSession::Lifecycle lifecycle, - QString status) - { - session.enqueueStatusForTest( - generation, lifecycle, std::move(status)); - } - - static void enqueueLifecycle(FrontendSession& session, - std::uint64_t generation, - FrontendSession::Lifecycle lifecycle, - QString status) - { - session.enqueueLifecycleForTest( - generation, lifecycle, std::move(status)); - } - - static void enqueueModels( - FrontendSession& session, - std::uint64_t generation, - std::vector models) - { - session.enqueueModelsForTest(generation, std::move(models)); - } - - static std::size_t pendingStateCount(const FrontendSession& session) - { - return session.pendingStateCountForTest(); - } - - static std::size_t pendingControlCount(const FrontendSession& session) - { - return session.pendingControlCountForTest(); - } - - static std::size_t postedWakeCount(const FrontendSession& session) - { - return session.postedWakeCountForTest(); - } - - static bool workerAffinityValidated(const FrontendSession& session) - { - return session.workerAffinityValidatedForTest(); - } - - static void trackOperation(FrontendSession& session, - FrontendSession::OperationCompletion completion) - { - session.trackOperationForTest(std::move(completion)); - } - - static void completeOperation( - FrontendSession& session, - std::uint64_t generation, - FrontendSession::OperationCompletion completion, - QString error) - { - session.completeOperationForTest( - generation, std::move(completion), std::move(error)); - } -}; - -} // namespace codexui - -namespace { - -namespace frontend = ai::openai::codex::frontend; -namespace sdk = ai::openai::codex::frontend::client; -namespace typed = ai::openai::codex::typed; - -bool expect(bool condition, const char* message) -{ - if (!condition) - std::cerr << message << '\n'; - return condition; -} - -frontend::Json threadReadStateEffect(std::string_view authority, - bool sourcePartial = false, - std::uint64_t omittedTurns = 0, - std::uint64_t omittedItems = 0) -{ - const bool responseTruncated = omittedTurns != 0 || omittedItems != 0; - return frontend::Json{ - {"scope", "thread"}, - {"authority", authority}, - {"truncation", - {{"sourcePartial", sourcePartial}, - {"responseTruncated", responseTruncated}, - {"responseOmittedTurns", omittedTurns}, - {"responseOmittedItems", omittedItems}}}, - }; -} - -frontend::Json threadReadBody(std::string_view threadId, bool fullyLoaded) -{ - return frontend::Json{ - {"id", threadId}, - {"fullyLoaded", fullyLoaded}, - {"turns", frontend::Json::array()}, - {"extensions", frontend::Json::object()}, - }; -} - -frontend::Json negotiatedThreadReadResult(std::string_view threadId, - std::string_view authority, - bool sourcePartial = false) -{ - if (authority == "absent") { - return frontend::Json{ - {"threadId", threadId}, - {"stateEffect", threadReadStateEffect(authority)}, - }; - } - const bool fullyLoaded = authority == "replace"; - return frontend::Json{ - {"thread", threadReadBody(threadId, fullyLoaded)}, - {"stateEffect", - threadReadStateEffect(authority, sourcePartial)}, - }; -} - -bool negotiatedThreadReadRequested(const frontend::Json& command) -{ - return command.value("threadReadStateEffectVersion", 0) == 1; -} - -bool testPeerCredentials() -{ - int sockets[2]{-1, -1}; - if (::socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) != 0) { - std::cerr << "could not create the Unix peer-credential fixture\n"; - return false; - } - - const uid_t currentUser = ::geteuid(); - const uid_t differentUser = currentUser == std::numeric_limits::max() ? currentUser - 1 : currentUser + 1; - bool passed = true; - passed &= expect(!codexui::detail::unixPeerCredentialError(sockets[0], currentUser), - "the connected process UID must be accepted"); - passed &= expect(codexui::detail::unixPeerCredentialError(sockets[0], differentUser).has_value(), - "a different process UID must be rejected"); - passed &= expect(codexui::detail::unixPeerCredentialError(-1, currentUser).has_value(), - "an invalid socket descriptor must fail closed"); - - ::close(sockets[0]); - ::close(sockets[1]); - return passed; -} - -bool testScopedItemPresentationChanges() -{ - sdk::StateUpdate turnUpdate; - turnUpdate.changes.push_back(sdk::TurnUpsertedChange{ - ai::openai::codex::typed::TurnId{"ambiguous-or-missing-turn"}}); - const auto unresolvedTurn = codexui::detail::stateUpdateScope(turnUpdate); - - sdk::StateUpdate scopedUpdate; - scopedUpdate.changes.push_back(sdk::ItemUpsertedChange{ - ai::openai::codex::typed::ItemId{"duplicate-item"}, - ai::openai::codex::typed::ThreadId{"target-thread"}, - ai::openai::codex::typed::TurnId{"target-turn"}}); - const auto scoped = codexui::detail::stateUpdateScope(scopedUpdate); - - codexui::FrontendSessionWorker resolvedParentSession; - std::vector resolvedParentOutbound; - frontend::Json resolvedParentThreads = frontend::Json::array({ - frontend::Json{ - {"id", "resolved-parent-thread"}, - {"fullyLoaded", true}, - {"turns", - frontend::Json::array({frontend::Json{ - {"id", "resolved-parent-turn"}, - {"threadId", "resolved-parent-thread"}, - {"status", "completed"}, - {"active", false}, - {"terminal", true}, - {"items", frontend::Json::array()}, - {"extensions", frontend::Json::object()}, - }})}, - {"extensions", frontend::Json::object()}, - }, - }); - const bool resolvedParentReady = - codexui::FrontendSessionWorkerTestAccess:: - synchronizeWithCapturedTransport( - resolvedParentSession, - resolvedParentOutbound, - std::move(resolvedParentThreads)); - sdk::StateUpdate resolvedParentItemUpdate; - resolvedParentItemUpdate.state = resolvedParentSession.state(); - resolvedParentItemUpdate.changes.push_back(sdk::ItemUpsertedChange{ - ai::openai::codex::typed::ItemId{"resolved-parent-item"}, - std::nullopt, - ai::openai::codex::typed::TurnId{"resolved-parent-turn"}}); - const auto resolvedParentItem = - codexui::detail::stateUpdateScope(resolvedParentItemUpdate); - sdk::StateUpdate resolvedTurnUpdate; - resolvedTurnUpdate.state = resolvedParentSession.state(); - resolvedTurnUpdate.changes.push_back(sdk::TurnUpsertedChange{ - ai::openai::codex::typed::TurnId{"resolved-parent-turn"}}); - const auto resolvedTurn = - codexui::detail::stateUpdateScope(resolvedTurnUpdate); - - sdk::StateUpdate streamedUpdate; - streamedUpdate.changes.push_back( - sdk::ItemContentReplacedChange{ai::openai::codex::typed::ItemId{"streamed-item"}, - sdk::ItemContentChannel::AgentText, - ai::openai::codex::typed::ThreadId{"target-thread"}, - ai::openai::codex::typed::TurnId{"target-turn"}}); - streamedUpdate.changes.push_back( - sdk::ItemContentReplacedChange{ai::openai::codex::typed::ItemId{"streamed-item"}, - sdk::ItemContentChannel::AgentText, - ai::openai::codex::typed::ThreadId{"target-thread"}, - ai::openai::codex::typed::TurnId{"target-turn"}}); - streamedUpdate.changes.push_back( - sdk::CursorAdvancedChange{ai::openai::codex::frontend::SequenceNumber{42}}); - const auto streamed = codexui::detail::stateUpdateScope(streamedUpdate); - - sdk::StateUpdate partiallyScopedUpdate; - partiallyScopedUpdate.changes.push_back( - sdk::ItemContentReplacedChange{ai::openai::codex::typed::ItemId{"partial-item"}, - sdk::ItemContentChannel::AgentText, - ai::openai::codex::typed::ThreadId{"target-thread"}, - std::nullopt}); - const auto partiallyScoped = codexui::detail::stateUpdateScope(partiallyScopedUpdate); - - sdk::StateUpdate appendedUpdate; - appendedUpdate.changes.push_back( - sdk::ItemContentAppendedChange{ - ai::openai::codex::typed::ItemId{"streamed-item"}, - sdk::ItemContentChannel::ReasoningText, - ai::openai::codex::typed::ThreadId{"target-thread"}, - ai::openai::codex::typed::TurnId{"target-turn"}, - 17, - 3, - std::string{"exact \xF0\x9F\x98\x80 bytes"}, - }); - const auto appended = codexui::detail::stateUpdateScope(appendedUpdate); - - sdk::StateUpdate oversizedAppendUpdate; - oversizedAppendUpdate.changes.push_back( - sdk::ItemContentAppendedChange{ - ai::openai::codex::typed::ItemId{"oversized-item"}, - sdk::ItemContentChannel::CommandOutput, - ai::openai::codex::typed::ThreadId{"target-thread"}, - ai::openai::codex::typed::TurnId{"target-turn"}, - 0, - 0, - std::string( - static_cast( - codexui::detail::maximumCoalescedContentDeltaBytes + 1), - 'x'), - }); - const auto oversizedAppend = - codexui::detail::stateUpdateScope(oversizedAppendUpdate); - - sdk::StateUpdate mixedUpdate = streamedUpdate; - mixedUpdate.changes.push_back(sdk::ItemUpsertedChange{ - ai::openai::codex::typed::ItemId{"structural-item"}, - ai::openai::codex::typed::ThreadId{"target-thread"}, - ai::openai::codex::typed::TurnId{"target-turn"}}); - const auto mixed = codexui::detail::stateUpdateScope(mixedUpdate); - - sdk::StateUpdate unscopedUpdate; - unscopedUpdate.changes.push_back( - sdk::ItemContentReplacedChange{ai::openai::codex::typed::ItemId{"duplicate-item"}, - sdk::ItemContentChannel::AgentText, - std::nullopt, - std::nullopt}); - const auto unscoped = codexui::detail::stateUpdateScope(unscopedUpdate); - - sdk::StateUpdate replacementUpdate; - replacementUpdate.changes.push_back(sdk::StateReplacedChange{}); - const auto replacement = codexui::detail::stateUpdateScope(replacementUpdate); - - sdk::StateUpdate threadUpdate; - threadUpdate.changes.push_back( - sdk::ThreadUpsertedChange{ai::openai::codex::typed::ThreadId{"target-thread"}}); - const auto threadScoped = codexui::detail::stateUpdateScope(threadUpdate); - - sdk::StateUpdate structuralThenFullUpdate = scopedUpdate; - structuralThenFullUpdate.changes.push_back( - sdk::ThreadUpsertedChange{ - ai::openai::codex::typed::ThreadId{"target-thread"}}); - const auto structuralThenFull = - codexui::detail::stateUpdateScope(structuralThenFullUpdate); - - sdk::StateUpdate fullThenStructuralUpdate = threadUpdate; - fullThenStructuralUpdate.changes.push_back(sdk::ItemUpsertedChange{ - ai::openai::codex::typed::ItemId{"duplicate-item"}, - ai::openai::codex::typed::ThreadId{"target-thread"}, - ai::openai::codex::typed::TurnId{"target-turn"}}); - const auto fullThenStructural = - codexui::detail::stateUpdateScope(fullThenStructuralUpdate); - - sdk::StateUpdate removedThreadUpdate; - removedThreadUpdate.changes.push_back( - sdk::ThreadRemovedChange{ai::openai::codex::typed::ThreadId{"removed-thread"}}); - const auto removedThreadScoped = - codexui::detail::stateUpdateScope(removedThreadUpdate); - - sdk::StateUpdate cursorUpdate; - cursorUpdate.changes.push_back( - sdk::CursorAdvancedChange{ai::openai::codex::frontend::SequenceNumber{43}}); - const auto cursor = codexui::detail::stateUpdateScope(cursorUpdate); - - sdk::StateUpdate oversizedIdentityUpdate; - for (int index = 0; - index <= codexui::detail::maximumCoalescedPresentationIdentities; - ++index) { - oversizedIdentityUpdate.changes.push_back( - sdk::ThreadUpsertedChange{ai::openai::codex::typed::ThreadId{ - "thread-" + std::to_string(index)}}); - } - const auto boundedIdentities = - codexui::detail::stateUpdateScope(oversizedIdentityUpdate); - - bool passed = expect(unresolvedTurn.affectedThreadIds.empty() - && unresolvedTurn.fullyAffectedThreadIds.empty() - && unresolvedTurn.structurallyAffectedThreadIds.empty() - && unresolvedTurn.affectedInspectorThreadIds.empty() - && unresolvedTurn.allThreadsAffected - && unresolvedTurn.allInspectorsAffected - && unresolvedTurn.allSidebarThreadsAffected - && unresolvedTurn.sidebarAffected - && unresolvedTurn.hasPresentationChange, - "a turn upsert without a unique parent lookup must conservatively refresh all threads"); - passed &= expect(scoped.affectedThreadIds == QStringList{QStringLiteral("target-thread")} - && scoped.fullyAffectedThreadIds.empty() - && scoped.structurallyAffectedThreadIds - == QStringList{QStringLiteral("target-thread")} - && scoped.affectedInspectorThreadIds - == QStringList{QStringLiteral("target-thread")} - && !scoped.allThreadsAffected && !scoped.allInspectorsAffected - && !scoped.sidebarAffected && scoped.hasPresentationChange, - "a scoped item upsert must structurally reconcile its canonical conversation and refresh its Inspector"); - passed &= expect( - resolvedParentReady - && resolvedParentItem.affectedThreadIds - == QStringList{QStringLiteral("resolved-parent-thread")} - && resolvedParentItem.fullyAffectedThreadIds.empty() - && resolvedParentItem.structurallyAffectedThreadIds - == QStringList{QStringLiteral("resolved-parent-thread")} - && resolvedParentItem.affectedInspectorThreadIds - == QStringList{QStringLiteral("resolved-parent-thread")} - && !resolvedParentItem.allThreadsAffected - && !resolvedParentItem.allInspectorsAffected, - "an item upsert resolved through its retained turn must use the structural conversation scope"); - passed &= expect( - resolvedTurn.affectedThreadIds - == QStringList{QStringLiteral("resolved-parent-thread")} - && resolvedTurn.fullyAffectedThreadIds - == QStringList{QStringLiteral("resolved-parent-thread")} - && resolvedTurn.structurallyAffectedThreadIds.empty() - && resolvedTurn.affectedInspectorThreadIds - == QStringList{QStringLiteral("resolved-parent-thread")} - && resolvedTurn.affectedSidebarThreadIds - == QStringList{QStringLiteral("resolved-parent-thread")}, - "a resolved turn upsert must remain deletion-capable while legacy turn.updated can replace its items"); - passed &= expect(streamed.affectedThreadIds == QStringList{QStringLiteral("target-thread")} - && streamed.fullyAffectedThreadIds.empty() - && streamed.structurallyAffectedThreadIds.empty() - && streamed.affectedInspectorThreadIds.empty() - && streamed.affectedItemContents - == std::vector{ - {QStringLiteral("target-thread"), - QStringLiteral("target-turn"), - QStringLiteral("streamed-item")}} - && !streamed.allThreadsAffected && !streamed.allInspectorsAffected - && !streamed.sidebarAffected && streamed.hasPresentationChange, - "streamed item content must carry its exact composite identity"); - passed &= expect(partiallyScoped.affectedThreadIds - == QStringList{QStringLiteral("target-thread")} - && partiallyScoped.fullyAffectedThreadIds - == QStringList{QStringLiteral("target-thread")} - && partiallyScoped.structurallyAffectedThreadIds.empty() - && partiallyScoped.affectedItemContents.empty() - && !partiallyScoped.allThreadsAffected, - "partially scoped item content must require bounded full thread reconciliation"); - passed &= expect( - appended.affectedItemContents.size() == 1 - && appended.affectedItemContents.front().channel - == sdk::ItemContentChannel::ReasoningText - && appended.affectedItemContents.front().append - && appended.affectedItemContents.front().append->baseContentBytes == 17 - && appended.affectedItemContents.front().append->discardPrefixBytes == 3 - && appended.affectedItemContents.front().append->deltaUtf8 - == QByteArray("exact \xF0\x9F\x98\x80 bytes") - && appended.coalescedContentDeltaBytes - == static_cast( - QByteArray("exact \xF0\x9F\x98\x80 bytes").size()), - "an authoritative append change must retain its channel and exact UTF-8 byte contract"); - passed &= expect( - oversizedAppend.affectedItemContents.size() == 1 - && oversizedAppend.affectedItemContents.front().channel - == sdk::ItemContentChannel::CommandOutput - && !oversizedAppend.affectedItemContents.front().append - && oversizedAppend.coalescedContentDeltaBytes == 0, - "an oversized append hint must degrade to an authoritative replacement without entering the GUI mailbox"); - passed &= expect(mixed.fullyAffectedThreadIds - .empty() - && mixed.structurallyAffectedThreadIds - == QStringList{QStringLiteral("target-thread")} - && mixed.affectedItemContents.size() == 1 - && !mixed.allThreadsAffected, - "a structural item upsert mixed with exact content must preserve both presentation hints"); - passed &= expect(unscoped.affectedThreadIds.empty() && unscoped.allThreadsAffected - && unscoped.fullyAffectedThreadIds.empty() - && unscoped.structurallyAffectedThreadIds.empty() - && unscoped.affectedItemContents.empty() - && unscoped.affectedInspectorThreadIds.empty() - && unscoped.allInspectorsAffected && !unscoped.sidebarAffected - && unscoped.hasPresentationChange, - "an unscoped item change must conservatively refresh all thread-bound presentations"); - passed &= expect(replacement.allThreadsAffected - && replacement.structurallyAffectedThreadIds.empty() - && replacement.allInspectorsAffected - && replacement.allSidebarThreadsAffected - && replacement.sidebarAffected && replacement.hasPresentationChange, - "a State replacement must conservatively refresh every presentation"); - passed &= expect(threadScoped.affectedThreadIds - == QStringList{QStringLiteral("target-thread")} - && threadScoped.fullyAffectedThreadIds - == QStringList{QStringLiteral("target-thread")} - && threadScoped.structurallyAffectedThreadIds.empty() - && threadScoped.affectedInspectorThreadIds - == QStringList{QStringLiteral("target-thread")} - && threadScoped.affectedSidebarThreadIds - == QStringList{QStringLiteral("target-thread")} - && !threadScoped.allThreadsAffected - && !threadScoped.allInspectorsAffected - && !threadScoped.allSidebarThreadsAffected - && threadScoped.sidebarAffected, - "a thread upsert must target only its conversation, Inspector dependencies, and Sidebar row"); - passed &= expect( - structuralThenFull.fullyAffectedThreadIds - == QStringList{QStringLiteral("target-thread")} - && structuralThenFull.structurallyAffectedThreadIds.empty() - && fullThenStructural.fullyAffectedThreadIds - == QStringList{QStringLiteral("target-thread")} - && fullThenStructural.structurallyAffectedThreadIds.empty(), - "deletion-capable mapper scope must dominate structural scope in either change order"); - passed &= expect( - removedThreadScoped.affectedThreadIds - == QStringList{QStringLiteral("removed-thread")} - && removedThreadScoped.fullyAffectedThreadIds - == QStringList{QStringLiteral("removed-thread")} - && removedThreadScoped.structurallyAffectedThreadIds.empty() - && removedThreadScoped.removedThreadIds - == QStringList{QStringLiteral("removed-thread")} - && removedThreadScoped.affectedSidebarThreadIds - == QStringList{QStringLiteral("removed-thread")} - && removedThreadScoped.affectedInspectorThreadIds - == QStringList{QStringLiteral("removed-thread")}, - "an authoritative thread removal must preserve its exact identity through the GUI scope"); - passed &= expect(!cursor.allThreadsAffected && !cursor.allInspectorsAffected - && !cursor.allSidebarThreadsAffected - && !cursor.sidebarAffected && cursor.hasPresentationChange, - "a cursor-only update must dispatch its revision without dirtying broad presentation"); - passed &= expect(boundedIdentities.allThreadsAffected - && boundedIdentities.allInspectorsAffected - && boundedIdentities.allSidebarThreadsAffected - && boundedIdentities.affectedThreadIds.empty() - && boundedIdentities.fullyAffectedThreadIds.empty() - && boundedIdentities.structurallyAffectedThreadIds.empty() - && boundedIdentities.affectedInspectorThreadIds.empty() - && boundedIdentities.affectedSidebarThreadIds.empty(), - "an oversized identity batch must stop at the presentation bound and degrade to full refreshes"); - return passed; -} - -bool testLifecycleAndDiagnostics() -{ - int lifecycleChanges = 0; - int statusChanges = 0; - bool reconnectCloseObserved = false; - codexui::FrontendSessionWorker session; - QObject::connect(&session, &codexui::FrontendSessionWorker::lifecycleChanged, [&lifecycleChanges] { ++lifecycleChanges; }); - QObject::connect(&session, &codexui::FrontendSessionWorker::statusChanged, [&statusChanges] { ++statusChanges; }); - - codexui::FrontendSessionWorkerTestAccess::setLifecycle(session, codexui::FrontendSessionWorker::Lifecycle::Ready); - codexui::FrontendSessionWorkerTestAccess::setLifecycle(session, codexui::FrontendSessionWorker::Lifecycle::Ready); - bool passed = expect(lifecycleChanges == 1, "an identical lifecycle and detail must not emit a duplicate transition"); - - codexui::FrontendSessionWorkerTestAccess::reportDiagnostic(session, QStringLiteral("projection diagnostic")); - codexui::FrontendSessionWorkerTestAccess::reportDiagnostic(session, QStringLiteral("projection diagnostic")); - codexui::FrontendSessionWorkerTestAccess::setLifecycle(session, codexui::FrontendSessionWorker::Lifecycle::Ready); - passed &= expect(session.lifecycle() == codexui::FrontendSessionWorker::Lifecycle::Ready - && session.statusText() == QStringLiteral("projection diagnostic") - && lifecycleChanges == 1 && statusChanges == 1, - "an error diagnostic must update status once without changing a ready lifecycle"); - - sdk::Error retryableError; - retryableError.message = "temporary backend failure"; - retryableError.retryable = true; - const sdk::ConnectionStateChange retryableChange{ - sdk::ConnectionState::Ready, sdk::ConnectionState::Disconnected, retryableError}; - codexui::FrontendSessionWorkerTestAccess::handleConnectionStateChange(session, retryableChange); - const int retryableSignalCount = lifecycleChanges; - codexui::FrontendSessionWorkerTestAccess::handleConnectionStateChange(session, retryableChange); - passed &= expect(session.lifecycle() == codexui::FrontendSessionWorker::Lifecycle::Disconnected - && codexui::FrontendSessionWorkerTestAccess::automaticReconnectEnabled(session) - && lifecycleChanges == retryableSignalCount && retryableSignalCount == 2, - "a retryable connection error must remain visibly disconnected while automatic reconnect is active"); - - sdk::Error terminalError; - terminalError.message = "terminal protocol failure"; - terminalError.retryable = false; - codexui::FrontendSessionWorkerTestAccess::handleConnectionStateChange( - session, - {sdk::ConnectionState::Disconnected, sdk::ConnectionState::Closed, terminalError}); - passed &= expect(session.lifecycle() == codexui::FrontendSessionWorker::Lifecycle::Failed - && !codexui::FrontendSessionWorkerTestAccess::automaticReconnectEnabled(session) - && lifecycleChanges == 3, - "a nonretryable connection error must produce one failed transition and disable automatic reconnect"); - codexui::FrontendSessionWorkerTestAccess::handleConnectionStateChange( - session, - {sdk::ConnectionState::Closed, sdk::ConnectionState::Disconnected, std::nullopt}); - passed &= expect(session.lifecycle() == codexui::FrontendSessionWorker::Lifecycle::Failed - && lifecycleChanges == 3, - "a following physical close must preserve the terminal failure"); - - codexui::FrontendSessionWorkerTestAccess::prepareReconnectReset(session); - codexui::FrontendSessionWorkerTestAccess::installConnectionWithTerminalClose(session, reconnectCloseObserved); - session.reconnectToBackend(); - passed &= expect(reconnectCloseObserved - && codexui::FrontendSessionWorkerTestAccess::automaticReconnectEnabled(session), - "the public reconnect path must override a terminal old-transport close callback"); - return passed; -} - -bool testPreReadyReconnectBound() -{ - codexui::FrontendSessionWorker session; - const int maximum = codexui::FrontendSessionWorkerTestAccess::maximumConsecutivePreReadyDisconnects(); - bool passed = true; - for (int attempt = 1; attempt < maximum; ++attempt) { - codexui::FrontendSessionWorkerTestAccess::disconnectTransport(session); - passed &= expect(codexui::FrontendSessionWorkerTestAccess::automaticReconnectEnabled(session) - && codexui::FrontendSessionWorkerTestAccess::consecutivePreReadyDisconnects(session) == attempt, - "a bounded number of pre-synchronization disconnects remains retryable"); - } - - codexui::FrontendSessionWorkerTestAccess::disconnectTransport(session); - passed &= expect(session.lifecycle() == codexui::FrontendSessionWorker::Lifecycle::Failed - && !codexui::FrontendSessionWorkerTestAccess::automaticReconnectEnabled(session) - && codexui::FrontendSessionWorkerTestAccess::consecutivePreReadyDisconnects(session) == maximum - && session.statusText().contains(QStringLiteral("stable synchronized state")), - "repeated unstable connection attempts stop at a visible terminal boundary"); - - codexui::FrontendSessionWorkerTestAccess::resetReconnectPolicy(session); - std::vector messages; - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::synchronizeWithCapturedTransport(session, messages) - && session.lifecycle() == codexui::FrontendSessionWorker::Lifecycle::Ready - && codexui::FrontendSessionWorkerTestAccess::consecutivePreReadyDisconnects(session) == 0, - "the first SDK synchronization callback must enter Ready without inventing retry failures"); - codexui::FrontendSessionWorkerTestAccess::disconnectTransport(session); - passed &= expect(codexui::FrontendSessionWorkerTestAccess::automaticReconnectEnabled(session) - && codexui::FrontendSessionWorkerTestAccess::consecutivePreReadyDisconnects(session) == 1, - "a disconnect before the Ready dwell boundary must retain unstable-connection retry history"); - - codexui::FrontendSessionWorker unstableReadySession; - for (int attempt = 1; attempt < maximum; ++attempt) { - codexui::FrontendSessionWorkerTestAccess::markUnstableSynchronized( - unstableReadySession); - codexui::FrontendSessionWorkerTestAccess::disconnectTransport( - unstableReadySession); - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::automaticReconnectEnabled( - unstableReadySession) - && codexui::FrontendSessionWorkerTestAccess::consecutivePreReadyDisconnects( - unstableReadySession) == attempt, - "a short-lived synchronized connection must retain exponential retry history"); - } - codexui::FrontendSessionWorkerTestAccess::markUnstableSynchronized( - unstableReadySession); - codexui::FrontendSessionWorkerTestAccess::disconnectTransport( - unstableReadySession); - passed &= expect( - !codexui::FrontendSessionWorkerTestAccess::automaticReconnectEnabled( - unstableReadySession) - && unstableReadySession.lifecycle() - == codexui::FrontendSessionWorker::Lifecycle::Failed, - "repeated post-synchronization flapping must stop at the same bounded retry boundary"); - - codexui::FrontendSessionWorker failedConnectSession; - for (int attempt = 1; attempt < maximum; ++attempt) { - codexui::FrontendSessionWorkerTestAccess::failTransport(failedConnectSession); - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::automaticReconnectEnabled(failedConnectSession) - && codexui::FrontendSessionWorkerTestAccess::consecutivePreReadyDisconnects(failedConnectSession) == attempt, - "a bounded number of failed pre-synchronization connection attempts remains retryable"); - codexui::FrontendSessionWorkerTestAccess::failTransport(failedConnectSession, false); - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::consecutivePreReadyDisconnects(failedConnectSession) == attempt, - "multiple failure signals for one connection attempt consume the retry budget only once"); - } - codexui::FrontendSessionWorkerTestAccess::failTransport(failedConnectSession); - passed &= expect( - failedConnectSession.lifecycle() == codexui::FrontendSessionWorker::Lifecycle::Failed - && !codexui::FrontendSessionWorkerTestAccess::automaticReconnectEnabled(failedConnectSession) - && codexui::FrontendSessionWorkerTestAccess::consecutivePreReadyDisconnects(failedConnectSession) == maximum, - "repeated failed connection attempts stop at the same visible terminal boundary"); - return passed; -} - -bool testReceiveRejectionPreservesPreciseError() -{ - codexui::FrontendSessionWorker session; - std::vector messages; - bool passed = expect( - codexui::FrontendSessionWorkerTestAccess::synchronizeWithCapturedTransport(session, messages), - "the receive-rejection fixture must reach synchronized State"); - - const auto duplicateWelcome = frontend::Codec::serializeServer( - frontend::ServerMessage{frontend::Welcome{ - "duplicate-session", - frontend::SessionRole::Observer, - frontend::SequenceNumber{0}, - frontend::SyncMode::Snapshot}}); - passed &= expect(duplicateWelcome.hasValue(), - "the duplicate-Welcome rejection fixture must encode"); - if (!duplicateWelcome) - return false; - - codexui::FrontendSessionWorkerTestAccess::receiveWire( - session, QByteArray::fromStdString(duplicateWelcome.value() + '\n')); - passed &= expect(session.lifecycle() == codexui::FrontendSessionWorker::Lifecycle::Failed - && !codexui::FrontendSessionWorkerTestAccess::automaticReconnectEnabled(session) - && session.statusText() == QStringLiteral("unexpected or duplicate Welcome") - && !session.statusText().contains( - QStringLiteral("frontend server message was rejected"), - Qt::CaseInsensitive), - "socketReadyRead must preserve the precise SDK lifecycle error instead of the generic receive rejection"); - codexui::FrontendSessionWorkerTestAccess::failTransport(session, false); - passed &= expect(session.lifecycle() == codexui::FrontendSessionWorker::Lifecycle::Failed - && !codexui::FrontendSessionWorkerTestAccess::automaticReconnectEnabled(session) - && session.statusText() == QStringLiteral("unexpected or duplicate Welcome") - && codexui::FrontendSessionWorkerTestAccess::consecutivePreReadyDisconnects(session) == 0, - "the socket error following a terminal SDK rejection must preserve its precise reason and retry budget"); - codexui::FrontendSessionWorkerTestAccess::disconnectTransport(session); - passed &= expect(session.lifecycle() == codexui::FrontendSessionWorker::Lifecycle::Failed - && !codexui::FrontendSessionWorkerTestAccess::automaticReconnectEnabled(session) - && session.statusText() == QStringLiteral("unexpected or duplicate Welcome") - && codexui::FrontendSessionWorkerTestAccess::consecutivePreReadyDisconnects(session) == 0, - "the physical disconnect following a terminal SDK rejection must preserve its precise reason and retry budget"); - return passed; -} - -bool testInboundFrameCapacityTracksSdk() -{ - codexui::FrontendSessionWorker session; - const sdk::ClientOptions defaults; - return expect( - codexui::FrontendSessionWorkerTestAccess::maximumFrameBytes(session) == defaults.maximumInboundMessageBytes - && codexui::FrontendSessionWorkerTestAccess::maximumFrameBytes(session) > 16U * 1024U * 1024U, - "the Qt JSONL receiver must accept the SDK's complete provider-derived server-message range"); -} - -std::vector -capturedCommands(const std::vector& messages, - std::string_view method); - -bool testIncompleteThreadReadIsBounded() -{ - codexui::FrontendSessionWorker incompatibleSession; - std::vector incompatibleOutbound; - bool passed = expect( - codexui::FrontendSessionWorkerTestAccess::rejectsMissingThreadReadStateEffects( - incompatibleSession, incompatibleOutbound), - "a backend without required thread-read State effects must fail the handshake without reconnecting"); - - codexui::FrontendSessionWorker session; - std::vector outbound; - frontend::Json threads = frontend::Json::array({ - frontend::Json{{"id", "partial-thread"}, - {"fullyLoaded", false}, - {"turns", frontend::Json::array()}, - {"extensions", frontend::Json::object()}}, - frontend::Json{{"id", "retry-thread"}, - {"fullyLoaded", false}, - {"turns", frontend::Json::array()}, - {"extensions", frontend::Json::object()}}, - frontend::Json{{"id", "complete-thread"}, - {"fullyLoaded", true}, - {"turns", frontend::Json::array()}, - {"extensions", frontend::Json::object()}}, - }); - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::synchronizeWithCapturedTransport( - session, outbound, std::move(threads)), - "the incomplete-thread recovery fixture must reach synchronized State"); - outbound.clear(); - - session.loadThread(QStringLiteral("complete-thread")); - // Without explicit omission provenance, absence from a complete thread - // list remains authoritative and must not trigger a speculative read. - session.loadThread(QStringLiteral("missing-thread")); - session.loadThread(QStringLiteral("partial-thread")); - session.loadThread(QStringLiteral("partial-thread")); - std::vector reads = capturedCommands(outbound, "thread.read"); - passed &= expect( - reads.size() == 1 - && negotiatedThreadReadRequested(reads.front()) - && reads.front().value("params", frontend::Json::object()) - == frontend::Json{{"threadId", "partial-thread"}, - {"includeTurns", true}}, - "only an incomplete retained thread may request one negotiated authoritative full read"); - if (reads.size() != 1 || !reads.front().contains("requestId")) - return false; - - const auto publishThreadCompleteness = [&session](std::uint64_t sequence, - bool fullyLoaded) { - frontend::FrontendEvent update{ - frontend::SequenceNumber{sequence}, - "thread.updated", - frontend::Json{{"thread", - {{"id", "partial-thread"}, - {"fullyLoaded", fullyLoaded}}}}, - }; - return codexui::FrontendSessionWorkerTestAccess::receive( - session, - frontend::ServerMessage{frontend::EventBatch{ - update.sequence, update.sequence, {std::move(update)}}}); - }; - passed &= expect( - publishThreadCompleteness(1, true) - && publishThreadCompleteness(2, false), - "intermediate State updates around an outstanding thread read must be accepted"); - session.loadThread(QStringLiteral("partial-thread")); - reads = capturedCommands(outbound, "thread.read"); - passed &= expect( - reads.size() == 1, - "State reconciliation must not release in-flight thread-read ownership before its operation completes"); - - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::receive( - session, - frontend::ServerMessage{frontend::Response::success( - reads.front()["requestId"].get(), - negotiatedThreadReadResult( - "partial-thread", "merge", true))}), - "the partial-thread Merge result must be accepted"); - session.loadThread(QStringLiteral("partial-thread")); - reads = capturedCommands(outbound, "thread.read"); - passed &= expect( - reads.size() == 1, - "a successful acknowledgement must remain de-duplicated until authoritative State completes the thread"); - - sdk::StateUpdate regressingReplacement; - regressingReplacement.state = session.state(); - regressingReplacement.changes.push_back(sdk::StateReplacedChange{}); - codexui::FrontendSessionWorkerTestAccess::publishStateUpdate( - session, regressingReplacement); - session.loadThread(QStringLiteral("partial-thread")); - session.loadThread(QStringLiteral("partial-thread")); - reads = capturedCommands(outbound, "thread.read"); - passed &= expect( - reads.size() == 2, - "a later replacement revision that regresses retained history must earn exactly one new automatic read"); - if (reads.size() != 2 || !reads.back().contains("requestId")) - return false; - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::receive( - session, - frontend::ServerMessage{frontend::Response::success( - reads.back()["requestId"].get(), - negotiatedThreadReadResult( - "partial-thread", "merge", true))}), - "the replacement-epoch recovery acknowledgement must be accepted"); - session.loadThread(QStringLiteral("partial-thread")); - reads = capturedCommands(outbound, "thread.read"); - passed &= expect( - reads.size() == 2, - "an unchanged incomplete replacement epoch must remain bounded after its successful read"); - - session.loadThread(QStringLiteral("partial-thread"), true); - reads = capturedCommands(outbound, "thread.read"); - passed &= expect( - reads.size() == 3, - "an explicit user retry may re-read a still-incomplete thread without enabling an automatic loop"); - if (reads.size() != 3 || !reads.back().contains("requestId")) - return false; - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::receive( - session, - frontend::ServerMessage{frontend::Response::success( - reads.back()["requestId"].get(), - negotiatedThreadReadResult( - "partial-thread", "merge", true))}), - "the explicit incomplete-thread retry acknowledgement must be accepted"); - - session.loadThread(QStringLiteral("retry-thread")); - reads = capturedCommands(outbound, "thread.read"); - if (!expect(reads.size() == 4 && reads.back().contains("requestId"), - "a different incomplete thread must receive its own bounded read")) - return false; - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::receive( - session, - frontend::ServerMessage{frontend::Response::failure( - reads.back()["requestId"].get(), - frontend::CommandError{frontend::ErrorCode::CapacityExceeded, - "thread read fence was overtaken"})}), - "the capacity-limited recovery read must be accepted as an operation response"); - sdk::StateUpdate liveRetryThreadUpdate; - liveRetryThreadUpdate.state = session.state(); - liveRetryThreadUpdate.changes.push_back( - sdk::ThreadUpsertedChange{ - ai::openai::codex::typed::ThreadId{"retry-thread"}}); - codexui::FrontendSessionWorkerTestAccess::publishStateUpdate( - session, liveRetryThreadUpdate); - session.loadThread(QStringLiteral("retry-thread")); - reads = capturedCommands(outbound, "thread.read"); - passed &= expect( - reads.size() == 4, - "a failed automatic read must consume the current replacement epoch instead of polling after live updates"); - - session.loadThread(QStringLiteral("retry-thread"), true); - reads = capturedCommands(outbound, "thread.read"); - passed &= expect( - reads.size() == 5 && reads.back().contains("requestId"), - "an explicit retry may re-read a capacity-limited recovery without enabling automatic polling"); - - codexui::FrontendSessionWorker authoritySession; - std::vector authorityOutbound; - frontend::Json authorityThreads = frontend::Json::array({ - frontend::Json{{"id", "replace-thread"}, - {"fullyLoaded", false}, - {"turns", frontend::Json::array()}, - {"extensions", frontend::Json::object()}}, - }); - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::synchronizeWithCapturedTransport( - authoritySession, - authorityOutbound, - std::move(authorityThreads), - 1), - "the negotiated authority fixture must reach synchronized State"); - std::optional authorityScope; - QObject::connect( - &authoritySession, - &codexui::FrontendSessionWorker::stateChanged, - [&authorityScope](const auto& scope) { authorityScope = scope; }); - authorityOutbound.clear(); - authoritySession.loadThread(QStringLiteral("replace-thread")); - std::vector authorityReads = capturedCommands( - authorityOutbound, "thread.read"); - if (!expect(authorityReads.size() == 1 - && negotiatedThreadReadRequested(authorityReads.front()) - && authorityReads.front().contains("requestId"), - "the complete authority fixture must negotiate one Replace read")) - return false; - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::receive( - authoritySession, - frontend::ServerMessage{frontend::Response::success( - authorityReads.front()["requestId"].get(), - negotiatedThreadReadResult( - "replace-thread", "replace"))}), - "the authoritative Replace result must be accepted"); - const auto* replaced = authoritySession.state().thread("replace-thread"); - authoritySession.loadThread(QStringLiteral("replace-thread")); - passed &= expect( - replaced && replaced->fullyLoaded - && capturedCommands(authorityOutbound, "thread.read").size() == 1, - "Replace must complete the cached thread and suppress further recovery reads"); - - authorityScope.reset(); - authoritySession.loadThread(QStringLiteral("absent-thread")); - authorityReads = capturedCommands(authorityOutbound, "thread.read"); - if (!expect(authorityReads.size() == 2 - && authorityReads.back().contains("requestId"), - "an omitted identity must receive one negotiated absence check")) - return false; - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::receive( - authoritySession, - frontend::ServerMessage{frontend::Response::success( - authorityReads.back()["requestId"].get(), - negotiatedThreadReadResult( - "absent-thread", "absent"))}) - && authoritySession.state().thread("absent-thread") == nullptr - && authorityScope - && authorityScope->removedThreadIds - == QStringList{QStringLiteral("absent-thread")}, - "Absent must publish one exact removal tombstone before completion"); - - codexui::FrontendSessionWorker omittedSession; - std::vector omittedOutbound; - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::synchronizeWithCapturedTransport( - omittedSession, - omittedOutbound, - frontend::Json::array(), - 1), - "the omitted-thread recovery fixture must reach synchronized State"); - passed &= expect( - omittedSession.state().capacityProvenance() - && omittedSession.state().capacityProvenance()->omittedThreads == 1, - "the recovery fixture must expose its bounded snapshot omission provenance"); - omittedOutbound.clear(); - omittedSession.loadThread(QStringLiteral("omitted-thread")); - omittedSession.loadThread(QStringLiteral("omitted-thread")); - std::vector omittedReads = capturedCommands( - omittedOutbound, "thread.read"); - passed &= expect( - omittedReads.size() == 1 - && omittedReads.front().value("params", frontend::Json::object()) - == frontend::Json{{"threadId", "omitted-thread"}, - {"includeTurns", true}}, - "a selected ID absent from an explicitly bounded snapshot must receive one recovery read"); - if (omittedReads.size() != 1 || !omittedReads.front().contains("requestId")) - return false; - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::receive( - omittedSession, - frontend::ServerMessage{frontend::Response::success( - omittedReads.front()["requestId"].get(), - negotiatedThreadReadResult( - "omitted-thread", "merge", true))}), - "the omitted-thread Merge result must be accepted"); - omittedSession.loadThread(QStringLiteral("omitted-thread")); - omittedReads = capturedCommands(omittedOutbound, "thread.read"); - passed &= expect( - omittedReads.size() == 1, - "a successful missing-thread recovery must remain bounded until State resolves the omission"); - omittedSession.loadThread(QStringLiteral("omitted-thread"), true); - omittedReads = capturedCommands(omittedOutbound, "thread.read"); - passed &= expect( - omittedReads.size() == 2, - "an explicit user retry may verify a still-omitted identity without enabling automatic polling"); - - const auto projectedThreads = [] { - return frontend::Json::array({ - frontend::Json{{"id", "projected-thread"}, - {"fullyLoaded", false}, - {"turns", frontend::Json::array()}, - {"extensions", frontend::Json::object()}}, - }); - }; - codexui::FrontendSessionWorker reconnectSession; - std::vector firstConnectionOutbound; - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::synchronizeWithCapturedTransport( - reconnectSession, - firstConnectionOutbound, - projectedThreads()), - "the projected-selection fixture must synchronize its first connection"); - firstConnectionOutbound.clear(); - reconnectSession.loadThread(QStringLiteral("projected-thread"), true); - std::vector firstConnectionReads = capturedCommands( - firstConnectionOutbound, "thread.read"); - if (!expect(firstConnectionReads.size() == 1 - && firstConnectionReads.front().contains("requestId"), - "a projected incomplete selection must issue one read on its first Ready boundary")) - return false; - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::receive( - reconnectSession, - frontend::ServerMessage{frontend::Response::success( - firstConnectionReads.front()["requestId"].get(), - negotiatedThreadReadResult( - "projected-thread", "merge", true))}), - "the first projected-selection read acknowledgement must be accepted"); - - codexui::FrontendSessionWorkerTestAccess::disconnectTransport( - reconnectSession); - std::vector secondConnectionOutbound; - const bool disconnectedForRetry = - reconnectSession.lifecycle() - == codexui::FrontendSessionWorker::Lifecycle::Disconnected - && codexui::FrontendSessionWorkerTestAccess::automaticReconnectEnabled( - reconnectSession); - const bool synchronizedAgain = - codexui::FrontendSessionWorkerTestAccess::synchronizeWithCapturedTransport( - reconnectSession, - secondConnectionOutbound, - projectedThreads()); - passed &= expect( - disconnectedForRetry && synchronizedAgain - && reconnectSession.lifecycle() - == codexui::FrontendSessionWorker::Lifecycle::Ready, - "the projected-selection fixture must disconnect and synchronize a new Ready connection"); - secondConnectionOutbound.clear(); - // Workbench invokes the explicit retry once when the retained projected - // selection crosses the new Ready boundary. Ordinary presentation refreshes - // can immediately follow it and must not submit duplicates. - reconnectSession.loadThread(QStringLiteral("projected-thread"), true); - reconnectSession.loadThread(QStringLiteral("projected-thread")); - reconnectSession.loadThread(QStringLiteral("projected-thread")); - const std::vector secondConnectionReads = capturedCommands( - secondConnectionOutbound, "thread.read"); - passed &= expect( - secondConnectionReads.size() == 1, - "a projected selection retained across disconnect and a new Ready connection must issue exactly one recovery read"); - return passed; -} - -std::vector -capturedCommands(const std::vector& messages, - std::string_view method = {}) -{ - std::vector result; - for (const sdk::OutboundMessage& message : messages) { - if (message.kind != sdk::OutboundKind::Command) - continue; - frontend::Json wire = frontend::Json::parse(message.compactJson, nullptr, false); - if (!wire.is_discarded() - && (method.empty() || wire.value("method", std::string{}) == method)) - result.push_back(std::move(wire)); - } - return result; -} - -frontend::Json modelListEntry(std::string id, - std::string model, - std::string displayName, - bool hidden = false) -{ - return frontend::Json{ - {"defaultReasoningEffort", "medium"}, - {"description", "FrontendSessionWorker model catalogue fixture"}, - {"displayName", std::move(displayName)}, - {"hidden", hidden}, - {"id", std::move(id)}, - {"isDefault", false}, - {"model", std::move(model)}, - {"supportedReasoningEfforts", - frontend::Json::array( - {{{"description", "Balanced"}, {"reasoningEffort", "medium"}}})}, - }; -} - -bool testModelCatalogRefresh() -{ - codexui::FrontendSessionWorker session; - std::vector outbound; - int catalogueSignals = 0; - QObject::connect(&session, &codexui::FrontendSessionWorker::modelCatalogChanged, - [&catalogueSignals] { ++catalogueSignals; }); - - bool passed = expect( - codexui::FrontendSessionWorkerTestAccess::synchronizeWithCapturedTransport(session, outbound), - "the model-catalogue fixture must reach a synchronized SDK connection"); - std::vector commands = capturedCommands(outbound, "model.list"); - passed &= expect(commands.size() == 1 - && commands.front().value("params", frontend::Json::object()) - == frontend::Json{{"includeHidden", false}, {"limit", 100}}, - "synchronization must request the first bounded visible-model page"); - if (commands.size() != 1 || !commands.front().contains("requestId")) - return false; - - const std::string firstRequestId = commands.front()["requestId"].get(); - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::receive( - session, - frontend::ServerMessage{frontend::Response::success( - firstRequestId, - frontend::Json{{"data", - frontend::Json::array({modelListEntry( - "preset-alpha", "model-alpha", "Alpha")})}, - {"nextCursor", "model-page-2"}})}), - "the first model catalogue page must be accepted"); - commands = capturedCommands(outbound, "model.list"); - passed &= expect(commands.size() == 2 && session.modelCatalog().empty() - && catalogueSignals == 0 - && commands.back().value("params", frontend::Json::object()) - == frontend::Json{{"cursor", "model-page-2"}, - {"includeHidden", false}, - {"limit", 100}}, - "a continuation page must not publish a partial catalogue"); - if (commands.size() != 2 || !commands.back().contains("requestId")) - return false; - - const std::string secondRequestId = commands.back()["requestId"].get(); - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::receive( - session, - frontend::ServerMessage{frontend::Response::success( - secondRequestId, - frontend::Json{{"data", - frontend::Json::array( - {modelListEntry("preset-hidden", "model-hidden", "Hidden", true), - modelListEntry("preset-alpha-duplicate", "model-alpha", "Duplicate"), - modelListEntry("preset-beta", "model-beta", "Beta")})}})}), - "the terminal model catalogue page must be accepted"); - const auto& catalogue = session.modelCatalog(); - passed &= expect(catalogue.size() == 2 && catalogue[0].model.value == "model-alpha" - && catalogue[0].displayName == "Alpha" - && catalogue[1].model.value == "model-beta" - && catalogue[1].displayName == "Beta" && catalogueSignals == 0, - "terminal publication must retain first-seen visible slugs in exact page order"); - QCoreApplication::processEvents(); - passed &= expect(catalogueSignals == 1, - "terminal model catalogue publication must emit exactly one queued change signal"); - return passed; -} - -bool testModelCatalogRefreshFailureIsDiagnosed() -{ - codexui::FrontendSessionWorker session; - std::vector outbound; - int catalogueSignals = 0; - QObject::connect(&session, &codexui::FrontendSessionWorker::modelCatalogChanged, - [&catalogueSignals] { ++catalogueSignals; }); - - bool passed = expect( - codexui::FrontendSessionWorkerTestAccess::synchronizeWithCapturedTransport(session, outbound), - "the model-catalogue failure fixture must reach a synchronized SDK connection"); - const std::vector commands = capturedCommands(outbound, "model.list"); - if (!expect(commands.size() == 1 && commands.front().contains("requestId"), - "the failure fixture must capture one model-list request")) - return false; - - const std::string requestId = commands.front()["requestId"].get(); - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::receive( - session, - frontend::ServerMessage{frontend::Response::failure( - requestId, - frontend::CommandError{frontend::ErrorCode::InternalError, - "model listing failed"})}), - "the model-catalogue failure response must be accepted"); - QCoreApplication::processEvents(); - passed &= expect(session.modelCatalog().empty() && catalogueSignals == 0, - "a failed model listing must not publish a partial or synthetic catalogue"); - passed &= expect(session.statusText().contains(QStringLiteral("model listing failed")), - "a failed model listing must surface its diagnostic"); - return passed; -} - -bool testArchivedThreadRefresh() -{ - codexui::FrontendSessionWorker session; - std::vector outbound; - int discoverySignals = 0; - std::optional discoveryScope; - QObject::connect( - &session, - &codexui::FrontendSessionWorker::stateChanged, - [&session, &discoverySignals, &discoveryScope]( - const codexui::detail::StateUpdateScope& scope) { - if (!session.archivedThreadDiscoveryComplete()) - return; - ++discoverySignals; - discoveryScope = scope; - }); - - bool passed = expect( - codexui::FrontendSessionWorkerTestAccess::synchronizeWithCapturedTransport(session, outbound), - "the archived-thread fixture must reach a synchronized SDK connection"); - std::vector commands = capturedCommands(outbound, "thread.list"); - passed &= expect(session.lifecycle() == codexui::FrontendSessionWorker::Lifecycle::Ready - && !session.archivedThreadDiscoveryComplete() - && codexui::FrontendSessionWorkerTestAccess::archivedThreadListInFlight(session) - && commands.size() == 1, - "synchronization must start exactly one incomplete archived-thread discovery request"); - if (commands.size() != 1) - return false; - - const frontend::Json& first = commands.front(); - passed &= expect(first.value("method", std::string{}) == "thread.list" - && first.contains("requestId") && first["requestId"].is_string() - && first.value("params", frontend::Json::object()) - == frontend::Json{{"archived", true}, {"limit", 100}}, - "the first discovery page must request archived threads with the bounded page size and no cursor"); - if (!first.contains("requestId") || !first["requestId"].is_string()) - return false; - - const std::string firstRequestId = first["requestId"].get(); - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::receive( - session, - frontend::ServerMessage{frontend::Response::success( - firstRequestId, - frontend::Json{{"threads", frontend::Json::array()}, - {"nextCursor", "archived-page-2"}})}), - "the first archived-thread page response must be accepted"); - commands = capturedCommands(outbound, "thread.list"); - passed &= expect(!session.archivedThreadDiscoveryComplete() - && codexui::FrontendSessionWorkerTestAccess::archivedThreadListInFlight(session) - && discoverySignals == 0 && commands.size() == 2, - "a continuation cursor must keep discovery incomplete and submit exactly one next page"); - if (commands.size() != 2) - return false; - - const frontend::Json& second = commands.back(); - passed &= expect(second.value("method", std::string{}) == "thread.list" - && second.contains("requestId") && second["requestId"].is_string() - && second.value("params", frontend::Json::object()) - == frontend::Json{{"archived", true}, - {"cursor", "archived-page-2"}, - {"limit", 100}}, - "the second discovery page must preserve the archived filter and exact opaque cursor"); - if (!second.contains("requestId") || !second["requestId"].is_string()) - return false; - - const std::string secondRequestId = second["requestId"].get(); - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::receive( - session, - frontend::ServerMessage{frontend::Response::success( - secondRequestId, - frontend::Json{{"threads", frontend::Json::array()}})}), - "the terminal archived-thread page response must be accepted"); - passed &= expect(session.archivedThreadDiscoveryComplete() - && !codexui::FrontendSessionWorkerTestAccess::archivedThreadListInFlight(session) - && codexui::FrontendSessionWorkerTestAccess::archivedThreadCursorCount(session) == 2 - && discoverySignals == 1 && discoveryScope - && discoveryScope->allThreadsAffected - && discoveryScope->allInspectorsAffected - && discoveryScope->allSidebarThreadsAffected - && discoveryScope->sidebarAffected - && discoveryScope->hasPresentationChange, - "the terminal page must publish completion and one conservative presentation refresh"); - - const std::size_t outboundAtCompletion = outbound.size(); - codexui::FrontendSessionWorkerTestAccess::beginArchivedThreadRefresh(session); - passed &= expect(outbound.size() == outboundAtCompletion && discoverySignals == 1, - "completed archived-thread discovery must not restart or emit duplicate completion refreshes"); - return passed; -} - -bool testArchivedThreadRefreshFailureIsTerminal() -{ - codexui::FrontendSessionWorker session; - std::vector outbound; - - bool passed = expect( - codexui::FrontendSessionWorkerTestAccess::synchronizeWithCapturedTransport(session, outbound), - "the archived-thread failure fixture must reach a synchronized SDK connection"); - const std::vector commands = capturedCommands(outbound, "thread.list"); - if (!expect(commands.size() == 1 && commands.front().contains("requestId"), - "the failure fixture must capture one archived-thread request")) - return false; - - int stateSignals = 0; - QObject::connect(&session, &codexui::FrontendSessionWorker::stateChanged, - [&stateSignals](const codexui::detail::StateUpdateScope&) { - ++stateSignals; - }); - const std::string requestId = commands.front()["requestId"].get(); - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::receive( - session, - frontend::ServerMessage{frontend::Response::failure( - requestId, - frontend::CommandError{frontend::ErrorCode::InternalError, - "archived listing failed"})}), - "the archived-thread failure response must be accepted"); - passed &= expect(!session.archivedThreadDiscoveryComplete() - && session.archivedThreadDiscoveryTerminal() - && session.archivedThreadDiscoveryStatus() - == codexui::FrontendSessionWorker::ArchivedThreadDiscoveryStatus::Failed - && !codexui::FrontendSessionWorkerTestAccess::archivedThreadListInFlight(session), - "a failed archived-thread request must stop in-flight work as a terminal failure without claiming a complete result"); - passed &= expect(stateSignals == 1, - "a terminal archived-thread failure must unblock presentation reconciliation"); - passed &= expect(session.statusText().contains(QStringLiteral("archived listing failed")), - "a failed archived-thread request must still surface its diagnostic"); - return passed; -} - -bool testArchivedThreadPaginationTruncationIsTerminal() -{ - codexui::FrontendSessionWorker session; - std::vector outbound; - bool passed = expect( - codexui::FrontendSessionWorkerTestAccess::synchronizeWithCapturedTransport(session, outbound), - "the archived-thread truncation fixture must synchronize"); - std::vector commands = capturedCommands(outbound, "thread.list"); - if (!expect(commands.size() == 1 && commands.front().contains("requestId"), - "the truncation fixture must capture the first archived-thread request")) - return false; - - const std::string firstRequestId = commands.front()["requestId"].get(); - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::receive( - session, - frontend::ServerMessage{frontend::Response::success( - firstRequestId, - frontend::Json{{"threads", frontend::Json::array()}, - {"nextCursor", "repeated-cursor"}})}), - "the first truncated archived-thread page must be accepted"); - commands = capturedCommands(outbound, "thread.list"); - if (!expect(commands.size() == 2 && commands.back().contains("requestId"), - "the truncation fixture must request the repeated-cursor page once")) - return false; - - int stateSignals = 0; - QObject::connect(&session, &codexui::FrontendSessionWorker::stateChanged, - [&stateSignals](const codexui::detail::StateUpdateScope&) { - ++stateSignals; - }); - const std::string secondRequestId = commands.back()["requestId"].get(); - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::receive( - session, - frontend::ServerMessage{frontend::Response::success( - secondRequestId, - frontend::Json{{"threads", frontend::Json::array()}, - {"nextCursor", "repeated-cursor"}})}), - "the repeated archived-thread cursor response must be accepted"); - passed &= expect(!session.archivedThreadDiscoveryComplete() - && session.archivedThreadDiscoveryTerminal() - && session.archivedThreadDiscoveryStatus() - == codexui::FrontendSessionWorker::ArchivedThreadDiscoveryStatus::CompleteWithTruncation - && !codexui::FrontendSessionWorkerTestAccess::archivedThreadListInFlight(session) - && stateSignals == 1, - "a repeated pagination cursor must terminate discovery as a truncated result and unblock reconciliation"); - passed &= expect(session.statusText().contains(QStringLiteral("invalid pagination boundary")), - "truncated archived-thread discovery must explain its pagination boundary"); - return passed; -} - -bool testTurnSteeringSubmission() -{ - codexui::FrontendSessionWorker session; - std::vector outbound; - bool passed = expect( - codexui::FrontendSessionWorkerTestAccess::synchronizeWithCapturedTransport(session, outbound), - "the turn-steering fixture must reach a synchronized SDK connection"); - - QString completionError = QStringLiteral("completion not called"); - const auto immediateError = session.steerTurn( - QStringLiteral("thread-active"), - QStringLiteral("turn-active"), - QStringLiteral("focus on the narrow fix"), - [&completionError](const QString& error) { completionError = error; }); - const std::vector commands = capturedCommands(outbound, "turn.steer"); - passed &= expect(!immediateError && commands.size() == 1, - "a valid steering prompt must submit exactly one typed turn.steer command"); - if (commands.size() != 1 || !commands.front().contains("requestId")) - return false; - - const frontend::Json expectedParams{ - {"expectedTurnId", "turn-active"}, - {"input", - frontend::Json::array({{{"text", "focus on the narrow fix"}, - {"text_elements", frontend::Json::array()}, - {"type", "text"}}})}, - {"threadId", "thread-active"}, - }; - const frontend::Json actualParams = commands.front().value("params", frontend::Json::object()); - passed &= expect(actualParams == expectedParams, - "steering must preserve the canonical thread/turn identities and exact typed text input"); - - const std::string requestId = commands.front()["requestId"].get(); - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::receive( - session, - frontend::ServerMessage{frontend::Response::success( - requestId, frontend::Json{{"turnId", "turn-active"}})}), - "the matching turn.steer response must be accepted"); - passed &= expect(completionError.isEmpty(), - "a matching accepted turn identity must complete steering successfully"); - - QString imageCompletion = QStringLiteral("completion not called"); - const auto imageError = session.steerTurn( - QStringLiteral("thread-active"), - QStringLiteral("turn-active"), - {}, - QStringList{QStringLiteral("/tmp/screenshot.png")}, - [&imageCompletion](const QString& error) { imageCompletion = error; }); - const std::vector imageCommands = capturedCommands(outbound, "turn.steer"); - passed &= expect(!imageError && imageCommands.size() == 2, - "an image-only steer must submit one additional typed turn.steer command"); - if (imageCommands.size() != 2 || !imageCommands.back().contains("requestId")) - return false; - const frontend::Json imageParams = imageCommands.back().value( - "params", frontend::Json::object()); - passed &= expect( - imageParams.value("input", frontend::Json::array()) - == frontend::Json::array({{{"path", "/tmp/screenshot.png"}, - {"type", "localImage"}}}), - "local image attachments must stay typed instead of being embedded into prompt text"); - const std::string imageRequestId = imageCommands.back()["requestId"].get(); - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::receive( - session, - frontend::ServerMessage{frontend::Response::success( - imageRequestId, frontend::Json{{"turnId", "turn-active"}})}), - "the image-only turn.steer response must be accepted"); - passed &= expect(imageCompletion.isEmpty(), - "an accepted image-only steer must complete successfully"); - - const std::size_t outboundBeforeInvalid = outbound.size(); - const auto missingIdentityError = session.steerTurn( - QStringLiteral("thread-active"), {}, QStringLiteral("do not send"), [](const QString&) {}); - passed &= expect(missingIdentityError.has_value() - && outbound.size() == outboundBeforeInvalid, - "steering without the canonical active turn identity must fail before transport submission"); - return passed; -} - -bool testOutboundQueue() -{ - codexui::FrontendSessionWorker session; - bool passed = true; - - sdk::OutboundMessage closedMessage{ - sdk::OutboundKind::Command, - R"({"closed":true})", - 15, - true}; - auto result = codexui::FrontendSessionWorkerTestAccess::send(session, closedMessage); - passed &= expect(result.status == sdk::SendStatus::Closed - && closedMessage.compactJson.empty(), - "a closed transport must reject and scrub the moved outbound message"); - - const std::string firstFrame = R"({"first":1})"; - const std::string firstWire = firstFrame + '\n'; - std::string written; - int writeCalls = 0; - result = codexui::FrontendSessionWorkerTestAccess::sendToTransport( - session, - firstFrame, - true, - 0, - [&written, &writeCalls](const char* bytes, qint64 size) { - ++writeCalls; - const qint64 accepted = writeCalls == 1 ? std::min(3, size) : size; - written.append(bytes, static_cast(accepted)); - return accepted; - }); - passed &= expect(result.status == sdk::SendStatus::Accepted - && codexui::FrontendSessionWorkerTestAccess::outboundDrainIsScheduled(session) - && written + codexui::FrontendSessionWorkerTestAccess::pendingWire(session) == firstWire, - "a positive short write must accept ownership and retain the exact suffix"); - passed &= expect(codexui::FrontendSessionWorkerTestAccess::drainOutbound( - session, - [&written](const char* bytes, qint64 size) { - written.append(bytes, static_cast(size)); - return size; - }) - && written == firstWire - && !codexui::FrontendSessionWorkerTestAccess::outboundDrainIsScheduled(session) - && codexui::FrontendSessionWorkerTestAccess::pendingWriteBytes(session) == 0, - "draining a short write must produce the original frame exactly once"); - - const std::size_t maximumFrameSize = - ai::openai::codex::frontend::DefaultFrontendMaximumInboundMessageBytes; - const std::string maximumFrame(maximumFrameSize, 'x'); - qint64 maximumWireWritten = 0; - bool maximumWireValid = true; - result = codexui::FrontendSessionWorkerTestAccess::sendToTransport( - session, - maximumFrame, - true, - 0, - [&maximumWireWritten, &maximumWireValid](const char* bytes, qint64 size) { - const qint64 accepted = std::min(4093, size); - for (qint64 index = 0; index < accepted; ++index) - maximumWireValid = maximumWireValid && bytes[index] == 'x'; - maximumWireWritten += accepted; - return accepted; - }); - passed &= expect(result.status == sdk::SendStatus::Accepted - && maximumWireValid - && codexui::FrontendSessionWorkerTestAccess::pendingWriteBytes(session) - == static_cast(maximumFrameSize + 1U) - maximumWireWritten, - "a maximum-size SDK frame must retain its exact suffix after a partial socket write"); - passed &= expect(codexui::FrontendSessionWorkerTestAccess::drainOutbound( - session, - [&maximumWireWritten, &maximumWireValid, maximumFrameSize]( - const char* bytes, qint64 size) { - for (qint64 index = 0; index < size; ++index) { - const auto wireIndex = static_cast( - maximumWireWritten + index); - const char expected = wireIndex == maximumFrameSize ? '\n' : 'x'; - maximumWireValid = maximumWireValid && bytes[index] == expected; - } - maximumWireWritten += size; - return size; - }) - && maximumWireValid - && maximumWireWritten == static_cast(maximumFrameSize + 1U) - && codexui::FrontendSessionWorkerTestAccess::pendingWriteBytes(session) == 0, - "a partially written maximum-size SDK frame must drain once in exact wire order"); - - written.clear(); - writeCalls = 0; - const std::string secondFrame = R"({"second":2})"; - const std::string thirdFrame = R"({"third":3})"; - const std::string orderedWire = secondFrame + '\n' + thirdFrame + '\n'; - result = codexui::FrontendSessionWorkerTestAccess::acceptOutbound( - session, - secondFrame, - 0, - [&written, &writeCalls](const char* bytes, qint64 size) { - ++writeCalls; - const qint64 accepted = std::min(2, size); - written.append(bytes, static_cast(accepted)); - return accepted; - }); - bool secondWriterCalled = false; - const auto secondResult = codexui::FrontendSessionWorkerTestAccess::acceptOutbound( - session, - thirdFrame, - 0, - [&secondWriterCalled](const char*, qint64 size) { - secondWriterCalled = true; - return size; - }); - for (int drain = 0; - drain < 3 && codexui::FrontendSessionWorkerTestAccess::pendingWriteBytes(session) > 0; - ++drain) { - const qint64 beforeDrain = codexui::FrontendSessionWorkerTestAccess::pendingWriteBytes(session); - const bool drained = codexui::FrontendSessionWorkerTestAccess::drainOutbound( - session, - [&written](const char* bytes, qint64 size) { - written.append(bytes, static_cast(size)); - return size; - }); - passed &= drained - && codexui::FrontendSessionWorkerTestAccess::pendingWriteBytes(session) < beforeDrain; - } - passed &= expect(result.status == sdk::SendStatus::Accepted - && secondResult.status == sdk::SendStatus::Accepted - && !secondWriterCalled - && codexui::FrontendSessionWorkerTestAccess::pendingWriteBytes(session) == 0 - && written == orderedWire, - "queued frames must preserve exact FIFO order"); - - const std::string blockedFrame = R"({"blocked":true})"; - result = codexui::FrontendSessionWorkerTestAccess::sendToTransport( - session, - blockedFrame, - true, - 0, - [](const char*, qint64) { return qint64{0}; }); - const std::string blockedWire = blockedFrame + '\n'; - const qint64 blockedWireBytes = static_cast(blockedWire.size()); - passed &= expect(result.status == sdk::SendStatus::Accepted - && codexui::FrontendSessionWorkerTestAccess::pendingWire(session) == blockedWire - && codexui::FrontendSessionWorkerTestAccess::pendingWriteBytes(session) == blockedWireBytes - && codexui::FrontendSessionWorkerTestAccess::outboundDrainIsScheduled(session) - && codexui::FrontendSessionWorkerTestAccess::drainOutbound( - session, - [](const char*, qint64) { return qint64{0}; }) - && codexui::FrontendSessionWorkerTestAccess::pendingWire(session) == blockedWire - && codexui::FrontendSessionWorkerTestAccess::pendingWriteBytes(session) == blockedWireBytes, - "zero write progress must retain the complete frame for retry"); - codexui::FrontendSessionWorkerTestAccess::clearOutbound(session); - passed &= expect(!codexui::FrontendSessionWorkerTestAccess::outboundDrainIsScheduled(session), - "transport cleanup must cancel a pending drain retry"); - - bool capacityWriterCalled = false; - result = codexui::FrontendSessionWorkerTestAccess::acceptOutbound( - session, - R"({"capacity":true})", - codexui::FrontendSessionWorkerTestAccess::maximumBufferedOutboundBytes(), - [&capacityWriterCalled](const char*, qint64 size) { - capacityWriterCalled = true; - return size; - }); - passed &= expect(result.status == sdk::SendStatus::Backpressure - && result.error && result.error->retryable - && !capacityWriterCalled - && codexui::FrontendSessionWorkerTestAccess::pendingWriteBytes(session) == 0, - "capacity rejection must be retryable and occur before queue or writer mutation"); - - const std::string exactFrame = R"({"exact":true})"; - const qint64 exactFrameBytes = static_cast(exactFrame.size() + 1U); - std::string exactWire; - result = codexui::FrontendSessionWorkerTestAccess::acceptOutbound( - session, - exactFrame, - codexui::FrontendSessionWorkerTestAccess::maximumBufferedOutboundBytes() - exactFrameBytes, - [&exactWire](const char* bytes, qint64 size) { - exactWire.append(bytes, static_cast(size)); - return size; - }); - passed &= expect(result.status == sdk::SendStatus::Accepted - && exactWire == exactFrame + '\n', - "an outbound frame that exactly fits the combined cap must be accepted"); - - const std::string queuedFrame = R"({"queued":true})"; - result = codexui::FrontendSessionWorkerTestAccess::acceptOutbound( - session, - queuedFrame, - 0, - [](const char*, qint64) { return qint64{0}; }); - const std::string combinedFrame = R"({"combined":true})"; - const qint64 combinedFrameBytes = static_cast(combinedFrame.size() + 1U); - const qint64 expectedQueuedBytes = static_cast(queuedFrame.size() + 1U); - const std::string queuedWire = codexui::FrontendSessionWorkerTestAccess::pendingWire(session); - const qint64 oneByteOver = codexui::FrontendSessionWorkerTestAccess::maximumBufferedOutboundBytes() - - expectedQueuedBytes - combinedFrameBytes + 1; - const auto combinedRejected = codexui::FrontendSessionWorkerTestAccess::acceptOutbound( - session, - combinedFrame, - oneByteOver, - [](const char*, qint64 size) { return size; }); - passed &= expect(result.status == sdk::SendStatus::Accepted - && codexui::FrontendSessionWorkerTestAccess::pendingWriteBytes(session) == expectedQueuedBytes - && combinedRejected.status == sdk::SendStatus::Backpressure - && codexui::FrontendSessionWorkerTestAccess::pendingWire(session) == queuedWire, - "the cap must include both Qt-buffered and application-held suffix bytes"); - const auto combinedAccepted = codexui::FrontendSessionWorkerTestAccess::acceptOutbound( - session, - combinedFrame, - oneByteOver - 1, - [](const char*, qint64 size) { return size; }); - passed &= expect(combinedAccepted.status == sdk::SendStatus::Accepted - && codexui::FrontendSessionWorkerTestAccess::pendingWire(session) - == queuedWire + combinedFrame + '\n', - "combined buffering exactly at the cap must remain admissible"); - codexui::FrontendSessionWorkerTestAccess::clearOutbound(session); - - result = codexui::FrontendSessionWorkerTestAccess::acceptOutbound( - session, - R"({"old":true})", - 0, - [](const char*, qint64 size) { return std::min(1, size); }); - codexui::FrontendSessionWorkerTestAccess::disconnectTransport(session); - std::string newWire; - const std::string newFrame = R"({"new":true})"; - const auto newResult = codexui::FrontendSessionWorkerTestAccess::acceptOutbound( - session, - newFrame, - 0, - [&newWire](const char* bytes, qint64 size) { - newWire.append(bytes, static_cast(size)); - return size; - }); - passed &= expect(result.status == sdk::SendStatus::Accepted - && newResult.status == sdk::SendStatus::Accepted - && newWire == newFrame + '\n', - "transport cleanup must not carry an old suffix into a new connection"); - - bool reentrantClearWasDeferred = false; - result = codexui::FrontendSessionWorkerTestAccess::acceptOutbound( - session, - R"({"reentrant":true})", - 0, - [&session, &reentrantClearWasDeferred](const char*, qint64) { - codexui::FrontendSessionWorkerTestAccess::clearOutbound(session); - reentrantClearWasDeferred = codexui::FrontendSessionWorkerTestAccess::outboundClearIsDeferred(session); - return qint64{1}; - }); - passed &= expect(result.status == sdk::SendStatus::Closed - && reentrantClearWasDeferred - && codexui::FrontendSessionWorkerTestAccess::pendingWriteBytes(session) == 0, - "reentrant transport cleanup must invalidate the in-flight queue access"); - - result = codexui::FrontendSessionWorkerTestAccess::acceptOutbound( - session, - R"({"failure":true})", - 0, - [](const char*, qint64) { return qint64{-1}; }); - passed &= expect(result.status == sdk::SendStatus::Failed - && codexui::FrontendSessionWorkerTestAccess::pendingWriteBytes(session) == 0, - "a negative write must fail without retaining owned frame data"); - return passed; -} - -bool testInboundBufferCompaction() -{ - codexui::FrontendSessionWorker session; - const QByteArray prefix(300 * 1024, 'p'); - const QByteArray tail(300 * 1024, 't'); - const QByteArray backlog = prefix + tail; - - codexui::FrontendSessionWorkerTestAccess::setInbound(session, backlog, 64 * 1024); - codexui::FrontendSessionWorkerTestAccess::compactInbound(session); - bool passed = expect( - codexui::FrontendSessionWorkerTestAccess::inboundBytes(session) == backlog - && codexui::FrontendSessionWorkerTestAccess::inboundOffset(session) == 64 * 1024, - "small consumed prefixes must remain as an offset instead of moving a large replay tail"); - - codexui::FrontendSessionWorkerTestAccess::setInbound(session, backlog, prefix.size()); - codexui::FrontendSessionWorkerTestAccess::compactInbound(session); - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::inboundBytes(session) == tail - && codexui::FrontendSessionWorkerTestAccess::inboundOffset(session) == 0, - "a large consumed prefix must compact once it reaches both the threshold and half the buffer"); - - const QByteArray completeAndPartial("done\npartial"); - codexui::FrontendSessionWorkerTestAccess::setInbound(session, completeAndPartial, 0); - const bool completeAtStart = codexui::FrontendSessionWorkerTestAccess::hasCompleteInboundFrame(session); - codexui::FrontendSessionWorkerTestAccess::setInbound(session, completeAndPartial, 5); - passed &= expect(completeAtStart - && !codexui::FrontendSessionWorkerTestAccess::hasCompleteInboundFrame(session), - "frame detection must ignore newlines in the consumed prefix"); - - codexui::FrontendSessionWorkerTestAccess::setInbound(session, tail, tail.size()); - codexui::FrontendSessionWorkerTestAccess::compactInbound(session); - passed &= expect(codexui::FrontendSessionWorkerTestAccess::inboundBytes(session).isEmpty() - && codexui::FrontendSessionWorkerTestAccess::inboundOffset(session) == 0, - "a fully consumed inbound buffer must reset without retaining capacity state"); - - const QByteArray firstPartial(16 * 1024 * 1024, 'a'); - codexui::FrontendSessionWorkerTestAccess::setInbound(session, firstPartial, 0); - passed &= expect( - !codexui::FrontendSessionWorkerTestAccess::hasCompleteInboundFrame(session) - && codexui::FrontendSessionWorkerTestAccess::inboundScanOffset(session) - == firstPartial.size(), - "a large partial frame must remember the exact prefix already scanned for a terminator"); - const QByteArray secondPartial(1024 * 1024, 'b'); - codexui::FrontendSessionWorkerTestAccess::appendInbound(session, secondPartial); - passed &= expect( - !codexui::FrontendSessionWorkerTestAccess::hasCompleteInboundFrame(session) - && codexui::FrontendSessionWorkerTestAccess::inboundScanOffset(session) - == firstPartial.size() + secondPartial.size(), - "receiving another chunk must scan only the newly appended partial-frame suffix"); - codexui::FrontendSessionWorkerTestAccess::appendInbound(session, QByteArray("\n")); - passed &= expect( - codexui::FrontendSessionWorkerTestAccess::hasCompleteInboundFrame(session) - && codexui::FrontendSessionWorkerTestAccess::inboundScanOffset(session) - == firstPartial.size() + secondPartial.size(), - "the incremental scan cursor must still detect the terminator at the first new byte"); - return passed; -} - -bool testThreadedFacadeMailbox() -{ - codexui::FrontendSession session; - QElapsedTimer wait; - wait.start(); - while (!codexui::FrontendSessionFacadeTestAccess::workerAffinityValidated(session) - && wait.elapsed() < 2'000) { - QCoreApplication::processEvents(); - QThread::msleep(1); - } - - bool passed = expect( - codexui::FrontendSessionFacadeTestAccess::workerAffinityValidated(session), - "the frontend engine, Unix socket, and timers must originate on the one worker thread"); - - int stateSignals = 0; - int statusSignals = 0; - bool callbacksOnGuiThread = true; - QThread* const guiThread = QThread::currentThread(); - QStringList deliveryOrder; - std::optional deliveredScope; - QObject::connect( - &session, - &codexui::FrontendSession::stateChanged, - [&stateSignals, &deliveredScope, &callbacksOnGuiThread, - guiThread](const auto& scope) { - callbacksOnGuiThread = callbacksOnGuiThread - && QThread::currentThread() == guiThread; - ++stateSignals; - deliveredScope = scope; - }); - QObject::connect(&session, - &codexui::FrontendSession::statusChanged, - [&statusSignals, &deliveryOrder, &callbacksOnGuiThread, - guiThread] { - callbacksOnGuiThread = - callbacksOnGuiThread - && QThread::currentThread() == guiThread; - ++statusSignals; - deliveryOrder.push_back(QStringLiteral("status")); - }); - QObject::connect( - &session, - &codexui::FrontendSession::stateChanged, - [&deliveryOrder](const auto&) { - deliveryOrder.push_back(QStringLiteral("state")); - }); - - const std::size_t wakesBefore = - codexui::FrontendSessionFacadeTestAccess::postedWakeCount(session); - const auto appendScope = [](QString itemId, - sdk::ItemContentChannel channel, - std::uint64_t base, - QByteArray delta) { - codexui::detail::StateUpdateScope scope; - scope.affectedThreadIds.push_back( - QStringLiteral("streaming-thread")); - scope.affectedItemContents.push_back({ - QStringLiteral("streaming-thread"), - QStringLiteral("streaming-turn"), - std::move(itemId), - channel, - codexui::detail::StateUpdateScope::ItemContentAppend{ - base, - 0, - std::move(delta), - }, - }); - scope.coalescedContentDeltaBytes = static_cast( - scope.affectedItemContents.front().append->deltaUtf8.size()); - scope.hasPresentationChange = true; - return scope; - }; - codexui::FrontendSessionFacadeTestAccess::enqueueState( - session, - 7, - appendScope(QStringLiteral("contiguous"), - sdk::ItemContentChannel::AgentText, - 10, - QByteArray("ab"))); - codexui::FrontendSessionFacadeTestAccess::enqueueState( - session, - 7, - appendScope(QStringLiteral("contiguous"), - sdk::ItemContentChannel::AgentText, - 12, - QByteArray("cd"))); - codexui::FrontendSessionFacadeTestAccess::enqueueState( - session, - 7, - appendScope(QStringLiteral("contiguous"), - sdk::ItemContentChannel::ReasoningText, - 20, - QByteArray("reasoning"))); - codexui::FrontendSessionFacadeTestAccess::enqueueState( - session, - 7, - appendScope(QStringLiteral("ambiguous"), - sdk::ItemContentChannel::AgentText, - 0, - QByteArray("first"))); - codexui::FrontendSessionFacadeTestAccess::enqueueState( - session, - 7, - appendScope(QStringLiteral("ambiguous"), - sdk::ItemContentChannel::AgentText, - 99, - QByteArray("second"))); - codexui::FrontendSessionFacadeTestAccess::enqueueState( - session, - 7, - appendScope( - QStringLiteral("oversized"), - sdk::ItemContentChannel::AgentText, - 0, - QByteArray( - static_cast( - codexui::detail::maximumCoalescedContentDeltaBytes + 1), - 'x'))); - { - codexui::detail::StateUpdateScope scope; - scope.affectedSidebarThreadIds = { - QStringLiteral("sidebar-a"), QStringLiteral("sidebar-b")}; - scope.sidebarAffected = true; - scope.hasPresentationChange = true; - codexui::FrontendSessionFacadeTestAccess::enqueueState( - session, 7, std::move(scope)); - } - { - codexui::detail::StateUpdateScope scope; - scope.affectedThreadIds = {QStringLiteral("removed-thread")}; - scope.fullyAffectedThreadIds = {QStringLiteral("removed-thread")}; - scope.removedThreadIds = {QStringLiteral("removed-thread")}; - scope.hasPresentationChange = true; - codexui::FrontendSessionFacadeTestAccess::enqueueState( - session, 7, std::move(scope)); - } - { - codexui::detail::StateUpdateScope scope; - scope.affectedSidebarThreadIds = { - QStringLiteral("sidebar-b"), QStringLiteral("sidebar-c")}; - scope.sidebarAffected = true; - scope.hasPresentationChange = true; - codexui::FrontendSessionFacadeTestAccess::enqueueState( - session, 7, std::move(scope)); - } - for (int index = 0; index < 1'000; ++index) { - codexui::detail::StateUpdateScope scope; - scope.affectedThreadIds.push_back(QStringLiteral("streaming-thread")); - scope.hasPresentationChange = true; - codexui::FrontendSessionFacadeTestAccess::enqueueState( - session, 7, std::move(scope)); - codexui::FrontendSessionFacadeTestAccess::enqueueStatus( - session, - 7, - QStringLiteral("diagnostic-%1").arg(index)); - } - int successfulCompletions = 0; - QString successfulCompletionValue; - codexui::FrontendSessionFacadeTestAccess::completeOperation( - session, - 7, - [&successfulCompletions, &successfulCompletionValue, - &callbacksOnGuiThread, guiThread](const QString& value) { - callbacksOnGuiThread = callbacksOnGuiThread - && QThread::currentThread() == guiThread; - ++successfulCompletions; - successfulCompletionValue = value; - }, - QStringLiteral("completed")); - - passed &= expect( - codexui::FrontendSessionFacadeTestAccess::pendingStateCount(session) == 1, - "interleaved control events must never let more than one full State wait for the GUI"); - passed &= expect( - codexui::FrontendSessionFacadeTestAccess::pendingControlCount(session) - == 2, - "a blocked GUI must retain only the latest replaceable status and the lossless completion"); - passed &= expect( - codexui::FrontendSessionFacadeTestAccess::postedWakeCount(session) - - wakesBefore - == 1, - "a burst of worker publications must post exactly one GUI wakeup"); - - wait.restart(); - while ((codexui::FrontendSessionFacadeTestAccess::pendingStateCount(session) != 0 - || statusSignals != 1 || successfulCompletions != 1) - && wait.elapsed() < 2'000) { - QCoreApplication::processEvents(); - QThread::msleep(1); - } - passed &= expect( - stateSignals == 1 && deliveredScope - && deliveredScope->affectedThreadIds - == QStringList{QStringLiteral("streaming-thread"), - QStringLiteral("removed-thread")} - && deliveredScope->removedThreadIds - == QStringList{QStringLiteral("removed-thread")}, - "the one latest State publication must retain the merged presentation scope"); - if (deliveredScope) { - const auto content = [&deliveredScope](QStringView itemId, - sdk::ItemContentChannel channel) - -> const codexui::detail::StateUpdateScope::ItemContentIdentity* { - const auto found = std::find_if( - deliveredScope->affectedItemContents.cbegin(), - deliveredScope->affectedItemContents.cend(), - [itemId, channel](const auto& candidate) { - return candidate.itemId == itemId - && candidate.channel == channel; - }); - return found == deliveredScope->affectedItemContents.cend() - ? nullptr - : &*found; - }; - const auto* contiguous = content( - QStringView{u"contiguous"}, sdk::ItemContentChannel::AgentText); - const auto* otherChannel = content( - QStringView{u"contiguous"}, sdk::ItemContentChannel::ReasoningText); - const auto* ambiguous = content( - QStringView{u"ambiguous"}, sdk::ItemContentChannel::AgentText); - const auto* oversized = content( - QStringView{u"oversized"}, sdk::ItemContentChannel::AgentText); - passed &= expect( - deliveredScope->affectedItemContents.size() == 4 && contiguous - && contiguous->append - && contiguous->append->baseContentBytes == 10 - && contiguous->append->deltaUtf8 == QByteArray("abcd") - && otherChannel && otherChannel->append - && otherChannel->append->deltaUtf8 == QByteArray("reasoning") - && ambiguous && !ambiguous->append && oversized - && !oversized->append - && deliveredScope->coalescedContentDeltaBytes == 13, - "the one-slot mailbox must merge only bounded contiguous same-channel appends and degrade ambiguous or oversized sequences to replacement"); - passed &= expect( - deliveredScope->sidebarAffected - && !deliveredScope->allSidebarThreadsAffected - && deliveredScope->affectedSidebarThreadIds - == QStringList{QStringLiteral("sidebar-a"), - QStringLiteral("sidebar-b"), - QStringLiteral("sidebar-c")}, - "the one-slot mailbox must merge and deduplicate targeted Sidebar rows"); - } - passed &= expect(statusSignals == 1 - && session.statusText() - == QStringLiteral("diagnostic-999"), - "replaceable status publications must collapse to the newest value around coalesced State updates"); - passed &= expect(successfulCompletions == 1 - && successfulCompletionValue - == QStringLiteral("completed"), - "duplicate provider completion attempts must publish exactly one ordered GUI callback"); - passed &= expect(!deliveryOrder.isEmpty() - && deliveryOrder.front() == QStringLiteral("state"), - "S-C-S interleaving must publish the newest State before callbacks observe it"); - - int shutdownCompletions = 0; - QString shutdownError; - std::vector shutdownOrder; - codexui::FrontendSessionFacadeTestAccess::trackOperation( - session, - [&shutdownCompletions, &shutdownError, &shutdownOrder, - &callbacksOnGuiThread, guiThread](const QString& error) { - callbacksOnGuiThread = callbacksOnGuiThread - && QThread::currentThread() == guiThread; - ++shutdownCompletions; - shutdownOrder.push_back(1); - shutdownError = error; - }); - codexui::FrontendSessionFacadeTestAccess::trackOperation( - session, - [&shutdownCompletions, &shutdownOrder, &callbacksOnGuiThread, - guiThread](const QString&) { - callbacksOnGuiThread = callbacksOnGuiThread - && QThread::currentThread() == guiThread; - ++shutdownCompletions; - shutdownOrder.push_back(2); - }); - session.shutdown(); - session.shutdown(); - passed &= expect(shutdownCompletions == 2 && !shutdownError.isEmpty() - && shutdownOrder == std::vector{1, 2}, - "shutdown must fail every retained operation token exactly once in submission order before joining"); - passed &= expect(callbacksOnGuiThread, - "all facade signals and completions must execute on the GUI thread"); - return passed; -} - -bool testFacadeReplaceableControlCoalescing() -{ - codexui::FrontendSession session; - const auto model = [](std::string id) { - typed::Model result; - result.id = typed::ModelId{id}; - result.model = typed::ModelId{id}; - result.displayName = std::move(id); - return result; - }; - const auto publish = [&session, &model]( - std::uint64_t generation, - codexui::FrontendSession::Lifecycle lifecycle, - QString status, - std::string modelId) { - codexui::FrontendSessionFacadeTestAccess::enqueueStatus( - session, generation, lifecycle, std::move(status)); - codexui::FrontendSessionFacadeTestAccess::enqueueModels( - session, generation, {model(std::move(modelId))}); - }; - - int statusSignals = 0; - int lifecycleSignals = 0; - int modelSignals = 0; - std::vector completionOrder; - bool completionSnapshotsCorrect = true; - QObject::connect(&session, - &codexui::FrontendSession::statusChanged, - [&statusSignals] { ++statusSignals; }); - QObject::connect(&session, - &codexui::FrontendSession::lifecycleChanged, - [&lifecycleSignals] { ++lifecycleSignals; }); - QObject::connect(&session, - &codexui::FrontendSession::modelCatalogChanged, - [&modelSignals] { ++modelSignals; }); - - publish(7, - codexui::FrontendSession::Lifecycle::Disconnected, - QStringLiteral("pre-old"), - "model-pre-old"); - publish(8, - codexui::FrontendSession::Lifecycle::Disconnected, - QStringLiteral("pre-current"), - "model-pre-current"); - publish(7, - codexui::FrontendSession::Lifecycle::Disconnected, - QStringLiteral("pre-stale"), - "model-pre-stale"); - - codexui::FrontendSessionFacadeTestAccess::enqueueLifecycle( - session, - 8, - codexui::FrontendSession::Lifecycle::Connecting, - QStringLiteral("connecting")); - publish(8, - codexui::FrontendSession::Lifecycle::Connecting, - QStringLiteral("mid-old"), - "model-mid-old"); - publish(8, - codexui::FrontendSession::Lifecycle::Connecting, - QStringLiteral("mid-current"), - "model-mid-current"); - codexui::FrontendSessionFacadeTestAccess::completeOperation( - session, - 8, - [&session, &completionOrder, - &completionSnapshotsCorrect](const QString&) { - completionOrder.push_back(1); - completionSnapshotsCorrect = completionSnapshotsCorrect - && session.lifecycle() - == codexui::FrontendSession::Lifecycle::Connecting - && session.statusText() == QStringLiteral("mid-current") - && session.modelCatalog().size() == 1 - && session.modelCatalog().front().model.value - == "model-mid-current"; - }, - QString{}); - - publish(8, - codexui::FrontendSession::Lifecycle::Connecting, - QStringLiteral("post-old"), - "model-post-old"); - publish(8, - codexui::FrontendSession::Lifecycle::Connecting, - QStringLiteral("post-current"), - "model-post-current"); - codexui::FrontendSessionFacadeTestAccess::completeOperation( - session, - 8, - [&session, &completionOrder, - &completionSnapshotsCorrect](const QString&) { - completionOrder.push_back(2); - completionSnapshotsCorrect = completionSnapshotsCorrect - && session.lifecycle() - == codexui::FrontendSession::Lifecycle::Connecting - && session.statusText() == QStringLiteral("post-current") - && session.modelCatalog().size() == 1 - && session.modelCatalog().front().model.value - == "model-post-current"; - }, - QString{}); - - codexui::FrontendSessionFacadeTestAccess::enqueueLifecycle( - session, - 8, - codexui::FrontendSession::Lifecycle::Ready, - QStringLiteral("ready")); - - bool passed = expect( - codexui::FrontendSessionFacadeTestAccess::pendingControlCount(session) - == 10, - "replaceable controls must remain bounded to one status and one model per lifecycle/completion segment"); - QCoreApplication::processEvents(); - passed &= expect( - completionOrder == std::vector{1, 2} - && completionSnapshotsCorrect, - "operation completions must remain FIFO barriers and observe the preceding status and model publications"); - passed &= expect( - lifecycleSignals == 2 - && session.lifecycle() - == codexui::FrontendSession::Lifecycle::Ready, - "semantically distinct lifecycle transitions must never be coalesced"); - passed &= expect( - statusSignals == 3 - && session.statusText() == QStringLiteral("ready"), - "each barrier segment must publish only its highest-generation latest status"); - passed &= expect( - modelSignals == 3 && session.modelCatalog().size() == 1 - && session.modelCatalog().front().model.value - == "model-post-current", - "each barrier segment must publish only its highest-generation latest model catalogue"); - session.shutdown(); - return passed; -} - -bool testImmediateFacadeShutdown() -{ - QTemporaryDir isolatedRuntime; - const QByteArray previousRuntime = qgetenv("XDG_RUNTIME_DIR"); - if (!isolatedRuntime.isValid()) - return expect(false, "immediate-shutdown test requires an isolated runtime directory"); - qputenv("XDG_RUNTIME_DIR", isolatedRuntime.path().toUtf8()); - - bool passed = true; - for (int iteration = 0; iteration < 32; ++iteration) { - codexui::FrontendSession session; - int completionCount = 0; - codexui::FrontendSessionFacadeTestAccess::trackOperation( - session, - [&completionCount](const QString&) { ++completionCount; }); - // This deliberately races the worker's first construction/attach. A - // stop observed before attach must discard pending worker commands; - // the facade owns the one terminal completion for their gates. - session.connectToBackend(); - session.shutdown(); - passed &= expect( - completionCount == 1, - "immediate facade shutdown must join safely and complete retained operations exactly once"); - } - if (previousRuntime.isNull()) - qunsetenv("XDG_RUNTIME_DIR"); - else - qputenv("XDG_RUNTIME_DIR", previousRuntime); - return passed; -} - -bool testFacadeGenerationGating() -{ - codexui::FrontendSession session; - int statusSignals = 0; - int completionSignals = 0; - QObject::connect(&session, - &codexui::FrontendSession::statusChanged, - [&statusSignals] { ++statusSignals; }); - - codexui::FrontendSessionFacadeTestAccess::enqueueStatus( - session, 3, QStringLiteral("current generation")); - QCoreApplication::processEvents(); - codexui::FrontendSessionFacadeTestAccess::enqueueStatus( - session, 2, QStringLiteral("stale generation")); - codexui::FrontendSessionFacadeTestAccess::completeOperation( - session, - 1, - [&completionSignals](const QString&) { ++completionSignals; }, - QString{}); - QCoreApplication::processEvents(); - - const bool passed = expect( - session.statusText() == QStringLiteral("current generation") - && statusSignals == 1 && completionSignals == 1, - "stale lifecycle controls must not regress a newer connection generation while operation completions remain lossless"); - session.shutdown(); - return passed; -} - -bool testFacadeStructuralScopeMerge() -{ - codexui::FrontendSession session; - std::optional delivered; - QObject::connect( - &session, - &codexui::FrontendSession::stateChanged, - [&delivered](const auto& scope) { delivered = scope; }); - - const auto structuralScope = [](QString threadId) { - codexui::detail::StateUpdateScope scope; - scope.affectedThreadIds.push_back(threadId); - scope.structurallyAffectedThreadIds.push_back( - std::move(threadId)); - scope.hasPresentationChange = true; - return scope; - }; - const auto fullScope = [](QString threadId) { - codexui::detail::StateUpdateScope scope; - scope.affectedThreadIds.push_back(threadId); - scope.fullyAffectedThreadIds.push_back(std::move(threadId)); - scope.hasPresentationChange = true; - return scope; - }; - const auto exactScope = [](QString threadId, QString itemId) { - codexui::detail::StateUpdateScope scope; - scope.affectedThreadIds.push_back(threadId); - scope.affectedItemContents.push_back({ - threadId, - QStringLiteral("turn"), - std::move(itemId), - sdk::ItemContentChannel::AgentText, - codexui::detail::StateUpdateScope::ItemContentAppend{ - 4, - 0, - QByteArray(" delta"), - }, - }); - scope.coalescedContentDeltaBytes = 6; - scope.hasPresentationChange = true; - return scope; - }; - const auto preservedExact = [](const auto& scope, QStringView itemId) { - return scope.affectedItemContents.size() == 1 - && scope.affectedItemContents.front().itemId == itemId - && scope.affectedItemContents.front().append - && scope.affectedItemContents.front().append->deltaUtf8 - == QByteArray(" delta") - && scope.coalescedContentDeltaBytes == 6; - }; - - codexui::FrontendSessionFacadeTestAccess::enqueueState( - session, - 1, - exactScope(QStringLiteral("exact-first"), - QStringLiteral("exact-first-item"))); - codexui::FrontendSessionFacadeTestAccess::enqueueState( - session, 1, structuralScope(QStringLiteral("exact-first"))); - QCoreApplication::processEvents(); - bool passed = expect( - delivered - && delivered->structurallyAffectedThreadIds - == QStringList{QStringLiteral("exact-first")} - && delivered->fullyAffectedThreadIds.empty() - && preservedExact(*delivered, QStringView{u"exact-first-item"}), - "exact append metadata followed by structural scope must survive mailbox coalescing"); - - delivered.reset(); - codexui::FrontendSessionFacadeTestAccess::enqueueState( - session, 1, structuralScope(QStringLiteral("structural-first"))); - codexui::FrontendSessionFacadeTestAccess::enqueueState( - session, - 1, - exactScope(QStringLiteral("structural-first"), - QStringLiteral("structural-first-item"))); - QCoreApplication::processEvents(); - passed &= expect( - delivered - && delivered->structurallyAffectedThreadIds - == QStringList{QStringLiteral("structural-first")} - && delivered->fullyAffectedThreadIds.empty() - && preservedExact(*delivered, - QStringView{u"structural-first-item"}), - "structural scope followed by exact append metadata must survive mailbox coalescing"); - - delivered.reset(); - codexui::FrontendSessionFacadeTestAccess::enqueueState( - session, - 1, - exactScope(QStringLiteral("full-later"), - QStringLiteral("full-later-item"))); - codexui::FrontendSessionFacadeTestAccess::enqueueState( - session, 1, structuralScope(QStringLiteral("full-later"))); - codexui::FrontendSessionFacadeTestAccess::enqueueState( - session, 1, fullScope(QStringLiteral("full-later"))); - QCoreApplication::processEvents(); - passed &= expect( - delivered - && delivered->fullyAffectedThreadIds - == QStringList{QStringLiteral("full-later")} - && delivered->structurallyAffectedThreadIds.empty() - && preservedExact(*delivered, QStringView{u"full-later-item"}), - "a later deletion-capable scope must dominate structural scope without discarding exact metadata"); - - delivered.reset(); - codexui::FrontendSessionFacadeTestAccess::enqueueState( - session, 1, fullScope(QStringLiteral("full-first"))); - codexui::FrontendSessionFacadeTestAccess::enqueueState( - session, 1, structuralScope(QStringLiteral("full-first"))); - codexui::FrontendSessionFacadeTestAccess::enqueueState( - session, - 1, - exactScope(QStringLiteral("full-first"), - QStringLiteral("full-first-item"))); - QCoreApplication::processEvents(); - passed &= expect( - delivered - && delivered->fullyAffectedThreadIds - == QStringList{QStringLiteral("full-first")} - && delivered->structurallyAffectedThreadIds.empty() - && preservedExact(*delivered, QStringView{u"full-first-item"}), - "an existing deletion-capable scope must dominate a later structural scope without discarding exact metadata"); - - session.shutdown(); - return passed; -} - -bool testFacadeScopeBound() -{ - codexui::FrontendSession session; - std::optional delivered; - QObject::connect( - &session, - &codexui::FrontendSession::stateChanged, - [&delivered](const auto& scope) { delivered = scope; }); - - { - codexui::detail::StateUpdateScope scope; - scope.affectedThreadIds.push_back(QStringLiteral("removed-thread")); - scope.fullyAffectedThreadIds.push_back(QStringLiteral("removed-thread")); - scope.removedThreadIds.push_back(QStringLiteral("removed-thread")); - scope.hasPresentationChange = true; - codexui::FrontendSessionFacadeTestAccess::enqueueState( - session, 1, std::move(scope)); - } - for (int index = 0; index < 1'100; ++index) { - codexui::detail::StateUpdateScope scope; - scope.affectedThreadIds.push_back(QStringLiteral("thread")); - scope.affectedItemContents.push_back({ - QStringLiteral("thread"), - QStringLiteral("turn"), - QStringLiteral("item-%1").arg(index), - sdk::ItemContentChannel::AgentText, - std::nullopt, - }); - scope.hasPresentationChange = true; - codexui::FrontendSessionFacadeTestAccess::enqueueState( - session, 1, std::move(scope)); - } - QCoreApplication::processEvents(); - bool passed = expect( - delivered && delivered->allThreadsAffected - && delivered->affectedThreadIds.empty() - && delivered->structurallyAffectedThreadIds.empty() - && delivered->affectedItemContents.empty() - && delivered->removedThreadIds - == QStringList{QStringLiteral("removed-thread")}, - "a blocked GUI must degrade an unbounded exact-scope burst to one bounded full refresh while retaining exact removals"); - - delivered.reset(); - for (int index = 0; - index <= codexui::detail::maximumCoalescedPresentationIdentities; - ++index) { - codexui::detail::StateUpdateScope scope; - scope.affectedThreadIds.push_back(QStringLiteral("thread")); - scope.structurallyAffectedThreadIds.push_back( - QStringLiteral("structural-%1").arg(index)); - scope.hasPresentationChange = true; - codexui::FrontendSessionFacadeTestAccess::enqueueState( - session, 1, std::move(scope)); - } - QCoreApplication::processEvents(); - passed &= expect( - delivered && delivered->allThreadsAffected - && delivered->affectedThreadIds.empty() - && delivered->structurallyAffectedThreadIds.empty(), - "a blocked GUI must bound structural thread identities and let all-thread dominance clear them"); - - delivered.reset(); - { - codexui::detail::StateUpdateScope removed; - removed.affectedThreadIds.push_back( - QStringLiteral("remove-then-upsert")); - removed.removedThreadIds.push_back( - QStringLiteral("remove-then-upsert")); - removed.hasPresentationChange = true; - codexui::FrontendSessionFacadeTestAccess::enqueueState( - session, 1, std::move(removed)); - - codexui::detail::StateUpdateScope upserted; - upserted.affectedThreadIds.push_back( - QStringLiteral("remove-then-upsert")); - upserted.fullyAffectedThreadIds.push_back( - QStringLiteral("remove-then-upsert")); - upserted.hasPresentationChange = true; - codexui::FrontendSessionFacadeTestAccess::enqueueState( - session, 1, std::move(upserted)); - } - QCoreApplication::processEvents(); - passed &= expect( - delivered && delivered->removedThreadIds.empty(), - "a newer exact upsert must supersede a coalesced removal tombstone"); - - delivered.reset(); - for (int index = 0; - index <= codexui::detail::maximumCoalescedPresentationIdentities; - ++index) { - codexui::detail::StateUpdateScope scope; - const QString threadId = QStringLiteral("removed-%1").arg(index); - scope.affectedThreadIds.push_back(threadId); - scope.removedThreadIds.push_back(threadId); - scope.hasPresentationChange = true; - codexui::FrontendSessionFacadeTestAccess::enqueueState( - session, 1, std::move(scope)); - } - QCoreApplication::processEvents(); - passed &= expect( - delivered && delivered->allThreadsAffected - && delivered->removedThreadIdsOverflowed - && delivered->removedThreadIds.size() - == codexui::detail::maximumCoalescedPresentationIdentities, - "a removal burst must expose bounded tombstone overflow so the selected identity can be verified explicitly"); - - delivered.reset(); - for (int index = 0; - index <= codexui::detail::maximumCoalescedPresentationIdentities; - ++index) { - codexui::detail::StateUpdateScope scope; - scope.affectedSidebarThreadIds.push_back( - QStringLiteral("sidebar-%1").arg(index)); - scope.sidebarAffected = true; - scope.hasPresentationChange = true; - codexui::FrontendSessionFacadeTestAccess::enqueueState( - session, 1, std::move(scope)); - } - QCoreApplication::processEvents(); - passed &= expect(delivered && delivered->sidebarAffected - && delivered->allSidebarThreadsAffected - && delivered->affectedSidebarThreadIds.empty(), - "a blocked GUI must bound targeted Sidebar identities and let full-refresh dominance clear them"); - session.shutdown(); - return passed; -} - -} // namespace - -int main(int argc, char* argv[]) -{ - QCoreApplication application(argc, argv); - return testPeerCredentials() && testScopedItemPresentationChanges() && testLifecycleAndDiagnostics() - && testPreReadyReconnectBound() && testReceiveRejectionPreservesPreciseError() - && testInboundFrameCapacityTracksSdk() - && testIncompleteThreadReadIsBounded() - && testModelCatalogRefresh() && testModelCatalogRefreshFailureIsDiagnosed() - && testArchivedThreadRefresh() - && testArchivedThreadRefreshFailureIsTerminal() - && testArchivedThreadPaginationTruncationIsTerminal() - && testTurnSteeringSubmission() - && testOutboundQueue() && testInboundBufferCompaction() - && testThreadedFacadeMailbox() - && testFacadeReplaceableControlCoalescing() - && testImmediateFacadeShutdown() - && testFacadeGenerationGating() - && testFacadeStructuralScopeMerge() - && testFacadeScopeBound() - ? 0 - : 1; -} diff --git a/tests/InteractiveRequestDialogTest.cpp b/tests/InteractiveRequestDialogTest.cpp deleted file mode 100644 index 6a1a620..0000000 --- a/tests/InteractiveRequestDialogTest.cpp +++ /dev/null @@ -1,610 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "ui/InteractiveRequestDialog.h" - -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -namespace frontend = ai::openai::codex::frontend; -namespace client = frontend::client; -namespace generated = frontend::generated; - -namespace codexui { - -struct InteractiveRequestDialogTestAccess { - static void submit(InteractiveRequestDialog& dialog) - { - dialog.submitCurrent(); - } - - static QRadioButton* approvalChoice(InteractiveRequestDialog& dialog, std::size_t index) - { - return index < dialog.approvalChoices.size() ? dialog.approvalChoices[index] : nullptr; - } - - static void setApprovalIndex(InteractiveRequestDialog& dialog, int index) - { - dialog.drafts[dialog.currentRequestId].approvalIndex = index; - } - - static QLabel* addNestedLayout(InteractiveRequestDialog& dialog) - { - auto* nested = new QVBoxLayout; - auto* label = new QLabel(QStringLiteral("nested fixture")); - nested->addWidget(label); - dialog.body->layout()->addItem(nested); - return label; - } - - static QLineEdit* freeTextEditor(InteractiveRequestDialog& dialog) - { - return dialog.questionEditors.empty() ? nullptr : dialog.questionEditors.front().freeText; - } - - static QString draftFreeText(const InteractiveRequestDialog& dialog) - { - const auto draft = dialog.drafts.find(dialog.currentRequestId); - if (draft == dialog.drafts.end()) - return {}; - const auto question = draft->second.questions.find("question-1"); - return question == draft->second.questions.end() ? QString{} : question->second.freeText; - } -}; - -} // namespace codexui - -namespace { - -struct RequestFixture { - std::string summary; - std::string header; - std::string prompt; - std::string option; - std::string description; - std::string command; - std::string cwd; - bool requestTruncated = false; - bool paramsTruncated = false; - bool connectionInvalidated = false; - bool itemTruncated = false; - bool itemPresent = true; - frontend::PendingRequestKind kind = frontend::PendingRequestKind::UserInput; - bool requestScopePresent = true; - bool duplicateItemId = false; - bool allowsFreeText = false; - bool secret = false; -}; - -bool expect(bool condition, const char* message) -{ - if (!condition) - std::cerr << message << '\n'; - return condition; -} - -frontend::CapabilityAdvertisement expandedCapabilities() -{ - std::vector defined; - for (const generated::CapabilityMetadata& capability : generated::AllCapabilities) { - if (capability.defined) - defined.push_back(static_cast(capability.id)); - } - const std::vector selected{ - frontend::FrontendCapability::CompleteBackendDomains, - frontend::FrontendCapability::DedicatedPendingRequests, - frontend::FrontendCapability::DedicatedNotificationEvents, - frontend::FrontendCapability::CompleteThreadItems, - frontend::FrontendCapability::ScopeProjectedState, - }; - return {std::move(defined), selected, selected, frontend::Json::object()}; -} - -client::State makeState(const RequestFixture& fixture) -{ - client::ClientOptions options; - options.credentialProvider = [] { - return client::AuthenticationContext{frontend::NoCredential{}, std::string{"interactive-request-test"}}; - }; - client::Client sdk(std::move(options)); - auto connection = sdk.openConnection({ - [](client::OutboundMessage) { - return client::SendResult{client::SendStatus::Accepted, std::nullopt}; - }, - [](std::string) {}, - }); - connection.transportConnected(); - - const frontend::Welcome welcome{ - "fixture-session", - frontend::SessionRole::Observer, - frontend::SequenceNumber{0}, - frontend::SyncMode::Snapshot, - frontend::Json{{"permittedScopes", frontend::Json::array({"observe", "control"})}, - {"projection", frontend::Json{{"identity", "interactive-request-test"}}}}, - expandedCapabilities(), - }; - if (!connection.receive(frontend::ServerMessage{welcome}).accepted) - return {}; - - frontend::ExpandedPendingRequest request; - request.pendingRequestId = "7"; - request.kind = fixture.kind; - if (fixture.requestScopePresent) { - request.threadId = "target-thread"; - request.turnId = "target-turn"; - } - request.itemId = "item-1"; - request.summary = fixture.summary; - request.details = frontend::Json{{"paramsTruncated", fixture.paramsTruncated}}; - if (fixture.kind == frontend::PendingRequestKind::UserInput) { - request.questions = std::vector{ - {"question-1", - fixture.header, - fixture.prompt, - fixture.allowsFreeText, - fixture.secret, - {{fixture.option, fixture.description, frontend::Json::object()}}, - frontend::Json::object()}, - }; - } - request.truncated = fixture.requestTruncated; - if (fixture.connectionInvalidated) - request.extensions["connectionInvalidated"] = true; - - frontend::ExpandedThreadItem item; - item.id = "item-1"; - item.type = frontend::ThreadItemKind::CommandExecution; - item.threadId = "target-thread"; - item.turnId = "target-turn"; - item.data = frontend::Json{{"command", fixture.command}, - {"cwd", fixture.cwd}, - {"status", "completed"}, - {"processId", "42"}, - {"exitCode", 0}, - {"durationMs", 13}}; - item.truncated = fixture.itemTruncated; - - frontend::ExpandedBackendSnapshotState state; - state.provider = frontend::Json{{"lifecycle", "ready"}, - {"generation", 1}, - {"desiredRunning", true}, - {"initialization", - frontend::Json{{"codexHome", "/tmp/codex"}, - {"platformFamily", "unix"}, - {"platformOs", "linux"}, - {"userAgent", "fixture"}}}, - {"lastError", - frontend::Json{{"category", "none"}, {"code", 0}, {"detailsOmitted", false}}}, - {"recovery", frontend::Json{{"attempts", 0}, {"delayMs", 0}, {"status", "idle"}}}}; - state.controller = frontend::Json{{"present", false}, {"controllerSessionId", "1"}}; - state.threadList = frontend::Json{{"hasLoadedPage", true}, - {"complete", true}, - {"pagesLoaded", 1}, - {"stamp", frontend::Json{{"freshness", "current"}, {"generation", 1}}}}; - std::vector items; - if (fixture.duplicateItemId) { - frontend::ExpandedThreadItem wrongItem = item; - wrongItem.threadId = "other-thread"; - wrongItem.turnId = "other-turn"; - wrongItem.data = frontend::Json{{"command", "wrong duplicate command"}, - {"cwd", "/wrong"}, - {"status", "completed"}}; - items.push_back(std::move(wrongItem)); - } - if (fixture.itemPresent) - items.push_back(std::move(item)); - if (!items.empty()) - state.items = std::move(items); - state.pendingRequests = std::vector{std::move(request)}; - state.capacity = frontend::Json{{"accumulatedContentBytes", 0}, - {"accumulatedProcessOutputBytes", 0}, - {"activeOperations", 0}, - {"droppedProcessOutputBytes", 0}, - {"evictedActivityRecords", 0}, - {"evictedFilesystemWatches", 0}, - {"evictedFuzzySearchSessions", 0}, - {"evictedNotices", 0}, - {"evictedProcesses", 0}, - {"observers", 0}, - {"pendingRequests", 1}, - {"retainedActivityRecords", 0}, - {"retainedFilesystemWatches", 0}, - {"retainedFuzzySearchSessions", 0}, - {"retainedItems", (fixture.itemPresent ? 1 : 0) + (fixture.duplicateItemId ? 1 : 0)}, - {"retainedNotices", 0}, - {"retainedProcesses", 0}, - {"retainedThreads", 0}, - {"retainedTurns", 0}, - {"sessions", 0}}; - state.truncation = frontend::Json{{"droppedBytes", 0}, - {"omittedEntries", 0}, - {"omittedFields", frontend::Json::array()}, - {"truncated", false}}; - - const auto encoded = frontend::Codec::encodeExpandedSnapshot( - frontend::ExpandedSnapshot{frontend::SequenceNumber{0}, std::move(state)}); - if (!encoded) - return {}; - if (!connection - .receive(frontend::ServerMessage{ - frontend::Snapshot{frontend::SequenceNumber{0}, encoded.value().at("state")}}) - .accepted) - return {}; - if (!connection.receive(frontend::ServerMessage{frontend::SyncComplete{frontend::SequenceNumber{0}}}).accepted) - return {}; - return sdk.state(); -} - -void settleDeferredDeletes() -{ - QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); - QCoreApplication::processEvents(); -} - -QCheckBox* onlyCheckBox(codexui::InteractiveRequestDialog& dialog) -{ - const auto boxes = dialog.findChildren(); - return boxes.size() == 1 ? boxes.front() : nullptr; -} - -bool hasLabel(codexui::InteractiveRequestDialog& dialog, const QString& value) -{ - for (QLabel* label : dialog.findChildren()) { - if (label->text() == value) - return true; - } - return false; -} - -bool testCanonicalRefreshAndPlainText() -{ - const RequestFixture first{"old summary", - "old header", - " old prompt", - "old & choice", - "old description", - "old command", - "/old/cwd"}; - const RequestFixture second{"new summary", - "new header", - " new prompt", - "new & choice", - "new description", - "new command", - "/new/cwd"}; - const client::State firstState = makeState(first); - const client::State secondState = makeState(second); - client::State currentState = firstState; - int responses = 0; - codexui::InteractiveRequestDialog dialog( - [¤tState]() -> const client::State& { return currentState; }, - [&responses](codexui::InteractiveRequestResponse) { ++responses; }); - - dialog.synchronize(currentState); - QCheckBox* oldChoice = onlyCheckBox(dialog); - bool passed = expect(oldChoice != nullptr, "the initial canonical request must create one choice"); - if (!oldChoice) - return false; - oldChoice->setChecked(true); - - for (QLabel* label : dialog.findChildren()) - passed &= expect(label->textFormat() == Qt::PlainText, "every dynamic request label must force plain text"); - passed &= expect(hasLabel(dialog, QStringLiteral("old summary")), - "markup-looking request text must remain literal"); - passed &= expect(oldChoice->text() == QStringLiteral("old && choice"), - "option ampersands must be escaped from Qt mnemonic handling"); - passed &= expect(!oldChoice->toolTip().contains(QStringLiteral("old description")), - "dynamic tooltips must not retain raw rich-text markup"); - - currentState = secondState; - dialog.synchronize(currentState); - settleDeferredDeletes(); - - QCheckBox* newChoice = onlyCheckBox(dialog); - passed &= expect(newChoice != nullptr, "the changed same-ID request must rebuild its choices"); - if (newChoice) { - passed &= expect(newChoice->text() == QStringLiteral("new && choice"), - "the changed same-ID request must show the new option"); - passed &= expect(!newChoice->isChecked(), "a changed same-ID request must discard the stale draft"); - } - passed &= expect(hasLabel(dialog, QStringLiteral("new summary")), - "the changed same-ID request must show its new summary"); - passed &= expect(hasLabel(dialog, QStringLiteral("Command: new command")), - "the changed linked item must refresh its command presentation"); - passed &= expect(!hasLabel(dialog, QStringLiteral("old summary")), - "the previous same-ID request presentation must be removed"); - passed &= expect(responses == 0, "refreshing canonical request data must not submit a response"); - - const auto firstSource = codexui::detail::interactiveRequestSource(firstState, client::PendingRequestId{"7"}); - const client::State equalState = makeState(first); - const auto equalSource = codexui::detail::interactiveRequestSource(equalState, client::PendingRequestId{"7"}); - passed &= expect(firstSource && equalSource && *firstSource == *equalSource, - "equivalent canonical request content must not differ only by source stamps"); - return passed; -} - -bool testSubmitTimeRevalidation() -{ - const RequestFixture first{"summary", "header", "prompt", "old choice", "description", "command", "/cwd"}; - const RequestFixture second{"changed summary", "header", "prompt", "new choice", "description", "command", "/cwd"}; - client::State currentState = makeState(first); - const client::State changedState = makeState(second); - int responses = 0; - codexui::InteractiveRequestDialog dialog( - [¤tState]() -> const client::State& { return currentState; }, - [&responses](codexui::InteractiveRequestResponse) { ++responses; }); - dialog.synchronize(currentState); - QCheckBox* choice = onlyCheckBox(dialog); - bool passed = expect(choice != nullptr, "the request must expose its answer choice before submission"); - if (!choice) - return false; - choice->setChecked(true); - auto* submit = dialog.findChild(QStringLiteral("interactiveRequestSubmit")); - passed &= expect(submit && submit->isEnabled(), "a complete answered request must be submit-enabled"); - - currentState = changedState; - if (submit) - submit->click(); - settleDeferredDeletes(); - passed &= expect(responses == 0, "submit must reject canonical same-ID changes made after presentation"); - passed &= expect(hasLabel(dialog, QStringLiteral("changed summary")), - "submit-time revalidation must rebuild the changed canonical request"); - return passed; -} - -bool testCompositeApprovalLookup() -{ - RequestFixture fixture{"summary", "header", "prompt", "choice", "description", "target command", "/target"}; - fixture.kind = frontend::PendingRequestKind::CommandExecutionApproval; - fixture.duplicateItemId = true; - const client::State scopedState = makeState(fixture); - const auto source = codexui::detail::interactiveRequestSource(scopedState, client::PendingRequestId{"7"}); - bool passed = expect(source && source->linkedItem.has_value(), - "a scoped approval must resolve its linked item despite duplicate bare IDs"); - if (source && source->linkedItem) { - const auto* command = std::get_if(&source->linkedItem->details); - passed &= expect(command && command->command == std::optional{"target command"} - && command->cwd && command->cwd->value == "/target", - "approval lookup must use the request thread, turn, and item identity"); - } - - fixture.requestScopePresent = false; - const auto unscoped = codexui::detail::interactiveRequestSource(makeState(fixture), client::PendingRequestId{"7"}); - passed &= expect(unscoped && !unscoped->linkedItem - && codexui::detail::interactiveRequestResponseSafety(*unscoped) - == codexui::InteractiveRequestResponseSafety::NegativeOnly, - "an approval with incomplete item identity must not resolve a bare-ID item"); - return passed; -} - -bool testIncompleteUserInputIsDisabled() -{ - const RequestFixture truncated{"summary", "header", "prompt", "choice", "description", "command", "/cwd", true}; - const RequestFixture paramsTruncated{ - "summary", "header", "prompt", "choice", "description", "command", "/cwd", false, true}; - const RequestFixture invalidated{ - "summary", "header", "prompt", "choice", "description", "command", "/cwd", false, false, true}; - const RequestFixture linkedTruncated{ - "summary", "header", "prompt", "choice", "description", "command", "/cwd", false, false, false, true}; - RequestFixture linkedMissing{ - "summary", "header", "prompt", "choice", "description", "command", "/cwd"}; - linkedMissing.itemPresent = false; - - const auto truncatedSource = codexui::detail::interactiveRequestSource(makeState(truncated), client::PendingRequestId{"7"}); - const auto paramsSource = codexui::detail::interactiveRequestSource(makeState(paramsTruncated), client::PendingRequestId{"7"}); - const auto invalidatedSource = codexui::detail::interactiveRequestSource(makeState(invalidated), client::PendingRequestId{"7"}); - const auto linkedSource = codexui::detail::interactiveRequestSource(makeState(linkedTruncated), client::PendingRequestId{"7"}); - const auto missingSource = codexui::detail::interactiveRequestSource(makeState(linkedMissing), client::PendingRequestId{"7"}); - bool passed = true; - passed &= expect(truncatedSource - && codexui::detail::interactiveRequestResponseSafety(*truncatedSource) - == codexui::InteractiveRequestResponseSafety::Disabled, - "a truncated request must be non-actionable"); - passed &= expect(paramsSource - && codexui::detail::interactiveRequestResponseSafety(*paramsSource) - == codexui::InteractiveRequestResponseSafety::Disabled, - "truncated typed request parameters must be non-actionable"); - passed &= expect(invalidatedSource - && codexui::detail::interactiveRequestResponseSafety(*invalidatedSource) - == codexui::InteractiveRequestResponseSafety::Disabled, - "a connection-invalidated request must be non-actionable"); - passed &= expect(linkedSource - && codexui::detail::interactiveRequestResponseSafety(*linkedSource) - == codexui::InteractiveRequestResponseSafety::Disabled, - "a request with truncated linked semantics must be non-actionable"); - passed &= expect(missingSource - && codexui::detail::interactiveRequestResponseSafety(*missingSource) - == codexui::InteractiveRequestResponseSafety::Disabled, - "a request with unavailable linked semantics must be non-actionable"); - if (truncatedSource) { - codexui::InteractiveRequestSource omitted = *truncatedSource; - omitted.request.truncated = false; - omitted.request.omittedFields = {"/questions"}; - passed &= expect(codexui::detail::interactiveRequestResponseSafety(omitted) - == codexui::InteractiveRequestResponseSafety::Disabled, - "a request with omitted semantic fields must be non-actionable"); - } - - client::State currentState = makeState(invalidated); - int responses = 0; - codexui::InteractiveRequestDialog dialog( - [¤tState]() -> const client::State& { return currentState; }, - [&responses](codexui::InteractiveRequestResponse) { ++responses; }); - dialog.synchronize(currentState); - auto* submit = dialog.findChild(QStringLiteral("interactiveRequestSubmit")); - passed &= expect(submit && !submit->isEnabled(), "an incomplete request must disable submission"); - codexui::InteractiveRequestDialogTestAccess::submit(dialog); - passed &= expect(responses == 0, "an incomplete request must also fail closed when submission is forced"); - return passed; -} - -bool testIncompleteApprovalAllowsOnlyNegativeResponses() -{ - RequestFixture incomplete{"summary", "header", "prompt", "choice", "description", "command", "/cwd"}; - incomplete.kind = frontend::PendingRequestKind::CommandExecutionApproval; - incomplete.itemTruncated = true; - client::State currentState = makeState(incomplete); - std::optional response; - codexui::InteractiveRequestDialog dialog( - [¤tState]() -> const client::State& { return currentState; }, - [&response](codexui::InteractiveRequestResponse value) { response = std::move(value); }); - dialog.synchronize(currentState); - - auto* approve = codexui::InteractiveRequestDialogTestAccess::approvalChoice(dialog, 0); - auto* approveForSession = codexui::InteractiveRequestDialogTestAccess::approvalChoice(dialog, 1); - auto* decline = codexui::InteractiveRequestDialogTestAccess::approvalChoice(dialog, 2); - auto* cancel = codexui::InteractiveRequestDialogTestAccess::approvalChoice(dialog, 3); - auto* submit = dialog.findChild(QStringLiteral("interactiveRequestSubmit")); - bool passed = expect(approve && approveForSession && decline && cancel && submit, - "an approval request must expose all four typed decisions"); - if (!approve || !approveForSession || !decline || !cancel || !submit) - return false; - passed &= expect(!approve->isEnabled() && !approveForSession->isEnabled() - && decline->isEnabled() && cancel->isEnabled(), - "incomplete approval semantics must disable positive decisions only"); - decline->setChecked(true); - passed &= expect(submit->isEnabled(), "a decline must remain submit-enabled for an incomplete approval"); - codexui::InteractiveRequestDialogTestAccess::submit(dialog); - passed &= expect(response && codexui::detail::interactiveResponseIsNegative(*response), - "an incomplete approval must submit its safe negative response"); - - response.reset(); - codexui::InteractiveRequestDialog forcedDialog( - [¤tState]() -> const client::State& { return currentState; }, - [&response](codexui::InteractiveRequestResponse value) { response = std::move(value); }); - forcedDialog.synchronize(currentState); - codexui::InteractiveRequestDialogTestAccess::setApprovalIndex(forcedDialog, 1); - codexui::InteractiveRequestDialogTestAccess::submit(forcedDialog); - passed &= expect(!response, "a forced positive response must still fail closed for incomplete approval semantics"); - - incomplete.connectionInvalidated = true; - currentState = makeState(incomplete); - codexui::InteractiveRequestDialog invalidatedDialog( - [¤tState]() -> const client::State& { return currentState; }, - [&response](codexui::InteractiveRequestResponse value) { response = std::move(value); }); - invalidatedDialog.synchronize(currentState); - decline = codexui::InteractiveRequestDialogTestAccess::approvalChoice(invalidatedDialog, 2); - passed &= expect(decline && !decline->isEnabled(), - "a connection-invalidated approval must disable negative decisions too"); - codexui::InteractiveRequestDialogTestAccess::setApprovalIndex(invalidatedDialog, 3); - codexui::InteractiveRequestDialogTestAccess::submit(invalidatedDialog); - passed &= expect(!response, "a connection-invalidated approval must reject a forced negative response"); - return passed; -} - -bool testNestedLayoutRebuildDeletesOnce() -{ - const RequestFixture first{"first", "header", "prompt", "choice", "description", "command", "/cwd"}; - const RequestFixture second{"second", "header", "prompt", "choice", "description", "command", "/cwd"}; - client::State currentState = makeState(first); - codexui::InteractiveRequestDialog dialog( - [¤tState]() -> const client::State& { return currentState; }, - [](codexui::InteractiveRequestResponse) {}); - dialog.synchronize(currentState); - QPointer nested = codexui::InteractiveRequestDialogTestAccess::addNestedLayout(dialog); - currentState = makeState(second); - dialog.synchronize(currentState); - settleDeferredDeletes(); - return expect(nested.isNull(), "rebuilding must clear a directly nested layout without a double delete"); -} - -bool testSecretFreeTextIsNotDraftedAndClearsAfterCapture() -{ - RequestFixture secretFixture{"summary", "header", "prompt", {}, {}, "command", "/cwd"}; - secretFixture.allowsFreeText = true; - secretFixture.secret = true; - client::State currentState = makeState(secretFixture); - std::optional response; - codexui::InteractiveRequestDialog dialog( - [¤tState]() -> const client::State& { return currentState; }, - [&response](codexui::InteractiveRequestResponse value) { response = std::move(value); }); - dialog.synchronize(currentState); - - QLineEdit* editor = codexui::InteractiveRequestDialogTestAccess::freeTextEditor(dialog); - bool passed = expect(editor && editor->echoMode() == QLineEdit::Password, - "a secret question must use a password editor"); - if (!editor) - return false; - editor->setText(QStringLiteral("first secret")); - dialog.synchronize(currentState); - passed &= expect(editor->text() == QStringLiteral("first secret"), - "an unchanged state refresh must not erase the live secret editor"); - passed &= expect(codexui::InteractiveRequestDialogTestAccess::draftFreeText(dialog).isEmpty(), - "secret free text must never be copied into the persistent request draft"); - - QPointer previousEditor = editor; - dialog.present(); - editor = codexui::InteractiveRequestDialogTestAccess::freeTextEditor(dialog); - passed &= expect(previousEditor && previousEditor->text().isEmpty(), - "rebuilding must clear the previous secret editor before disposal"); - passed &= expect(editor && editor->text().isEmpty(), - "a rebuilt secret editor must not restore secret free text from a draft"); - if (!editor) - return false; - - editor->setText(QStringLiteral("submitted secret")); - codexui::InteractiveRequestDialogTestAccess::submit(dialog); - passed &= expect(editor->text().isEmpty(), - "successful response capture must immediately clear the live secret editor"); - passed &= expect(codexui::InteractiveRequestDialogTestAccess::draftFreeText(dialog).isEmpty(), - "successful response capture must leave no secret draft text"); - const auto* answers = response - ? std::get_if>(&response->value) - : nullptr; - passed &= expect(answers && answers->size() == 1 && answers->front().answers.size() == 1 - && answers->front().answers.front() == "submitted secret", - "the typed response must still receive the captured secret answer"); - - RequestFixture ordinaryFixture = secretFixture; - ordinaryFixture.secret = false; - currentState = makeState(ordinaryFixture); - codexui::InteractiveRequestDialog ordinaryDialog( - [¤tState]() -> const client::State& { return currentState; }, - [](codexui::InteractiveRequestResponse) {}); - ordinaryDialog.synchronize(currentState); - editor = codexui::InteractiveRequestDialogTestAccess::freeTextEditor(ordinaryDialog); - if (!expect(editor != nullptr, "a non-secret free-text question must create an editor")) - return false; - editor->setText(QStringLiteral("ordinary answer")); - ordinaryDialog.present(); - editor = codexui::InteractiveRequestDialogTestAccess::freeTextEditor(ordinaryDialog); - passed &= expect(editor && editor->text() == QStringLiteral("ordinary answer"), - "non-secret free text must retain the existing draft behavior"); - return passed; -} - -} // namespace - -int main(int argc, char** argv) -{ - QApplication application(argc, argv); - bool passed = true; - passed &= testCanonicalRefreshAndPlainText(); - passed &= testSubmitTimeRevalidation(); - passed &= testCompositeApprovalLookup(); - passed &= testIncompleteUserInputIsDisabled(); - passed &= testIncompleteApprovalAllowsOnlyNegativeResponses(); - passed &= testNestedLayoutRebuildDeletesOnce(); - passed &= testSecretFreeTextIsNotDraftedAndClearsAfterCapture(); - return passed ? 0 : 1; -} diff --git a/tests/Phase1ThreadTurnUxTest.cpp b/tests/Phase1ThreadTurnUxTest.cpp deleted file mode 100644 index 1ae4e2b..0000000 --- a/tests/Phase1ThreadTurnUxTest.cpp +++ /dev/null @@ -1,1455 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "ui/AnchoredTurnSurface.h" -#include "ui/SidebarWidget.h" -#include "ui/ThreadSetupDialog.h" -#include "ui/UpcomingTurnDock.h" -#include "ui/UiStyle.h" -#include "ui/WorkbenchWidget.h" - -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -namespace -{ - -namespace frontend = ai::openai::codex::frontend; -namespace sdk = ai::openai::codex::frontend::client; -namespace typed = ai::openai::codex::typed; - -bool expect(bool condition, const char* message) -{ - if (!condition) - std::cerr << message << '\n'; - return condition; -} - -void settleEvents(int passes = 4, int delayMs = 20) -{ - for (int pass = 0; pass < passes; ++pass) - { - QEventLoop loop; - QTimer::singleShot(delayMs, &loop, &QEventLoop::quit); - loop.exec(); - QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); - QCoreApplication::processEvents(); - } -} - -sdk::ExecutionConfiguration configuration(std::string model, - typed::ReasoningEffort effort, - std::string cwd) -{ - sdk::ExecutionConfiguration result; - result.approvalPolicy = typed::ApprovalPolicy::onRequest(); - result.approvalsReviewer = typed::ApprovalsReviewer::user(); - result.collaborationMode.mode = typed::ModeKind::defaultMode(); - result.collaborationMode.settings.model = typed::ModelId{model}; - result.cwd = typed::AbsolutePath{std::move(cwd)}; - result.effort = std::move(effort); - result.model = typed::ModelId{std::move(model)}; - result.modelProvider = "openai"; - result.personality = typed::Personality::friendly(); - result.sandboxPolicy = typed::WorkspaceWriteSandboxPolicy{}; - result.serviceTier = std::string{"default"}; - result.summary = typed::ReasoningSummary::automatic(); - return result; -} - -sdk::State threadDiscoveryState( - const std::vector>& threadFixtures, - const std::optional& activeSharedTurnThread = std::nullopt) -{ - sdk::ClientOptions options; - options.requestedCapabilities.clear(); - options.credentialProvider = [] { - return sdk::AuthenticationContext{frontend::NoCredential{}, - std::string{"thread-discovery-test"}}; - }; - sdk::Client client(std::move(options)); - auto connection = client.openConnection({ - [](sdk::OutboundMessage) { - return sdk::SendResult{sdk::SendStatus::Accepted, std::nullopt}; - }, - [](std::string) {}, - }); - connection.transportConnected(); - if (!connection - .receive(frontend::ServerMessage{frontend::Welcome{ - "fixture-session", - frontend::SessionRole::Observer, - frontend::SequenceNumber{0}, - frontend::SyncMode::Snapshot}}) - .accepted) - return {}; - - frontend::Json threads = frontend::Json::array(); - for (const auto& [id, archived] : threadFixtures) { - frontend::Json turns = frontend::Json::array(); - if (activeSharedTurnThread) { - const bool active = id == *activeSharedTurnThread; - turns.push_back(frontend::Json{{"id", "shared-turn"}, - {"threadId", id}, - {"status", active ? "inProgress" : "completed"}, - {"active", active}, - {"terminal", !active}, - {"items", frontend::Json::array()}, - {"extensions", frontend::Json::object()}}); - } - threads.push_back(frontend::Json{{"id", id}, - {"title", id}, - {"status", "idle"}, - {"fullyLoaded", true}, - {"archived", archived}, - {"turns", std::move(turns)}, - {"extensions", frontend::Json::object()}}); - } - frontend::Json state{{"backendRevision", 1}, - {"lifecycle", "ready"}, - {"diagnostics", - {{"received", 0}, {"recent", frontend::Json::array()}}}, - {"sessions", frontend::Json::array()}, - {"threadList", - {{"hasLoadedPage", true}, {"complete", true}, {"pagesLoaded", 1}}}, - {"threads", std::move(threads)}, - {"pendingRequests", frontend::Json::array()}, - {"codexExtensions", frontend::Json::array()}, - {"omittedCodexExtensions", 0}, - {"journal", - {{"oldestReplayableAfter", 0}, {"currentSequence", 0}}}, - {"sequenceExhausted", false}}; - if (!connection - .receive(frontend::ServerMessage{ - frontend::Snapshot{frontend::SequenceNumber{0}, std::move(state)}}) - .accepted) - return {}; - if (!connection - .receive(frontend::ServerMessage{ - frontend::SyncComplete{frontend::SequenceNumber{0}}}) - .accepted) - return {}; - return client.state(); -} - -bool testUpcomingTurnCanonicalRebase() -{ - codexui::UpcomingTurnDock dock; - dock.resize(960, dock.baseHeight()); - dock.show(); - dock.setActionState(true, false, true, true, false, false); - - const sdk::ExecutionConfiguration first = - configuration("gpt-5.6", typed::ReasoningEffort::high(), "/workspace/first"); - dock.setCanonicalConfiguration(first, QStringLiteral("thread-a")); - settleEvents(); - - auto* model = dock.findChild(QStringLiteral("upcomingModel")); - auto* effort = dock.findChild(QStringLiteral("upcomingReasoning")); - auto* cwd = dock.findChild(QStringLiteral("upcomingWorkspace")); - auto* settings = dock.findChild(QStringLiteral("upcomingTurnSettings")); - auto* composer = dock.findChild(QStringLiteral("upcomingComposer")); - auto* editor = dock.findChild(QStringLiteral("upcomingPromptEditor")); - auto* send = dock.findChild(QStringLiteral("upcomingSendButton")); - const auto stableControlTree = dock.findChildren(); - bool passed = expect(model && effort && cwd, - "the upcoming-turn canonical controls must be discoverable"); - if (!model || !effort || !cwd || !settings || !composer || !editor || !send) - return false; - const int stableBaseHeight = dock.height(); - const int stableComposerTop = composer->geometry().top(); - - passed &= expect(model->currentText() == QStringLiteral("gpt-5.6") - && effort->currentData().toString() == QStringLiteral("high") - && cwd->text() == QStringLiteral("/workspace/first"), - "untouched upcoming-turn controls must show canonical thread settings"); - passed &= expect(dock.draft().threadIdentity == QStringLiteral("thread-a") - && dock.draft().empty() && !dock.hasSettingsChanges(), - "canonical values must not become local turn overrides"); - - const int xhighIndex = effort->findData(QStringLiteral("xhigh")); - effort->setCurrentIndex(xhighIndex); - QCoreApplication::processEvents(); - const codexui::UpcomingTurnDraft changed = dock.draft(); - passed &= expect(xhighIndex >= 0 && dock.hasSettingsChanges() - && changed.effort.hasValue() - && changed.effort->value == "xhigh" && changed.model.isOmitted() - && changed.cwd.isOmitted(), - "only an explicitly changed control must enter the typed turn draft"); - auto* changedSurface = effort->parentWidget(); - auto* settingsHint = dock.findChild(QStringLiteral("upcomingSettingsHint")); - passed &= expect(changedSurface && changedSurface->property("changed").toBool() - && settingsHint && settingsHint->isVisible() - && !settingsHint->text().isEmpty() - && dock.height() == stableBaseHeight - && composer->geometry().top() == stableComposerTop - && settings->geometry().bottom() < composer->geometry().top(), - "changed upcoming-turn values must have a visible persistent-setting indication"); - - const sdk::ExecutionConfiguration refreshed = - configuration("gpt-5.7", typed::ReasoningEffort::low(), "/workspace/refreshed"); - dock.setCanonicalConfiguration(refreshed, QStringLiteral("thread-a")); - settleEvents(); - passed &= expect(model->currentText() == QStringLiteral("gpt-5.7") - && cwd->text() == QStringLiteral("/workspace/refreshed") - && effort->currentData().toString() == QStringLiteral("xhigh"), - "same-thread refreshes must rebase untouched controls without overwriting a user change"); - passed &= expect(model == dock.findChild(QStringLiteral("upcomingModel")) - && effort == dock.findChild(QStringLiteral("upcomingReasoning")) - && cwd == dock.findChild(QStringLiteral("upcomingWorkspace")) - && editor == dock.findChild(QStringLiteral("upcomingPromptEditor")) - && send == dock.findChild(QStringLiteral("upcomingSendButton")) - && stableControlTree == dock.findChildren(), - "a harmless same-thread refresh must retain the complete upcoming-turn control tree"); - passed &= expect(dock.draft().effort.hasValue() - && dock.draft().effort->value == "xhigh", - "a same-thread state update must preserve the pending typed override"); - - dock.setCanonicalConfiguration(refreshed, QStringLiteral("thread-b")); - settleEvents(); - passed &= expect(effort->currentData().toString() == QStringLiteral("low") - && dock.draft().threadIdentity == QStringLiteral("thread-b") - && dock.draft().empty() && !dock.hasSettingsChanges(), - "switching threads must discard the prior thread's draft and rebase every control"); - - const int defaultIndex = effort->findData(QStringLiteral("default")); - effort->setCurrentIndex(defaultIndex); - const codexui::UpcomingTurnDraft normalizedBeforeCompletion = dock.draft(); - sdk::ExecutionConfiguration normalized = refreshed; - normalized.effort = typed::ReasoningEffort::medium(); - dock.setCanonicalConfiguration(normalized, QStringLiteral("thread-b")); - passed &= expect(effort->currentData().toString() == QStringLiteral("default") - && dock.hasSettingsChanges(), - "a canonical update must not overwrite a submitted control before operation completion"); - dock.acknowledgeSubmittedSettings(normalizedBeforeCompletion); - passed &= expect(effort->currentData().toString() == QStringLiteral("medium") - && dock.draft().empty() && !dock.hasSettingsChanges(), - "a newer authoritative revision must resolve an explicit-null setting even when normalized"); - - effort->setCurrentIndex(defaultIndex); - const codexui::UpcomingTurnDraft normalizedAfterCompletion = dock.draft(); - dock.acknowledgeSubmittedSettings(normalizedAfterCompletion); - normalized.effort = typed::ReasoningEffort::high(); - dock.setCanonicalConfiguration(normalized, QStringLiteral("thread-b")); - passed &= expect(effort->currentData().toString() == QStringLiteral("high") - && dock.draft().empty() && !dock.hasSettingsChanges(), - "the first newer authoritative revision must resolve a submitted reset without stale intent"); - - effort->setCurrentIndex(xhighIndex); - const codexui::UpcomingTurnDraft submittedXhigh = dock.draft(); - effort->setCurrentIndex(effort->findData(QStringLiteral("low"))); - dock.acknowledgeSubmittedSettings(submittedXhigh); - passed &= expect(effort->currentData().toString() == QStringLiteral("low") - && dock.hasSettingsChanges() && dock.draft().effort.hasValue() - && dock.draft().effort->value == "low", - "submission acknowledgement must preserve an edit made after the submitted draft"); - - const codexui::UpcomingTurnDraft staleThreadB = dock.draft(); - const sdk::ExecutionConfiguration thirdThread = - configuration("gpt-5.8", typed::ReasoningEffort::medium(), "/workspace/third"); - dock.setCanonicalConfiguration(thirdThread, QStringLiteral("thread-c")); - effort->setCurrentIndex(xhighIndex); - const codexui::UpcomingTurnDraft threadCBeforeStaleAcknowledgement = dock.draft(); - dock.acknowledgeSubmittedSettings(staleThreadB); - passed &= expect(dock.draft().threadIdentity == QStringLiteral("thread-c") - && effort->currentData().toString() == QStringLiteral("xhigh") - && dock.hasSettingsChanges() && dock.draft().effort.hasValue() - && dock.draft().effort->value == "xhigh" - && dock.draft().presentationKeys - == threadCBeforeStaleAcknowledgement.presentationKeys, - "a late acknowledgement from another thread must not mutate the current thread's draft"); - return passed; -} - -bool testAnchoredGrowingComposer() -{ - codexui::AnchoredTurnSurface surface; - auto* conversation = new QWidget; - conversation->setObjectName(QStringLiteral("phase1TestConversation")); - auto* dock = new codexui::UpcomingTurnDock; - surface.setConversationWidget(conversation); - surface.setUpcomingTurnDock(dock); - surface.resize(960, 760); - surface.show(); - dock->setActionState(true, false, true, true, false, false); - settleEvents(); - - auto* editor = dock->findChild(QStringLiteral("upcomingPromptEditor")); - bool passed = expect(editor != nullptr, - "the anchored upcoming-turn surface must expose its prompt editor"); - if (!editor) - return false; - - const QRect conversationBaseline = conversation->geometry(); - const int editorBaselineHeight = editor->height(); - const int dockBaselineHeight = dock->height(); - passed &= expect(editor->document()->blockCount() == 1 && editorBaselineHeight <= 48, - "the empty prompt editor must begin as one visible line"); - passed &= expect(conversationBaseline.height() == surface.height() - dock->baseHeight(), - "the conversation viewport must reserve only the fixed dock base height"); - passed &= expect(dock->geometry().bottom() + 1 == surface.height(), - "the upcoming-turn dock must remain anchored to the surface bottom edge"); - - QString longPrompt; - for (int line = 0; line < 80; ++line) - longPrompt += QStringLiteral("A deliberately long prompt line used to exercise upward growth.\n"); - editor->setPlainText(longPrompt); - settleEvents(6); - - const QRect conversationWhileExpanded = conversation->geometry(); - passed &= expect(editor->height() > editorBaselineHeight && editor->height() <= 236 - && dock->height() > dockBaselineHeight, - "multiline prompt input must grow upward until its bounded maximum height"); - passed &= expect(conversationWhileExpanded == conversationBaseline, - "prompt expansion must not resize or move the conversation viewport"); - passed &= expect(dock->geometry().bottom() + 1 == surface.height() - && dock->geometry().top() < surface.height() - dockBaselineHeight, - "an expanded dock must keep its base fixed and move only its top edge upward"); - const bool internallyScrollable = editor->verticalScrollBarPolicy() == Qt::ScrollBarAsNeeded - && (editor->verticalScrollBar()->maximum() > 0 - || editor->document()->size().height() > editor->viewport()->height()); - if (!internallyScrollable) { - std::cerr << "scroll diagnostics: policy=" << editor->verticalScrollBarPolicy() - << " maximum=" << editor->verticalScrollBar()->maximum() - << " documentHeight=" << editor->document()->size().height() - << " viewportHeight=" << editor->viewport()->height() - << " blocks=" << editor->document()->blockCount() - << " editorHeight=" << editor->height() << '\n'; - } - passed &= expect(internallyScrollable, - "after maximum growth the prompt editor must scroll internally"); - - editor->setPlainText(QStringLiteral("short prompt")); - settleEvents(6); - passed &= expect(editor->height() <= editorBaselineHeight + 8 - && dock->height() <= dockBaselineHeight + 8, - "shrinking prompt text must return the editor and dock to their compact baseline"); - passed &= expect(conversation->geometry() == conversationBaseline - && dock->geometry().bottom() + 1 == surface.height(), - "prompt shrinkage must not jump the conversation or unanchor the dock"); - - surface.resize(1000, 760); - editor->setPlainText(QString(320, QLatin1Char('w'))); - settleEvents(6); - const int wideWrappedHeight = editor->height(); - surface.resize(520, 760); - settleEvents(6); - const int narrowWrappedHeight = editor->height(); - passed &= expect(wideWrappedHeight > editorBaselineHeight - && narrowWrappedHeight > wideWrappedHeight - && conversation->height() == surface.height() - dock->baseHeight() - && dock->geometry().bottom() + 1 == surface.height(), - "a wrapped prompt must reflow upward after a width change without resizing the conversation height"); - surface.resize(1000, 760); - settleEvents(6); - passed &= expect(editor->height() < narrowWrappedHeight - && conversation->height() == surface.height() - dock->baseHeight() - && dock->geometry().bottom() + 1 == surface.height(), - "widening a wrapped prompt must shrink it without moving the dock base"); - return passed; -} - -bool testNarrowUpcomingTurnLayout() -{ - codexui::UpcomingTurnDock dock; - dock.setCanonicalConfiguration( - configuration("gpt-test", typed::ReasoningEffort::high(), "/workspace"), - QStringLiteral("thread-narrow")); - dock.setActionState(true, false, true, true, false, false); - dock.resize(520, dock.baseHeight()); - dock.show(); - settleEvents(); - - auto* settings = dock.findChild(QStringLiteral("upcomingTurnSettings")); - auto* composer = dock.findChild(QStringLiteral("upcomingComposer")); - auto* model = dock.findChild(QStringLiteral("upcomingModel")); - auto* effort = dock.findChild(QStringLiteral("upcomingReasoning")); - auto* style = dock.findChild(QStringLiteral("upcomingStyle")); - auto* access = dock.findChild(QStringLiteral("upcomingAccess")); - auto* network = dock.findChild(QStringLiteral("upcomingNetwork")); - auto* approval = dock.findChild(QStringLiteral("upcomingApproval")); - auto* workspace = dock.findChild(QStringLiteral("upcomingWorkspace")); - auto* more = dock.findChild(QStringLiteral("upcomingMore")); - auto* status = dock.findChild(QStringLiteral("upcomingTurnStatus")); - auto* send = dock.findChild(QStringLiteral("upcomingSendButton")); - bool passed = expect(settings && composer && model && effort && style && access - && network && approval && workspace && more && status && send, - "the narrow upcoming-turn layout controls must be discoverable"); - if (!settings || !composer || !model || !effort || !style || !access - || !network || !approval || !workspace || !more || !status || !send) - return false; - - const auto inDock = [&dock](QWidget* widget) { - return QRect(widget->mapTo(&dock, QPoint(0, 0)), widget->size()); - }; - const QRect modelRect = inDock(model); - const QRect effortRect = inDock(effort); - const QRect styleRect = inDock(style); - const QRect accessRect = inDock(access); - const QRect networkRect = inDock(network); - const QRect approvalRect = inDock(approval); - const QRect workspaceRect = inDock(workspace); - const QRect moreRect = inDock(more); - const QRect statusRect = inDock(status); - const QRect sendRect = inDock(send); - passed &= expect(settings->geometry().bottom() < composer->geometry().top() - && modelRect.top() == effortRect.top() - && effortRect.top() == accessRect.top() - && accessRect.top() == networkRect.top() - && modelRect.bottom() < workspaceRect.top() - && std::abs(workspaceRect.top() - approvalRect.top()) <= 2 - && std::abs(approvalRect.top() - styleRect.top()) <= 2 - && std::abs(styleRect.top() - moreRect.top()) <= 2 - && modelRect.left() == workspaceRect.left() - && effortRect.left() == approvalRect.left() - && accessRect.left() == styleRect.left() - && networkRect.left() == moreRect.left() - && statusRect.right() < sendRect.left(), - "the two-row settings and composer actions must not overlap at narrow width"); - const std::array widths{ - modelRect.width(), effortRect.width(), accessRect.width(), networkRect.width(), - workspaceRect.width(), approvalRect.width(), styleRect.width(), moreRect.width()}; - const auto [minimumWidth, maximumWidth] = std::minmax_element(widths.begin(), widths.end()); - passed &= expect(*minimumWidth >= 70 && *maximumWidth - *minimumWidth <= 1, - "all eight primary settings must retain equal readable widths"); - return passed; -} - -bool testUpcomingTurnNetworkAccess() -{ - codexui::UpcomingTurnDock dock; - sdk::ExecutionConfiguration canonical = - configuration("gpt-test", typed::ReasoningEffort::high(), "/workspace"); - typed::WorkspaceWriteSandboxPolicy workspacePolicy; - workspacePolicy.networkAccess = false; - workspacePolicy.writableRoots = std::vector{ - typed::AbsolutePath{"/workspace/extra"}}; - workspacePolicy.excludeSlashTmp = true; - canonical.sandboxPolicy = workspacePolicy; - dock.setCanonicalConfiguration(canonical, QStringLiteral("thread-network")); - dock.setActionState(true, false, true, true, false, false); - - auto* access = dock.findChild(QStringLiteral("upcomingAccess")); - auto* network = dock.findChild(QStringLiteral("upcomingNetwork")); - bool passed = expect(access && network, - "the access and network controls must be discoverable"); - if (!access || !network) - return false; - passed &= expect(access->currentData().toString() == QStringLiteral("workspace-write") - && network->currentData().toString() == QStringLiteral("restricted") - && network->isEnabled(), - "workspace access must expose its canonical network restriction"); - - network->setCurrentIndex(network->findData(QStringLiteral("enabled"))); - codexui::UpcomingTurnDraft draft = dock.draft(); - const auto* submittedWorkspace = draft.sandboxPolicy.hasValue() - ? std::get_if(&*draft.sandboxPolicy) - : nullptr; - passed &= expect(submittedWorkspace && submittedWorkspace->networkAccessOrDefault() - && submittedWorkspace->writableRoots == workspacePolicy.writableRoots - && submittedWorkspace->excludeSlashTmp == workspacePolicy.excludeSlashTmp, - "changing only network access must preserve the canonical workspace policy details"); - - access->setCurrentIndex(access->findData(QStringLiteral("read-only"))); - draft = dock.draft(); - const auto* readOnly = draft.sandboxPolicy.hasValue() - ? std::get_if(&*draft.sandboxPolicy) - : nullptr; - passed &= expect(readOnly && readOnly->networkAccessOrDefault(), - "network access must remain enabled when switching to read-only access"); - - access->setCurrentIndex(access->findData(QStringLiteral("danger-full-access"))); - draft = dock.draft(); - passed &= expect(network->currentData().toString() == QStringLiteral("enabled") - && network->currentText() == QStringLiteral("Included") && !network->isEnabled() - && draft.sandboxPolicy.hasValue() - && std::holds_alternative( - *draft.sandboxPolicy), - "full access must clearly display its inherent network access without offering an unsupported override"); - return passed; -} - -bool testTypedModelCatalog() -{ - codexui::UpcomingTurnDock dock; - dock.setActionState(true, false, true, true, false, false); - dock.setCanonicalConfiguration( - configuration("retired-model", typed::ReasoningEffort::high(), "/workspace"), - QStringLiteral("thread-models")); - - typed::Model alpha; - alpha.id = typed::ModelId{"preset-alpha"}; - alpha.model = typed::ModelId{"model-alpha"}; - alpha.displayName = "Alpha model"; - alpha.isDefault = true; - alpha.defaultReasoningEffort = typed::ReasoningEffort{"ultra"}; - alpha.supportedReasoningEfforts = { - typed::ReasoningEffortOption{"Maximum analysis", typed::ReasoningEffort{"ultra"}}}; - alpha.supportsPersonality = false; - typed::Model beta; - beta.id = typed::ModelId{"preset-beta"}; - beta.model = typed::ModelId{"model-beta"}; - beta.displayName = "Beta model"; - beta.defaultReasoningEffort = typed::ReasoningEffort::low(); - beta.supportedReasoningEfforts = { - typed::ReasoningEffortOption{"Fast analysis", typed::ReasoningEffort::low()}}; - beta.supportsPersonality = true; - dock.setModelCatalog({alpha, beta}); - - auto* model = dock.findChild(QStringLiteral("upcomingModel")); - auto* effort = dock.findChild(QStringLiteral("upcomingReasoning")); - auto* personality = dock.findChild(QStringLiteral("upcomingStyle")); - bool passed = expect(model && effort && personality, - "the typed model catalogue must populate model-dependent controls"); - if (!model || !effort || !personality) - return false; - passed &= expect(model->findData(QStringLiteral("model-alpha")) >= 0 - && model->findData(QStringLiteral("model-beta")) >= 0 - && model->findData(QStringLiteral("preset-alpha")) < 0 - && model->currentData().toString() == QStringLiteral("retired-model"), - "model choices must submit the model slug and retain a canonical model absent from the catalogue"); - - codexui::UpcomingTurnDock defaultDock; - defaultDock.setCanonicalConfiguration(std::nullopt, QStringLiteral("new-thread"), true); - defaultDock.setModelCatalog({alpha, beta}); - auto* defaultModel = defaultDock.findChild(QStringLiteral("upcomingModel")); - auto* defaultEffort = defaultDock.findChild(QStringLiteral("upcomingReasoning")); - passed &= expect(defaultModel && defaultEffort - && defaultModel->currentData().toString() == QStringLiteral("model-alpha") - && defaultModel->currentText() == QStringLiteral("Alpha model") - && defaultModel->toolTip() == QStringLiteral("Codex default model") - && defaultEffort->currentData().toString() == QStringLiteral("default") - && defaultEffort->currentText().contains(QStringLiteral("Ultra")) - && defaultDock.draft().empty(), - "a new thread must visibly preselect the advertised model and its Codex reasoning default without submitting overrides"); - - dock.setCanonicalConfiguration( - configuration("model-alpha", typed::ReasoningEffort::high(), "/workspace"), - QStringLiteral("thread-models")); - passed &= expect(model->currentText() == QStringLiteral("Alpha model") - && model->currentData().toString() == QStringLiteral("model-alpha") - && effort->findData(QStringLiteral("ultra")) >= 0 - && !personality->isEnabled(), - "the selected typed model must expose its advertised effort and personality capability"); - - model->setCurrentIndex(model->findData(QStringLiteral("model-beta"))); - beta.displayName = "Beta model updated"; - dock.setModelCatalog({alpha, beta}); - const codexui::UpcomingTurnDraft draft = dock.draft(); - passed &= expect(model->currentText() == QStringLiteral("Beta model updated") - && draft.model.hasValue() - && draft.model->value == "model-beta" - && effort->currentData().toString() == QStringLiteral("low") - && draft.effort.hasValue() && draft.effort->value == "low" - && personality->isEnabled(), - "a model change must preserve its slug and choose an advertised compatible effort"); - return passed; -} - -bool testUpcomingTurnActionStates() -{ - codexui::UpcomingTurnDock dock; - dock.setCanonicalConfiguration( - configuration("gpt-test", typed::ReasoningEffort::high(), "/workspace"), - QStringLiteral("thread-actions")); - auto* editor = dock.findChild(QStringLiteral("upcomingPromptEditor")); - auto* send = dock.findChild(QStringLiteral("upcomingSendButton")); - auto* stop = dock.findChild(QStringLiteral("upcomingStopButton")); - auto* sandbox = dock.findChild(QStringLiteral("upcomingAccess")); - auto* network = dock.findChild(QStringLiteral("upcomingNetwork")); - auto* approval = dock.findChild(QStringLiteral("upcomingApproval")); - bool passed = expect(editor && send && stop && sandbox && network && approval, - "the upcoming-turn action controls must be discoverable"); - if (!editor || !send || !stop || !sandbox || !network || !approval) - return false; - - dock.setActionState(true, - true, - true, - false, - true, - true, - QStringLiteral("thread-actions"), - QStringLiteral("turn-a")); - editor->setPlainText(QStringLiteral("redirect the active turn")); - passed &= expect(!send->isHidden() && send->text() == QStringLiteral("Steer") - && send->isEnabled() && !stop->isHidden() && stop->isEnabled() - && editor->isEnabled() && !sandbox->isEnabled() - && !network->isEnabled() && !approval->isEnabled(), - "a running turn must permit steering and stopping while locking execution settings"); - QString shortcutPrompt; - bool shortcutSteering = false; - int submissionCount = 0; - QObject::connect(&dock, &codexui::UpcomingTurnDock::sendRequested, - [&shortcutPrompt, &shortcutSteering, &submissionCount](const QString& prompt, bool steering) { - ++submissionCount; - shortcutPrompt = prompt; - shortcutSteering = steering; - }); - QKeyEvent submitShortcut(QEvent::KeyPress, Qt::Key_Return, Qt::ControlModifier); - QCoreApplication::sendEvent(editor, &submitShortcut); - passed &= expect(shortcutPrompt == QStringLiteral("redirect the active turn") - && shortcutSteering && submissionCount == 1, - "the extracted prompt editor must preserve Ctrl+Enter submission semantics"); - dock.setActionState(false, - false, - false, - false, - true, - true, - QStringLiteral("thread-actions"), - QStringLiteral("turn-a")); - passed &= expect(!send->isHidden() && send->text() == QStringLiteral("Steer") - && !send->isEnabled() && !stop->isHidden() && !stop->isEnabled() - && !editor->isEnabled(), - "a running write in flight must retain disabled Steer and Stop actions"); - dock.setActionState(true, - true, - true, - false, - true, - true, - QStringLiteral("thread-actions"), - QStringLiteral("turn-b")); - auto* status = dock.findChild(QStringLiteral("upcomingTurnStatus")); - passed &= expect(editor->toPlainText() == QStringLiteral("redirect the active turn") - && !send->isEnabled() && !send->toolTip().isEmpty() - && status && status->text().contains(QStringLiteral("previous active turn")), - "a steering draft must stay bound to its exact active turn and remain blocked after turn rollover"); - QCoreApplication::sendEvent(editor, &submitShortcut); - send->click(); - passed &= expect(submissionCount == 1, - "an obsolete turn-bound control state must not submit through click or Ctrl+Enter"); - editor->insertPlainText(QStringLiteral(" ")); - passed &= expect(send->isEnabled() && send->toolTip().isEmpty() - && status && status->text() == QStringLiteral("Ctrl+Enter to steer"), - "editing a retained steering draft must explicitly bind it to the new active turn"); - - dock.setActionState(false, - false, - false, - false, - false, - false, - QStringLiteral("thread-actions")); - passed &= expect(stop->isHidden() && !send->isHidden() - && send->text() == QStringLiteral("Send") - && !send->isEnabled() && !editor->isEnabled(), - "a non-writable thread must restore the disabled Send action without a stale Stop"); - dock.setActionState(true, false, true, true, false, false); - passed &= expect(!send->isHidden() && editor->isEnabled() - && !send->isEnabled() && !send->toolTip().isEmpty() - && sandbox->isEnabled() && network->isEnabled() && approval->isEnabled(), - "an idle thread must not silently reinterpret a steer draft as a new turn"); - editor->insertPlainText(QStringLiteral(" ")); - passed &= expect(send->isEnabled() && send->toolTip().isEmpty(), - "editing a preserved draft must explicitly bind it to the current Send action"); - editor->setPlainText(QStringLiteral("newer draft")); - dock.clearPromptIfUnchanged(QStringLiteral("accepted steer")); - passed &= expect(editor->toPlainText() == QStringLiteral("newer draft"), - "an accepted steer must not clear a newer prompt draft"); - dock.clearPromptIfUnchanged(QStringLiteral("newer draft")); - passed &= expect(editor->toPlainText().isEmpty(), - "an accepted steer may clear only the exact submitted prompt"); - - QTemporaryDir attachmentDirectory; - QFile screenshot(QDir(attachmentDirectory.path()).filePath(QStringLiteral("screen.png"))); - const bool screenshotWritten = screenshot.open(QIODevice::WriteOnly) - && screenshot.write("fake png") == 8; - screenshot.close(); - QString attachmentError; - passed &= expect(screenshotWritten - && dock.addAttachmentPaths( - {screenshot.fileName()}, &attachmentError), - qPrintable(attachmentError)); - auto* attachmentSummary = dock.findChild( - QStringLiteral("upcomingAttachmentSummary")); - passed &= expect(send->isEnabled() && attachmentSummary - && !attachmentSummary->isHidden() - && dock.attachments().size() == 1, - "an attachment must make an otherwise empty turn sendable and remain inspectable"); - dock.clearAttachmentsIfUnchanged({}); - passed &= expect(dock.attachments().size() == 1, - "a failed or unrelated submission must not clear selected attachments"); - const QList submittedAttachments = dock.attachments(); - dock.clearAttachmentsIfUnchanged(submittedAttachments); - passed &= expect(dock.attachments().isEmpty() && !send->isEnabled(), - "only the exact accepted attachment draft may be cleared"); - return passed; -} - -bool testUnsupportedCanonicalSettingsFailSoft() -{ - codexui::UpcomingTurnDock dock; - dock.setActionState(true, false, true, true, false, false); - sdk::ExecutionConfiguration unsupported = - configuration("gpt-test", typed::ReasoningEffort::high(), "/workspace"); - typed::UnknownSandboxPolicy unknownSandbox; - unknownSandbox.type = "future-sandbox"; - unsupported.sandboxPolicy = std::move(unknownSandbox); - unsupported.approvalPolicy = typed::GranularAskForApproval{}; - dock.setCanonicalConfiguration(unsupported, QStringLiteral("thread-unsupported")); - - auto* sandbox = dock.findChild(QStringLiteral("upcomingAccess")); - auto* approval = dock.findChild(QStringLiteral("upcomingApproval")); - bool passed = expect(sandbox && approval, - "the closed execution-setting controls must be discoverable"); - if (!sandbox || !approval) - return false; - passed &= expect(sandbox->currentData().toString() == QStringLiteral("future-sandbox") - && approval->currentData().toString() == QStringLiteral("granular") - && !sandbox->isEnabled() && !approval->isEnabled() - && dock.draft().empty(), - "unsupported typed policies must remain visible, read-only, and absent from the write draft"); - - dock.setCanonicalConfiguration( - configuration("gpt-test", typed::ReasoningEffort::high(), "/workspace"), - QStringLiteral("thread-supported")); - passed &= expect(sandbox->isEnabled() && approval->isEnabled() - && sandbox->findData(QStringLiteral("future-sandbox")) < 0 - && approval->findData(QStringLiteral("granular")) < 0, - "unsupported fallback choices must not leak into another thread's editable controls"); - return passed; -} - -bool testUnavailableCanonicalSettingsRemainEditable() -{ - codexui::UpcomingTurnDock dock; - dock.setCanonicalConfiguration(std::nullopt, QStringLiteral("thread-partial")); - typed::Model advertisedDefault; - advertisedDefault.id = typed::ModelId{"preset-default"}; - advertisedDefault.model = typed::ModelId{"model-default"}; - advertisedDefault.displayName = "Default model"; - advertisedDefault.defaultReasoningEffort = typed::ReasoningEffort::high(); - advertisedDefault.isDefault = true; - dock.setModelCatalog({advertisedDefault}); - dock.setActionState(true, false, true, true, false, false); - - auto* model = dock.findChild(QStringLiteral("upcomingModel")); - auto* effort = dock.findChild(QStringLiteral("upcomingReasoning")); - auto* sandbox = dock.findChild(QStringLiteral("upcomingAccess")); - auto* approval = dock.findChild(QStringLiteral("upcomingApproval")); - auto* cwd = dock.findChild(QStringLiteral("upcomingWorkspace")); - auto* personality = dock.findChild(QStringLiteral("upcomingStyle")); - auto* reviewer = dock.findChild(QStringLiteral("upcomingApprovalReviewer")); - auto* summary = dock.findChild(QStringLiteral("upcomingReasoningSummary")); - auto* collaboration = dock.findChild(QStringLiteral("upcomingCollaborationMode")); - auto* serviceTier = dock.findChild(QStringLiteral("upcomingServiceTier")); - bool passed = expect(model && effort && sandbox && approval && cwd && personality - && reviewer && summary && collaboration && serviceTier, - "partial-thread settings controls must be discoverable"); - if (!model || !effort || !sandbox || !approval || !cwd || !personality - || !reviewer || !summary || !collaboration || !serviceTier) - return false; - passed &= expect(model->isEnabled() && effort->isEnabled() - && sandbox->isEnabled() && approval->isEnabled() && cwd->isEnabled() - && model->currentData().toString() == QStringLiteral("unavailable") - && model->currentText() == QStringLiteral("Unavailable") - && model->toolTip().isEmpty() - && effort->currentData().toString() == QStringLiteral("unavailable") - && sandbox->currentData().toString() == QStringLiteral("unavailable") - && approval->currentData().toString() == QStringLiteral("unavailable") - && effort->currentText() == QStringLiteral("Unavailable") - && dock.draft().empty(), - "missing canonical thread configuration must remain wholly unavailable without inventing a default-model override"); - const int unavailableModelIndex = model->findData(QStringLiteral("unavailable")); - passed &= expect(unavailableModelIndex >= 0 - && !(model->model()->flags(model->model()->index(unavailableModelIndex, 0)) - & Qt::ItemIsEnabled), - "the unavailable model sentinel must be display-only rather than a writable model choice"); - QStyleOptionComboBox choiceOption; - choiceOption.initFrom(model); - const QRect nativeChoiceIndicator = model->style()->subControlRect( - QStyle::CC_ComboBox, &choiceOption, QStyle::SC_ComboBoxArrow, model); - passed &= expect(model->hasFrame() - && model->property("codexChevron").toBool() - && nativeChoiceIndicator.isValid() && !nativeChoiceIndicator.isEmpty(), - "each upcoming-turn combo must reserve a visible choice-indicator region"); - - model->setCurrentIndex(model->findData(QStringLiteral("model-default"))); - QCoreApplication::processEvents(); - passed &= expect(effort->currentData().toString() == QStringLiteral("high") - && collaboration->isEnabled(), - "choosing a model from unavailable state must establish a valid reasoning value before collaboration is enabled"); - collaboration->setCurrentIndex(collaboration->findData(QStringLiteral("plan"))); - - sandbox->setCurrentIndex(sandbox->findData(QStringLiteral("danger-full-access"))); - approval->setCurrentIndex(approval->findData(QStringLiteral("never"))); - cwd->setText(QStringLiteral("/workspace/explicit")); - QMetaObject::invokeMethod(cwd, "textEdited", Q_ARG(QString, cwd->text())); - const codexui::UpcomingTurnDraft draft = dock.draft(); - passed &= expect(draft.sandboxPolicy.hasValue() - && std::holds_alternative( - *draft.sandboxPolicy) - && draft.approvalPolicy.hasValue() - && std::holds_alternative(*draft.approvalPolicy) - && draft.cwd.hasValue() && *draft.cwd == "/workspace/explicit" - && draft.model.hasValue() && draft.model->value == "model-default" - && draft.effort.hasValue() && draft.effort->value == "high" - && draft.collaborationMode.hasValue() - && draft.collaborationMode->settings.reasoningEffort.hasValue() - && draft.collaborationMode->settings.reasoningEffort->value == "high", - "explicit edits on a partial thread must produce only valid typed turn overrides"); - - dock.setCanonicalConfiguration( - configuration("model-default", typed::ReasoningEffort::high(), "/workspace/stored"), - QStringLiteral("thread-complete")); - passed &= expect(model->findData(QStringLiteral("unavailable")) < 0 - && effort->findData(QStringLiteral("unavailable")) < 0 - && personality->findData(QStringLiteral("unavailable")) < 0 - && sandbox->findData(QStringLiteral("unavailable")) < 0 - && approval->findData(QStringLiteral("unavailable")) < 0 - && reviewer->findData(QStringLiteral("unavailable")) < 0 - && serviceTier->findData(QStringLiteral("unavailable")) < 0 - && summary->findData(QStringLiteral("unavailable")) < 0 - && collaboration->findData(QStringLiteral("unavailable")) < 0, - "display-only unavailable sentinels must not leak into another thread's writable choices"); - return passed; -} - -bool testCollaborationModeSwitching() -{ - codexui::UpcomingTurnDock codeDock; - sdk::ExecutionConfiguration planConfiguration = - configuration("gpt-test", typed::ReasoningEffort::high(), "/workspace"); - planConfiguration.collaborationMode.mode = typed::ModeKind::plan(); - planConfiguration.collaborationMode.settings.developerInstructions = - std::string{"plan-mode-instructions"}; - codeDock.setCanonicalConfiguration(planConfiguration, QStringLiteral("thread-plan")); - codeDock.setActionState(true, false, true, true, false, false); - - auto* codeChoice = codeDock.findChild( - QStringLiteral("upcomingCollaborationMode")); - bool passed = expect(codeChoice - && codeChoice->currentData().toString() == QStringLiteral("plan") - && codeChoice->itemText(codeChoice->findData(QStringLiteral("default"))) - == QStringLiteral("Code"), - "the collaboration selector must present wire default as Code"); - if (!codeChoice) - return false; - - codeChoice->setCurrentIndex(codeChoice->findData(QStringLiteral("default"))); - QCoreApplication::processEvents(); - const codexui::UpcomingTurnDraft codeDraft = codeDock.draft(); - passed &= expect(codeDraft.collaborationMode.hasValue() - && codeDraft.collaborationMode->mode.value == "default" - && codeDraft.collaborationMode->settings.developerInstructions.isNull() - && codeDraft.collaborationMode->settings.model.value == "gpt-test" - && codeDraft.collaborationMode->settings.reasoningEffort.hasValue() - && codeDraft.collaborationMode->settings.reasoningEffort->value == "high", - "Plan to Code must clear Plan instructions and preserve model settings"); - - codexui::UpcomingTurnDock planDock; - sdk::ExecutionConfiguration codeConfiguration = - configuration("gpt-test", typed::ReasoningEffort::high(), "/workspace"); - codeConfiguration.collaborationMode.settings.developerInstructions = - std::string{"code-mode-instructions"}; - planDock.setCanonicalConfiguration(codeConfiguration, QStringLiteral("thread-code")); - planDock.setActionState(true, false, true, true, false, false); - - auto* planChoice = planDock.findChild( - QStringLiteral("upcomingCollaborationMode")); - passed &= expect(planChoice - && planChoice->currentData().toString() == QStringLiteral("default") - && planChoice->currentText() == QStringLiteral("Code"), - "canonical Code mode must remain selected by its default wire key"); - if (!planChoice) - return false; - - planChoice->setCurrentIndex(planChoice->findData(QStringLiteral("plan"))); - QCoreApplication::processEvents(); - const codexui::UpcomingTurnDraft planDraft = planDock.draft(); - passed &= expect(planDraft.collaborationMode.hasValue() - && planDraft.collaborationMode->mode.value == "plan" - && planDraft.collaborationMode->settings.developerInstructions.isNull() - && planDraft.collaborationMode->settings.model.value == "gpt-test" - && planDraft.collaborationMode->settings.reasoningEffort.hasValue() - && planDraft.collaborationMode->settings.reasoningEffort->value == "high", - "Code to Plan must clear Code instructions and preserve model settings"); - return passed; -} - -bool setInstructions(codexui::ThreadSetupDialog& dialog, - const QString& base, - const QString& developer) -{ - auto* baseEdit = dialog.findChild(QStringLiteral("baseInstructionsEdit")); - auto* developerEdit = - dialog.findChild(QStringLiteral("developerInstructionsEdit")); - if (!baseEdit || !developerEdit) - return false; - baseEdit->setPlainText(base); - developerEdit->setPlainText(developer); - return true; -} - -bool testThreadSetupResults() -{ - codexui::ThreadSetupDialog newThread(codexui::ThreadSetupDialog::Mode::NewThread); - newThread.show(); - settleEvents(); - auto* newThreadName = newThread.findChild(QStringLiteral("threadNameEdit")); - bool passed = expect(newThreadName && newThreadName->hasFocus(), - "New Thread must initially focus its primary name field"); - newThread.setSuggestedThreadName(QStringLiteral("Chosen name")); - newThread.setTemporary(true); - passed &= expect(setInstructions(newThread, - QStringLiteral("Base α"), - QStringLiteral("Developer β")), - "the New Thread dialog must expose both foundational instruction fields"); - // result() returns a value, so retain one variant for safe inspection. - const codexui::ThreadSetupResult createdResult = newThread.result(); - const auto* created = std::get_if(&createdResult); - passed &= expect(created && created->name == QStringLiteral("Chosen name") - && created->temporary - && created->instructions.baseInstructions == QStringLiteral("Base α") - && created->instructions.developerInstructions - == QStringLiteral("Developer β"), - "New Thread must return name, lifetime and exact foundational instructions"); - - codexui::ThreadSetupDialog fork(codexui::ThreadSetupDialog::Mode::ForkThread); - fork.setSuggestedThreadName(QStringLiteral("Forked name")); - fork.setTemporary(false); - passed &= expect(setInstructions(fork, QString{}, QStringLiteral("Fork constraint")), - "the Fork dialog must expose both foundational instruction fields"); - const codexui::ThreadSetupResult forkResult = fork.result(); - const auto* forked = std::get_if(&forkResult); - passed &= expect(forked && forked->name == QStringLiteral("Forked name") - && !forked->temporary && forked->instructions.baseInstructions.isEmpty() - && forked->instructions.developerInstructions - == QStringLiteral("Fork constraint"), - "Fork must preserve blank-as-inherit semantics and its explicit instruction override"); - - codexui::ThreadSetupDialog resume( - codexui::ThreadSetupDialog::Mode::ResumeWithOptions); - passed &= expect(setInstructions(resume, - QStringLiteral("Resume base"), - QStringLiteral("Resume developer")), - "Resume with options must expose both foundational instruction fields"); - const codexui::ThreadSetupResult resumeResult = resume.result(); - const auto* resumed = std::get_if(&resumeResult); - passed &= expect(resumed - && resumed->instructions.baseInstructions - == QStringLiteral("Resume base") - && resumed->instructions.developerInstructions - == QStringLiteral("Resume developer") - && !resume.findChild(QStringLiteral("threadNameEdit")) - && !resume.findChild( - QStringLiteral("temporaryThreadCheckBox")), - "Resume with options must contain only its supported foundational overrides"); - return passed; -} - -bool testThreadActionGating() -{ - const sdk::State emptyState; - sdk::ThreadState thread; - thread.id = typed::ThreadId{"thread-actions"}; - thread.status = "idle"; - thread.fullyLoaded = true; - thread.archived = false; - - const codexui::ThreadActionAvailability idle = - codexui::detail::threadActionAvailability(emptyState, thread); - bool passed = expect(idle.open && idle.rename && idle.fork && idle.resumeWithOptions - && idle.archive && idle.remove && !idle.interrupt && !idle.unarchive, - "an idle fully loaded thread must expose only its safe lifecycle actions"); - - thread.archived = true; - const codexui::ThreadActionAvailability archived = - codexui::detail::threadActionAvailability(emptyState, thread); - passed &= expect(archived.open && archived.rename && archived.fork && archived.unarchive - && archived.remove && archived.resumeWithOptions && !archived.archive - && !archived.interrupt, - "an archived thread must remain forkable/resumable and expose Unarchive"); - - thread.archived = false; - thread.status = "detached"; - thread.fullyLoaded = true; - const codexui::ThreadActionAvailability detached = - codexui::detail::threadActionAvailability(emptyState, thread); - passed &= expect(detached.open && detached.rename && detached.fork - && detached.resumeWithOptions && detached.archive && !detached.unarchive - && detached.remove && !detached.interrupt, - "a raw provider status without an active typed turn must not masquerade as running"); - - thread.fullyLoaded = false; - const codexui::ThreadActionAvailability partial = - codexui::detail::threadActionAvailability(emptyState, thread); - passed &= expect(partial.open && partial.rename && !partial.fork && !partial.resumeWithOptions - && !partial.archive && !partial.unarchive && !partial.remove - && !partial.interrupt, - "a partial potentially-running thread must fail closed for destructive actions"); - return passed; -} - -bool testScopedDuplicateTurnActionGating() -{ - const sdk::State state = threadDiscoveryState( - {{"completed-thread", false}, {"running-thread", false}}, - std::string{"running-thread"}); - const auto* completed = state.thread("completed-thread"); - const auto* running = state.thread("running-thread"); - bool passed = expect(completed && running, - "the duplicate-turn action fixture must retain both parent threads"); - if (!completed || !running) - return false; - - const auto completedStatus = codexui::detail::threadUiStatus(state, *completed); - const auto runningStatus = codexui::detail::threadUiStatus(state, *running, true); - const auto& completedActions = completedStatus.actions; - const auto& runningActions = runningStatus.actions; - passed &= expect(!completedActions.interrupt && completedActions.archive - && completedActions.remove && !completedStatus.running - && !completedStatus.awaitingResponse, - "a completed scoped turn must derive one non-running presentation without inheriting sibling state"); - passed &= expect(runningActions.interrupt && !runningActions.archive - && !runningActions.remove && runningStatus.running - && runningStatus.awaitingResponse, - "an active scoped turn must derive its running, attention, and action presentation together"); - return passed; -} - -bool testTargetedSidebarRefreshKeepsUnchangedRows() -{ - const sdk::State initial = threadDiscoveryState( - {{"thread-a", false}, {"thread-b", false}}, std::string{"no-active-thread"}); - const sdk::State updated = threadDiscoveryState( - {{"thread-a", false}, {"thread-b", false}}, std::string{"thread-a"}); - codexui::SidebarWidget sidebar; - sidebar.setThreads(initial, QStringLiteral("thread-a"), true); - - const auto rowFor = [&sidebar](const QString& threadId) -> QFrame* { - for (QFrame* row : sidebar.findChildren(QStringLiteral("threadRow"))) { - if (row->property("threadId").toString() == threadId) - return row; - } - return nullptr; - }; - const auto detailsFor = [](QFrame* row) { - if (!row) - return QString{}; - for (QLabel* label : row->findChildren()) { - if (label->property("kind").toString() == QStringLiteral("meta")) - return label->toolTip(); - } - return QString{}; - }; - - QFrame* threadARow = rowFor(QStringLiteral("thread-a")); - QFrame* threadBRow = rowFor(QStringLiteral("thread-b")); - const QString threadBBefore = detailsFor(threadBRow); - sidebar.updateThreads(updated, - QStringLiteral("thread-a"), - true, - {QStringLiteral("thread-a")}); - - bool passed = expect(threadARow && threadBRow - && rowFor(QStringLiteral("thread-a")) == threadARow - && rowFor(QStringLiteral("thread-b")) == threadBRow, - "a targeted Sidebar update must retain existing row widgets"); - passed &= expect(detailsFor(threadARow).contains(QStringLiteral("Running")) - && detailsFor(threadBRow) == threadBBefore, - "a targeted Sidebar update must recompute only the affected row"); - - const sdk::State removed = threadDiscoveryState({{"thread-b", false}}); - sidebar.updateThreads(removed, - QStringLiteral("thread-a"), - true, - {QStringLiteral("thread-a")}); - settleEvents(); - passed &= expect(rowFor(QStringLiteral("thread-a")) == nullptr - && rowFor(QStringLiteral("thread-b")) != nullptr, - "a targeted removal must fall back to authoritative tree reconstruction"); - - sidebar.updateThreads(initial, - QStringLiteral("thread-a"), - true, - {QStringLiteral("thread-a")}); - settleEvents(); - passed &= expect(rowFor(QStringLiteral("thread-a")) != nullptr - && rowFor(QStringLiteral("thread-b")) != nullptr, - "a targeted insertion must fall back to authoritative tree reconstruction"); - - const sdk::State archived = threadDiscoveryState( - {{"thread-a", true}, {"thread-b", false}}); - sidebar.updateThreads(archived, - QStringLiteral("thread-a"), - true, - {QStringLiteral("thread-a")}); - settleEvents(); - passed &= expect(detailsFor(rowFor(QStringLiteral("thread-a"))) - .contains(QStringLiteral("Archived")), - "an archive-boundary change must rebuild the thread hierarchy"); - return passed; -} - -bool testThreadOrganizationPersistenceAndSafeMoves() -{ - QTemporaryDir temporaryDirectory; - if (!expect(temporaryDirectory.isValid(), "the thread-organization test needs a temporary settings directory")) - return false; - const QString settingsPath = temporaryDirectory.filePath(QStringLiteral("organization.ini")); - QSettings settings(settingsPath, QSettings::IniFormat); - - codexui::detail::ThreadOrganization organization; - organization.load(settings); - const QString project = organization.createFolder(QStringLiteral("Project")); - const QString subproject = organization.createFolder(QStringLiteral("Subproject"), project); - const QString leaf = organization.createFolder(QStringLiteral("Deep work"), subproject); - const QString sibling = organization.createFolder(QStringLiteral("Sibling"), project); - const QString promotionCollision = organization.createFolder(QStringLiteral("Deep work"), project); - - bool passed = expect(!project.isEmpty() && !subproject.isEmpty() && !leaf.isEmpty() - && !sibling.isEmpty() && !promotionCollision.isEmpty(), - "nested thread folders must be creatable"); - passed &= expect(organization.folderPath(leaf) - == QStringLiteral("Project › Subproject › Deep work"), - "folder paths must preserve the complete user-defined hierarchy"); - passed &= expect(organization.createFolder(QStringLiteral("sibling"), project).isEmpty(), - "folder names must be unique among siblings without case ambiguity"); - passed &= expect(organization.createFolder(QStringLiteral("invalid\tname"), project).isEmpty(), - "folder names must reject control characters that change menu presentation"); - passed &= expect(organization.createFolder(QStringLiteral("invalid\u2028name"), project).isEmpty(), - "folder names must reject Unicode line separators that change menu presentation"); - const QSet subprojectDestinations = organization.movableFolderParents(subproject); - passed &= expect(subprojectDestinations.contains(QString{}) - && subprojectDestinations.contains(project) - && subprojectDestinations.contains(sibling) - && !subprojectDestinations.contains(subproject) - && !subprojectDestinations.contains(leaf), - "folder move destinations must be computed once and exclude the moved subtree"); - passed &= expect(!organization.moveFolder(project, leaf), - "moving a folder below its own descendant must fail closed"); - passed &= expect(organization.moveThread(QStringLiteral("thread-direct"), subproject) - && organization.moveThread(QStringLiteral("thread-leaf"), leaf), - "stable thread IDs must be movable into nested folders"); - passed &= expect(organization.setFolderExpanded(subproject, false), - "folder disclosure state must be retained as local presentation metadata"); - - passed &= expect(organization.save(settings), - "valid thread organization must fit its persistence budget"); - settings.sync(); - codexui::detail::ThreadOrganization restored; - restored.load(settings); - passed &= expect(restored.folderPath(leaf) - == QStringLiteral("Project › Subproject › Deep work") - && restored.folderForThread(QStringLiteral("thread-direct")) == subproject - && restored.folderForThread(QStringLiteral("thread-leaf")) == leaf - && restored.folder(subproject) && !restored.folder(subproject)->expanded, - "nested folders, assignments, and disclosure state must survive restart"); - - QSettings::setDefaultFormat(QSettings::IniFormat); - QSettings::setPath(QSettings::IniFormat, QSettings::UserScope, temporaryDirectory.path()); - QCoreApplication::setOrganizationName(QStringLiteral("CodexUITest")); - QCoreApplication::setApplicationName(QStringLiteral("ThreadOrganization")); - QSettings defaultSettings; - passed &= expect(restored.save(defaultSettings), - "restored thread organization must remain persistable"); - defaultSettings.sync(); - auto sidebar = std::make_unique(); - sidebar->setThreads(sdk::State{}, {}, false); - auto* tree = sidebar->findChild(QStringLiteral("threadTree")); - bool sawProjectFolder = false; - bool sawNestedFolder = false; - if (tree) { - QTreeWidgetItemIterator iterator(tree); - while (*iterator) { - sawProjectFolder = sawProjectFolder || (*iterator)->text(0) == QStringLiteral("Project"); - sawNestedFolder = sawNestedFolder || (*iterator)->text(0) == QStringLiteral("Subproject"); - ++iterator; - } - } - passed &= expect(tree && sawProjectFolder && sawNestedFolder, - "the native sidebar tree must render saved nested folders even before threads are synchronized"); - auto concurrentSidebar = std::make_unique(); - auto* firstNewFolder = sidebar->findChild(QStringLiteral("newThreadFolderButton")); - auto* concurrentNewFolder = - concurrentSidebar->findChild(QStringLiteral("newThreadFolderButton")); - passed &= expect(firstNewFolder && firstNewFolder->isEnabled() && concurrentNewFolder - && !concurrentNewFolder->isEnabled(), - "a second CodexUI window must treat shared folder organization as read-only instead of overwriting it"); - sidebar.reset(); - concurrentSidebar->setThreads(sdk::State{}, {}, false); - passed &= expect(concurrentNewFolder->isEnabled(), - "a read-only CodexUI window must acquire and reload thread organization after the writer exits"); - - passed &= expect(restored.removeFolderAndPromoteContents(subproject), - "a folder must be safely removable without deleting canonical threads"); - passed &= expect(restored.folderForThread(QStringLiteral("thread-direct")) == project - && restored.folderForThread(QStringLiteral("thread-leaf")) == leaf - && restored.folder(leaf) && restored.folder(leaf)->parentId == project - && restored.folder(leaf)->name == QStringLiteral("Deep work (2)"), - "folder deletion must promote direct threads and subfolders without sibling-name collisions"); - passed &= expect(restored.moveThread(QStringLiteral("thread-direct"), {}) - && restored.folderForThread(QStringLiteral("thread-direct")).isEmpty(), - "threads must be movable back to the unfiled root"); - - QSettings malformedSettings(temporaryDirectory.filePath(QStringLiteral("malformed.ini")), - QSettings::IniFormat); - QJsonArray malformedFolders{ - QJsonObject{{QStringLiteral("id"), QStringLiteral("control")}, - {QStringLiteral("name"), QStringLiteral("bad\tname")}}, - QJsonObject{{QStringLiteral("id"), QStringLiteral("duplicate-a")}, - {QStringLiteral("name"), QStringLiteral("Same")}}, - QJsonObject{{QStringLiteral("id"), QStringLiteral("duplicate-b")}, - {QStringLiteral("name"), QStringLiteral("same")}}}; - QString parentId; - for (int depth = 0; depth < 40; ++depth) { - const QString id = QStringLiteral("depth-%1").arg(depth); - malformedFolders.append(QJsonObject{{QStringLiteral("id"), id}, - {QStringLiteral("name"), id}, - {QStringLiteral("parentId"), parentId}}); - parentId = id; - } - malformedSettings.setValue( - QStringLiteral("sidebar/threadOrganizationV1"), - QJsonDocument(QJsonObject{ - {QStringLiteral("folders"), malformedFolders}, - {QStringLiteral("threadFolders"), - QJsonObject{{QStringLiteral("bad\tthread"), QStringLiteral("duplicate-a")}}}}) - .toJson(QJsonDocument::Compact)); - codexui::detail::ThreadOrganization normalized; - normalized.load(malformedSettings); - passed &= expect(!normalized.folder(QStringLiteral("control")) - && normalized.folder(QStringLiteral("duplicate-a")) - && normalized.folder(QStringLiteral("duplicate-b")) - && normalized.folder(QStringLiteral("duplicate-a"))->name - != normalized.folder(QStringLiteral("duplicate-b"))->name - && normalized.folderForThread(QStringLiteral("bad\tthread")).isEmpty() - && normalized.folder(parentId) - && normalized.folderPath(parentId).count(QChar(0x203a)) - < static_cast(32), - "loaded folder metadata must reject control text, normalize duplicate siblings, and cap hierarchy depth"); - - passed &= expect(restored.moveThread(QStringLiteral("thread-obsolete"), project) - && restored.retainThreadAssignments( - QSet{QStringLiteral("thread-leaf")}) - && restored.folderForThread(QStringLiteral("thread-obsolete")).isEmpty() - && restored.folderForThread(QStringLiteral("thread-leaf")) == leaf - && !restored.retainThreadAssignments( - QSet{QStringLiteral("thread-leaf")}), - "an authoritative thread list must prune only stale local assignments"); - - QSettings assignmentLimitSettings( - temporaryDirectory.filePath(QStringLiteral("assignment-limit.ini")), - QSettings::IniFormat); - codexui::detail::ThreadOrganization assignmentLimited; - assignmentLimited.load(assignmentLimitSettings); - const QString assignmentFolder = assignmentLimited.createFolder(QStringLiteral("Folder")); - bool acceptedAssignmentBudget = !assignmentFolder.isEmpty(); - for (int index = 0; index < 8'192 && acceptedAssignmentBudget; ++index) { - acceptedAssignmentBudget = assignmentLimited.moveThread( - QStringLiteral("thread-%1").arg(index), assignmentFolder); - } - passed &= expect(acceptedAssignmentBudget - && !assignmentLimited.moveThread(QStringLiteral("thread-over-limit"), - assignmentFolder) - && !assignmentLimited.moveThread(QString(1'025, QLatin1Char('x')), - assignmentFolder) - && !assignmentLimited.moveThread(QStringLiteral("bad\tthread"), - assignmentFolder), - "runtime thread moves must enforce assignment-count and identifier bounds"); - - QSettings storageLimitSettings( - temporaryDirectory.filePath(QStringLiteral("storage-limit.ini")), - QSettings::IniFormat); - codexui::detail::ThreadOrganization storageLimited; - storageLimited.load(storageLimitSettings); - const QString storageFolder = storageLimited.createFolder(QStringLiteral("Folder")); - QString lastAcceptedThread; - QString rejectedThread; - for (int index = 0; index < 8'192; ++index) { - const QString prefix = QStringLiteral("%1:").arg(index); - const QString threadId = prefix + QString(1'024 - prefix.size(), QLatin1Char('"')); - if (!storageLimited.moveThread(threadId, storageFolder)) { - rejectedThread = threadId; - break; - } - lastAcceptedThread = threadId; - } - const bool storageSaved = storageLimited.save(storageLimitSettings); - storageLimitSettings.sync(); - const QByteArray storedOrganization = storageLimitSettings - .value(QStringLiteral("sidebar/threadOrganizationV1")) - .toByteArray(); - codexui::detail::ThreadOrganization storageRestored; - storageRestored.load(storageLimitSettings); - passed &= expect(!storageFolder.isEmpty() && !lastAcceptedThread.isEmpty() - && !rejectedThread.isEmpty() && storageSaved - && storedOrganization.size() <= 4 * 1'024 * 1'024 - && storageRestored.folderForThread(lastAcceptedThread) == storageFolder - && storageRestored.folderForThread(rejectedThread).isEmpty(), - "runtime moves must never create a thread organization larger than its 4 MiB load limit"); - return passed; -} - -bool testArchivedThreadAssignmentPruningWaitsForCompleteDiscovery() -{ - QTemporaryDir temporaryDirectory; - if (!expect(temporaryDirectory.isValid(), - "the archived-thread organization test needs temporary settings")) - return false; - - QSettings::setDefaultFormat(QSettings::IniFormat); - QSettings::setPath(QSettings::IniFormat, QSettings::UserScope, - temporaryDirectory.path()); - QCoreApplication::setOrganizationName(QStringLiteral("CodexUITest")); - QCoreApplication::setApplicationName(QStringLiteral("ArchivedThreadOrganization")); - - constexpr auto archivedThreadId = "thread-archived"; - QSettings settings; - codexui::detail::ThreadOrganization seed; - seed.load(settings); - const QString folderId = seed.createFolder(QStringLiteral("Archived work")); - bool passed = expect(!folderId.isEmpty() - && seed.moveThread(QString::fromLatin1(archivedThreadId), folderId) - && seed.save(settings), - "the archived thread must start with a persisted folder assignment"); - settings.sync(); - - codexui::SidebarWidget sidebar; - sidebar.setThreads(threadDiscoveryState({{"thread-active", false}}), {}, false); - - QSettings afterActiveDiscoverySettings; - codexui::detail::ThreadOrganization afterActiveDiscovery; - afterActiveDiscovery.load(afterActiveDiscoverySettings); - passed &= expect(afterActiveDiscovery.folderForThread( - QString::fromLatin1(archivedThreadId)) == folderId, - "a complete active-thread page must not prune archived assignments before archived discovery completes"); - - sidebar.setThreads( - threadDiscoveryState({{"thread-active", false}, {archivedThreadId, true}}), {}, true); - QSettings afterArchivedDiscoverySettings; - codexui::detail::ThreadOrganization afterArchivedDiscovery; - afterArchivedDiscovery.load(afterArchivedDiscoverySettings); - auto* tree = sidebar.findChild(QStringLiteral("threadTree")); - bool archivedFolderRendered = false; - if (tree) { - QTreeWidgetItemIterator iterator(tree); - while (*iterator) { - const QTreeWidgetItem* item = *iterator; - if (item->text(0) == QStringLiteral("Archived work") && item->parent() - && item->parent()->text(0) == QStringLiteral("ARCHIVED")) { - archivedFolderRendered = true; - break; - } - ++iterator; - } - } - passed &= expect(afterArchivedDiscovery.folderForThread( - QString::fromLatin1(archivedThreadId)) == folderId - && archivedFolderRendered, - "the later archived-thread result must retain and render its saved folder assignment"); - - sidebar.setThreads(threadDiscoveryState({{"thread-active", false}}), {}, true); - QSettings afterCompleteDiscoverySettings; - codexui::detail::ThreadOrganization afterCompleteDiscovery; - afterCompleteDiscovery.load(afterCompleteDiscoverySettings); - passed &= expect(afterCompleteDiscovery.folderForThread( - QString::fromLatin1(archivedThreadId)).isEmpty(), - "an actually absent thread assignment must be pruned after all thread discovery completes"); - return passed; -} - -bool testMissingSelectedThreadRetentionPolicy() -{ - using codexui::detail::shouldClearMissingSelectedThread; - return expect( - !shouldClearMissingSelectedThread(true, true, false, 1, false) - && shouldClearMissingSelectedThread(true, true, false, 0, false) - && shouldClearMissingSelectedThread(true, true, false, 1, true) - && shouldClearMissingSelectedThread(false, false, true, 1, true) - && !shouldClearMissingSelectedThread(false, true, false, 0, false) - && !shouldClearMissingSelectedThread(true, false, false, 0, false) - && !shouldClearMissingSelectedThread(true, true, true, 0, false), - "a missing selection must survive only incomplete discovery, pending creation, or unresolved snapshot omission"); -} - -bool testProjectedSelectionReconnectRecoveryPolicy() -{ - using codexui::detail::shouldRetryProjectedSelectionAfterReady; - return expect( - shouldRetryProjectedSelectionAfterReady( - true, - QStringLiteral("projected-thread"), - QStringLiteral("projected-thread")) - && !shouldRetryProjectedSelectionAfterReady( - false, - QStringLiteral("projected-thread"), - QStringLiteral("projected-thread")) - && !shouldRetryProjectedSelectionAfterReady( - true, - QStringLiteral("ordinary-thread"), - QStringLiteral("projected-thread")) - && !shouldRetryProjectedSelectionAfterReady( - true, QString{}, QString{}), - "only a retained projected-agent selection may receive one retry at a new Ready boundary"); -} - -} // namespace - -int main(int argc, char** argv) -{ - if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) - qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen")); - QApplication application(argc, argv); - application.setStyleSheet(codexui::UiStyle::applicationStyleSheet()); - - bool passed = true; - passed &= testUpcomingTurnCanonicalRebase(); - passed &= testAnchoredGrowingComposer(); - passed &= testNarrowUpcomingTurnLayout(); - passed &= testUpcomingTurnNetworkAccess(); - passed &= testTypedModelCatalog(); - passed &= testUpcomingTurnActionStates(); - passed &= testUnsupportedCanonicalSettingsFailSoft(); - passed &= testUnavailableCanonicalSettingsRemainEditable(); - passed &= testCollaborationModeSwitching(); - passed &= testThreadSetupResults(); - passed &= testThreadActionGating(); - passed &= testScopedDuplicateTurnActionGating(); - passed &= testTargetedSidebarRefreshKeepsUnchangedRows(); - passed &= testThreadOrganizationPersistenceAndSafeMoves(); - passed &= testArchivedThreadAssignmentPruningWaitsForCompleteDiscovery(); - passed &= testMissingSelectedThreadRetentionPolicy(); - passed &= testProjectedSelectionReconnectRecoveryPolicy(); - return passed ? 0 : 1; -} diff --git a/tests/PresentationRefreshAccumulatorTest.cpp b/tests/PresentationRefreshAccumulatorTest.cpp deleted file mode 100644 index 7dd5530..0000000 --- a/tests/PresentationRefreshAccumulatorTest.cpp +++ /dev/null @@ -1,280 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "ui/PresentationRefreshAccumulator.h" - -#include - -#include - -namespace { - -namespace detail = codexui::detail; -namespace client = ai::openai::codex::frontend::client; - -bool expect(bool condition, const char* message) -{ - if (!condition) - std::cerr << message << '\n'; - return condition; -} - -detail::StateUpdateScope::ItemContentIdentity identity( - int index, - QByteArray delta = {}, - std::uint64_t base = 0, - std::uint64_t discard = 0) -{ - detail::StateUpdateScope::ItemContentIdentity result; - result.threadId = QStringLiteral("thread"); - result.turnId = QStringLiteral("turn"); - result.itemId = QStringLiteral("item-%1").arg(index); - result.channel = client::ItemContentChannel::CommandOutput; - if (!delta.isNull()) - { - result.append = detail::StateUpdateScope::ItemContentAppend{ - base, discard, std::move(delta)}; - } - return result; -} - -bool testIdentityBound() -{ - codexui::ConversationContentUpdates updates; - std::uint64_t retainedBytes = 0; - bool passed = true; - for (qsizetype index = 0; - index < detail::maximumCoalescedPresentationIdentities; - ++index) - { - passed &= detail::mergeConversationContentUpdate( - updates, retainedBytes, identity(static_cast(index))) - == detail::BoundedMergeResult::Retained; - } - const auto retainedSize = updates.size(); - passed &= expect( - detail::mergeConversationContentUpdate( - updates, - retainedBytes, - identity(static_cast(detail::maximumCoalescedPresentationIdentities))) - == detail::BoundedMergeResult::CapacityExceeded - && updates.size() == retainedSize && retainedBytes == 0, - "the frame accumulator must retain exactly 1024 identities and reject the 1025th without mutation"); - return passed; -} - -bool testAggregateByteBound() -{ - codexui::ConversationContentUpdates updates; - std::uint64_t retainedBytes = 0; - QByteArray maximum( - static_cast(detail::maximumCoalescedContentDeltaBytes), 'x'); - bool passed = detail::mergeConversationContentUpdate( - updates, retainedBytes, identity(0, std::move(maximum))) - == detail::BoundedMergeResult::Retained; - const auto retainedSize = updates.size(); - passed &= expect( - detail::mergeConversationContentUpdate( - updates, - retainedBytes, - identity(1, QByteArray(1, 'y'))) - == detail::BoundedMergeResult::CapacityExceeded - && updates.size() == retainedSize - && retainedBytes == detail::maximumCoalescedContentDeltaBytes, - "the frame accumulator must reject the first byte beyond its aggregate 1 MiB bound without mutation"); - return passed; -} - -bool testReplacementReleasesRetainedBytes() -{ - codexui::ConversationContentUpdates updates; - std::uint64_t retainedBytes = 0; - const auto half = detail::maximumCoalescedContentDeltaBytes / 2; - bool passed = detail::mergeConversationContentUpdate( - updates, - retainedBytes, - identity(0, QByteArray(static_cast(half), 'a'))) - == detail::BoundedMergeResult::Retained; - passed &= detail::mergeConversationContentUpdate( - updates, - retainedBytes, - identity(0, QByteArray(1, 'b'), 9'999)) - == detail::BoundedMergeResult::Retained; - passed &= expect(retainedBytes == 0 && updates.size() == 1 - && !updates.front().append, - "a non-contiguous update must become an authoritative replacement and release retained delta bytes"); - passed &= expect( - detail::mergeConversationContentUpdate( - updates, - retainedBytes, - identity(1, - QByteArray( - static_cast(detail::maximumCoalescedContentDeltaBytes), - 'c'))) - == detail::BoundedMergeResult::Retained - && retainedBytes == detail::maximumCoalescedContentDeltaBytes, - "released replacement bytes must be available to a later independent exact append"); - return passed; -} - -bool testContiguousAppendMerge() -{ - codexui::ConversationContentUpdates updates; - std::uint64_t retainedBytes = 0; - bool passed = detail::mergeConversationContentUpdate( - updates, retainedBytes, identity(0, QByteArray("abc"), 10)) - == detail::BoundedMergeResult::Retained; - passed &= detail::mergeConversationContentUpdate( - updates, retainedBytes, identity(0, QByteArray("def"), 13)) - == detail::BoundedMergeResult::Retained; - passed &= expect(updates.size() == 1 && updates.front().append - && updates.front().append->baseContentBytes == 10 - && updates.front().append->deltaUtf8Bytes == 6 - && updates.front().append->delta == QStringLiteral("abcdef") - && retainedBytes == 6, - "contiguous updates for one channel must merge with exact byte accounting"); - return passed; -} - -detail::StateUpdateScope structuralScope() -{ - detail::StateUpdateScope scope; - scope.affectedThreadIds.push_back(QStringLiteral("thread")); - scope.structurallyAffectedThreadIds.push_back(QStringLiteral("thread")); - return scope; -} - -detail::StateUpdateScope exactScope() -{ - detail::StateUpdateScope scope; - scope.affectedThreadIds.push_back(QStringLiteral("thread")); - scope.affectedItemContents.push_back( - identity(0, QByteArray("append"), 7)); - return scope; -} - -bool retainedStructuralAppend( - const detail::SelectedPresentationRefreshAccumulator& accumulator) -{ - return accumulator.refreshPending - && !accumulator.fullRefreshPending - && accumulator.structuralReconciliationPending - && accumulator.contentChanges.size() == 1 - && accumulator.contentChanges.front().append - && accumulator.contentChanges.front().append->baseContentBytes == 7 - && accumulator.contentChanges.front().append->delta - == QStringLiteral("append") - && accumulator.retainedContentUtf8Bytes == 6; -} - -bool testStructuralAndExactMergeInBothOrders() -{ - const auto structural = structuralScope(); - const auto exact = exactScope(); - detail::SelectedPresentationRefreshAccumulator structuralThenExact; - detail::mergeSelectedPresentationRefresh( - structuralThenExact, structural, QStringLiteral("thread"), false); - detail::mergeSelectedPresentationRefresh( - structuralThenExact, exact, QStringLiteral("thread"), false); - - detail::SelectedPresentationRefreshAccumulator exactThenStructural; - detail::mergeSelectedPresentationRefresh( - exactThenStructural, exact, QStringLiteral("thread"), false); - detail::mergeSelectedPresentationRefresh( - exactThenStructural, structural, QStringLiteral("thread"), false); - - return expect( - retainedStructuralAppend(structuralThenExact) - && retainedStructuralAppend(exactThenStructural), - "structural reconciliation and exact append metadata must survive frame accumulation in both arrival orders"); -} - -bool testFullRefreshDominatesStructuralAndExact() -{ - auto structural = structuralScope(); - auto exact = exactScope(); - detail::StateUpdateScope full; - full.affectedThreadIds.push_back(QStringLiteral("thread")); - full.fullyAffectedThreadIds.push_back(QStringLiteral("thread")); - - detail::SelectedPresentationRefreshAccumulator accumulator; - detail::mergeSelectedPresentationRefresh( - accumulator, structural, QStringLiteral("thread"), false); - detail::mergeSelectedPresentationRefresh( - accumulator, exact, QStringLiteral("thread"), false); - detail::mergeSelectedPresentationRefresh( - accumulator, full, QStringLiteral("thread"), false); - bool passed = expect( - accumulator.refreshPending && accumulator.fullRefreshPending - && !accumulator.structuralReconciliationPending - && accumulator.contentChanges.empty() - && accumulator.retainedContentUtf8Bytes == 0, - "a later deletion-capable refresh must dominate structural and exact presentation metadata"); - - detail::mergeSelectedPresentationRefresh( - accumulator, structural, QStringLiteral("thread"), false); - detail::mergeSelectedPresentationRefresh( - accumulator, exact, QStringLiteral("thread"), false); - passed &= expect( - accumulator.fullRefreshPending - && !accumulator.structuralReconciliationPending - && accumulator.contentChanges.empty(), - "structural and exact publications must not weaken an accumulated full refresh"); - return passed; -} - -bool testUnscopedChangeFallsBackToFullRefresh() -{ - detail::StateUpdateScope unscoped; - unscoped.affectedThreadIds.push_back(QStringLiteral("thread")); - detail::SelectedPresentationRefreshAccumulator accumulator; - detail::mergeSelectedPresentationRefresh( - accumulator, unscoped, QStringLiteral("thread"), false); - return expect( - accumulator.refreshPending && accumulator.fullRefreshPending - && !accumulator.structuralReconciliationPending - && accumulator.contentChanges.empty(), - "a selected update without structural or exact metadata must retain the authoritative refresh fallback"); -} - -bool testSidebarIdentityBound() -{ - QStringList ordered; - QSet retained; - bool passed = true; - for (qsizetype index = 0; - index < detail::maximumCoalescedPresentationIdentities; - ++index) - { - const QString id = QStringLiteral("thread-%1").arg(index); - passed &= detail::appendUniqueSidebarThread(ordered, retained, id) - == detail::BoundedMergeResult::Retained; - passed &= detail::appendUniqueSidebarThread(ordered, retained, id) - == detail::BoundedMergeResult::Retained; - } - passed &= expect( - ordered.size() == detail::maximumCoalescedPresentationIdentities - && retained.size() == detail::maximumCoalescedPresentationIdentities - && detail::appendUniqueSidebarThread( - ordered, retained, QStringLiteral("thread-overflow")) - == detail::BoundedMergeResult::CapacityExceeded - && ordered.size() == detail::maximumCoalescedPresentationIdentities, - "sidebar duplicates must not consume capacity and the 1025th unique identity must be rejected"); - return passed; -} - -} // namespace - -int main(int argc, char** argv) -{ - QCoreApplication application(argc, argv); - bool passed = true; - passed &= testIdentityBound(); - passed &= testAggregateByteBound(); - passed &= testReplacementReleasesRetainedBytes(); - passed &= testContiguousAppendMerge(); - passed &= testStructuralAndExactMergeInBothOrders(); - passed &= testFullRefreshDominatesStructuralAndExact(); - passed &= testUnscopedChangeFallsBackToFullRefresh(); - passed &= testSidebarIdentityBound(); - return passed ? 0 : 1; -} diff --git a/tests/PromptLimitTest.cpp b/tests/PromptLimitTest.cpp deleted file mode 100644 index d3b8172..0000000 --- a/tests/PromptLimitTest.cpp +++ /dev/null @@ -1,56 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "app/FrontendSession.h" - -#include - -#include -#include - -#include - -namespace { - -constexpr qsizetype maximumPromptScalars = static_cast( - ai::openai::codex::typed::MaximumTurnInputTextUnicodeScalars); - -bool expect(bool condition, const char* message) -{ - if (!condition) - std::cerr << message << '\n'; - return condition; -} - -} // namespace - -int main() -{ - bool passed = true; - passed &= expect(!codexui::FrontendSession::promptValidationError(QStringLiteral("Grüße €")), - "a normal UTF-8 prompt must be accepted"); - - const QString asciiBoundary(maximumPromptScalars, QLatin1Char('a')); - passed &= expect(!codexui::FrontendSession::promptValidationError(asciiBoundary), - "an ASCII prompt at Codex's exact Unicode-scalar limit must be accepted"); - passed &= expect(codexui::FrontendSession::promptValidationError(asciiBoundary + QLatin1Char('a')).has_value(), - "an ASCII prompt one Unicode scalar over Codex's limit must be rejected"); - - QString astralBoundary; - astralBoundary.reserve(maximumPromptScalars * 2); - for (qsizetype index = 0; index < maximumPromptScalars; ++index) - astralBoundary.append(QChar::highSurrogate(0x1f642)).append(QChar::lowSurrogate(0x1f642)); - passed &= expect(astralBoundary.size() == maximumPromptScalars * 2, - "the astral fixture must use one UTF-16 surrogate pair per Unicode scalar"); - passed &= expect(!codexui::FrontendSession::promptValidationError(astralBoundary), - "an astral prompt at Codex's exact Unicode-scalar limit must be accepted"); - passed &= expect(codexui::FrontendSession::promptValidationError( - astralBoundary + QString::fromUcs4(U"\U0001f642")).has_value(), - "an astral prompt one Unicode scalar over Codex's limit must be rejected"); - - const QString mixedBoundary = QString(maximumPromptScalars - 2, QLatin1Char('a')) - + QString::fromUcs4(U"\U0001f642") + QChar(0x20ac); - passed &= expect(!codexui::FrontendSession::promptValidationError(mixedBoundary), - "mixed BMP and astral text must be counted as Unicode scalars"); - - return passed ? 0 : 1; -} diff --git a/tests/codex/ConversationScrollTest.cpp b/tests/codex/ConversationScrollTest.cpp new file mode 100644 index 0000000..8d5ca28 --- /dev/null +++ b/tests/codex/ConversationScrollTest.cpp @@ -0,0 +1,519 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include + +#include "codex/Configuration.h" +#include "codex/FrontendSession.h" +#include "codex/PresentationProtocol.h" +#include "codex/ProtocolNormalizer.h" +#include "codex/ShellWidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace codexui::codex { + +class ShellWidgetScrollTest { +public: + static bool run(FrontendSession &session) { + ShellWidget shell(session); + shell.resize(1280, 820); + shell.show(); + spinEvents(60); + + populate(shell, 14, 84); + QScrollBar *scrollBar = shell.conversationScroll->verticalScrollBar(); + scrollBar->setValue(std::min(330, scrollBar->maximum() - 40)); + shell.conversationFollowsLatest = false; + spinEvents(20); + + const ShellWidget::ConversationScrollAnchor pausedAnchor = + shell.captureConversationScrollAnchor(); + const int pausedOffset = anchorOffset(shell, pausedAnchor.key); + const int pausedValue = scrollBar->value(); + + const bool appendKeptViewportHeight = observeViewportHeight( + shell, [&shell] { insertCard(shell, 14, 84); }, 50); + bool result = expect(scrollBar->value() == pausedValue, + "paused bottom append preserves scrollbar value"); + result &= expect(appendKeptViewportHeight, + "card insertion never changes viewport height"); + result &= expect(anchorOffset(shell, pausedAnchor.key) == pausedOffset, + "paused bottom append preserves the visible card"); + result &= expect(!shell.conversationFollowsLatest, + "paused bottom append does not enable following"); + + const ShellWidget::ConversationScrollAnchor reflowAnchor = + shell.captureConversationScrollAnchor(); + const int reflowOffset = anchorOffset(shell, reflowAnchor.key); + QWidget *first = card(shell, QStringLiteral("card:0")); + first->setFixedHeight(first->height() + 73); + shell.conversationScrollRebuilding = true; + shell.settleConversationScroll(false, reflowAnchor, false); + spinEvents(60); + result &= expect(anchorOffset(shell, reflowAnchor.key) == reflowOffset, + "paused reflow above preserves the visible card offset"); + result &= expect(!shell.conversationFollowsLatest, + "paused reflow above remains paused"); + + const ShellWidget::ConversationScrollAnchor rebuildAnchor = + shell.captureConversationScrollAnchor(); + const int rebuildOffset = anchorOffset(shell, rebuildAnchor.key); + shell.stopConversationScrollAnimation(); + shell.conversationScrollRebuilding = true; + populate(shell, 17, 84, 73); + shell.settleConversationScroll(false, rebuildAnchor, false); + spinEvents(70); + result &= expect(anchorOffset(shell, rebuildAnchor.key) == rebuildOffset, + "paused reconstruction preserves the visible card offset"); + result &= expect(!shell.conversationFollowsLatest, + "paused reconstruction remains paused"); + + const ShellWidget::ConversationScrollAnchor lateReflowAnchor = + shell.captureConversationScrollAnchor(); + const int lateReflowOffset = anchorOffset(shell, lateReflowAnchor.key); + first = card(shell, QStringLiteral("card:0")); + first->setFixedHeight(first->height() + 41); + shell.conversationLayout->invalidate(); + shell.conversationContent->updateGeometry(); + spinEvents(70); + result &= + expect(anchorOffset(shell, lateReflowAnchor.key) == lateReflowOffset, + "paused late range change restores the retained visual anchor"); + result &= expect(!shell.conversationFollowsLatest, + "paused late range change remains paused"); + + shell.conversationPausedAnchor = shell.captureConversationScrollAnchor(); + shell.conversationPausedAnchorValid = true; + for (int index = 16; index >= 7; --index) { + QWidget *removed = card(shell, QStringLiteral("card:%1").arg(index)); + shell.conversationLayout->removeWidget(removed); + delete removed; + } + shell.conversationLayout->invalidate(); + shell.conversationContent->updateGeometry(); + spinEvents(80); + result &= expect(!shell.conversationFollowsLatest, + "a layout range clamp cannot re-enable following"); + + populate(shell, 17, 84, 73); + + scrollBar->setValue(scrollBar->maximum()); + shell.conversationFollowsLatest = true; + const int formerMaximum = scrollBar->maximum(); + const ShellWidget::ConversationScrollAnchor followAnchor = + shell.captureConversationScrollAnchor(); + shell.conversationScrollRebuilding = true; + insertCard(shell, 17, 180); + shell.settleConversationScroll(true, followAnchor, true); + spinEvents(70); + const int animatedValue = scrollBar->value(); + result &= expect(animatedValue > formerMaximum && + animatedValue < scrollBar->maximum(), + "bottom following advances through an intermediate value"); + spinEvents(300); + result &= expect(scrollBar->value() == scrollBar->maximum(), + "smooth following reaches the latest content"); + + const int secondFormerMaximum = scrollBar->maximum(); + insertCard(shell, 18, 220); + spinEvents(45); + result &= expect(scrollBar->value() > secondFormerMaximum, + "a later append starts another smooth follow"); + scrollBar->triggerAction(QAbstractSlider::SliderSingleStepSub); + spinEvents(20); + const int interruptedValue = scrollBar->value(); + result &= expect(!shell.conversationFollowsLatest, + "user scroll immediately pauses smooth following"); + spinEvents(300); + result &= expect(scrollBar->value() == interruptedValue, + "interrupted following does not resume or jump"); + + ItemPresentation emptyOutput; + emptyOutput.raw = {{"type", "commandExecution"}, + {"command", "true"}, + {"status", "inProgress"}, + {"cwd", "/workspace"}, + {"aggregatedOutput", ""}}; + ItemPresentation whitespaceOutput = emptyOutput; + whitespaceOutput.raw["aggregatedOutput"] = " \n\t"; + whitespaceOutput.raw["nonVisualProtocolMetadata"] = 7; + result &= expect(shell.conversationItemFingerprint(emptyOutput) == + shell.conversationItemFingerprint(whitespaceOutput), + "nonvisual command updates do not invalidate a card"); + whitespaceOutput.raw["aggregatedOutput"] = "visible\n"; + result &= expect(shell.conversationItemFingerprint(emptyOutput) != + shell.conversationItemFingerprint(whitespaceOutput), + "visible command output invalidates its card"); + + const nlohmann::json commandItems = + nlohmann::json::array({{{"id", "empty"}, + {"type", "commandExecution"}, + {"command", "true"}, + {"status", "completed"}, + {"aggregatedOutput", ""}}, + {{"id", "whitespace"}, + {"type", "commandExecution"}, + {"command", "printf whitespace"}, + {"status", "completed"}, + {"aggregatedOutput", " \n\t"}}, + {{"id", "control"}, + {"type", "commandExecution"}, + {"command", "printf control"}, + {"status", "completed"}, + {"aggregatedOutput", "\x1b[0m\x1b]0;\x07"}}, + {{"id", "visible"}, + {"type", "commandExecution"}, + {"command", "printf visible"}, + {"status", "completed"}, + {"aggregatedOutput", "visible\n"}}}); + const nlohmann::json hydratedThread = { + {"id", "output-visibility"}, + {"status", {{"type", "idle"}}}, + {"turns", nlohmann::json::array({{{"id", "output-turn"}, + {"status", "completed"}, + {"items", commandItems}}})}}; + shell.model.applyEvent(presentation::result( + 1, 1, "thread.read", "read-output-visibility", true, + {{"thread", hydratedThread}}, presentation::Authority::Merge, + {{"threadId", "output-visibility"}})); + shell.selectedThreadId = "output-visibility"; + shell.refreshConversation(); + spinEvents(80); + const auto outputSurfaceCount = [&shell](const std::string &itemId) { + const std::string key = std::string("output-turn\x1f") + itemId; + const auto card = shell.conversationCards.find(key); + if (card == shell.conversationCards.end()) + return 0; + const QList views = + card->second->findChildren(); + return static_cast( + std::count_if(views.begin(), views.end(), [](QPlainTextEdit *view) { + return view->property("kind").toString() == QStringLiteral("code"); + })); + }; + result &= expect(outputSurfaceCount("empty") == 0 && + outputSurfaceCount("whitespace") == 0 && + outputSurfaceCount("control") == 0, + "non-presentable command output creates no black box"); + result &= expect(outputSurfaceCount("visible") == 1, + "presentable command output creates one black box"); + + const std::string emptyCommandKey = + std::string("output-turn") + '\x1f' + "empty"; + const std::string insertedCommandKey = + std::string("output-turn") + '\x1f' + "inserted"; + QWidget *retainedEmptyCard = shell.conversationCards.at(emptyCommandKey); + const nlohmann::json insertedCommand = {{"id", "inserted"}, + {"type", "commandExecution"}, + {"command", "printf inserted"}, + {"status", "inProgress"}, + {"aggregatedOutput", "inserted\n"}}; + shell.model.applyEvent(presentation::event( + 2, 1, "conversation.item.upsert", {{"item", insertedCommand}}, + presentation::Authority::Merge, + {{"threadId", "output-visibility"}, + {"turnId", "output-turn"}, + {"itemId", "inserted"}})); + shell.dirtyConversationItems[insertedCommandKey] = {"output-turn", + "inserted"}; + const bool incrementalInsertKeptViewportHeight = observeViewportHeight( + shell, [&shell] { shell.refreshConversationItems(); }, 80); + result &= expect( + shell.conversationCards.contains(insertedCommandKey) && + shell.conversationCards.at(emptyCommandKey) == retainedEmptyCard, + "new command card inserts without reconstructing retained cards"); + result &= expect(incrementalInsertKeptViewportHeight, + "new command insertion keeps viewport geometry fixed"); + + nlohmann::json updatedCommand = insertedCommand; + updatedCommand["aggregatedOutput"] = + "inserted\nwith a second visible line\n"; + shell.model.applyEvent(presentation::event( + 3, 1, "conversation.item.upsert", {{"item", updatedCommand}}, + presentation::Authority::Merge, + {{"threadId", "output-visibility"}, + {"turnId", "output-turn"}, + {"itemId", "inserted"}})); + shell.dirtyConversationItems[insertedCommandKey] = {"output-turn", + "inserted"}; + QWidget *insertedCard = shell.conversationCards.at(insertedCommandKey); + const bool commandUpdateKeptViewportHeight = observeViewportHeight( + shell, [&shell] { shell.refreshConversationItems(); }, 80); + result &= expect(commandUpdateKeptViewportHeight && + shell.conversationCards.at(insertedCommandKey) == + insertedCard, + "command update mutates in place with fixed viewport"); + + nlohmann::json completedCommand = updatedCommand; + completedCommand["status"] = "completed"; + completedCommand["exitCode"] = 0; + shell.model.applyEvent(presentation::event( + 4, 1, "conversation.item.upsert", {{"item", completedCommand}}, + presentation::Authority::Merge, + {{"threadId", "output-visibility"}, + {"turnId", "output-turn"}, + {"itemId", "inserted"}})); + shell.dirtyConversationItems[insertedCommandKey] = {"output-turn", + "inserted"}; + const bool completionKeptViewportHeight = observeViewportHeight( + shell, [&shell] { shell.refreshConversationItems(); }, 80); + result &= expect(completionKeptViewportHeight && + shell.conversationCards.at(insertedCommandKey) == + insertedCard, + "command completion mutates the retained card in place"); + + nlohmann::json nonvisualCommand = completedCommand; + nonvisualCommand["nonVisualProtocolMetadata"] = "ignored"; + shell.model.applyEvent(presentation::event( + 5, 1, "conversation.item.upsert", {{"item", nonvisualCommand}}, + presentation::Authority::Merge, + {{"threadId", "output-visibility"}, + {"turnId", "output-turn"}, + {"itemId", "inserted"}})); + shell.dirtyConversationItems[insertedCommandKey] = {"output-turn", + "inserted"}; + const int valueBeforeNonvisualUpdate = + shell.conversationScroll->verticalScrollBar()->value(); + shell.refreshConversationItems(); + spinEvents(80); + result &= expect( + shell.conversationCards.at(insertedCommandKey) == insertedCard && + shell.conversationScroll->verticalScrollBar()->value() == + valueBeforeNonvisualUpdate, + "nonvisual command completion data does not touch card or scroll"); + + const std::string streamedAgentKey = + std::string("output-turn") + '\x1f' + "streamed-agent"; + const nlohmann::json startedAgent = {{"id", "streamed-agent"}, + {"type", "agentMessage"}, + {"phase", "commentary"}, + {"text", ""}}; + nlohmann::json normalizedStart; + ProtocolNormalizer normalizer( + [&normalizedStart](const nlohmann::json &frame) { + normalizedStart = frame; + return true; + }); + normalizer.serverNotification("item/started", + {{"threadId", "output-visibility"}, + {"turnId", "output-turn"}, + {"item", startedAgent}}); + result &= expect( + normalizedStart.value("scope", nlohmann::json::object()) + .value("itemId", std::string{}) == "streamed-agent", + "item start derives stable scope identity from the item payload"); + normalizedStart["sequence"] = 6; + normalizedStart["generation"] = 1; + shell.handleEvent(normalizedStart); + shell.handleEvent( + presentation::event(7, 1, "conversation.item.append", + {{"field", "text"}, {"text", "streamed reply"}}, + presentation::Authority::Merge, + {{"threadId", "output-visibility"}, + {"turnId", "output-turn"}, + {"itemId", "streamed-agent"}})); + spinEvents(100); + bool streamedTextVisible = false; + const auto streamedCard = shell.conversationCards.find(streamedAgentKey); + if (streamedCard != shell.conversationCards.end()) { + for (QLabel *label : streamedCard->second->findChildren()) + streamedTextVisible |= + label->text().contains(QStringLiteral("streamed reply")); + } + result &= expect(streamedCard != shell.conversationCards.end() && + streamedTextVisible, + "streamed agent card appears without another prompt"); + + ShellWidget::PendingPrompt awaiting; + awaiting.id = 90; + awaiting.prompt = QStringLiteral("Prompt before streamed output"); + shell.pendingPrompts["output-visibility"].push_back(std::move(awaiting)); + shell.refreshConversation(); + spinEvents(80); + const QString pendingAnchor = + shell.pendingPromptAnchorKey("output-visibility", 90); + QWidget *pendingBeforeOutput = nullptr; + for (int index = 0; index < shell.conversationLayout->count(); ++index) { + QWidget *candidate = shell.conversationLayout->itemAt(index)->widget(); + if (candidate && + candidate->property("conversationAnchorKey").toString() == + pendingAnchor) { + pendingBeforeOutput = candidate; + break; + } + } + const std::string laterAgentKey = + std::string("output-turn") + '\x1f' + "later-agent"; + const nlohmann::json laterAgent = {{"id", "later-agent"}, + {"type", "agentMessage"}, + {"phase", "commentary"}, + {"text", "later reply"}}; + shell.model.applyEvent(presentation::event( + 8, 1, "conversation.item.upsert", {{"item", laterAgent}}, + presentation::Authority::Merge, + {{"threadId", "output-visibility"}, + {"turnId", "output-turn"}, + {"itemId", "later-agent"}})); + shell.dirtyConversationItems[laterAgentKey] = {"output-turn", + "later-agent"}; + const bool orderedInsertKeptViewportHeight = observeViewportHeight( + shell, [&shell] { shell.refreshConversationItems(); }, 80); + QWidget *laterAgentCard = shell.conversationCards.at(laterAgentKey); + result &= + expect(pendingBeforeOutput && + shell.conversationLayout->indexOf(pendingBeforeOutput) < + shell.conversationLayout->indexOf(laterAgentCard), + "new streamed cards remain after the locally admitted prompt"); + result &= expect(orderedInsertKeptViewportHeight, + "post-prompt insertion keeps viewport geometry fixed"); + + shell.localNewThreadIntent = true; + shell.selectedThreadId.clear(); + ShellWidget::PendingPrompt acknowledged; + acknowledged.id = 91; + acknowledged.prompt = QStringLiteral("Fast acknowledgment"); + acknowledged.status = ShellWidget::PendingPromptStatus::Acknowledged; + acknowledged.acknowledgedAtMilliseconds = + QDateTime::currentMSecsSinceEpoch(); + shell.newThreadPendingPrompts.push_back(std::move(acknowledged)); + shell.refreshConversation(); + spinEvents(60); + QFrame *pendingCard = nullptr; + for (int index = 0; index < shell.conversationLayout->count(); ++index) { + QWidget *candidate = shell.conversationLayout->itemAt(index)->widget(); + if (candidate && + candidate->property("conversationAnchorKey").toString() == + QStringLiteral("pending:new:91")) { + pendingCard = qobject_cast(candidate); + break; + } + } + bool acceptedLabelFound = false; + if (pendingCard) { + for (QLabel *label : pendingCard->findChildren()) + acceptedLabelFound |= + label->text().contains(QStringLiteral("Accepted by app-server")); + } + const QImage firstFrame = + pendingCard ? pendingCard->grab().toImage() : QImage{}; + spinEvents(120); + const QImage secondFrame = + pendingCard ? pendingCard->grab().toImage() : QImage{}; + result &= expect(pendingCard && acceptedLabelFound, + "fast acknowledgment retains a visible transition card"); + result &= expect(!firstFrame.isNull() && firstFrame != secondFrame, + "acknowledgment transition visibly animates"); + + return result; + } + +private: + static void spinEvents(int milliseconds) { + for (int elapsed = 0; elapsed < milliseconds; elapsed += 2) { + QApplication::processEvents(QEventLoop::AllEvents, 2); + QThread::msleep(2); + } + QApplication::processEvents(QEventLoop::AllEvents); + } + + static bool observeViewportHeight(ShellWidget &shell, + const std::function &operation, + int milliseconds) { + const int expectedHeight = shell.conversationScroll->viewport()->height(); + bool stable = true; + operation(); + for (int elapsed = 0; elapsed < milliseconds; elapsed += 2) { + QApplication::processEvents(QEventLoop::AllEvents, 2); + stable &= + shell.conversationScroll->viewport()->height() == expectedHeight; + QThread::msleep(2); + } + QApplication::processEvents(QEventLoop::AllEvents); + return stable && + shell.conversationScroll->viewport()->height() == expectedHeight; + } + + static bool expect(bool condition, const char *message) { + std::cout << (condition ? "PASS " : "FAIL ") << message << '\n'; + return condition; + } + + static QWidget *newCard(int index, int height) { + auto *result = new QWidget; + result->setFixedHeight(height); + result->setProperty("conversationAnchorKey", + QStringLiteral("card:%1").arg(index)); + return result; + } + + static void populate(ShellWidget &shell, int count, int height, + int firstExtraHeight = 0) { + while (QLayoutItem *item = shell.conversationLayout->takeAt(0)) { + delete item->widget(); + delete item; + } + shell.conversationCards.clear(); + shell.conversationTrailingSpace = nullptr; + for (int index = 0; index < count; ++index) + shell.conversationLayout->addWidget( + newCard(index, height + (index == 0 ? firstExtraHeight : 0))); + shell.addConversationTrailingSpace(); + shell.conversationLayout->addStretch(); + shell.conversationLayout->invalidate(); + shell.conversationContent->updateGeometry(); + spinEvents(50); + } + + static void insertCard(ShellWidget &shell, int index, int height) { + shell.conversationLayout->insertWidget( + std::max(0, shell.conversationLayout->count() - 2), + newCard(index, height)); + shell.conversationLayout->invalidate(); + shell.conversationContent->updateGeometry(); + } + + static QWidget *card(ShellWidget &shell, const QString &key) { + for (int index = 0; index < shell.conversationLayout->count(); ++index) { + QWidget *candidate = shell.conversationLayout->itemAt(index)->widget(); + if (candidate && + candidate->property("conversationAnchorKey").toString() == key) + return candidate; + } + return nullptr; + } + + static int anchorOffset(ShellWidget &shell, const QString &key) { + QWidget *anchored = card(shell, key); + return anchored + ? anchored + ->mapTo(shell.conversationScroll->viewport(), QPoint(0, 0)) + .y() + : -100000; + } +}; + +} // namespace codexui::codex + +int main(int argc, char **argv) { + QApplication application(argc, argv); + auto *configuration = + utils::Config::configRoot.newSubCommand(); + codexui::codex::FrontendSession session(*configuration); + return codexui::codex::ShellWidgetScrollTest::run(session) ? 0 : 1; +} diff --git a/tests/codex/GreenfieldLayoutTest.cpp b/tests/codex/GreenfieldLayoutTest.cpp new file mode 100644 index 0000000..11ff93d --- /dev/null +++ b/tests/codex/GreenfieldLayoutTest.cpp @@ -0,0 +1,529 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/PresentationModel.h" +#include "codex/PresentationProtocol.h" +#include "codex/middle/ComposerPane.h" +#include "codex/middle/ConversationCards.h" +#include "codex/middle/ConversationView.h" +#include "codex/middle/InspectorPane.h" +#include "codex/middle/MiddleRegionWidget.h" +#include "codex/middle/ThreadPane.h" +#include "codex/ui/ExpandingPromptEditor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace codexui::codex::middle { +namespace { + +bool expect(bool condition, const char *message) { + if (condition) + return true; + std::cerr << "FAILED: " << message << '\n'; + return false; +} + +bool hasLabelContaining(const QWidget &root, const QString &text) { + for (const QLabel *label : root.findChildren()) { + if (label->text().contains(text)) + return true; + } + return false; +} + +void spin(int milliseconds = 0) { + QElapsedTimer timer; + timer.start(); + do { + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + if (milliseconds > 0) + QThread::msleep(1); + } while (timer.elapsed() < milliseconds); +} + +VisibleCardData textCard(const std::string &thread, int index) { + const std::string turn = index < 15 ? "turn-1" : "turn-2"; + const std::string item = "item-" + std::to_string(index); + return {AuthoritativeItemKey{thread, turn, item}, + CardKind::AgentMessage, + thread, + turn, + item, + AgentMessageData{ + QStringLiteral("A materialized response line %1 with enough " + "content to occupy normal card height.") + .arg(index), + false}}; +} + +ConversationSnapshot longConversation(const std::string &thread) { + ConversationSnapshot snapshot; + snapshot.threadId = thread; + snapshot.sections = {{"turn-one", "turn-1", {}}, {"turn-two", "turn-2", {}}}; + for (int index = 0; index < 30; ++index) + snapshot.sections[index < 15 ? 0 : 1].cards.push_back( + textCard(thread, index)); + return snapshot; +} + +QWheelEvent wheelFor(QWidget *target, int pixelDelta) { + const QPointF local(target->rect().center()); + return QWheelEvent(local, target->mapToGlobal(local.toPoint()), QPoint(), + QPoint(0, pixelDelta), Qt::NoButton, Qt::NoModifier, + Qt::ScrollUpdate, false); +} + +bool testOverlayGeometryAndRegionRouting() { + MiddleRegionWidget region; + bool result = + expect(region.composer().extraOverlayHeight() == 0 && + region.conversation().trailingSpaceHeight() == 0, + "composer construction reports no pre-canonical trailing space"); + region.resize(1500, 820); + region.show(); + spin(20); + + QSplitter *splitter = region.splitterWidget(); + result &= expect(splitter->count() == 3 && splitter->handleWidth() == 8, + "middle region keeps the three-pane splitter geometry"); + result &= expect(splitter->widget(0)->minimumWidth() == 220 && + splitter->widget(0)->maximumWidth() == 440 && + splitter->widget(1)->minimumWidth() == 480 && + splitter->widget(2)->minimumWidth() == 300 && + splitter->widget(2)->maximumWidth() == 520, + "pane width constraints match the visual contract"); + + ConversationView &view = region.conversation(); + view.reconcile(longConversation("layout-thread")); + spin(20); + const QRect viewGeometry = view.geometry(); + const QRect viewportGeometry = view.viewport()->geometry(); + const int canonical = region.composer().canonicalReserveHeight(); + result &= + expect(canonical > 0 && + region.composer().canonicalReserve()->height() == canonical, + "composer establishes one compact canonical reserve"); + + QString longPrompt; + for (int line = 0; line < 14; ++line) + longPrompt += QStringLiteral("A deliberately long prompt line %1 that " + "grows the editor upward.\n") + .arg(line); + region.composer().promptEditor()->setPlainText(longPrompt); + spin(30); + const int extra = region.composer().extraOverlayHeight(); + result &= expect(extra > 0 && view.trailingSpaceHeight() == extra, + "prompt growth is mirrored by exact trailing scroll space"); + result &= + expect(view.geometry() == viewGeometry && + view.viewport()->geometry() == viewportGeometry && + region.composer().canonicalReserve()->height() == canonical, + "prompt growth overlays without shifting the message viewport"); + region.composer().clearDraft(); + spin(30); + result &= expect( + region.composer().extraOverlayHeight() == 0 && + view.trailingSpaceHeight() == 0 && view.geometry() == viewGeometry && + view.viewport()->geometry() == viewportGeometry, + "prompt contraction restores canonical layout and removes space"); + + ComposerPane::Actions rejected; + rejected.submit = [](QString, std::vector) { return false; }; + region.composer().setActions(std::move(rejected)); + region.composer().promptEditor()->setPlainText( + QStringLiteral("must survive rejected admission")); + QMetaObject::invokeMethod(region.composer().promptEditor(), "submitRequested", + Qt::DirectConnection); + result &= expect(region.composer().promptEditor()->toPlainText() == + QStringLiteral("must survive rejected admission"), + "rejected admission preserves the complete composer draft"); + ComposerPane::Actions accepted; + accepted.submit = [](QString, std::vector) { return true; }; + region.composer().setActions(std::move(accepted)); + QMetaObject::invokeMethod(region.composer().promptEditor(), "submitRequested", + Qt::DirectConnection); + result &= expect(region.composer().promptEditor()->toPlainText().isEmpty(), + "successful local admission clears the draft exactly once"); + + result &= expect(view.isAtBottom(), "conversation begins at the bottom"); + QWheelEvent overLeftHandle = wheelFor(splitter->handle(1), 180); + result &= + expect(region.routeScrollEvent(splitter->handle(1), &overLeftHandle) && + view.mode() == ConversationView::Mode::Paused, + "the left middle splitter handle routes wheel input"); + QWheelEvent overRightHandle = wheelFor(splitter->handle(2), 180); + const int beforeRight = view.verticalScrollBar()->value(); + result &= + expect(region.routeScrollEvent(splitter->handle(2), &overRightHandle) && + view.verticalScrollBar()->value() < beforeRight, + "the right middle splitter handle routes wheel input"); + return result; +} + +bool testThreadSelectionProjection() { + PresentationModel model; + model.applyEvent(presentation::event( + 1, 1, "thread.upsert", {{"thread", {{"id", "thread-a"}, {"name", "A"}}}}, + presentation::Authority::Merge, {{"threadId", "thread-a"}})); + model.applyEvent(presentation::event( + 2, 1, "thread.upsert", {{"thread", {{"id", "thread-b"}, {"name", "B"}}}}, + presentation::Authority::Merge, {{"threadId", "thread-b"}})); + + ThreadPane pane; + pane.refresh(model, "thread-a"); + bool result = expect(pane.visiblySelectedThreadId() == "thread-a", + "thread selection is projected from Shell state"); + pane.refresh(model, "draft:new-thread"); + result &= expect(pane.visiblySelectedThreadId().empty(), + "a New Thread draft cannot retain an old visible row"); + + model.applyEvent(presentation::event(3, 1, "agents.activity.upsert", + {{"activity", + {{"id", "thread-b"}, + {"type", "subAgentActivity"}, + {"status", "inProgress"}, + {"agentThreadId", "thread-b"}}}}, + presentation::Authority::Merge, + {{"threadId", "thread-a"}, + {"turnId", "turn-a"}, + {"itemId", "thread-b"}})); + pane.refresh(model, "thread-b"); + auto *list = pane.findChild(QStringLiteral("threadList")); + QListWidgetItem *selected = list ? list->currentItem() : nullptr; + result &= expect( + selected && + selected->data(Qt::UserRole).toString() == + QStringLiteral("thread-b") && + pane.visiblySelectedThreadId() == "thread-b", + "a hydrated selected child thread remains visible outside root ordering"); + QWidget *row = selected && list ? list->itemWidget(selected) : nullptr; + auto *title = + row ? row->findChild(QStringLiteral("threadTitle")) : nullptr; + auto *status = + row ? row->findChild(QStringLiteral("threadStatus")) : nullptr; + auto *rowLayout = row ? qobject_cast(row->layout()) : nullptr; + result &= expect( + selected && selected->sizeHint().height() == 48 && rowLayout && + rowLayout->contentsMargins() == QMargins(5, 2, 5, 2) && + rowLayout->spacing() == 8 && title && status && + title->property("kind").toString() == QStringLiteral("title") && + status->property("kind").toString() == QStringLiteral("meta") && + title->textInteractionFlags().testFlag(Qt::TextSelectableByMouse) && + status->textInteractionFlags().testFlag(Qt::TextSelectableByMouse), + "thread row typography and 48-pixel card geometry match the UI contract"); + pane.refresh(model, "thread-a"); + bool retainedSupplement = false; + if (list) { + for (int index = 0; index < list->count(); ++index) { + retainedSupplement |= list->item(index)->data(Qt::UserRole).toString() == + QStringLiteral("thread-b"); + } + } + result &= + expect(retainedSupplement && pane.visiblySelectedThreadId() == "thread-a", + "a previously selected retained thread survives navigation"); + model.applyEvent(presentation::event( + 4, 1, "thread.removed", nlohmann::json::object(), + presentation::Authority::Remove, {{"threadId", "thread-b"}})); + pane.refresh(model, "thread-a"); + bool retainedAfterRemoval = false; + if (list) { + for (int index = 0; index < list->count(); ++index) { + retainedAfterRemoval |= + list->item(index)->data(Qt::UserRole).toString() == + QStringLiteral("thread-b"); + } + } + result &= expect(!retainedAfterRemoval, + "an authoritative removal drops a retained thread"); + return result; +} + +bool testThreadRowReorderOwnership() { + PresentationModel model; + model.applyEvent(presentation::event( + 1, 1, "thread.upsert", {{"thread", {{"id", "thread-a"}, {"name", "A"}}}}, + presentation::Authority::Merge, {{"threadId", "thread-a"}})); + model.applyEvent(presentation::event( + 2, 1, "thread.upsert", {{"thread", {{"id", "thread-b"}, {"name", "B"}}}}, + presentation::Authority::Merge, {{"threadId", "thread-b"}})); + + ThreadPane pane; + pane.resize(320, 500); + pane.show(); + pane.refresh(model, "thread-a"); + spin(20); + auto *list = pane.findChild(QStringLiteral("threadList")); + QListWidgetItem *threadA = nullptr; + if (list) { + for (int row = 0; row < list->count(); ++row) { + if (list->item(row)->data(Qt::UserRole).toString() == + QStringLiteral("thread-a")) { + threadA = list->item(row); + break; + } + } + } + bool result = expect(list && threadA, + "the stable thread row exists before list reordering"); + if (!list || !threadA) + return false; + QPointer originalRow = list->itemWidget(threadA); + + model.applyEvent(presentation::result( + 3, 1, "threads.list", "reordered-threads", true, + {{"threads", + nlohmann::json::array({{{"id", "thread-a"}, {"name", "A"}}, + {{"id", "thread-b"}, {"name", "B"}}})}}, + presentation::Authority::Replace)); + pane.refresh(model, "thread-a"); + QPointer movedRow = list->itemWidget(threadA); + result &= expect(originalRow && movedRow && originalRow != movedRow, + "moving an item never reattaches its deferred-delete row"); + if (!originalRow || !movedRow || originalRow == movedRow) + return false; + + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + spin(20); + result &= expect(originalRow.isNull() && movedRow && + list->itemWidget(threadA) == movedRow, + "deferred deletion cannot invalidate the moved thread row"); + list->setCurrentItem(threadA); + list->viewport()->repaint(); + spin(20); + result &= expect(pane.visiblySelectedThreadId() == "thread-a", + "the reordered row remains selectable after repaint"); + return result; +} + +bool testNestedCommandScrollOwnership() { + MiddleRegionWidget region; + region.resize(1500, 820); + region.show(); + ConversationSnapshot snapshot = longConversation("command-thread"); + QString output; + for (int line = 0; line < 100; ++line) + output += QStringLiteral("command output line %1\n").arg(line); + snapshot.sections.back().cards.push_back( + {AuthoritativeItemKey{"command-thread", "turn-2", "command"}, + CardKind::CommandExecution, "command-thread", "turn-2", "command", + CommandExecutionData{QStringLiteral("run-command"), + output, + QStringLiteral("inProgress"), + {}, + std::nullopt}}); + region.conversation().reconcile(snapshot); + spin(30); + + CommandOutputView *commandOutput = nullptr; + for (QWidget *widget : region.findChildren()) + if (auto *candidate = dynamic_cast(widget)) { + commandOutput = candidate; + break; + } + bool result = + expect(commandOutput && commandOutput->verticalScrollBar()->maximum() > 0, + "long command output owns a real nested scrollbar"); + if (!commandOutput) + return false; + commandOutput->verticalScrollBar()->setValue( + commandOutput->verticalScrollBar()->maximum() / 2); + spin(); + QWheelEvent owned = wheelFor(commandOutput, 120); + result &= expect(!region.routeScrollEvent(commandOutput, &owned), + "a nested output consumes input while it can scroll"); + commandOutput->verticalScrollBar()->setValue( + commandOutput->verticalScrollBar()->minimum()); + spin(); + const int outerBefore = region.conversation().verticalScrollBar()->value(); + QWheelEvent handedOff = wheelFor(commandOutput, 120); + result &= expect(region.routeScrollEvent(commandOutput, &handedOff) && + region.conversation().verticalScrollBar()->value() < + outerBefore, + "nested output hands input to the message view at its edge"); + return result; +} + +bool testInfoViewerLayout() { + InspectorPane inspector; + inspector.resize(420, 700); + inspector.show(); + PresentationModel model; + inspector.refresh(model, {}); + inspector.tabs()->setCurrentIndex(4); + auto *infoTabs = + inspector.findChild(QStringLiteral("infoTabs")); + auto *protocol = + inspector.findChild(QStringLiteral("protocolInfoLog")); + auto *state = + inspector.findChild(QStringLiteral("stateInfoView")); + auto *statistics = + inspector.findChild(QStringLiteral("protocolInfoStats")); + bool result = expect(infoTabs && protocol && state && statistics, + "Info tab exposes retained State and Protocol viewers"); + if (!infoTabs || !protocol || !state || !statistics) + return false; + infoTabs->setCurrentIndex(1); + inspector.appendProtocolFrame( + {{"kind", "event"}, + {"type", "conversation.item.upsert"}, + {"sequence", 1}, + {"generation", 1}, + {"authority", "app-server"}, + {"scope", {{"threadId", "thread"}, {"itemId", "item"}}}}); + inspector.appendProtocolFrame( + {{"kind", "result"}, + {"action", "thread.read"}, + {"sequence", 2}, + {"generation", 1}, + {"authority", "app-server"}, + {"ok", false}, + {"error", {{"message", "thread hydration failed"}}}, + {"scope", {{"threadId", "thread"}}}}); + for (int sequence = 3; sequence <= 90; ++sequence) { + inspector.appendProtocolFrame( + {{"kind", "event"}, + {"type", + QStringLiteral("protocol.test.%1").arg(sequence).toStdString()}, + {"sequence", sequence}, + {"generation", 1}, + {"authority", "app-server"}, + {"scope", {{"threadId", "thread"}}}}); + } + inspector.refresh(model, {}); + spin(20); + result &= + expect(protocol->verticalScrollBarPolicy() == Qt::ScrollBarAsNeeded && + state->verticalScrollBarPolicy() == Qt::ScrollBarAsNeeded, + "both Info viewers use the common as-needed scrollbar policy"); + result &= + expect(protocol->verticalScrollBar()->property("kind") == "infoViewer" && + state->verticalScrollBar()->property("kind") == "infoViewer", + "both Info viewer scrollbars use the shared visual style"); + result &= expect(protocol->toPlainText().contains( + QStringLiteral("thread hydration failed")), + "failed protocol results retain their error detail"); + QScrollBar *protocolScroll = protocol->verticalScrollBar(); + result &= expect(protocolScroll->maximum() > 0 && + protocolScroll->value() == protocolScroll->maximum(), + "Protocol follows new frames while already at the tail"); + protocolScroll->setValue(protocolScroll->maximum() / 3); + spin(); + const int pausedValue = protocolScroll->value(); + inspector.appendProtocolFrame({{"kind", "event"}, + {"type", "protocol.test.visible-append"}, + {"sequence", 91}, + {"generation", 1}, + {"authority", "app-server"}}); + inspector.refresh(model, {}); + spin(20); + result &= + expect(protocolScroll->value() == pausedValue, + "a visible Protocol append preserves a user-paused position"); + infoTabs->setCurrentIndex(0); + inspector.appendProtocolFrame({{"kind", "event"}, + {"type", "protocol.test.hidden-append"}, + {"sequence", 92}, + {"generation", 1}, + {"authority", "app-server"}}); + infoTabs->setCurrentIndex(1); + spin(20); + result &= + expect(protocolScroll->value() == pausedValue, + "Protocol refresh preserves its paused position across tabs"); + protocolScroll->setValue(protocolScroll->maximum()); + inspector.appendProtocolFrame({{"kind", "event"}, + {"type", "protocol.test.following-append"}, + {"sequence", 93}, + {"generation", 1}, + {"authority", "app-server"}}); + spin(20); + result &= + expect(protocolScroll->value() == protocolScroll->maximum(), + "Protocol continues following when an append starts at the tail"); + result &= + expect(!statistics->text().isEmpty() && + statistics->geometry().top() >= protocol->geometry().bottom(), + "Protocol statistics are laid out below the expanding log"); + return result; +} + +bool testInspectorDetailParity() { + PresentationModel model; + model.applyEvent(presentation::event( + 1, 1, "thread.upsert", {{"thread", {{"id", "owner-thread"}}}}, + presentation::Authority::Merge, {{"threadId", "owner-thread"}})); + model.applyEvent(presentation::event( + 2, 1, "agents.activity.upsert", + {{"activity", + {{"id", "agent-one"}, + {"type", "subAgentActivity"}, + {"status", "inProgress"}, + {"agentThreadId", "child-thread"}, + {"senderThreadId", "sender-thread"}, + {"receiverThreadIds", + nlohmann::json::array({"receiver-one", "receiver-two"})}}}}, + presentation::Authority::Merge, + {{"threadId", "owner-thread"}, + {"turnId", "turn-one"}, + {"itemId", "agent-one"}})); + model.applyEvent(presentation::event( + 3, 1, "pending-request.upsert", + {{"requestId", "request-one"}, + {"category", "userInput"}, + {"request", + {{"message", "Choose an option"}, + {"questions", nlohmann::json::array({1, 2, 3})}}}}, + presentation::Authority::Merge, + {{"threadId", "owner-thread"}, {"requestId", "request-one"}})); + + InspectorPane inspector; + inspector.resize(420, 700); + inspector.show(); + inspector.refresh(model, "owner-thread"); + inspector.tabs()->setCurrentIndex(1); + spin(20); + bool result = expect( + hasLabelContaining( + inspector, + QStringLiteral("thread child-thread | sender sender-thread | " + "receivers receiver-one, receiver-two")), + "Agents show child, sender, and receiver thread identities"); + inspector.tabs()->setCurrentIndex(3); + spin(20); + result &= expect(hasLabelContaining(inspector, QStringLiteral("3 questions")), + "Requests show their retained question count"); + return result; +} + +} // namespace +} // namespace codexui::codex::middle + +int main(int argc, char **argv) { + QApplication application(argc, argv); + using namespace codexui::codex::middle; + bool result = testOverlayGeometryAndRegionRouting(); + result &= testThreadSelectionProjection(); + result &= testThreadRowReorderOwnership(); + result &= testNestedCommandScrollOwnership(); + result &= testInfoViewerLayout(); + result &= testInspectorDetailParity(); + if (result) + std::cout << "Greenfield layout tests passed\n"; + return result ? 0 : 1; +} diff --git a/tests/codex/GreenfieldMiddleTest.cpp b/tests/codex/GreenfieldMiddleTest.cpp new file mode 100644 index 0000000..1e99ffc --- /dev/null +++ b/tests/codex/GreenfieldMiddleTest.cpp @@ -0,0 +1,653 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/middle/ConversationCards.h" +#include "codex/middle/ConversationView.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace codexui::codex::middle { +namespace { + +bool expect(bool condition, const char *message) { + if (condition) + return true; + std::cerr << "FAILED: " << message << '\n'; + return false; +} + +void spin(int milliseconds = 0) { + QElapsedTimer timer; + timer.start(); + do { + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + if (milliseconds > 0) + QThread::msleep(1); + } while (timer.elapsed() < milliseconds); +} + +VisibleCardData agentCard(const std::string &threadId, + const std::string &turnId, int index, + QString text = {}) { + const std::string itemId = "agent-" + std::to_string(index); + if (text.isEmpty()) + text = QStringLiteral("Codex output line %1 with enough text to wrap a " + "little in the viewport.") + .arg(index); + return {AuthoritativeItemKey{threadId, turnId, itemId}, + CardKind::AgentMessage, + threadId, + turnId, + itemId, + AgentMessageData{std::move(text), index % 3 == 0}}; +} + +ConversationSnapshot conversation(const std::string &threadId, int count) { + ConversationSnapshot result; + result.threadId = threadId; + TurnSection first{"turn:" + threadId + ":1", "turn-1", {}}; + TurnSection second{"turn:" + threadId + ":2", "turn-2", {}}; + for (int index = 0; index < count; ++index) + (index < count / 2 ? first : second) + .cards.push_back(agentCard( + threadId, index < count / 2 ? "turn-1" : "turn-2", index)); + result.sections.push_back(std::move(first)); + result.sections.push_back(std::move(second)); + return result; +} + +ConversationCard *card(ConversationView &view, const std::string &key) { + for (QWidget *widget : view.findChildren()) { + auto *candidate = dynamic_cast(widget); + if (!candidate) + continue; + if (candidate->property("conversationAnchorKey").toString() == + QString::fromStdString(key)) + return candidate; + } + return nullptr; +} + +std::pair firstVisible(ConversationView &view) { + std::vector cards; + for (QWidget *widget : view.findChildren()) + if (auto *candidate = dynamic_cast(widget)) + cards.push_back(candidate); + std::ranges::sort(cards, [&view](QWidget *left, QWidget *right) { + return left->mapTo(view.viewport(), QPoint{}).y() < + right->mapTo(view.viewport(), QPoint{}).y(); + }); + for (ConversationCard *candidate : cards) { + const int top = candidate->mapTo(view.viewport(), QPoint{}).y(); + if (top + candidate->height() >= 0) + return { + candidate->property("conversationAnchorKey").toString().toStdString(), + top}; + } + return {}; +} + +void wheel(ConversationView &view, int pixelDelta) { + const QPointF local(view.viewport()->rect().center()); + QWheelEvent event(local, view.viewport()->mapToGlobal(local.toPoint()), + QPoint(), QPoint(0, pixelDelta), Qt::NoButton, + Qt::NoModifier, Qt::ScrollUpdate, false); + QApplication::sendEvent(view.viewport(), &event); + spin(); +} + +void mouseWheelNotch(ConversationView &view, int angleDelta) { + const QPointF local(view.viewport()->rect().center()); + QWheelEvent event(local, view.viewport()->mapToGlobal(local.toPoint()), + QPoint(), QPoint(0, angleDelta), Qt::NoButton, + Qt::NoModifier, Qt::ScrollUpdate, false); + QApplication::sendEvent(view.viewport(), &event); + spin(); +} + +bool testFollowPauseAndStableAnchor() { + ConversationView view; + view.resize(620, 340); + view.show(); + ConversationSnapshot snapshot = conversation("thread-a", 34); + bool result = expect(view.reconcile(snapshot), "initial projection renders"); + spin(); + QScrollArea nativeReference; + nativeReference.setWidgetResizable(true); + auto *nativeContent = new QWidget; + nativeContent->setMinimumHeight(5000); + nativeReference.setWidget(nativeContent); + nativeReference.resize(view.size()); + nativeReference.show(); + spin(); + result &= + expect(view.verticalScrollBar()->singleStep() == + nativeReference.verticalScrollBar()->singleStep(), + "conversation line-step matches the previous native QScrollArea"); + result &= expect(view.mode() == ConversationView::Mode::Following && + view.isAtBottom(), + "a new thread starts following at its real bottom"); + + const int oldValue = view.verticalScrollBar()->value(); + snapshot.sections.back().cards.push_back(agentCard("thread-a", "turn-2", 34)); + result &= expect(view.reconcile(snapshot), "a new card materializes"); + int previous = view.verticalScrollBar()->value(); + bool monotonic = previous >= oldValue; + QElapsedTimer animation; + animation.start(); + while (animation.elapsed() < 400 && !view.isAtBottom()) { + spin(8); + const int current = view.verticalScrollBar()->value(); + monotonic = monotonic && current >= previous; + previous = current; + } + result &= expect(monotonic && view.isAtBottom(), + "follow animation is monotonic and reaches the new bottom"); + + const int beforeWheelNotch = view.verticalScrollBar()->value(); + mouseWheelNotch(view, 120); + result &= expect( + view.mode() == ConversationView::Mode::Paused && !view.isAtBottom() && + beforeWheelNotch - view.verticalScrollBar()->value() == + std::min(beforeWheelNotch, + view.verticalScrollBar()->singleStep() * + std::max(1, QApplication::wheelScrollLines())), + "native mouse-wheel handling uses the configured line " + "distance and " + "pauses following immediately"); + const auto anchor = firstVisible(view); + result &= + expect(!anchor.first.empty(), "paused view has a visible card anchor"); + + // Reflow a card above the anchor and append another card in one projection. + const auto anchorPosition = + std::ranges::find_if(snapshot.sections.front().cards, + [&anchor](const VisibleCardData &candidate) { + return stableKey(candidate.key) == anchor.first; + }); + if (anchorPosition != snapshot.sections.front().cards.begin() && + anchorPosition != snapshot.sections.front().cards.end()) { + auto &message = std::get((anchorPosition - 1)->payload); + message.text += QStringLiteral( + "\nA reflowing upstream update.\nA second line.\nA third line."); + } + snapshot.sections.back().cards.push_back(agentCard("thread-a", "turn-2", 35)); + result &= expect(view.reconcile(snapshot), + "paused incoming changes still materialize"); + spin(); + const auto after = firstVisible(view); + result &= expect(after.first == anchor.first && + std::abs(after.second - anchor.second) <= 1, + "paused reconciliation preserves key and pixel anchor"); + result &= + expect(card(view, stableKey(snapshot.sections.back().cards.back().key)), + "paused mode never withholds a later card"); + + const int unchangedValue = view.verticalScrollBar()->value(); + const auto unchangedAnchor = firstVisible(view); + result &= expect(!view.reconcile(snapshot), + "an identical visible projection is a true no-op"); + spin(); + result &= expect(view.verticalScrollBar()->value() == unchangedValue && + firstVisible(view) == unchangedAnchor, + "a no-op changes neither scroll nor visible geometry"); + + while (!view.isAtBottom()) + wheel(view, -300); + view.verticalScrollBar()->triggerAction(QAbstractSlider::SliderPageStepSub); + spin(); + const auto pageStepAnchor = firstVisible(view); + result &= expect(view.mode() == ConversationView::Mode::Paused && + !pageStepAnchor.first.empty(), + "a scrollbar page action pauses at its resulting anchor"); + auto &upstream = std::get( + snapshot.sections.front().cards.front().payload); + upstream.text += QStringLiteral( + "\nTrack-action upstream reflow.\nSecond line.\nThird line."); + result &= expect(view.reconcile(snapshot), + "page-action coverage applies an upstream reflow"); + spin(); + const auto afterPageStepReflow = firstVisible(view); + result &= expect( + afterPageStepReflow.first == pageStepAnchor.first && + std::abs(afterPageStepReflow.second - pageStepAnchor.second) <= 1, + "page-action scroll ownership survives later reflow"); + return result; +} + +bool testThreadLocalScrollAndComposerExtent() { + ConversationView view; + view.resize(620, 340); + view.show(); + ConversationSnapshot first = conversation("thread-a", 30); + ConversationSnapshot second = conversation("thread-b", 26); + view.reconcile(first); + spin(); + wheel(view, 220); + const auto saved = firstVisible(view); + bool result = expect(view.mode() == ConversationView::Mode::Paused, + "first thread is paused before switching"); + + view.reconcile(second); + spin(); + result &= expect(view.mode() == ConversationView::Mode::Following && + view.isAtBottom(), + "a new thread does not inherit another thread's pause"); + view.reconcile(first); + spin(); + const auto restored = firstVisible(view); + result &= expect(view.mode() == ConversationView::Mode::Paused && + restored.first == saved.first && + std::abs(restored.second - saved.second) <= 1, + "switching back restores that thread's own visual anchor"); + + const int beforeExtent = view.verticalScrollBar()->maximum(); + const int beforeValue = view.verticalScrollBar()->value(); + view.setTrailingSpaceHeight(137); + spin(); + result &= + expect(view.trailingSpaceHeight() == 137 && + view.verticalScrollBar()->maximum() == beforeExtent + 137 && + view.verticalScrollBar()->value() == beforeValue && + view.mode() == ConversationView::Mode::Paused, + "composer growth adds exact scroll extent without moving content"); + while (!view.isAtBottom()) + wheel(view, -240); + result &= expect(view.mode() == ConversationView::Mode::Following, + "reaching the extended bottom restores following"); + view.setTrailingSpaceHeight(0); + spin(); + result &= + expect(view.isAtBottom() && view.trailingSpaceHeight() == 0, + "composer contraction removes extent and accepts bottom clamp"); + return result; +} + +bool testPromptAdmissionFollowOwnership() { + ConversationView view; + view.resize(620, 340); + view.show(); + ConversationSnapshot snapshot = conversation("prompt-follow", 30); + view.reconcile(snapshot); + spin(); + + view.setTrailingSpaceHeight(120); + bool result = expect(view.mode() == ConversationView::Mode::Paused, + "composer growth preserves the painted viewport"); + view.prepareForLocalPromptAdmission(); + VisibleCardData pending{ + LocalPromptKey{1001}, + CardKind::LocalPrompt, + "prompt-follow", + {}, + {}, + LocalPromptData{1001, + QStringLiteral("a newly admitted pending prompt"), + 0, + PromptState::InFlight, + 0, + {}}}; + snapshot.sections.back().cards.push_back(pending); + view.reconcile(snapshot); + view.setTrailingSpaceHeight(0); + QElapsedTimer follow; + follow.start(); + while (follow.elapsed() < 400 && !view.isAtBottom()) + spin(8); + ConversationCard *pendingCard = card(view, stableKey(pending.key)); + result &= expect( + view.mode() == ConversationView::Mode::Following && view.isAtBottom() && + pendingCard && + pendingCard->mapTo(view.viewport(), QPoint{}).y() + + pendingCard->height() <= + view.viewport()->height(), + "composer-owned pause resumes and reveals the complete admitted prompt"); + + wheel(view, 180); + const auto userAnchor = firstVisible(view); + view.setTrailingSpaceHeight(120); + view.prepareForLocalPromptAdmission(); + VisibleCardData later = pending; + later.key = LocalPromptKey{1002}; + std::get(later.payload).submissionId = 1002; + std::get(later.payload).prompt = + QStringLiteral("must not displace a user-owned reading position"); + snapshot.sections.back().cards.push_back(later); + view.reconcile(snapshot); + view.setTrailingSpaceHeight(0); + spin(40); + const auto retainedAnchor = firstVisible(view); + result &= + expect(view.mode() == ConversationView::Mode::Paused && + retainedAnchor.first == userAnchor.first && + std::abs(retainedAnchor.second - userAnchor.second) <= 1, + "local admission never overrides an explicit user scroll pause"); + return result; +} + +bool testMutableCardsAndCommandOutput() { + const std::string thread = "card-thread"; + TurnSection section{"turn:cards", "turn", {}}; + section.cards = { + {AuthoritativeItemKey{thread, "turn", "user"}, CardKind::UserMessage, + thread, "turn", "user", UserMessageData{QStringLiteral("hello")}}, + {AuthoritativeItemKey{thread, "turn", "agent"}, CardKind::AgentMessage, + thread, "turn", "agent", + AgentMessageData{QStringLiteral("answer"), false}}, + {AuthoritativeItemKey{thread, "turn", "command"}, + CardKind::CommandExecution, thread, "turn", "command", + CommandExecutionData{QStringLiteral("printf test"), + QStringLiteral(" \n\t\x1b[0m"), + QStringLiteral("inProgress"), + {}, + std::nullopt}}, + {AuthoritativeItemKey{thread, "turn", "activity"}, + CardKind::AgentActivity, thread, "turn", "activity", + AgentActivityData{QStringLiteral("tool"), + QStringLiteral("inProgress"), + {}, + QStringLiteral("prompt"), + {}, + {}}}, + {AuthoritativeItemKey{thread, "turn", "reasoning"}, CardKind::Reasoning, + thread, "turn", "reasoning", ReasoningData{QStringLiteral("summary")}}, + {AuthoritativeItemKey{thread, "turn", "files"}, CardKind::FileChanges, + thread, "turn", "files", + FileChangesData{QStringLiteral("inProgress"), 1, + nlohmann::json::array()}}, + {AuthoritativeItemKey{thread, "turn", "plan"}, CardKind::Plan, thread, + "turn", "plan", PlanData{QStringLiteral("plan step")}}, + {AuthoritativeItemKey{thread, "turn", "generic"}, + CardKind::GenericActivity, thread, "turn", "generic", + GenericActivityData{QStringLiteral("custom activity"), + {{"detail", "initial"}}}}, + {LocalPromptKey{77}, + CardKind::LocalPrompt, + thread, + {}, + {}, + LocalPromptData{ + 77, QStringLiteral("pending"), 0, PromptState::InFlight, 0, {}}}, + }; + ConversationSnapshot snapshot{thread, {section}, 0, false}; + ConversationView view; + view.resize(650, 520); + view.show(); + view.reconcile(snapshot); + spin(); + + std::unordered_map identities; + for (const auto &value : snapshot.sections.front().cards) + identities[stableKey(value.key)] = card(view, stableKey(value.key)); + auto *commandCard = identities[stableKey( + CardKey{AuthoritativeItemKey{thread, "turn", "command"}})]; + auto *output = dynamic_cast( + commandCard->findChild( + QStringLiteral("commandOutputView"))); + bool result = expect(output && output->isHidden(), + "control-only command output has no black surface"); + + auto &cards = snapshot.sections.front().cards; + std::get(cards[0].payload).text += + QStringLiteral(" updated"); + std::get(cards[1].payload).text += + QStringLiteral(" updated"); + auto &command = std::get(cards[2].payload); + command.output = QString(120, QLatin1Char('x')) + QStringLiteral("\nvisible"); + command.status = QStringLiteral("completed"); + std::get(cards[3].payload).resultText = + QStringLiteral("result"); + std::get(cards[4].payload).summary += QStringLiteral(" more"); + std::get(cards[5].payload).pathCount = 2; + std::get(cards[6].payload).text += QStringLiteral(" updated"); + auto &generic = std::get(cards[7].payload); + generic.type = QStringLiteral("updated custom activity"); + generic.raw["detail"] = "updated"; + std::get(cards[8].payload).state = PromptState::Failed; + std::get(cards[8].payload).error = QStringLiteral("error"); + result &= + expect(view.reconcile(snapshot), "all card types accept visible updates"); + const int immediateOuterRange = view.verticalScrollBar()->maximum(); + const int immediateCommandHeight = commandCard->height(); + const int immediatePreferredOutputHeight = output->sizeHint().height(); + spin(); + result &= + expect(view.verticalScrollBar()->maximum() == immediateOuterRange && + commandCard->height() == immediateCommandHeight && + output->sizeHint().height() == immediatePreferredOutputHeight, + "command output has no delayed outer geometry settlement"); + for (const auto &value : cards) + result &= expect(card(view, stableKey(value.key)) == + identities[stableKey(value.key)], + "same-key same-kind card updates in place"); + result &= + expect(!output->isHidden() && output->minimumHeight() == 0 && + output->maximumHeight() == 220 && + output->verticalScrollBarPolicy() == Qt::ScrollBarAsNeeded, + "visible command output grows from zero with the 220px cap"); + + QString longOutput; + for (int line = 0; line < 80; ++line) + longOutput += QStringLiteral("line %1 with terminal output\n").arg(line); + output->setOutput(longOutput); + view.resize(650, 520); + spin(); + result &= expect(output->verticalScrollBar()->maximum() > 0 && + output->followsLatest(), + "long command output exposes its own scrollbar and follows"); + + // A scrollbar move immediately after an output update is user-owned. It + // must not be overwritten by a deferred follow-latest settlement. + output->setOutput(longOutput + QStringLiteral("new output before gesture\n")); + const int immediateGestureValue = output->verticalScrollBar()->maximum() / 3; + output->verticalScrollBar()->setValue(immediateGestureValue); + spin(); + result &= + expect(!output->followsLatest() && + output->verticalScrollBar()->value() == immediateGestureValue, + "an immediate inner-scroll gesture supersedes following"); + + output->verticalScrollBar()->setValue(output->verticalScrollBar()->maximum() / + 2); + spin(); + const int preserved = output->verticalScrollBar()->value(); + output->setOutput(longOutput + QStringLiteral("one more line\n")); + spin(); + result &= expect(!output->followsLatest() && + output->verticalScrollBar()->value() == preserved, + "paused command output preserves its inner scroll value"); + output->verticalScrollBar()->setValue(output->verticalScrollBar()->maximum()); + spin(); + result &= expect(output->followsLatest(), + "inner output following resumes at its real bottom"); + + command.output = QStringLiteral("\x1b]0;terminal title\x07\x1b[0m \n\t"); + result &= expect(view.reconcile(snapshot), + "non-presentable replacement updates the command card"); + const int hiddenOuterRange = view.verticalScrollBar()->maximum(); + const int hiddenCommandHeight = commandCard->height(); + result &= expect(output->isHidden(), + "non-presentable replacement removes the black surface"); + spin(); + result &= + expect(output->isHidden() && + view.verticalScrollBar()->maximum() == hiddenOuterRange && + commandCard->height() == hiddenCommandHeight, + "hidden command output causes no delayed outer reflow"); + return result; +} + +bool testInitialCommandGeometrySettlement() { + const std::string thread = "initial-command-thread"; + QString output; + for (int word = 0; word < 32; ++word) + output += QStringLiteral("width-sensitive-output "); + const VisibleCardData command{ + AuthoritativeItemKey{thread, "turn", "command"}, + CardKind::CommandExecution, + thread, + "turn", + "command", + CommandExecutionData{QStringLiteral("printf output"), + output, + QStringLiteral("completed"), + {}, + 0}}; + ConversationSnapshot snapshot{ + thread, {{"turn:initial-command", "turn", {command}}}, 0, false}; + + ConversationView view; + view.resize(650, 520); + view.show(); + spin(); + bool result = expect(view.reconcile(snapshot), + "initial visible command output is inserted"); + ConversationCard *commandCard = card(view, stableKey(command.key)); + auto *outputView = commandCard ? dynamic_cast( + commandCard->findChild( + QStringLiteral("commandOutputView"))) + : nullptr; + result &= expect(commandCard && outputView && !outputView->isHidden() && + outputView->height() < outputView->maximumHeight(), + "initial output is visible and below its height cap"); + if (!commandCard || !outputView) + return false; + const int immediateRange = view.verticalScrollBar()->maximum(); + const int immediateCardHeight = commandCard->height(); + const int immediateOutputHeight = outputView->height(); + const int immediateHint = outputView->sizeHint().height(); + spin(); + result &= expect(view.verticalScrollBar()->maximum() == immediateRange && + commandCard->height() == immediateCardHeight && + outputView->height() == immediateOutputHeight && + outputView->sizeHint().height() == immediateHint, + "initial wrapped output has no delayed geometry settlement"); + return result; +} + +bool testCommandOutputStateAcrossNavigation() { + const std::string thread = "command-navigation-thread"; + QString output; + for (int line = 0; line < 80; ++line) + output += QStringLiteral("retained line %1\n").arg(line); + const VisibleCardData command{ + AuthoritativeItemKey{thread, "turn", "command"}, + CardKind::CommandExecution, + thread, + "turn", + "command", + CommandExecutionData{QStringLiteral("produce output"), + output, + QStringLiteral("completed"), + {}, + 0}}; + const ConversationSnapshot commandThread{ + thread, {{"turn:command-navigation", "turn", {command}}}, 0, false}; + + ConversationView view; + view.resize(650, 520); + view.show(); + view.reconcile(commandThread); + spin(); + ConversationCard *commandCard = card(view, stableKey(command.key)); + auto *initialOutput = commandCard + ? dynamic_cast( + commandCard->findChild( + QStringLiteral("commandOutputView"))) + : nullptr; + bool result = + expect(initialOutput && initialOutput->verticalScrollBar()->maximum() > 0, + "navigation test has independently scrollable output"); + if (!initialOutput) + return false; + const int pausedValue = initialOutput->verticalScrollBar()->maximum() / 3; + initialOutput->verticalScrollBar()->setValue(pausedValue); + spin(); + result &= expect(!initialOutput->followsLatest(), + "command output is paused before thread navigation"); + + view.reconcile(conversation("other-thread", 8)); + spin(); + view.reconcile(commandThread); + spin(); + commandCard = card(view, stableKey(command.key)); + auto *restoredOutput = commandCard + ? dynamic_cast( + commandCard->findChild( + QStringLiteral("commandOutputView"))) + : nullptr; + result &= + expect(restoredOutput && !restoredOutput->followsLatest() && + restoredOutput->verticalScrollBar()->value() == pausedValue, + "thread navigation restores paused command output state"); + return result; +} + +bool testPendingPromptAnimation() { + VisibleCardData pending{LocalPromptKey{901}, + CardKind::LocalPrompt, + "prompt-thread", + {}, + {}, + LocalPromptData{901, + QStringLiteral("pending prompt"), + 0, + PromptState::InFlight, + 0, + {}}}; + ConversationCard card(pending); + card.resize(560, 92); + card.show(); + spin(40); + const QImage first = card.grab().toImage(); + spin(110); + const QImage second = card.grab().toImage(); + bool result = + expect(first != second, + "an unacknowledged prompt visibly animates its blue sweep"); + + auto &accepted = std::get(pending.payload); + accepted.state = PromptState::Accepted; + accepted.acceptedAtMilliseconds = QDateTime::currentMSecsSinceEpoch(); + result &= expect(card.apply(pending), + "the real acknowledged state updates the pending card"); + spin(560); + const QImage settled = card.grab().toImage(); + spin(100); + result &= expect(settled == card.grab().toImage(), + "the acknowledgment transition stops after 500ms"); + return result; +} + +} // namespace +} // namespace codexui::codex::middle + +int main(int argc, char **argv) { + QApplication application(argc, argv); + using namespace codexui::codex::middle; + bool result = testFollowPauseAndStableAnchor(); + result &= testThreadLocalScrollAndComposerExtent(); + result &= testPromptAdmissionFollowOwnership(); + result &= testMutableCardsAndCommandOutput(); + result &= testInitialCommandGeometrySettlement(); + result &= testCommandOutputStateAcrossNavigation(); + result &= testPendingPromptAnimation(); + if (result) + std::cout << "Greenfield middle-region tests passed\n"; + return result ? 0 : 1; +} diff --git a/tests/codex/GreenfieldProjectionTest.cpp b/tests/codex/GreenfieldProjectionTest.cpp new file mode 100644 index 0000000..188e49a --- /dev/null +++ b/tests/codex/GreenfieldProjectionTest.cpp @@ -0,0 +1,368 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/middle/ConversationProjection.h" +#include "codex/middle/PromptCoordinator.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace codexui::codex::middle { +namespace { + +bool expect(bool condition, const char *message) { + if (condition) + return true; + std::cerr << "FAILED: " << message << '\n'; + return false; +} + +ItemPresentation item(std::string id, nlohmann::json raw) { + return ItemPresentation{std::move(id), std::move(raw), {}}; +} + +void appendItem(ThreadPresentation &thread, const std::string &turnId, + ItemPresentation presentation) { + TurnPresentation &turn = thread.turns.at(turnId); + turn.itemOrder.push_back(presentation.id); + turn.items.emplace(presentation.id, std::move(presentation)); +} + +ThreadPresentation baseThread(std::string id) { + ThreadPresentation thread; + thread.id = std::move(id); + thread.turnOrder = {"turn-1"}; + TurnPresentation turn; + turn.id = "turn-1"; + turn.status = "completed"; + thread.turns.emplace(turn.id, std::move(turn)); + appendItem(thread, "turn-1", + item("user-old", + {{"type", "userMessage"}, + {"content", {{{"type", "text"}, {"text", "old prompt"}}}}})); + appendItem(thread, "turn-1", + item("answer-old", {{"type", "agentMessage"}, + {"phase", "final_answer"}, + {"text", "old answer"}})); + return thread; +} + +void addTurn(ThreadPresentation &thread, const std::string &turnId, + std::string status = "inProgress") { + thread.turnOrder.push_back(turnId); + TurnPresentation turn; + turn.id = turnId; + turn.status = std::move(status); + thread.turns.emplace(turn.id, std::move(turn)); +} + +const VisibleCardData *cardForSubmission(const ConversationSnapshot &snapshot, + std::uint64_t id) { + return snapshot.find(LocalPromptKey{id}); +} + +bool testCanonicalGroupingAndProjection() { + ThreadPresentation thread = baseThread("thread-a"); + addTurn(thread, "turn-2"); + appendItem(thread, "turn-2", + item("command", {{"type", "commandExecution"}, + {"command", "true"}, + {"status", "completed"}, + {"aggregatedOutput", " \n\t\x1b[0m"}})); + + const ConversationSnapshot snapshot = ConversationProjection::project( + thread, {}, ConversationProjection::DefaultAuthoritativeItemLimit, 10); + bool result = expect(snapshot.sections.size() == 2, + "one transparent section is projected per turn"); + result &= expect(snapshot.sections[0].turnId == "turn-1" && + snapshot.sections[0].cards.size() == 2 && + snapshot.sections[1].turnId == "turn-2" && + snapshot.sections[1].cards.size() == 1, + "thread, turn, and item order are retained"); + const auto *command = + std::get_if(&snapshot.sections[1].cards[0].payload); + result &= expect(command && command->output.isEmpty(), + "non-presentable command output is projected as absent"); + result &= expect(std::holds_alternative( + snapshot.sections[0].cards[0].key) && + stableKey(snapshot.sections[0].cards[0].key) != + stableKey(snapshot.sections[0].cards[1].key), + "authoritative cards have typed collision-free stable keys"); + + const ConversationSnapshot limited = + ConversationProjection::project(thread, {}, 1, 10); + result &= + expect(limited.hasMore && limited.hiddenAuthoritativeItemCount == 2 && + limited.cardKeys().size() == 1, + "history limit is based only on authoritative items"); + + ThreadPresentation emptyPlan = baseThread("thread-empty-plan"); + appendItem(emptyPlan, "turn-1", + item("empty-plan", {{"type", "plan"}, {"text", ""}})); + const ConversationSnapshot emptyPlanSnapshot = + ConversationProjection::project(emptyPlan, {}, 80, 10); + const VisibleCardData &emptyPlanCard = + emptyPlanSnapshot.sections.front().cards.back(); + const auto *generic = + std::get_if(&emptyPlanCard.payload); + result &= expect(emptyPlanCard.kind == CardKind::GenericActivity && generic && + generic->type == QStringLiteral("plan"), + "an empty plan retains the generic raw-data fallback"); + return result; +} + +bool testQueueIsolationAndRealAcknowledgement() { + ThreadPresentation first = baseThread("thread-a"); + ThreadPresentation second = baseThread("thread-b"); + PromptCoordinator prompts; + const auto firstId = + prompts.admit(first.id, QStringLiteral("same"), {}, + nlohmann::json::object(), &first, std::nullopt, 100); + const auto secondId = + prompts.admit(second.id, QStringLiteral("other"), {}, + nlohmann::json::object(), &second, std::nullopt, 101); + + const auto firstDispatch = prompts.beginNext(first.id); + bool result = expect(firstDispatch && firstDispatch->id == firstId, + "the first queued prompt begins dispatch"); + result &= expect(!prompts.beginNext(first.id), + "a thread has at most one in-flight prompt"); + const auto secondDispatch = prompts.beginNext(second.id); + result &= expect(secondDispatch && secondDispatch->id == secondId, + "different threads have independent in-flight queues"); + + addTurn(first, "turn-2"); + appendItem( + first, "turn-2", + item("user-new", {{"type", "userMessage"}, + {"content", {{{"type", "text"}, {"text", "same"}}}}})); + prompts.reconcile(first.id, first); + result &= expect(prompts.submission(first.id, firstId)->state == + PromptState::InFlight && + !prompts.submission(first.id, firstId)->materializedItem, + "events and elapsed time cannot manufacture an ack"); + + result &= expect(prompts.acknowledge(first.id, firstId, "turn-2", 200), + "the matching completion acknowledges the in-flight prompt"); + prompts.reconcile(first.id, first); + const PromptSubmission *accepted = prompts.submission(first.id, firstId); + result &= expect(accepted && accepted->state == PromptState::Accepted && + accepted->materializedItem && + accepted->materializedItem->itemId == "user-new", + "an acknowledged prompt binds to its authoritative item"); + + const ConversationSnapshot transitioning = ConversationProjection::project( + first, prompts.submissions(first.id), 80, 699); + const VisibleCardData *local = cardForSubmission(transitioning, firstId); + result &= expect(local && local->kind == CardKind::LocalPrompt, + "the accepted presentation transition lasts 500ms"); + const ConversationSnapshot materialized = ConversationProjection::project( + first, prompts.submissions(first.id), 80, 700); + const VisibleCardData *authoritative = + cardForSubmission(materialized, firstId); + result &= + expect(authoritative && authoritative->kind == CardKind::UserMessage && + authoritative->itemId == "user-new", + "the authoritative user item assumes the local stable key"); + result &= expect(stableKey(local->key) == stableKey(authoritative->key), + "materialization does not change the visual identity"); + prompts.compactResolved("thread-a", 700); + accepted = prompts.submission(first.id, firstId); + const ConversationSnapshot compacted = ConversationProjection::project( + first, prompts.submissions(first.id), 80, 701); + result &= expect( + accepted && accepted->prompt.isEmpty() && accepted->attachments.empty() && + accepted->clientUserMessageId.empty() && + accepted->turnOptions.empty() && + compacted.find(LocalPromptKey{firstId}) && + compacted.find(LocalPromptKey{firstId})->kind == + CardKind::UserMessage, + "resolved aliases release dispatch payload and retain identity"); + return result; +} + +bool testDispatchChoiceAndPreHydrationTail() { + ThreadPresentation thread = baseThread("thread-dispatch"); + PromptCoordinator prompts; + const auto id = prompts.admit( + thread.id, QStringLiteral("queued while active"), {}, + nlohmann::json::object(), &thread, std::string("turn-1"), 300); + const auto dispatch = prompts.beginNext(thread.id, std::nullopt); + bool result = + expect(dispatch && dispatch->id == id && !dispatch->expectedTurnId, + "dispatch-time state replaces a stale admission turn"); + + PromptCoordinator beforeHydration; + const auto tailId = beforeHydration.admit( + "thread-tail", QStringLiteral("after retained history"), {}, + nlohmann::json::object(), nullptr, std::nullopt, 400); + ThreadPresentation retained = baseThread("thread-tail"); + beforeHydration.reconcile(retained.id, retained); + const ConversationSnapshot atTail = ConversationProjection::project( + retained, beforeHydration.submissions(retained.id), 80, 401); + const auto keys = atTail.cardKeys(); + result &= + expect(keys.size() == 3 && keys.back() == CardKey{LocalPromptKey{tailId}}, + "a pre-hydration prompt stays after retained history"); + return result; +} + +bool testClientIdentityBindsBeforeAcknowledgement() { + ThreadPresentation thread = baseThread("thread-client-id"); + PromptCoordinator prompts; + const auto id = + prompts.admit(thread.id, QStringLiteral("identity matched"), {}, + nlohmann::json::object(), &thread, std::nullopt, 500); + const auto dispatch = prompts.beginNext(thread.id); + bool result = expect(dispatch && !dispatch->clientUserMessageId.empty(), + "every dispatch carries a stable client message id"); + if (!dispatch) + return false; + + addTurn(thread, "turn-client"); + appendItem( + thread, "turn-client", + item("user-client", + {{"type", "userMessage"}, + {"clientId", dispatch->clientUserMessageId}, + {"content", {{{"type", "text"}, {"text", "identity matched"}}}}})); + prompts.reconcile(thread.id, thread); + const PromptSubmission *pending = prompts.submission(thread.id, id); + result &= expect(pending && pending->state == PromptState::InFlight && + pending->materializedItem && + pending->materializedItem->itemId == "user-client", + "client identity binds without manufacturing an ack"); + const ConversationSnapshot snapshot = ConversationProjection::project( + thread, prompts.submissions(thread.id), 80, 501); + result &= expect( + snapshot.cardKeys().size() == 3 && snapshot.find(LocalPromptKey{id}) && + snapshot.find(LocalPromptKey{id})->kind == CardKind::LocalPrompt, + "early materialization keeps one awaiting visual card"); + result &= expect(prompts.fail(thread.id, id, QStringLiteral("rejected")), + "the exact terminal callback can fail a bound prompt"); + const ConversationSnapshot failed = ConversationProjection::project( + thread, prompts.submissions(thread.id), 80, 502); + const VisibleCardData *failedCard = failed.find(LocalPromptKey{id}); + const auto *failedPrompt = + failedCard ? std::get_if(&failedCard->payload) : nullptr; + result &= expect(failed.cardKeys().size() == 3 && failedPrompt && + failedPrompt->state == PromptState::Failed && + failedPrompt->error == QStringLiteral("rejected"), + "a failure remains explicit after early materialization"); + return result; +} + +bool testAnchoredDuplicatePrompts() { + ThreadPresentation thread = baseThread("thread-duplicates"); + PromptCoordinator prompts; + const auto firstId = + prompts.admit(thread.id, QStringLiteral("repeat"), {}, + nlohmann::json::object(), &thread, std::nullopt, 1000); + const auto secondId = + prompts.admit(thread.id, QStringLiteral("repeat"), {}, + nlohmann::json::object(), &thread, std::nullopt, 1001); + + bool result = expect(prompts.beginNext(thread.id).has_value(), + "first duplicate dispatches"); + result &= expect(prompts.acknowledge(thread.id, firstId, "turn-2", 1010), + "first duplicate is acknowledged by id"); + result &= expect(prompts.beginNext(thread.id, "turn-2").has_value(), + "second duplicate dispatches only after first ack"); + result &= expect(prompts.acknowledge(thread.id, secondId, "turn-2", 1020), + "second duplicate is acknowledged by id"); + + addTurn(thread, "turn-2"); + appendItem(thread, "turn-2", + item("repeat-1", + {{"type", "userMessage"}, + {"content", {{{"type", "text"}, {"text", "repeat"}}}}})); + prompts.reconcile(thread.id, thread); + const ConversationSnapshot partiallyMaterialized = + ConversationProjection::project(thread, prompts.submissions(thread.id), + 80, 1600); + const auto partialKeys = partiallyMaterialized.cardKeys(); + const auto materializedFirst = + std::ranges::find(partialKeys, CardKey{LocalPromptKey{firstId}}); + const auto waitingSecond = + std::ranges::find(partialKeys, CardKey{LocalPromptKey{secondId}}); + result &= expect(materializedFirst != partialKeys.end() && + waitingSecond != partialKeys.end() && + materializedFirst < waitingSecond, + "partial materialization cannot invert prompt order"); + + appendItem(thread, "turn-2", + item("repeat-2", + {{"type", "userMessage"}, + {"content", {{{"type", "text"}, {"text", "repeat"}}}}})); + prompts.reconcile(thread.id, thread); + const PromptSubmission *first = prompts.submission(thread.id, firstId); + const PromptSubmission *second = prompts.submission(thread.id, secondId); + result &= expect( + first && second && first->materializedItem && second->materializedItem && + first->materializedItem->itemId == "repeat-1" && + second->materializedItem->itemId == "repeat-2", + "identical prompts bind in admission order without collision"); + + const ConversationSnapshot waiting = ConversationProjection::project( + thread, prompts.submissions(thread.id), 80, 1021); + const auto keys = waiting.cardKeys(); + const auto firstPosition = + std::ranges::find(keys, CardKey{LocalPromptKey{firstId}}); + const auto secondPosition = + std::ranges::find(keys, CardKey{LocalPromptKey{secondId}}); + result &= + expect(firstPosition != keys.end() && secondPosition != keys.end() && + firstPosition < secondPosition, + "same-anchor local prompts retain admission order"); + result &= expect( + waiting.sections.size() == 2 && waiting.sections[1].turnId == "turn-2" && + waiting.sections[1].cards.size() == 2, + "acknowledged duplicates share one authoritative turn section"); + + PromptCoordinator moved; + const auto draftId = + moved.admit("", QStringLiteral("draft"), {}, nlohmann::json::object(), + nullptr, std::nullopt, 1); + result &= expect(moved.reassignThread("", "assigned") && + moved.submission("assigned", draftId) && + stableKey(LocalPromptKey{draftId}) == + stableKey(CardKey{LocalPromptKey{draftId}}), + "new-thread assignment preserves the local prompt key"); + return result; +} + +bool testCommandOutputVisibility() { + bool result = expect(!terminalOutputHasVisibleText(QStringView{}), + "empty output is not visible"); + result &= expect( + !terminalOutputHasVisibleText(QStringView{QStringLiteral(" \n\t")}), + "whitespace output is not visible"); + result &= expect(!terminalOutputHasVisibleText( + QStringView{QStringLiteral("\x1b[0m\x1b]0;title\x07")}), + "ANSI and control output is not visible"); + result &= expect( + terminalOutputHasVisibleText(QStringView{QStringLiteral("done\n")}), + "printable command output is visible"); + return result; +} + +} // namespace +} // namespace codexui::codex::middle + +int main() { + using namespace codexui::codex::middle; + bool result = testCanonicalGroupingAndProjection(); + result &= testQueueIsolationAndRealAcknowledgement(); + result &= testDispatchChoiceAndPreHydrationTail(); + result &= testClientIdentityBindsBeforeAcknowledgement(); + result &= testAnchoredDuplicatePrompts(); + result &= testCommandOutputVisibility(); + if (result) + std::cout << "Greenfield projection tests passed\n"; + return result ? 0 : 1; +} diff --git a/tests/codex/GreenfieldShellIntegrationTest.cpp b/tests/codex/GreenfieldShellIntegrationTest.cpp new file mode 100644 index 0000000..e81b56e --- /dev/null +++ b/tests/codex/GreenfieldShellIntegrationTest.cpp @@ -0,0 +1,725 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include +#include + +#include "codex/Configuration.h" +#include "codex/FrontendSession.h" +#include "codex/PresentationProtocol.h" +#include "codex/ShellWidget.h" +#include "codex/middle/ConversationCards.h" +#include "codex/middle/ConversationView.h" +#include "codex/ui/ExpandingPromptEditor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace codexui::codex { + +class FrontendSessionTestPeer final { +public: + static int takeClientDescriptor(FrontendSession &session) { + return std::exchange(session.clientDescriptor, -1); + } + + static void deliver(FrontendSession &session, nlohmann::json frame) { + session.receiveMessage(std::move(frame)); + } +}; + +namespace { + +using presentation::Authority; + +bool expect(bool condition, const char *message) { + if (condition) + return true; + std::cerr << "FAILED: " << message << '\n'; + return false; +} + +void spin(int milliseconds = 0) { + QElapsedTimer timer; + timer.start(); + do { + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + if (milliseconds > 0) + QThread::msleep(1); + } while (timer.elapsed() < milliseconds); +} + +class PresentationPeer final { +public: + explicit PresentationPeer(int descriptor) : descriptor_(descriptor) {} + ~PresentationPeer() { + if (descriptor_ >= 0) + ::close(descriptor_); + } + + PresentationPeer(const PresentationPeer &) = delete; + PresentationPeer &operator=(const PresentationPeer &) = delete; + + bool send(const nlohmann::json &frame) { + std::string encoded = frame.dump(); + encoded.push_back('\n'); + std::size_t offset = 0; + QElapsedTimer timer; + timer.start(); + while (offset < encoded.size() && timer.elapsed() < 1000) { + const ssize_t written = ::write(descriptor_, encoded.data() + offset, + encoded.size() - offset); + if (written > 0) { + offset += static_cast(written); + } else if (written < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + spin(1); + } else { + return false; + } + } + spin(2); + return offset == encoded.size(); + } + + std::optional waitFor(std::string_view action, + std::string_view threadId = {}, + int timeoutMilliseconds = 1000) { + QElapsedTimer timer; + timer.start(); + while (timer.elapsed() < timeoutMilliseconds) { + pump(); + const auto found = std::find_if( + frames_.begin(), frames_.end(), [&](const nlohmann::json &frame) { + if (frame.value("action", std::string{}) != action) + return false; + if (threadId.empty()) + return true; + const nlohmann::json data = + frame.value("data", nlohmann::json::object()); + return data.value("threadId", std::string{}) == threadId; + }); + if (found != frames_.end()) { + nlohmann::json result = std::move(*found); + frames_.erase(found); + return result; + } + spin(1); + } + return std::nullopt; + } + + bool has(std::string_view action) { + pump(); + return std::ranges::any_of(frames_, [&](const nlohmann::json &frame) { + return frame.value("action", std::string{}) == action; + }); + } + + void discard() { + pump(); + frames_.clear(); + } + +private: + void pump() { + char buffer[8192]; + for (;;) { + const ssize_t count = ::read(descriptor_, buffer, sizeof(buffer)); + if (count > 0) { + incoming_.append(buffer, static_cast(count)); + continue; + } + if (count < 0 && errno != EAGAIN && errno != EWOULDBLOCK) + std::cerr << "peer read failed: " << std::strerror(errno) << '\n'; + break; + } + for (;;) { + const std::size_t newline = incoming_.find('\n'); + if (newline == std::string::npos) + break; + const std::string line = incoming_.substr(0, newline); + incoming_.erase(0, newline + 1); + if (!line.empty()) + frames_.push_back(nlohmann::json::parse(line)); + } + } + + int descriptor_ = -1; + std::string incoming_; + std::deque frames_; +}; + +nlohmann::json thread(std::string id, std::string name, + std::string status = "completed") { + return {{"id", std::move(id)}, + {"name", std::move(name)}, + {"cwd", "/tmp/codexui-shell-test"}, + {"status", std::move(status)}, + {"turns", nlohmann::json::array()}}; +} + +nlohmann::json threadWithAgentMessage(std::string id, std::string name, + std::string message) { + nlohmann::json value = thread(std::move(id), std::move(name)); + value["turns"] = nlohmann::json::array( + {{{"id", "current-turn"}, + {"status", "completed"}, + {"items", nlohmann::json::array({{{"id", "current-message"}, + {"type", "agentMessage"}, + {"phase", "final_answer"}, + {"text", std::move(message)}}})}}}); + return value; +} + +nlohmann::json threadWithPlanAndAgent(std::string id, std::string name) { + nlohmann::json value = thread(std::move(id), std::move(name)); + value["turns"] = nlohmann::json::array( + {{{"id", "turn-a"}, + {"status", "completed"}, + {"items", + nlohmann::json::array({{{"id", "plan-a"}, + {"type", "plan"}, + {"text", "retained plan marker"}}, + {{"id", "agent-a"}, + {"type", "subAgentActivity"}, + {"status", "completed"}, + {"prompt", "retained agent marker"}}})}}}); + return value; +} + +bool selectThread(QListWidget *list, std::string_view id) { + if (!list) + return false; + for (int row = 0; row < list->count(); ++row) { + QListWidgetItem *item = list->item(row); + if (item && item->data(Qt::UserRole).toString().toStdString() == id) { + list->setCurrentRow(row); + spin(2); + return true; + } + } + return false; +} + +bool submit(codexui::ExpandingPromptEditor *editor, const QString &prompt) { + if (!editor || !editor->isEnabled()) + return false; + editor->setPlainText(prompt); + return QMetaObject::invokeMethod(editor, "submitRequested", + Qt::DirectConnection); +} + +const middle::LocalPromptData *localPrompt(ShellWidget &shell, + const QString &prompt) { + for (QWidget *widget : shell.findChildren()) { + auto *card = dynamic_cast(widget); + if (!card) + continue; + const auto *local = + std::get_if(&card->data().payload); + if (local && local->prompt == prompt) + return local; + } + return nullptr; +} + +bool hasAgentMessage(ShellWidget &shell, const QString &message) { + for (QWidget *widget : shell.findChildren()) { + auto *card = dynamic_cast(widget); + if (!card) + continue; + const auto *agent = + std::get_if(&card->data().payload); + if (agent && agent->text == message) + return true; + } + return false; +} + +bool hasPresentedText(QWidget &root, const QString &marker) { + return std::ranges::any_of( + root.findChildren(), [&marker](QLabel *label) { + return label && + (label->text().contains(marker) || + label->property("markdownSource").toString().contains(marker)); + }); +} + +bool runShellFlow(FrontendSession &session, PresentationPeer &peer) { + ShellWidget shell(session); + shell.resize(1500, 850); + shell.show(); + spin(10); + bool result = true; + + auto *conversation = dynamic_cast( + shell.findChild(QStringLiteral("conversationScroll"))); + result &= expect(conversation, "the shell owns the conversation viewport"); + if (!conversation) + return false; + const QPointF wheelPosition(conversation->viewport()->rect().center()); + QWheelEvent wheel( + wheelPosition, + conversation->viewport()->mapToGlobal(wheelPosition.toPoint()), QPoint(), + QPoint(0, -120), Qt::NoButton, Qt::NoModifier, Qt::ScrollUpdate, false); + QApplication::sendEvent(conversation->viewport(), &wheel); + result &= expect(wheel.isAccepted(), + "native wheel delivery crosses the shell router once"); + + std::uint64_t sequence = 1; + result &= peer.send( + presentation::event(sequence++, 1, "connection.lifecycle", + {{"state", "connected"}}, Authority::Merge)); + result &= peer.send(presentation::event(sequence++, 1, "connection.bridge", + {{"state", "opened"}, + {"connectionId", "test-controller"}, + {"role", "controller"}}, + Authority::Merge)); + result &= peer.send(presentation::event( + sequence++, 1, "thread.upsert", {{"thread", thread("thread-a", "A")}}, + Authority::Merge, {{"threadId", "thread-a"}})); + result &= peer.send(presentation::event( + sequence++, 1, "thread.upsert", {{"thread", thread("thread-b", "B")}}, + Authority::Merge, {{"threadId", "thread-b"}})); + spin(10); + peer.discard(); // bridge bootstrap operations are outside this scenario. + + auto *list = shell.findChild(QStringLiteral("threadList")); + auto *editor = shell.findChild( + QStringLiteral("upcomingPromptEditor")); + result &= expect(selectThread(list, "thread-a"), + "the visible A row becomes the prompt destination"); + const auto readA = peer.waitFor("thread.read", "thread-a"); + result &= expect(readA.has_value(), "selecting A requests hydration"); + if (!readA) + return false; + result &= peer.send(presentation::result( + sequence++, 1, "thread.read", + readA->value("correlationId", std::string{}), true, + {{"thread", threadWithPlanAndAgent("thread-a", "A")}}, Authority::Replace, + {{"threadId", "thread-a"}})); + spin(10); + + result &= expect(submit(editor, QStringLiteral("prompt A1")), + "A1 is admitted through the real composer"); + const auto startA = peer.waitFor("turn.start", "thread-a"); + result &= expect(startA.has_value() && !peer.has("thread.create"), + "A1 starts on selected A and never creates a new thread"); + if (!startA) + return false; + const nlohmann::json startAData = + startA->value("data", nlohmann::json::object()); + const std::string clientId = + startAData.value("clientUserMessageId", std::string{}); + result &= expect(!clientId.empty(), + "turn.start carries the prompt correlation identity"); + + result &= expect(submit(editor, QStringLiteral("prompt A2")), + "A2 remains independently editable while A1 awaits ack"); + spin(20); + result &= expect(!peer.has("turn.steer"), + "A2 waits behind the one in-flight operation for A"); + + result &= peer.send(presentation::event( + sequence++, 1, "conversation.item.upsert", + {{"item", + {{"id", "user-a1"}, + {"type", "userMessage"}, + {"clientId", clientId}, + {"content", {{{"type", "text"}, {"text", "prompt A1"}}}}}}}, + Authority::Merge, + {{"threadId", "thread-a"}, {"turnId", "turn-a"}, {"itemId", "user-a1"}})); + spin(10); + const middle::LocalPromptData *beforeAck = + localPrompt(shell, QStringLiteral("prompt A1")); + result &= + expect(beforeAck && beforeAck->state == middle::PromptState::InFlight, + "materialization alone cannot acknowledge A1"); + + result &= expect(selectThread(list, "thread-b"), + "B can be selected while A remains active"); + const auto readB = peer.waitFor("thread.read", "thread-b"); + result &= expect(readB.has_value(), "selecting B requests its own hydration"); + if (!readB) + return false; + result &= peer.send( + presentation::result(sequence++, 1, "thread.read", + readB->value("correlationId", std::string{}), true, + {{"thread", thread("thread-b", "B")}}, + Authority::Replace, {{"threadId", "thread-b"}})); + spin(10); + result &= expect(submit(editor, QStringLiteral("prompt B1")), + "B1 is admitted while A1 is in flight"); + const auto startB = peer.waitFor("turn.start", "thread-b"); + result &= + expect(startB.has_value(), "different threads dispatch independently"); + + result &= peer.send(presentation::result( + sequence++, 1, "turn.start", + startA->value("correlationId", std::string{}), true, + {{"turn", {{"id", "turn-a"}, {"status", "inProgress"}}}}, + Authority::Merge, {{"threadId", "thread-a"}, {"turnId", "turn-a"}})); + const auto steerA = peer.waitFor("turn.steer", "thread-a"); + result &= + expect(steerA.has_value(), "A1's real background ack releases queued A2"); + result &= expect( + list && list->currentItem() && + list->currentItem()->data(Qt::UserRole).toString().toStdString() == + "thread-b", + "a background acknowledgment does not change selection"); + + peer.discard(); + result &= expect(selectThread(list, "thread-a"), + "switching back restores A's retained prompt state"); + spin(10); + result &= expect( + !peer.waitFor("thread.read", "thread-a", 100).has_value(), + "switching back to hydrated A does not issue a destructive reread"); + const middle::LocalPromptData *accepted = + localPrompt(shell, QStringLiteral("prompt A1")); + result &= expect(accepted && accepted->state == middle::PromptState::Accepted, + "only the correlated turn result acknowledges A1"); + auto *inspector = shell.findChild(QStringLiteral("inspector")); + auto *inspectorTabs = inspector ? inspector->findChild( + QString{}, Qt::FindDirectChildrenOnly) + : nullptr; + result &= expect( + inspector && inspectorTabs && + hasPresentedText(*inspector, QStringLiteral("retained plan marker")), + "A's retained plan is present immediately after return"); + if (inspectorTabs) { + inspectorTabs->setCurrentIndex(1); + spin(); + } + result &= expect( + inspector && + hasPresentedText(*inspector, QStringLiteral("retained agent marker")), + "A's retained agent detail survives thread navigation"); + + result &= peer.send(presentation::event( + sequence++, 1, "thread.upsert", {{"thread", thread("thread-c", "C")}}, + Authority::Merge, {{"threadId", "thread-c"}})); + spin(5); + result &= expect(selectThread(list, "thread-c"), + "C is selected for hydration supersession coverage"); + const auto readC1 = peer.waitFor("thread.read", "thread-c"); + result &= expect(readC1.has_value(), "C issues its first hydration read"); + if (!readC1) + return false; + + result &= peer.send(presentation::event(sequence++, 2, "connection.lifecycle", + {{"state", "disconnected"}}, + Authority::Merge)); + result &= peer.send(presentation::event(sequence++, 2, "connection.lifecycle", + {{"state", "connected"}}, + Authority::Merge)); + result &= + peer.send(presentation::event(sequence++, 2, "connection.bridge", + {{"state", "opened"}, + {"connectionId", "test-controller-2"}, + {"role", "controller"}}, + Authority::Merge)); + const auto readC2 = peer.waitFor("thread.read", "thread-c"); + result &= expect(readC2.has_value(), + "the new connection owns a fresh hydration read"); + if (!readC2) + return false; + result &= peer.send(presentation::result( + sequence++, 2, "thread.read", + readC2->value("correlationId", std::string{}), true, + {{"thread", threadWithAgentMessage("thread-c", "C", "current C marker")}}, + Authority::Replace, {{"threadId", "thread-c"}})); + result &= peer.send( + presentation::result(sequence++, 2, "thread.read", + readC1->value("correlationId", std::string{}), true, + {{"thread", thread("thread-c", "stale C")}}, + Authority::Replace, {{"threadId", "thread-c"}})); + spin(10); + result &= expect(hasAgentMessage(shell, QStringLiteral("current C marker")), + "a late successful stale read cannot replace newer cards"); + peer.discard(); + result &= expect(submit(editor, QStringLiteral("prompt C1")), + "C remains hydrated after the stale read callback"); + const auto startC = peer.waitFor("turn.start", "thread-c"); + result &= expect(startC.has_value() && !peer.has("thread.read"), + "a stale read cannot overwrite newer hydration state"); + + result &= peer.send(presentation::result( + sequence++, 2, "turn.start", + startB ? startB->value("correlationId", std::string{}) : std::string{}, + false, {{"code", -32001}, {"message", "transport cancelled"}}, + Authority::None, {{"threadId", "thread-b"}})); + spin(10); + result &= expect(selectThread(list, "thread-b"), + "B remains selectable after reconnection"); + const middle::LocalPromptData *cancelled = + localPrompt(shell, QStringLiteral("prompt B1")); + result &= expect(cancelled && cancelled->state == middle::PromptState::Failed, + "an exact terminal callback after reconnect is never lost"); + + peer.discard(); + result &= + peer.send(presentation::event(sequence++, 2, "agents.activity.upsert", + {{"activity", + {{"id", "child-failure"}, + {"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "child-failure"}}}}, + Authority::Merge, + {{"threadId", "thread-b"}, + {"turnId", "turn-b"}, + {"itemId", "child-failure"}})); + const auto childRead = peer.waitFor("thread.read", "child-failure"); + result &= expect(childRead.has_value(), + "a started historical child is hydrated once"); + if (!childRead) + return false; + result &= peer.send(presentation::result( + sequence++, 2, "thread.read", + childRead->value("correlationId", std::string{}), false, + {{"code", -32002}, {"message", "child hydration failed"}}, + Authority::None, {{"threadId", "child-failure"}})); + spin(10); + result &= + expect(!peer.waitFor("thread.read", "child-failure", 100).has_value(), + "a failed child hydration does not enter an automatic retry loop"); + + peer.discard(); + result &= peer.send(presentation::event( + sequence++, 2, "thread.upsert", {{"thread", thread("thread-d", "D")}}, + Authority::Merge, {{"threadId", "thread-d"}})); + spin(5); + result &= expect(selectThread(list, "thread-d"), + "D is selected for thread-not-found recovery coverage"); + const auto readD = peer.waitFor("thread.read", "thread-d"); + result &= expect(readD.has_value(), "D is hydrated before its first prompt"); + if (!readD) + return false; + result &= peer.send( + presentation::result(sequence++, 2, "thread.read", + readD->value("correlationId", std::string{}), true, + {{"thread", thread("thread-d", "D")}}, + Authority::Replace, {{"threadId", "thread-d"}})); + spin(5); + result &= expect(submit(editor, QStringLiteral("prompt D1")), + "D1 is admitted before recovery"); + const auto firstStartD = peer.waitFor("turn.start", "thread-d"); + result &= expect(firstStartD.has_value(), "D1 begins with turn.start"); + if (!firstStartD) + return false; + const std::string firstDClientId = + firstStartD->value("data", nlohmann::json::object()) + .value("clientUserMessageId", std::string{}); + result &= peer.send(presentation::result( + sequence++, 2, "turn.start", + firstStartD->value("correlationId", std::string{}), false, + {{"code", -32004}, {"message", "thread thread-d not found"}}, + Authority::None, {{"threadId", "thread-d"}})); + const auto firstResumeD = peer.waitFor("thread.resume", "thread-d"); + result &= expect(firstResumeD.has_value(), + "thread-not-found triggers one explicit resume"); + if (!firstResumeD) + return false; + result &= peer.send( + presentation::result(sequence++, 2, "thread.resume", + firstResumeD->value("correlationId", std::string{}), + true, {{"thread", thread("thread-d", "D")}}, + Authority::Merge, {{"threadId", "thread-d"}})); + const auto retriedStartD = peer.waitFor("turn.start", "thread-d"); + result &= expect(retriedStartD.has_value() && + retriedStartD->value("data", nlohmann::json::object()) + .value("clientUserMessageId", std::string{}) == + firstDClientId, + "D1 retries once with the same client message identity"); + if (!retriedStartD) + return false; + result &= peer.send(presentation::result( + sequence++, 2, "turn.start", + retriedStartD->value("correlationId", std::string{}), true, + {{"turn", {{"id", "turn-d"}, {"status", "inProgress"}}}}, + Authority::Merge, {{"threadId", "thread-d"}, {"turnId", "turn-d"}})); + spin(5); + + result &= expect(submit(editor, QStringLiteral("prompt D2")), + "the prompt after recovery remains dispatchable"); + const auto firstSteerD = peer.waitFor("turn.steer", "thread-d"); + result &= expect(firstSteerD.has_value(), + "the next prompt steers the recovered active turn"); + if (!firstSteerD) + return false; + const std::string secondDClientId = + firstSteerD->value("data", nlohmann::json::object()) + .value("clientUserMessageId", std::string{}); + result &= peer.send(presentation::result( + sequence++, 2, "turn.steer", + firstSteerD->value("correlationId", std::string{}), false, + {{"code", -32004}, {"message", "thread thread-d not found"}}, + Authority::None, {{"threadId", "thread-d"}})); + const auto secondResumeD = peer.waitFor("thread.resume", "thread-d"); + result &= expect(secondResumeD.has_value(), + "D2 receives its single bounded recovery attempt"); + if (!secondResumeD) + return false; + result &= expect(submit(editor, QStringLiteral("prompt D during recovery")), + "another prompt remains admissible during recovery"); + spin(10); + result &= + expect(selectThread(list, "thread-b") && selectThread(list, "thread-d"), + "thread navigation remains available during recovery"); + result &= + expect(!peer.waitFor("thread.read", "thread-d", 100).has_value() && + !peer.waitFor("turn.steer", "thread-d", 100).has_value(), + "an in-flight resume gates navigation hydration and dispatch"); + result &= peer.send( + presentation::result(sequence++, 2, "thread.resume", + secondResumeD->value("correlationId", std::string{}), + true, {{"thread", thread("thread-d", "D")}}, + Authority::Merge, {{"threadId", "thread-d"}})); + const auto retriedSteerD = peer.waitFor("turn.steer", "thread-d"); + result &= expect(retriedSteerD.has_value() && + retriedSteerD->value("data", nlohmann::json::object()) + .value("clientUserMessageId", std::string{}) == + secondDClientId, + "D2 retry also preserves its exact identity"); + if (!retriedSteerD) + return false; + result &= peer.send(presentation::result( + sequence++, 2, "turn.steer", + retriedSteerD->value("correlationId", std::string{}), false, + {{"code", -32004}, {"message", "thread thread-d not found again"}}, + Authority::None, {{"threadId", "thread-d"}})); + spin(10); + const middle::LocalPromptData *failedD2 = + localPrompt(shell, QStringLiteral("prompt D2")); + result &= expect( + failedD2 && failedD2->state == middle::PromptState::Failed && + !peer.waitFor("thread.resume", "thread-d", 100).has_value(), + "a repeated not-found is terminal and cannot start a second recovery"); + const auto postRecoverySteerD = peer.waitFor("turn.steer", "thread-d"); + result &= expect(postRecoverySteerD.has_value(), + "the queued prompt dispatches after recovery finishes"); + if (!postRecoverySteerD) + return false; + result &= peer.send(presentation::result( + sequence++, 2, "turn.steer", + postRecoverySteerD->value("correlationId", std::string{}), true, + {{"turn", {{"id", "turn-d"}, {"status", "inProgress"}}}}, + Authority::Merge, {{"threadId", "thread-d"}, {"turnId", "turn-d"}})); + + peer.discard(); + result &= peer.send(presentation::event( + sequence++, 2, "thread.upsert", {{"thread", thread("thread-e", "E")}}, + Authority::Merge, {{"threadId", "thread-e"}})); + spin(5); + result &= expect(selectThread(list, "thread-e"), + "E is selected for failed-hydration admission coverage"); + const auto readE = peer.waitFor("thread.read", "thread-e"); + result &= expect(readE.has_value(), "E requests its first hydration read"); + if (!readE) + return false; + result &= peer.send(presentation::result( + sequence++, 2, "thread.read", + readE->value("correlationId", std::string{}), false, + {{"code", -32005}, {"message", "thread hydration failed"}}, + Authority::None, {{"threadId", "thread-e"}})); + spin(10); + result &= expect(submit(editor, QStringLiteral("prompt E1")), + "the composer delivers E1 to the admission boundary"); + spin(10); + result &= + expect(editor && editor->toPlainText() == QStringLiteral("prompt E1"), + "failed hydration rejects admission without clearing the draft"); + result &= + expect(!peer.waitFor("turn.start", "thread-e", 100).has_value() && + !peer.waitFor("thread.read", "thread-e", 100).has_value(), + "failed hydration cannot send or enter an automatic read loop"); + + peer.discard(); + result &= peer.send(presentation::event( + sequence++, 2, "thread.upsert", {{"thread", thread("thread-f", "F")}}, + Authority::Merge, {{"threadId", "thread-f"}})); + spin(5); + result &= expect(selectThread(list, "thread-f"), + "F is selected for dispatch/disconnect coverage"); + const auto readF = peer.waitFor("thread.read", "thread-f"); + result &= expect(readF.has_value(), "F is hydrated before admission"); + if (!readF) + return false; + result &= peer.send( + presentation::result(sequence++, 2, "thread.read", + readF->value("correlationId", std::string{}), true, + {{"thread", thread("thread-f", "F")}}, + Authority::Replace, {{"threadId", "thread-f"}})); + spin(5); + result &= expect(submit(editor, QStringLiteral("prompt F1")), + "F1 is admitted while connected"); + FrontendSessionTestPeer::deliver( + session, + presentation::event(sequence++, 2, "connection.lifecycle", + {{"state", "disconnected"}}, Authority::Merge)); + spin(10); + const middle::LocalPromptData *disconnectedF1 = + localPrompt(shell, QStringLiteral("prompt F1")); + result &= expect( + disconnectedF1 && + !peer.waitFor("turn.start", "thread-f", 100).has_value(), + "a disconnect crossing the zero-delay timer keeps F1 pending and unsent"); + FrontendSessionTestPeer::deliver( + session, presentation::event(sequence++, 2, "connection.lifecycle", + {{"state", "connected"}}, Authority::Merge)); + FrontendSessionTestPeer::deliver( + session, presentation::event(sequence++, 2, "connection.bridge", + {{"state", "opened"}, + {"connectionId", "test-controller-3"}, + {"role", "controller"}}, + Authority::Merge)); + const auto startF = peer.waitFor("turn.start", "thread-f"); + result &= expect(startF.has_value(), + "the same queued F1 dispatches after reconnect opens"); + if (!startF) + return false; + result &= peer.send(presentation::result( + sequence++, 2, "turn.start", + startF->value("correlationId", std::string{}), true, + {{"turn", {{"id", "turn-f"}, {"status", "inProgress"}}}}, + Authority::Merge, {{"threadId", "thread-f"}, {"turnId", "turn-f"}})); + return result; +} + +} // namespace +} // namespace codexui::codex + +int main(int argc, char **argv) { + auto *configuration = + utils::Config::configRoot.newSubCommand(); + QApplication application(argc, argv); + core::SNodeC::init(argc, argv); + + codexui::codex::FrontendSession session(*configuration); + codexui::codex::PresentationPeer peer( + codexui::codex::FrontendSessionTestPeer::takeClientDescriptor(session)); + const bool result = codexui::codex::runShellFlow(session, peer); + if (result) + std::cout << "Greenfield shell integration test passed\n"; + return result ? 0 : 1; +} diff --git a/tests/codex/PresentationPipelineTest.cpp b/tests/codex/PresentationPipelineTest.cpp new file mode 100644 index 0000000..bd5d4eb --- /dev/null +++ b/tests/codex/PresentationPipelineTest.cpp @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/PresentationModel.h" +#include "codex/PresentationProtocol.h" +#include "codex/ProtocolNormalizer.h" + +#include +#include +#include +#include + +namespace { + +bool expect(bool condition, const char *message) { + std::cout << (condition ? "PASS " : "FAIL ") << message << '\n'; + return condition; +} + +std::string stringMember(const nlohmann::json &value, const char *name) { + const auto member = value.find(name); + return member != value.end() && member->is_string() + ? member->get() + : std::string{}; +} + +} // namespace + +int main() { + using codexui::codex::PresentationModel; + using codexui::codex::ProtocolNormalizer; + + PresentationModel model; + std::vector frames; + ProtocolNormalizer normalizer([&](const nlohmann::json &frame) { + frames.push_back(frame); + model.applyEvent(frame); + return true; + }); + + normalizer.transportEvent("connected"); + normalizer.bridgeEvent({{"kind", "bridge.connection"}, + {"event", "connected"}, + {"connectionId", "frontend-test"}, + {"role", "observer"}}); + normalizer.bridgeEvent({{"kind", "bridge.controller"}, + {"controllerConnectionId", "frontend-test"}}); + normalizer.connectionSettings( + {{"selected", "ipv6"}, + {"available", nlohmann::json::array({{{"key", "ipv6"}, + {"label", "IPv6"}, + {"kind", "network"}, + {"host", "::1"}, + {"port", 4500}, + {"tls", false}}})}}); + + normalizer.operationResult( + "threads.list", "list-1", nlohmann::json::object(), + {{"id", "list-1"}, + {"result", + {{"data", nlohmann::json::array({{{"id", "thread-1"}, + {"preview", "Architecture pipeline"}, + {"cwd", "/workspace"}, + {"status", {{"type", "idle"}}}}})}, + {"nextCursor", nullptr}, + {"backwardsCursor", nullptr}}}}); + + normalizer.operationResult( + "thread.read", "read-1", {{"threadId", "thread-1"}}, + {{"id", "read-1"}, + {"result", + {{"thread", + {{"id", "thread-1"}, + {"preview", "Architecture pipeline"}, + {"cwd", "/workspace"}, + {"status", {{"type", "idle"}}}, + {"turns", + nlohmann::json::array( + {{{"id", "turn-1"}, + {"status", "completed"}, + {"items", nlohmann::json::array( + {{{"id", "user-1"}, + {"type", "userMessage"}, + {"content", + nlohmann::json::array( + {{{"type", "text"}, + {"text", "Inspect"}}})}}})}}})}}}}}}); + + normalizer.serverNotification("turn/started", + {{"threadId", "thread-1"}, + {"turn", + {{"id", "turn-2"}, + {"status", "inProgress"}, + {"items", nlohmann::json::array()}}}}); + normalizer.serverNotification("item/started", + {{"threadId", "thread-1"}, + {"turnId", "turn-2"}, + {"item", + {{"id", "command-1"}, + {"type", "commandExecution"}, + {"command", "printf PIPELINE_OK"}, + {"cwd", "/workspace"}, + {"status", "inProgress"}, + {"aggregatedOutput", nullptr}, + {"exitCode", nullptr}}}}); + normalizer.serverNotification("item/commandExecution/outputDelta", + {{"threadId", "thread-1"}, + {"turnId", "turn-2"}, + {"itemId", "command-1"}, + {"delta", "PIPELINE_OK\n"}}); + normalizer.serverNotification("item/completed", + {{"threadId", "thread-1"}, + {"turnId", "turn-2"}, + {"item", + {{"id", "command-1"}, + {"type", "commandExecution"}, + {"command", "printf PIPELINE_OK"}, + {"cwd", "/workspace"}, + {"status", "completed"}, + {"aggregatedOutput", "PIPELINE_OK\n"}, + {"exitCode", 0}}}}); + normalizer.serverNotification( + "turn/diff/updated", + {{"threadId", "thread-1"}, + {"turnId", "turn-2"}, + {"diff", "diff --git a/README.md b/README.md\n+PIPELINE_OK\n"}}); + normalizer.serverNotification( + "turn/plan/updated", + {{"threadId", "thread-1"}, + {"turnId", "turn-2"}, + {"explanation", "Keep live inspector state"}, + {"plan", nlohmann::json::array({{{"step", "Retain the plan"}, + {"status", "completed"}}})}}); + normalizer.serverNotification("turn/completed", + {{"threadId", "thread-1"}, + {"turn", + {{"id", "turn-2"}, + {"status", "completed"}, + {"items", nlohmann::json::array()}}}}); + + normalizer.operationResult("thread.read", "read-2", + {{"threadId", "thread-1"}}, + {{"id", "read-2"}, + {"result", + {{"thread", + {{"id", "thread-1"}, + {"preview", "Architecture pipeline"}, + {"cwd", "/workspace"}, + {"status", {{"type", "idle"}}}, + {"turns", nlohmann::json::array()}}}}}}); + + bool validFrames = !frames.empty(); + std::uint64_t expectedSequence = 1; + for (const nlohmann::json &frame : frames) { + validFrames &= codexui::codex::presentation::isPresentationFrame(frame); + validFrames &= frame.value("sequence", 0ULL) == expectedSequence++; + validFrames &= frame.value("generation", 0ULL) == 1; + } + + const auto *thread = model.thread("thread-1"); + const auto *turn = thread == nullptr + ? nullptr + : [&]() -> const codexui::codex::TurnPresentation * { + const auto found = thread->turns.find("turn-2"); + return found == thread->turns.end() ? nullptr : &found->second; + }(); + const auto *item = turn == nullptr + ? nullptr + : [&]() -> const codexui::codex::ItemPresentation * { + const auto found = turn->items.find("command-1"); + return found == turn->items.end() ? nullptr : &found->second; + }(); + + bool passed = true; + passed &= expect(validFrames, + "normalizer emits ordered versioned generation frames"); + passed &= expect( + model.connection().connected && + model.connection().connectionId == "frontend-test" && + model.connection().role == "controller" && + stringMember(model.connection().settings, "selected") == "ipv6", + "connection, controller, and transport settings form coherent state"); + passed &= expect(thread != nullptr && thread->turnOrder.size() == 2 && + thread->cwd == "/workspace", + "list, full read, and live events retain one stable thread"); + passed &= expect(turn != nullptr && turn->status == "completed" && + turn->itemOrder.size() == 1, + "live turn lifecycle resolves one stable turn"); + passed &= expect( + item != nullptr && + stringMember(item->raw, "command") == "printf PIPELINE_OK" && + stringMember(item->raw, "aggregatedOutput") == "PIPELINE_OK\n" && + item->raw.value("exitCode", -1) == 0 && + stringMember(item->raw, "status") == "completed", + "command lifecycle retains its authoritative result"); + passed &= + expect(turn != nullptr && + stringMember(turn->domains.at("turn.diff.changed"), "diff") == + "diff --git a/README.md b/README.md\n+PIPELINE_OK\n", + "live turn diff is retained in its authoritative turn scope"); + passed &= + expect(turn != nullptr && + stringMember(turn->plan, "explanation") == + "Keep live inspector state" && + turn->plan.value("steps", nlohmann::json::array()).size() == 1, + "incomplete thread reads preserve live plan and inspector state"); + passed &= expect(!model.activeTurnId("thread-1").has_value(), + "completed stream leaves no active turn"); + return passed ? 0 : 1; +} diff --git a/tests/codex/SocketPairContractTest.cpp b/tests/codex/SocketPairContractTest.cpp new file mode 100644 index 0000000..2b5b234 --- /dev/null +++ b/tests/codex/SocketPairContractTest.cpp @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/ipc/SNodeSocketPairEndpoint.h" +#include "codex/ipc/SocketPair.h" + +#include +#include + +#include "codex/ipc/QtSocketPairEndpoint.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr std::size_t MaximumQueuedBytes = 64; +constexpr std::size_t MaximumReadBytesPerEvent = 64U * 1024U; +constexpr std::string_view FromQt = "qt-frame-1\nqt-frame-2\n"; +constexpr std::string_view FromSNode = "snode-frame-1\nsnode-frame-2\n"; +constexpr std::string_view Acknowledgement = "snode-ack\n"; + +bool expect(bool condition, const char *message) { + std::cout << (condition ? "PASS " : "FAIL ") << message << '\n'; + return condition; +} + +} // namespace + +int main(int argc, char *argv[]) { + QCoreApplication application(argc, argv); + core::SNodeC::init(argc, argv); + + codexui::codex::ipc::SocketPair pair; + if (!expect(pair.isValid(), "nonblocking Unix socketpair is created")) + return 1; + + codexui::codex::ipc::QtSocketPairEndpoint qtEndpoint( + pair.releaseFirstEndpoint(), MaximumQueuedBytes); + const int snodeDescriptor = pair.releaseSecondEndpoint(); + + std::atomic_bool snodeCreated = false; + std::atomic_bool snodeBounded = false; + std::atomic_bool snodeReceived = false; + std::atomic_bool snodeClosed = false; + std::atomic_bool snodeError = false; + std::atomic_int eventLoopResult = -1; + std::string receivedBySNode; + std::promise snodeReady; + std::future ready = snodeReady.get_future(); + + std::thread snodeThread([&] { + auto *endpoint = codexui::codex::ipc::SNodeSocketPairEndpoint::create( + snodeDescriptor, MaximumQueuedBytes, MaximumReadBytesPerEvent); + snodeCreated = endpoint != nullptr; + if (!endpoint) { + snodeReady.set_value(); + QMetaObject::invokeMethod(&application, &QCoreApplication::quit, + Qt::QueuedConnection); + return; + } + + endpoint->setOnData([&, endpoint](const char *data, std::size_t size) { + receivedBySNode.append(data, size); + if (!snodeReceived && receivedBySNode == FromQt) { + snodeReceived = true; + static_cast( + endpoint->send(Acknowledgement.data(), Acknowledgement.size())); + } + }); + endpoint->setOnError([&](int) { snodeError = true; }); + endpoint->setOnClosed([&] { + snodeClosed = true; + core::SNodeC::stop(); + QMetaObject::invokeMethod(&application, &QCoreApplication::quit, + Qt::QueuedConnection); + }); + + const std::string oversized(MaximumQueuedBytes + 1, 'x'); + snodeBounded = !endpoint->send(oversized); + static_cast(endpoint->send(FromSNode.substr(0, 14).data(), 14)); + static_cast( + endpoint->send(FromSNode.substr(14).data(), FromSNode.size() - 14)); + snodeReady.set_value(); + eventLoopResult = core::SNodeC::start(utils::Timeval({5, 0})); + }); + + if (ready.wait_for(std::chrono::seconds(2)) != std::future_status::ready) { + core::SNodeC::stop(); + snodeThread.join(); + expect(false, "SNode.C socketpair endpoint becomes ready"); + return 1; + } + + std::string receivedByQt; + bool qtSent = false; + bool qtReceived = false; + bool qtClosed = false; + bool qtError = false; + qtEndpoint.setOnData([&](const char *data, std::size_t size) { + receivedByQt.append(data, size); + if (!qtSent && receivedByQt.starts_with(FromSNode)) { + qtSent = qtEndpoint.send(FromQt.substr(0, 11).data(), 11) && + qtEndpoint.send(FromQt.substr(11).data(), FromQt.size() - 11); + } + if (receivedByQt == std::string(FromSNode) + std::string(Acknowledgement)) { + qtReceived = true; + qtEndpoint.close(); + } + }); + qtEndpoint.setOnError([&](int) { qtError = true; }); + qtEndpoint.setOnClosed([&] { qtClosed = true; }); + + const std::string oversized(MaximumQueuedBytes + 1, 'x'); + const bool qtBounded = !qtEndpoint.send(oversized); + + QTimer::singleShot(5000, &application, [&] { + core::SNodeC::stop(); + application.quit(); + }); + application.exec(); + core::SNodeC::stop(); + if (qtEndpoint.isOpen()) + qtEndpoint.close(); + snodeThread.join(); + + bool passed = true; + passed &= expect(snodeCreated, "SNode.C endpoint is created"); + passed &= expect(qtBounded && snodeBounded, + "both endpoints reject writes beyond their queue bound"); + passed &= expect(qtSent && snodeReceived && receivedBySNode == FromQt, + "Qt-to-SNode.C frames preserve byte order"); + passed &= + expect(qtReceived && receivedByQt == std::string(FromSNode) + + std::string(Acknowledgement), + "SNode.C-to-Qt frames preserve byte order"); + passed &= expect(qtClosed && snodeClosed, + "closing one endpoint cleanly closes both sides"); + passed &= expect(!qtError && !snodeError, + "normal exchange and shutdown report no transport error"); + passed &= expect(eventLoopResult == 0, "SNode.C event loop exits cleanly"); + return passed ? 0 : 1; +} diff --git a/ui-review/STATE-MATRIX.md b/ui-review/STATE-MATRIX.md index dcc527f..dedb1a0 100644 --- a/ui-review/STATE-MATRIX.md +++ b/ui-review/STATE-MATRIX.md @@ -1,72 +1,45 @@ -# CodexUI UI state matrix - -This matrix complements [UI-INVENTORY.md](UI-INVENTORY.md). Screenshot paths are relative to this directory. Every captured state came from the current real CodexUI/AISuite/backend/app-server stack through normal application behavior. No synthetic frontend protocol state was injected. - -## Captured canonical states - -| State | Screenshot | Reproduction path | Owning Qt classes | Canonical trigger | Determinism and interaction notes | -|---|---|---|---|---|---| -| Disconnected at launch | `screenshots/01-disconnected.png` | Start CodexUI while its dedicated local backend socket is unavailable. | `WorkbenchWidget`, `SidebarWidget`, `ConversationWidget`, `FrontendSession` | `QLocalSocket` connection failure; AISuite connection state is disconnected. | Deterministic. The empty center remains usable only as presentation; Send and controller-dependent work are disabled. Automatic retry status evolves after the static capture. | -| Connected, no thread selected | `screenshots/02-connected-no-thread.png` | Start backend/app-server and CodexUI with no selected thread in the isolated state. Wait for synchronization. | `WorkbenchWidget`, `SidebarWidget`, `ConversationWidget`, `InspectorWidget` | AISuite Ready/Synchronized state with no selected thread. | Deterministic. New Thread is available; conversation and Inspector show explicit empty states. | -| Completed normal conversation | `screenshots/03-thread-normal-conversation.png` | Create a real thread, submit a short prompt, wait for the real app-server turn to complete. | `ConversationWidget`, coordinated by `WorkbenchWidget` | `ThreadUpserted`, `TurnUpserted`, `ItemUpserted`/content changes, then terminal turn state. | Deterministic given a successful app-server response, although response copy varies. The reflected prompt appears only after canonical State; no optimistic duplicate exists. | -| Active streaming turn | `screenshots/04-active-streaming-turn.png` | Submit a real prompt that produces commentary/tool activity and capture before terminal completion. | `ConversationWidget`, `WorkbenchWidget`, `FrontendSession` | Incremental AISuite item/content occurrences and active `TurnState`. | Timing-dependent but repeatedly reproducible. Static capture omits append cadence: changed segments update in place/reconcile, the timeline follows the latest item when already at the bottom, and Stop is enabled while active. | -| High-content/scrollable conversation | `screenshots/05-long-conversation.png` | Use the real active thread after it accumulated a long test-report prompt/response and scroll within the conversation. | `ConversationWidget` | Fully/partially loaded thread with enough ordered segments to exceed viewport height. | Deterministic from retained state. Vertical scrolling is within the central conversation; horizontal scrolling remains disabled. Very large bodies use a bounded plain-text editor. The widget window is capped at 32 turns/256 items. | -| Inspector Info | `screenshots/06-inspector-info.png` | Select a loaded thread, select Info. | `InspectorWidget`, `WorkbenchWidget` | Selected `ThreadState`, latest `TurnState`, synchronization/controller/list facts. | Deterministic. Values are read-only and mostly selectable. The page scrolls independently. It exposes load/completeness facts that are not prominent in the timeline. | -| Inspector Plan, empty | `screenshots/07-inspector-plan.png` | Select Plan when the latest turn contains no retained plan semantic view. | `InspectorWidget` | No applicable typed plan item in selected/latest turn. | Deterministic for this thread. A contentful plan would replace the explanatory empty state; tab selection does not mutate backend state. | -| Inspector Agents, empty | `screenshots/08-inspector-agents.png` | Select Agents when the latest turn contains no retained collaboration/subagent activity. | `InspectorWidget` | No applicable typed collaboration semantic view. | Deterministic for this thread. This is also the Inspector's default tab. | -| Inspector Changes, empty | `screenshots/09-inspector-changes.png` | Select Changes when the latest turn contains no retained file-change semantic view. | `InspectorWidget` | No applicable typed file-change item. | Deterministic for this thread. A contentful state would show canonical change cards and actions supported by existing typed semantics. | -| Backend lost/reconnect | `screenshots/12-error-reconnect.png` | Keep a completed thread selected, stop the real backend, and wait for the socket failure/retry presentation. | `FrontendSession`, `WorkbenchWidget`, status areas in all three panels | Physical local-socket closure and retryable connection-state changes. | Deterministic. Retained immutable conversation state stays visible while controller actions are disabled. Backoff/reconnect copy changes over time; Commands also offers explicit Reconnect. | -| Narrow window | `screenshots/13-narrow-window.png` | Resize the actual app client to its 1100 x 700 minimum. | `MainWindow`, `WorkbenchWidget`, all three panels | Pure Qt resize/layout event; canonical state unchanged. | Deterministic. Sidebar/Inspector minima and the center minimum create a dense three-panel result. Text elides/wraps; each content area retains its own vertical scrolling. | -| Large window | `screenshots/14-large-window.png` | Resize the actual app client to 1800 x 1080. | `MainWindow`, `WorkbenchWidget`, all three panels | Pure Qt resize/layout event. | Deterministic. The center absorbs most additional width because the splitter stretch factors are `0/1/0`; side panels remain bounded. | -| New-thread draft | `screenshots/15-new-thread-draft.png` | Click New Thread and do not submit a prompt. | `SidebarWidget`, `WorkbenchWidget`, `ConversationWidget` | Local draft selection only; no `thread.start` has yet been submitted. | Deterministic. Copy explains that the thread is created by the first prompt and backend defaults are used. Leaving the draft does not create a canonical empty thread. | -| Both side panels hidden | `screenshots/16-panels-hidden.png` | Use Hide in Sidebar and Inspector. | `WorkbenchWidget`, `SidebarWidget`, `InspectorWidget` | Pure presentation visibility state. | Deterministic. Top-bar restore controls appear and the conversation receives the released width. Canonical state is unaffected. | - -## Important states not captured - -| State | Why no screenshot was manufactured | Responsible UI/source | Canonical trigger and expected behavior | -|---|---|---|---| -| Approval request | A harmless real command in the capture run did not require approval. Forcing a fake pending request would violate the baseline rules. | `InteractiveRequestDialog`, `WorkbenchWidget`, `FrontendSession` | A canonical typed pending command/file or patch/exec approval opens/updates the non-modal dialog. Complete semantics permit positive/negative response; incomplete valid semantics permit negative response only. | -| User-input request | The real app-server reported that `request_user_input` was unavailable in the capture mode. | `InteractiveRequestDialog`, `WorkbenchWidget`, `FrontendSession` | A canonical `request.userInput` pending request presents ordered questions/options/free text, with password echo for secret input and typed submission. | -| Contentful Plan | The real captured turn contained no plan item. | `InspectorWidget` | A typed plan semantic view produces ordered plan steps and statuses. Updates are presentation-key reconciled. | -| Contentful Agents | No subagent/collaboration activity occurred in the captured turn. | `InspectorWidget` | Typed collaboration semantics replace the empty state with agent/activity cards. | -| Contentful Changes | The captured turn produced no retained file-change item. | `InspectorWidget` | Typed file-change semantics produce canonical change presentation. | -| Terminal failed turn | The real capture turn completed normally; deliberately causing an app-server failure was unnecessary. | `ConversationWidget`, `WorkbenchWidget` | Terminal `TurnState` plus failure detail produces failure summary while retaining preceding timeline content. | -| Non-retryable connection failure | The real disconnect used a normal physical backend stop, which is retryable. No incompatible/auth-failing backend was introduced. | `FrontendSession`, `WorkbenchWidget` | Terminal AISuite connection error disables automatic retries, displays the reason, and leaves explicit Reconnect available where meaningful. | -| Thread list truncated/incomplete | The isolated list did not cross the current canonical list bounds. | `SidebarWidget`, `InspectorWidget` | `ThreadListState.complete == false` or truncation metadata is presented truthfully; it must not be mistaken for an exhaustive list. | -| Conversation history window notice | The capture content was long vertically but did not naturally exceed the 32-turn/256-item materialization window. | `ConversationWidget::latestTimelineWindow` | A larger canonical history shows only the latest window and an explicit “latest N of M” notice while preserving full canonical State outside the QWidget tree. | -| Unsupported pending request kinds | No permissions approval, authentication, attestation, dynamic-tool, or MCP elicitation request occurred. | `InteractiveRequestDialog` | These pending kinds remain visible but currently have no complete typed response form in CodexUI. | - -## Source-discovered transient states - -These transitions are important but a single screenshot is not the most faithful representation. - -| State/transition | Steps or trigger | Visible/interactive behavior | -|---|---|---| -| Connecting → synchronizing → ready | Launch CodexUI with an available backend. | Status text and action enablement change as the local socket, Hello/synchronization, controller, and provider become ready. State refreshes are deferred/coalesced rather than rendered inside receive callbacks. | -| Thread not loaded → thread read → loaded | Select a row whose `fullyLoaded` flag is false. | The center shows loading/incomplete facts; `FrontendSession::loadThread` submits one typed read while one is pending. Completion/state update supplies the authoritative conversation. | -| Submit → reflected user message → streaming → final | Enter prompt and press Ctrl+Enter/Send. | Composer is cleared after accepted submission; canonical user-message projection appends; commentary/activity and final response follow. The timeline follows only when the viewport was already following the latest content. | -| User scrolls away during streaming | Scroll upward while an active turn continues. | New state is reconciled without forcibly snapping to bottom; returning near the end restores follow-latest behavior. | -| Interrupt active turn | Press Stop while an active turn is selected. | A typed interrupt operation is submitted. Stop enablement and turn presentation follow canonical active/terminal state rather than local completion assumptions. | -| Switch threads during streaming | Select another Sidebar row while a turn is active. | Selection changes immediately; thread-specific state scopes prevent another thread's prompt/output from appearing in the selected timeline. Returning shows the latest canonical state. | -| Same-ID pending request changes | Backend updates a still-pending request without changing its ID. | Dialog fingerprinting refreshes the shown details and revalidates response safety before submission. | -| Request resolved elsewhere | Another frontend/backend action resolves the currently displayed request. | Canonical pending-request removal disables/closes or advances the dialog; stale positive actions are not sent. | -| Partial socket write | Qt accepts only a prefix of an encoded frame. | No visual state by itself. `FrontendSession` retains the bounded unsent suffix and drains it in order on `bytesWritten`; it does not duplicate a command. | - -## Phase 1 states found only in requirements - -The following required states from `docs/ux-design/threads-turns-configuration.md` do not exist in the current UI and therefore have no screenshot: - -| Required future state | Current absence | -|---|---| -| New Thread creation dialog | New Thread opens an inline draft only; no name, Base Instructions, Developer Instructions, or temporary toggle. | -| Upcoming-turn effective configuration | Composer has no editable Model, Reasoning, Workspace, Sandbox/access, Approval, Service tier, Personality, or Collaboration controls. | -| Changed persistent value for this and later turns | No single editable inherited-value presentation or persistence explanation exists. | -| Historical effective turn configuration | Canonical `TurnState` lacks the complete authoritative configuration needed to render it. | -| Advanced resume with options | Resume is currently an internal operation in the send path, without a user-facing options surface. | -| Thread lifecycle context menu | Rename, Fork, Archive/Unarchive, Delete, More/Copy ID, and contextual Interrupt/Resume actions are not exposed. | - -## Capture completeness - -Fourteen actual window captures cover connection failure, ready/empty state, draft creation, normal conversation, real streaming, retained high-content scrolling, every Inspector tab, backend loss/retry, both size extremes, and panel visibility. The two security-sensitive interactive request surfaces and several data-dependent Inspector variants are documented from source and canonical trigger paths rather than fabricated. - -This is sufficient to hand the current/as-built UI, interaction state model, Phase 1 gap, and exact environment to the Figma phase. Figma should treat uncaptured states above as explicit required variants, not infer their appearance from unrelated screenshots. +# CodexUI Current Interaction State Matrix + +| Surface | State | Current behavior | +| --- | --- | --- | +| Connection | Disconnected | Conversation remains inspectable; mutation controls reflect unavailable controller transport. | +| Connection | Connected observer | Read operations remain available; mutations require explicit controller ownership. | +| Connection | Connected controller | Thread, turn, and request mutations are enabled. | +| Thread list | Background activity | Status changes without changing the user's selection. | +| Thread list | Selected thread removed | Selection clears and the conversation returns to its empty state. | +| New Thread | Draft | Dialog values and prompt remain local until the first admission starts creation. | +| New Thread | Creation pending | Admitted prompts appear as animated pending cards and remain bound to the draft. | +| Prompt | Locally admitted | Composer clears immediately; a muted-blue card with a sweeping highlight appears in the destination thread. | +| Prompt | Additional prompt admitted | Composer remains enabled; the card is queued behind the in-flight prompt for that thread. | +| Prompt | Authoritative item arrives before result | Exact `clientUserMessageId` correlation may bind the item, but the card remains pending until its operation callback. | +| Prompt | Acknowledged | The matching `turn.start` or `turn.steer` callback begins a 500-millisecond accepted transition; the authoritative item inherits the card's stable visual key. | +| Prompt | Failed | Animation stops and the card remains with an explicit error state. | +| Prompt | Disconnect before queued dispatch | Pending card remains unsent; bridge-open re-drives the same queued submission. | +| Navigation | Switch away from pending prompt | Pending card and queue remain associated with their stable thread ID. | +| Navigation | Return before acknowledgment | The same animated pending card is displayed. | +| Navigation | Return to materialized running thread | Retained Plan, Agents, Changes, and other per-thread presentation reappear without an automatic destructive read. | +| Thread | First selection in a connection generation | One full read hydrates the retained presentation before prompt dispatch; Reload is the explicit forced read. | +| Thread | Hydration failed | Submission leaves the composer draft intact and performs no dispatch; Reload must succeed before admission. | +| Thread | Provider reports `notLoaded` | Resume completes before the queued prompt is dispatched. | +| Thread | Prompt reports thread not found | One resume-and-retry is allowed; a repeated failure becomes a terminal prompt error. | +| Conversation | At bottom | New cards and stream updates smoothly follow the bottom with a short retargetable animation. | +| Conversation | User scrolls during smooth follow | The animation stops immediately and automatic following pauses. | +| Conversation | User scrolled upward | Automatic following pauses; a visible-card/pixel-offset anchor preserves the reading position through appends, reflow, and reconstruction. | +| Conversation | Nonvisual protocol update | The typed projection is unchanged, so no card, geometry, or scroll mutation occurs. | +| Conversation | Paused while history grows | The effective history window grows with appended cards so the visible anchor is not evicted. | +| Conversation | User returns to bottom | Automatic following resumes. | +| Composer | Short prompt | One-line compact height. | +| Composer | Multiline prompt | Editor overlays the unchanged message viewport; matching trailing scroll space is added without moving existing messages. | +| Composer | User reaches extended bottom | The final card sits above the composer with the normal gap and bottom-follow resumes. | +| Composer | Prompt shrinks | Trailing space is removed; Qt may clamp the scroll position to the reduced range. | +| Composer | Shrink clamps to conversation bottom | Bottom-follow is reactivated for subsequent incoming content. | +| Composer | Maximum prompt height | Editor stops growing and scrolls internally. | +| Command execution | No visible output | No output box is shown. | +| Command execution output | Fits below 220 px | Box grows to content without a minimum blank area. | +| Command execution output | Exceeds 220 px at bottom | Scrollbar appears and appended output follows the bottom. | +| Command execution output | User scrolled upward | Output following pauses until its scrollbar returns to the bottom. | +| Center chrome | Wheel or touchpad input | Message view scrolls unless a nested control can scroll in that direction; edge events return to the message view. | +| Info / State | Content exceeds viewport | Common styled vertical scrollbar appears as needed. | +| Info / Protocol | Content exceeds viewport | Styled log scrollbar appears; statistics remain below the expanding log. | +| Pending request | Unresolved | Thread and global attention surfaces identify required user action. | +| Pending request | Resolved | Actionable request disappears exactly once for its stable request identity. | diff --git a/ui-review/UI-INVENTORY.md b/ui-review/UI-INVENTORY.md index c39cbcb..eb81ce2 100644 --- a/ui-review/UI-INVENTORY.md +++ b/ui-review/UI-INVENTORY.md @@ -1,257 +1,94 @@ -# CodexUI as-built UI inventory - -This document records the current CodexUI presentation as implemented and observed. It is a baseline for later design work, not a redesign proposal. The accompanying state-by-state capture log is in [STATE-MATRIX.md](STATE-MATRIX.md). - -## Exact baseline - -| Component | Baseline | -|---|---| -| CodexUI | `8a6440243c1d97cca07ee52305ed0262a5d8ab52` (`master`, 2026-08-19, `Add CodexUI UX redesign roadmap`) | -| AISuite source and installed package | `8be3408830d78ca6ace58792f3cccb9139631f18` (`master`, 2026-08-19, `Merge pull request #40 from SNodeC/agent/support-full-codex-user-input`) | -| AISuite package/API version | `0.1.1`; Codex shared-library SOVERSION `2` | -| Codex app-server | `codex-cli 0.144.6` | -| SNode.C reported by `codex-backend --version` | `1.0-rc1` | - -CodexUI was configured from `/home/voc/projects/drafts/CodexUI/codexui` into the existing incremental build directory `/home/voc/projects/drafts/CodexUI/build-codex`. It used Ninja, GCC 16.2.0, CMake 4.3.4, `RelWithDebInfo`, and Qt 6.10.2 (`Widgets` and `Network`). `find_package(AISuite 0.1.1 CONFIG REQUIRED)` resolved to the exact-head workspace-local AISuite install at: - -```text -/home/voc/projects/drafts/AISuite-extraction/build/Desktop_GCC-Release/local-install -``` - -AISuite itself was built as `Release` with Ninja in the existing incremental directory: - -```text -/home/voc/projects/drafts/AISuite-extraction/build/Desktop_GCC-Release -``` - -Both incremental builds completed successfully. `ldd` confirmed that the captured CodexUI executable loaded the AISuite libraries from that exact workspace-local installation. - -## Capture environment - -| Property | Value | -|---|---| -| Operating environment | Linux `7.1.8+deb14-amd64`, x86-64 | -| Desktop/session | KDE Plasma 6.7.4, Wayland session | -| Display variables | `DISPLAY=:0`, `WAYLAND_DISPLAY=wayland-0`, `XDG_RUNTIME_DIR=/run/user/1000` | -| Screen | Built-in `eDP-1`, 1920 x 1200 at 60 Hz | -| Scale/device pixel ratio | Output scale `1`; no Qt scale override was set | -| Color/display features | sRGB, HDR disabled/incapable, brightness 100% | -| Screenshot path | The application was launched through Qt's XCB platform plugin under XWayland so the exact application client window could be captured with native X11 window capture (`xwd`) and converted losslessly to PNG. | - -The default comparison size is the application's own 1536 x 960 client size. Narrow and large variants are 1100 x 700 and 1800 x 1080 respectively. - -The real current `codex-backend` and real Codex app-server were used. To avoid mutating or competing with the user's concurrently used Codex home, the capture run used an isolated temporary Codex home initialized from the user's valid configuration/authentication and persisted state, with normal backend/app-server behavior thereafter. The frontend connected over a dedicated local Unix socket and used the normal AISuite immutable state projection. No fake protocol state or production-code instrumentation was introduced. A real thread, prompt, streamed response, final response, restart/reload, and backend disconnect/reconnect were exercised. - -## Source-level structure - -### Main application and workbench - -`Application` constructs `MainWindow`, which owns a single `WorkbenchWidget`. `MainWindow` sets the window title to **CodexUI — Codex Workbench**, a minimum size of 1100 x 700, and a default size of 1536 x 960. It applies the global stylesheet and a 12-pixel application font. - -`WorkbenchWidget` is the principal composition and presentation coordinator: - -```text -WorkbenchWidget -├── top bar (56 px) -├── horizontal QSplitter -│ ├── SidebarWidget -│ ├── ConversationWidget -│ └── InspectorWidget -└── status bar (40 px) -``` - -The outer layout has zero margins and spacing. The splitter has an 8-pixel handle, non-collapsible children, stretch factors `0 / 1 / 0`, and initial sizes `282 / 834 / 404`. Restoring both side panels applies the same nominal widths while leaving at least 500 pixels for the center. Sidebar and Inspector each have a Hide action; when hidden, matching controls in the top bar restore them. - -The top bar contains the workbench title, a Commands menu, current model/provider status, pending-request count, and the right-panel restore control. Commands currently includes reconnect behavior rather than a broad command palette. The bottom status bar presents connection/provider identity, controller state, synchronization/state status, agent activity, and request count. - -`WorkbenchWidget` receives immutable AISuite `client::State` revisions from `FrontendSession`. State notifications are coalesced before presentation refresh (approximately one 16 ms presentation interval), and the update scope is used to avoid unconditional conversation, Sidebar, and Inspector rebuilds. The controller is acquired and released through the typed SDK. No separate conversation model exists in the UI. - -### Sidebar and thread list - -`SidebarWidget` owns the Work header, Hide button, New Thread button, thread-list scroll area, and backend status footer. Its width is constrained to 220–440 pixels. The main margins are approximately 10 pixels horizontally and 14–17 pixels vertically. - -The New Thread control is 36 pixels high. The thread area is a vertically scrolling `QScrollArea` with horizontal scrolling disabled. Each custom `ThreadRow` is at least 58 pixels high, with roughly 5 pixels between rows. A row presents an activity/status dot, title or shortened ID, and a secondary status/preview line. Long values are elided and exposed through tooltips. Selection is mouse-driven by the custom frame; the current implementation is not a model/view list and does not expose a thread context menu. - -The footer distinguishes application/backend availability and synchronization. Thread rows are reconstructed from the canonical thread collection, while presentation equality prevents needless widget replacement when nothing visible changed. - -Dynamic states include no threads, selected thread, active thread, attention/status colors, not-loaded versus loaded threads, truncated list metadata, connected/synchronizing/disconnected backend status, and a pending new-thread draft. - -### Conversation - -`ConversationWidget` owns the selected-thread context, turn summary, failure presentation, scrollable timeline, and composer. Its minimum width is 480 pixels. The central content margins are approximately 24 pixels horizontally and 14 pixels at the top. - -The principal scroll area disables horizontal scrolling. Its content contains both the timeline and the composer; the composer is therefore at the bottom of the scroll content rather than a fixed sibling outside the scroll area. The timeline is deliberately windowed to the latest 32 turns and 256 timeline items. Activity groups show at most 16 detailed activity entries. A notice reports when older canonical history is not materialized. - -The renderer reconciles stable turn/segment identity and presentation keys so unchanged widgets are retained. New reflected prompts and final/streamed messages append at the end. When the viewport follows the latest item, scrolling uses a short animation and deferred layout settling; when the user has scrolled away, the current viewport is preserved. - -Presentation types include: - -- turn header/summary cards with status, item count, failure, and token usage; -- user-message cards from `userMessageSemanticView()`; -- Codex commentary and final message presentation; -- reasoning summaries and reasoning activity; -- command, tool, plan, file-change, collaboration, and other activity cards built from typed semantic views; -- truthful unavailable/partial semantics where the SDK does not expose complete typed detail. - -Normal text uses wrapping labels and is mouse-selectable where appropriate. Very large message bodies (over the widget's large-message threshold) use a read-only `QPlainTextEdit` with a fixed 240-pixel height, allowing local scrolling without creating an extremely tall label. - -The conversation distinguishes no selection, loading/incomplete selection, idle thread, active turn, terminal turn, failed turn, disconnected state with retained canonical content, and thread-list or projection truncation. - -### Turn presentation - -Turn presentation is assembled within `ConversationWidget`; there is no independent turn model/controller. Each rendered turn has a compact header and its ordered item segments. Turn status comes from typed `TurnState::status`, with active/terminal/connection-invalidated flags. Token usage and failure detail are rendered from bounded typed/opaque state fields. - -Historical turns remain in AISuite State, but only the bounded latest window is materialized as Qt widgets. The current State does not expose the complete effective execution configuration for an historical turn, so it cannot yet display the Phase 1 historical configuration table authoritatively. - -### Composer - -The composer is a 100-pixel-high raised panel with a multi-line plain-text editor, an Attach affordance, a **Ctrl+Enter to send** hint, Send, and Stop. Attach is visibly present but disabled. Send is enabled only when the session/controller/thread state permits it and the editor has content. Stop is enabled for an active turn. - -Ctrl+Enter submits; ordinary Enter inserts a newline. Focus is shown with a stronger border. Sending does not maintain an optimistic local prompt cache: the reflected user message arrives through canonical AISuite State. New Thread currently enters a draft state; the first submitted prompt calls typed `thread.start` and then starts the turn. For an existing idle thread, the UI starts a turn; otherwise it resumes the thread before starting the turn. - -### Inspector - -`InspectorWidget` is constrained to 300–520 pixels wide and defaults to the **Agents** tab. It owns four tabs—Plan, Agents, Changes, and Info—each with its own vertical `QScrollArea`. Main horizontal margins are approximately 18–20 pixels, with a 14-pixel top margin. - -- **Plan** resolves plan semantic views from the selected/latest turn and renders ordered steps and status. -- **Agents** resolves collaboration/subagent activity and summary state. -- **Changes** resolves file-change semantic views and offers typed, read-only change presentation. -- **Info** displays thread identity, title/status, workspace/model/provider, loading/realtime state, latest-turn facts, synchronization, state/list completeness, controller, and retained diagnostics. - -Inspector content is rebuilt only when its presentation key changes. Empty states are explicit. Dynamic copy is plain text and frequently mouse-selectable. Switching tabs does not alter canonical state. - -### Approval and user-input requests - -`InteractiveRequestDialog` is a non-modal `QDialog`, minimum width 520 pixels and initial size approximately 580 x 430. It has a scrollable body, current/queued-request presentation, submit/negative actions, next request, and close behavior. It stays synchronized with canonical pending requests rather than retaining an independent request model. - -Implemented typed presentations cover simple command/file approvals, patch/exec review approvals, and `request.userInput` questions. Same-ID requests are refreshed when their semantic fingerprint changes. Provider-controlled values are forced to plain text. Incomplete approval semantics allow safe negative decisions while positive actions fail closed. Invalidated requests are completely disabled. Secret answers use password echo mode and drafts are cleared aggressively after use. - -Permissions approval, authentication, attestation, dynamic-tool calls, and MCP elicitation remain visible pending-request kinds without complete typed response UI. The dialog can therefore communicate their presence but cannot complete those product flows. - -The capture app-server reported `request_user_input` unavailable in the capture mode and a harmless command did not trigger approval. Consequently the actual dialogs could not be reached naturally during this run; no fake request state was injected. - -### Connection and reconnect states - -`FrontendSession` owns the `QLocalSocket`, AISuite client, serialized-frame queue, local peer verification, state callbacks, and reconnect timer. The UI distinguishes initial connecting, synchronizing, ready, disconnected/retrying, and terminal failure. Socket closures judged retryable use bounded exponential backoff; terminal protocol/capacity/authentication failures do not loop automatically. Commands exposes an explicit Reconnect action. - -The send path preserves partial socket writes through a bounded ordered output queue. Inbound work is byte/frame-budgeted and resumed through the event loop, keeping presentation work out of the socket callback. Diagnostics are shown separately and do not themselves force a false connection-state transition. - -The reconnect capture was produced by stopping the real current backend while retaining the selected conversation. It shows canonical content remaining visible while the status changes to connection refused/unavailable. Restart/reload of the current UI also restored the same real thread from backend/app-server state. - -## Current visual implementation vocabulary - -These are literal current conventions, not normalized design tokens proposed for future use. - -### Typography - -- Application stack: `Inter`, then `Noto Sans`, then `DejaVu Sans`. -- Global/application size: 12 px. -- Main heading: approximately 18 px, weight 600. -- Titles/body emphasis: approximately 13 px. -- Sections and metadata: approximately 10 px; a few dense labels use 9 px. -- Most dynamic textual content is literal plain text; message/output content is selectable. - -### Colors - -| Role | Current value | -|---|---| -| Application background | `#0e1013` | -| Panel background | `#13161a` | -| Raised/card background | `#181c21` | -| Divider/border | `#2b3038` (input borders use approximately `#343b45`) | -| Primary text | `#e8edf2` | -| Secondary text | `#949ead` | -| Selection/action blue | `#4f94f5` | -| Success/ready green | `#40c27d` | -| Warning/attention amber | `#f5a83b` | -| Collaboration/accent purple | `#7a63e0` | -| Stop/destructive background | approximately `#521a1a` | - -### Geometry and spacing - -- Top bar: 56 px; status bar: 40 px. -- Default three-panel widths: 282 / 834 / 404; splitter handle: 8 px. -- Sidebar: 220–440 px; Inspector: 300–520 px; Conversation minimum: 480 px. -- Standard panel/content insets are generally 10–24 px; local gaps are usually 4–12 px. -- Thread rows: minimum 58 px with approximately 5 px between rows. -- Raised cards/composer: approximately 10 px corner radius; compact summary and badges use 5–7 px radii. -- Normal buttons: approximately 32 px high, 7 px radius, 12 px horizontal padding, 11 px semibold text. -- New Thread: 36 px high. Composer: 100 px high. Large message editor: 240 px high. -- Scrollbars: 8 px wide, 2 px margin, 28 px minimum handle, 3 px handle radius. -- Tabs: minimum 62 px wide and 30 px high, with approximately 7 px radius. - -Notable explicit control sizes include Commands 132 x 32, model/provider 210 x 32, request count 106 x 32, Send/Stop 66 x 32, Attach 54 x 24, Sidebar Hide 52 x 24, and Inspector Hide 58 x 24. - -### Reusable presentation patterns - -- Dark flat application surface with slightly raised cards. -- Uppercase 9–10 px section labels and subdued metadata. -- Colored status dot plus literal status text. -- Rounded status badges for turn/request state. -- Selected thread row uses blue-tinted fill and left emphasis. -- User input is boxed; Codex narrative is less heavily boxed; tool/activity output is grouped in raised activity cards. -- Empty Inspector pages use a section heading, emphasized empty title, and explanatory secondary copy. -- Long identifiers and paths elide in compact surfaces, with full tooltips or selectable detail in Inspector/dialogs. - -## Phase 1 — current implementation mapping - -The authoritative target semantics are in `docs/ux-design/threads-turns-configuration.md`. This section only maps current implementation to those requirements. - -### Already present - -- Thread collection, selection, loading, and canonical historical conversation rendering. -- A New Thread draft and first-prompt thread creation through typed AISuite operations. -- Normal selection of incomplete threads triggers a typed thread read; existing threads are resumed when required before a turn starts. -- Current thread title/ID, workspace, model/provider, status, and load state are visible in existing surfaces. -- Typed start, resume, turn start, and interrupt/Stop flows. -- One prompt composer for first and subsequent turns. -- The public AISuite SDK already exposes many lifecycle operations needed later: start, resume, read, fork, rename, archive, unarchive, remove, and interrupt. - -### Present but different from Phase 1 semantics - -- **New Thread** currently opens an inline empty draft rather than a dedicated creation dialog. -- The thread is not created until the first prompt is submitted; creation-specific name, Base Instructions, Developer Instructions, and temporary/ephemeral status cannot be edited. -- The composer submits prompts but does not expose the next turn's effective mutable execution configuration. -- Thread selection loads state; resume is primarily an internal send prerequisite rather than a user-visible normal/advanced resume workflow. -- Current model/workspace/provider values are informational rather than the single editable next-turn controls specified by Phase 1. - -### Completely missing from the current UI - -- Dedicated creation dialog with optional name, Base Instructions, Developer Instructions, and temporary status. -- Editable next-turn Model, Reasoning, Workspace/cwd, Sandbox/access, Approval, Service tier, Reasoning summary, Personality, and Collaboration controls. -- Clear persistence semantics (“this and subsequent turns”) for mutable execution settings. -- Read-only historical effective-configuration presentation for completed turns. -- Advanced Resume with options and foundational-instruction handling. -- Thread context menu actions: Open, Rename, Fork, Interrupt active, Resume with options, Archive/Unarchive, Delete, More/Copy ID. -- User-facing rename, fork, archive/unarchive, delete, and copy-ID flows despite several corresponding SDK methods already existing. - -### Likely Qt implementation touch points - -Without prescribing a design, the existing ownership boundaries imply that later Phase 1 work would involve: - -- `SidebarWidget` for creation entry and thread lifecycle/context actions; -- `ConversationWidget` for the next-turn configuration associated with the composer and historical turn detail affordance; -- `WorkbenchWidget` for coordinating selection, lifecycle actions, persistent next-turn configuration, and dialogs; -- `FrontendSession` for exposing the additional typed AISuite operations and passing explicit typed parameters; -- a focused creation/configuration dialog or panel component if the approved Figma composition requires one. - -### Authoritative data/actions still missing or incomplete - -AISuite's typed operation parameters already carry much of the necessary write-side data. Thread start/resume/fork and turn start include combinations of base/developer instructions, cwd, model/provider, reasoning effort, approval policy, sandbox policy, service tier, personality, and ephemeral state. Model listing is also available. - -The canonical public state does not currently retain enough authoritative read-side information to implement all Phase 1 history and inheritance semantics: - -- `ThreadState` exposes ID, title, preview, cwd, model/provider, provider status, load/realtime state, timestamps, and ordered turns, but not the complete current persistent execution configuration or foundational instructions. -- `TurnState` exposes identity, typed status, activity/terminal flags, items, failure, and token usage, but not the complete effective configuration actually used by that historical turn. -- A dedicated typed collaboration-mode setting is not evident in the current thread/turn configuration surface. - -Those are contract gaps to resolve before a UI can truthfully display complete inherited and historical configuration. They are not filled by local UI memory in the current application. - -## Baseline limits - -- Approval and user-input dialogs were source-inventoried but could not be reached naturally in the capture run. -- Plan, Agents, and Changes tabs were captured in their valid empty states; a naturally available contentful example was not present in the isolated run. -- The active streaming screenshot is a real streamed turn, but a static PNG cannot show append cadence, scroll animation, focus transitions, or transient state-coalescing behavior. Those are recorded in the state matrix. -- The capture run used XCB/XWayland solely to obtain deterministic application-window PNGs. The normal desktop session is Wayland, so compositor decorations and subpixel font rendering may differ slightly from a native-Wayland run; widget geometry and application styling are unchanged. - -Within those limitations, the source inventory, real-state captures, state matrix, Phase 1 mapping, and exact reproducibility metadata form a sufficient as-built baseline for the next Figma phase. +# CodexUI Current UI Inventory + +## Top bar + +- One-line CodexUI lockup: 36-pixel brand mark, equally high application title, + and current-size "Codex agent workspace" subtitle on the title baseline. +- Workspace breadcrumb. +- Inspector visibility, pending-request attention, controller ownership, and + connection controls. +- Stable desktop identity through the `codex-ui` application ID and icon. + +## Thread sidebar + +- App-server thread list with selected, active, pending-request, completed, and + failed status presentation. +- Explicit New Thread action. +- Per-thread Reload, Rename, Fork, Archive/Unarchive, and Delete context actions. +- Selection is keyed by stable thread ID and is not changed by background + activity. + +## Conversation region + +- Thread title, workspace, and status context. +- One transparent section per app-server turn, containing server-ordered user, + Codex, plan, reasoning, Command execution, file-change, and collaboration + cards projected from `PresentationModel`. +- Stable keyed in-place reconciliation for authoritative cards and local prompt + cards; visually identical projections perform no widget or geometry update. +- Per-thread pending prompt cards with muted blue content and a brighter blue + highlight sweeping left and right until the correlated operation callback, + followed by a 500-millisecond accepted transition. +- Windowed materialization of long conversations with an explicit Load More + control. +- Short, interruptible smooth bottom-follow only while the user remains at the + bottom; paused reading uses a stable visible-card/pixel-offset anchor. +- Wheel and touchpad forwarding from surrounding center chrome and splitter + handles. + +## Command execution output + +- Visible card label: **Command execution**. +- No output surface for empty, whitespace-only, ANSI-only, or control-only + output. +- Read-only monospace output with zero content minimum height. +- Automatic growth to 220 pixels. +- Styled vertical scrollbar beyond the maximum. +- Independent follow-bottom/pause state retained across in-place output updates. + +## Upcoming-turn surface + +- Model and model-constrained reasoning. +- Workspace, sandbox/access, network, approval, and personality controls. +- Permission profile, reviewer, service tier, reasoning summary, and + collaboration mode in compact secondary controls. +- Bounded attachment list with custom file selection. +- One-line expanding prompt editor, Send/Steer, and Stop. +- Canonical-height layout reservation plus a dynamic trailing conversation + spacer when the surface grows over the unchanged message viewport. + +## New Thread dialog + +- Workspace selection. +- Optional name. +- Optional base and developer instructions. +- Ephemeral lifetime choice. +- Creation is completed through the first admitted prompt. + +## Inspector + +- **Plan:** structured current plan or authoritative textual plan fallback. +- **Agents:** identified collaboration and subagent activity. +- **Changes:** per-file unified diff with addition/deletion counts, copy, and + expanded viewing. +- **Requests:** typed approval and input requests with explicit resolution. +- **Info / State:** retained normalized presentation domains. +- **Info / Protocol:** bounded frame log with the statistics summary below it. + +State and Protocol use the common styled, as-needed vertical scrollbars. +Plan, Agents, Changes, and Requests retain their visible per-thread state across +thread and tab navigation. + +## Status bar + +- Connection state and controller role. +- Selected-thread activity and pending-request summary. +- Model and workspace context. + +## Local presentation state + +CodexUI locally owns visible selection, drafts, pending prompt cards, per-thread +submission queues, scroll-follow state, nested-output scroll state, splitter +sizes, tab selection, and focus. `PresentationModel` is the sole retained store +for normalized presentation domains; these local interaction values do not +replace AISuite or app-server domain authority. diff --git a/ui-review/UX-DESIGN-DECISIONS.md b/ui-review/UX-DESIGN-DECISIONS.md index c07e53b..6638ff8 100644 --- a/ui-review/UX-DESIGN-DECISIONS.md +++ b/ui-review/UX-DESIGN-DECISIONS.md @@ -1,554 +1,92 @@ -# CodexUI UX Design — Roadmap and Phase 1: Threads, Turns, and Configuration +# CodexUI UI/UX Decisions -This document is the starting point for the CodexUI UI/UX redesign. It records the agreed roadmap and the semantic and behavioral requirements that will later be used as design input for Figma. +This document records the implemented CodexUI visual and interaction contract. -The redesign is intentionally developed **semantics first**: decide how CodexUI should behave and what the user should understand, record those decisions here, then let Figma determine the best visual and interaction design. The approved Figma design will subsequently be implemented in the Qt application. +## Visual system -## Global visual constraints +- CodexUI uses a light theme with neutral application surfaces and restrained + blue, green, amber, and red state colors. +- Hover, focus, selection, disabled, warning, error, pending, and active states + remain visually distinct. +- User messages are blue-tinted cards. Codex narrative is visually lighter. + Commands, tool activity, files, and collaboration activity use raised cards. +- Scrollbars use one compact application style across conversation, nested + output, State, Protocol, and Inspector surfaces. -The following visual requirements apply to the complete redesign and are not optional stylistic suggestions: +## Application layout -- **CodexUI is to be designed as a light-theme application.** A dark theme is not the target visual direction for the redesign. -- Figma should use a bright, neutral application surface with sufficient contrast and restrained accent colors suitable for long productive desktop sessions. -- Hover, selection, focus, pressed, active, disabled, warning, error, and attention states must remain visually distinct without relying on dark surfaces. -- **Particular care is required for temporary surfaces and frames shown on interaction**, including thread-row hover backgrounds, context menus, popovers, dropdowns, tooltips, dialogs, inline configuration panels, and similar overlays. Their background colors must remain clearly distinguishable from both the underlying application surface and the selected/active state. -- Hover feedback must be subtle enough not to visually overpower the thread list, but strong enough that the currently interactive row or control is unambiguous. -- Selection and hover must not collapse into the same visual state. A selected thread must remain recognizable while hovered, focused, or while its context menu is open. -- Figma may refine the existing visual language, but existing dark prototype screens are **reference material for structure and interaction only**, not a requirement to preserve the dark palette. +The window consists of a 64-pixel identity/status bar, a hideable thread +sidebar, the center conversation/composer region, a hideable Inspector, and a +40-pixel status bar. Horizontal splitters resize the three main regions. -## UI/UX redesign roadmap +The center region is wheel- and touchpad-scroll sensitive across its full +width, including non-scrollable chrome and the splitter handles. Nested +scrollable controls consume wheel events while they can move in that direction; +at an edge, the conversation receives the event. -The redesign is divided into the following phases, in priority order: +## Conversation structure -1. **Thread/Turn Configuration & Lifecycle** — thread creation, foundational instructions, mutable execution settings, first and subsequent turns, historical effective settings, fork/resume behavior, and thread lifecycle actions. **This is the first implementation target because it is required to make CodexUI productively usable as a Codex client.** -2. **Thread Navigation & Multi-thread Work** — active/running/attention states, navigation and orientation across multiple threads, concurrent work, archived-thread access, filtering/search/grouping, and related thread-list behavior. *(To be designed.)* -3. **Conversation & Turn Presentation** — user/Codex messages, reasoning, tool activity, commands, file changes, progress, active/completed turns, and historical turn presentation. *(To be designed.)* -4. **Composer & Productivity** — prompt editing, attachments, turn-local inputs, keyboard behavior, send/interrupt workflow, and efficient composition. Persistent execution configuration itself is defined in Phase 1. *(To be designed.)* -5. **Agents & Collaboration** — parent/sub-agent hierarchy, delegation, live agent activity, completed agents, collaboration state, and navigation into agent work. *(To be designed.)* -6. **Approvals & User Input** — approval requests, user-input requests, attention behavior, multiple pending requests, and security-sensitive interaction. *(To be designed.)* -7. **Inspector** — role and information architecture of Info, Plan, Agents, Changes, and any information that should move elsewhere. *(To be designed.)* -8. **Status & Attention System** — working, waiting for user, approval required, completed, failed, disconnected, unread changes, and cross-UI attention semantics. *(To be designed.)* -9. **Keyboard Workflow** — shortcuts, focus movement, thread switching, search, command-oriented operation, and other keyboard-first productivity behavior. *(To be designed.)* -10. **Responsive Layout & Visual System** — panel resizing/collapse, narrow and large windows, saved layout preferences, density, typography, colors, spacing, hierarchy, and final component language. *(To be designed.)* +`PresentationModel` is the retained normalized presentation source. The +conversation projects it into one transparent section per app-server turn, +with cards in server order. Stable turn/item and local-submission keys drive a +single reconcile path for both first display and updates. Retained cards mutate +in place, and identical visible projections do not trigger layout work. -Only Phase 1 is specified below. Phases 2–10 intentionally remain open until they are discussed and agreed. +## Conversation following ---- +The message view smoothly follows appended or streamed content only while +already at the bottom. Geometry bursts retarget one short monotonic animation. +Manual upward scrolling interrupts it and pauses following. Returning to the +bottom restores it. While paused, a visible-card/pixel-offset anchor preserves +the reading position across appends, card reflow, and reconstruction. This +follow mode and anchor are retained independently for each thread. -# Phase 1 — Thread/Turn Configuration & Lifecycle +## Composer -## Purpose and priority +The upcoming-turn settings and composer remain anchored to the bottom. The +prompt editor starts at one line, grows upward to its maximum, and then scrolls +internally. The message view reserves the canonical composer height. Additional +growth overlays, but does not resize, the viewport. A trailing content spacer +grows by the overlap so the user can scroll the final card above the composer. +Spacer growth does not move the existing reading position. Shrinking the +composer removes the spacer and restores the canonical geometry. -Thread/Turn Configuration & Lifecycle is the minimum UX feature set required to turn CodexUI from a viewer/controller into a productively usable Codex client. +## Pending prompt presentation -The user must be able to understand and control: +Local admission creates a muted-blue prompt card immediately. A brighter blue +highlight sweeps left and right until app-server acknowledgment. +The card belongs to its destination thread and persists through navigation. +Only the correlated `turn.start` or `turn.steer` completion callback +acknowledges it. Each request carries a unique `clientUserMessageId`; after a +successful callback the card keeps a 500-millisecond accepted transition before +normal message presentation. Failure produces an explicit error state. -- how a thread is created and identified; -- the foundational instructions under which it operates; -- how Codex will execute the upcoming turn; -- which execution settings persist into subsequent turns; -- what settings a historical turn actually used; -- how existing threads are opened, resumed, forked, interrupted, archived, renamed, and deleted. +The input remains enabled after admission. Multiple prompts can be composed +while earlier cards are pending. They are dispatched sequentially per thread. -These requirements deliberately avoid prescribing detailed visual layout. Figma should be free to determine hierarchy, compactness, controls, icons, progressive disclosure, dialogs, and other presentation details while preserving the semantics defined here and the global visual constraints above. +## Command execution output -## 1. Core model +Output boxes grow from zero to 220 pixels. Longer output receives a styled +vertical scrollbar. Each box independently follows output at its bottom and +pauses when the user scrolls upward. -CodexUI should reflect the Codex model without unnecessarily exposing app-server protocol mechanics: +## Inspector -```text -Thread -├── identity / lifetime -├── foundational instructions -├── current execution configuration -└── Turns - ├── user input - ├── effective execution configuration - └── result / activity -``` +The Inspector contains Plan, Agents, Changes, Requests, and Info. Info contains +State and Protocol viewers. Both use application scrollbars. In Protocol, the +log expands above a statistics summary placed at the bottom. Plan, Agents, +Changes, and Requests retain their last visible per-thread presentation across +thread and tab navigation. -A central rule is: +## Desktop integration -> **A mutable execution setting has only one editable representation.** +The application ID, executable, desktop entry, startup window class, icon name, +and installed SVG use `codex-ui`, giving Linux launchers and taskbars one stable +desktop identity. -CodexUI must not present separate editable "thread reasoning" and "turn reasoning" controls for what is actually one persistent value. +## Long-operation feedback -## 2. Creating a thread - -**New Thread** opens a dedicated thread-creation dialog. - -It contains only properties genuinely associated with creating or establishing the thread: - -- optional thread name; -- Base Instructions; -- Developer Instructions; -- temporary / ephemeral status. - -The thread name is optional. If the user does not provide one, a useful title should be derived from the first turn. The exact interaction and wording are left to Figma. - -Base and Developer Instructions normally inherit the Codex/app-server defaults but can be customized for the new thread. - -The exact organization, progressive disclosure, and wording of the dialog are design decisions for Figma. - -## 3. Normal execution settings do not belong in the New Thread dialog - -The creation dialog should not ask for normal mutable execution settings such as: - -- Model; -- Reasoning; -- Answer style / Personality; -- Workspace / cwd; -- Sandbox / access; -- Approval policy; -- Service tier; -- Reasoning summary; -- Collaboration mode. - -The new thread initially receives the appropriate Codex/app-server defaults. These settings become relevant when the user is about to start actual work. - -## 4. First turn - -After thread creation, the new thread is selected and the normal turn composer is presented. - -The first-turn workflow conceptually contains: - -- prompt / user input; -- the effective execution configuration that the upcoming turn will use. - -Important execution settings include Model, Reasoning, Answer style / Personality, Workspace, Access / Sandbox, Approval, Service tier, Reasoning summary, Collaboration mode, and other supported mutable execution settings. - -The values initially reflect the app-server/current-thread defaults. The user may leave them unchanged and simply start working. - -Figma may decide which settings are immediately visible and which use progressive disclosure, but all supported settings remain part of the functional design. - -## 5. Subsequent turns use the same UX - -There should not be a separate configuration model for the first turn. - -Every upcoming turn follows the same model: - -```text -current inherited configuration - + - user changes - ↓ - upcoming turn -``` - -The composer presents the execution configuration that the next turn will actually use. - -### Stable center-pane layout - -The upcoming-turn workflow is **not part of the scrollable conversation/message container**. The center pane is vertically divided into two sibling regions: - -```text -CENTER PANE -┌────────────────────────────────────┐ -│ │ -│ Conversation / messages │ -│ independently scrollable │ -│ │ -├────────────────────────────────────┤ -│ Upcoming-turn execution settings │ -│ Prompt / attachments / Send │ -└────────────────────────────────────┘ -``` - -The complete upcoming-turn block—execution settings plus composer—is anchored to the bottom of the center pane. The conversation region occupies the remaining space and scrolls independently. - -Incoming messages, streaming output, tool activity, and other conversation growth must **never move the upcoming-turn block vertically**. In particular, the composer must not be laid out as the last child of the message history, because that causes streaming content to push it downward and subsequent scrolling/layout correction to move it back upward. The turn controls should remain spatially stable while the conversation changes above them. - -### Compact and expanding prompt editor - -The prompt editor should occupy as little vertical space as practical when idle or when the prompt is short: - -- the editor starts with **one visible text line**; -- attachments, send/stop actions, and keyboard hints should be arranged compactly around that one-line state rather than forcing a permanently tall text box; -- as the prompt becomes multiline, the editor grows automatically **upward**; -- growth must overlay the lower portion of the conversation viewport rather than resize, shrink, or vertically shift the conversation region; -- the bottom edge and primary actions of the upcoming-turn block remain spatially stable while the editor expands; -- the editor grows only to a sensible maximum height; Figma may determine the exact maximum, but it should provide comfortable editing of a substantial multiline prompt without consuming the entire center pane; -- after that maximum is reached, the prompt editor becomes internally vertically scrollable instead of growing further; -- collapsing back to fewer lines should reduce the overlay again without causing the conversation viewport itself to jump. - -Conceptually: - -```text -Short prompt -┌────────────────────────────────────┐ -│ Conversation / messages │ -│ │ -├────────────────────────────────────┤ -│ Settings │ -│ Ask Codex… Send │ ← one-line compact state -└────────────────────────────────────┘ - -Longer prompt -┌────────────────────────────────────┐ -│ Conversation / messages │ -│ ┌──────────┐│ -│ lower content may be │ prompt ││ -│ visually covered by │ grows ││ -│ composer expansion │ upward ││ -├─────────────────────────┴──────────┤ -│ Settings Send │ ← bottom remains anchored -└────────────────────────────────────┘ -``` - -This prevents long-prompt editing from stealing permanent vertical space while preserving a stable message viewport during streaming and normal reading. - -## 6. Persistent mutable execution settings - -Settings such as the following are conceptually the thread's current execution configuration: - -- Model; -- Reasoning effort; -- Answer style / Personality; -- Workspace / cwd; -- Sandbox / access; -- Approval policy / reviewer; -- Service tier; -- Reasoning summary; -- Collaboration mode. - -They should nevertheless be presented in connection with the upcoming turn because this is where their operational meaning is clearest to the user. - -Figma should determine the best compact presentation and which settings deserve immediate visibility versus progressive disclosure. Progressive disclosure changes prominence, not functionality. - -## 7. Changing an execution setting - -If the current inherited value is, for example: - -```text -Reasoning = High -``` - -and the user selects: - -```text -Reasoning = XHigh -``` - -before starting the next turn, the semantics are: - -> The upcoming turn uses XHigh, and XHigh becomes the inherited value for subsequent turns. - -Conceptually: - -```text -Thread current value - High - │ - ▼ -User selects XHigh - │ - ▼ -Upcoming turn uses XHigh - │ - + - ▼ -Thread current value becomes XHigh -``` - -The same principle applies to the other persistent mutable execution settings where supported. - -The UI should communicate the persistence without requiring the user to understand `turn/start` versus `thread/settings/update`. A tooltip or equivalent explanation such as **"Changes apply to this and subsequent turns"** is appropriate. - -## 8. Exactly one editable control - -CodexUI should not expose duplicate controls such as: - -```text -Thread Reasoning: High ▾ -... -Turn Reasoning: XHigh ▾ -``` - -That suggests two independent values when there is actually one persistent setting. - -Instead there is one editable Reasoning control associated with the upcoming-turn workflow. The same rule applies to Model, Answer style / Personality, Workspace, Access, Approval, Service tier, Reasoning summary, Collaboration mode, and other persistent execution settings. - -## 9. True turn-specific data - -Some information genuinely belongs only to one turn, including: - -- prompt / UserInput; -- attachments, images, and other input elements; -- output schema / output requirements; -- other genuinely turn-local request data. - -These values do not become inherited thread configuration. - -The UI should conceptually distinguish them from persistent execution settings. - -## 10. Historical turns - -A completed or historical turn should, where authoritative app-server/AISuite data is available, retain and be able to display the **effective configuration actually used for that turn**. - -For example: - -```text -Turn details - -Model GPT-5.x Codex -Reasoning XHigh -Answer style Pragmatic -Workspace ~/AISuite -Access Workspace Write -Approval On Request -``` - -These values are read-only historical information. They are not another place to modify thread configuration. - -This allows CodexUI to truthfully represent histories such as: - -```text -Turn 12 High -Turn 13 High -Turn 14 XHigh -Turn 15 XHigh - -Current upcoming-turn reasoning: - Medium -``` - -A reconnected CodexUI should use authoritative persisted/projected values rather than infer historical settings from the thread's current configuration. - -## 11. Thread header - -The thread header should clearly establish identity and context, particularly: - -- thread name; -- workspace/repository where useful; -- relevant status. - -It may contain a compact read-only summary of the current execution configuration if that improves orientation. - -The header must not create a second set of editable execution controls. The single editable configuration surface remains associated with the upcoming-turn workflow. - -The exact header design is left to Figma. - -## 12. Normal opening and resume - -Selecting an existing thread should simply open it. - -If CodexUI internally needs to load or resume the thread, this normally happens automatically: - -```text -Select thread - ↓ -load/resume if required - ↓ -show conversation -``` - -There should be no mandatory resume dialog during ordinary use. Whether `thread/resume` was required is normally an implementation detail. - -## 13. Base and Developer Instructions - -Base and Developer Instructions form the thread's foundational context. - -**Base Instructions** define fundamental Codex behavior for the thread. - -**Developer Instructions** provide higher-priority project/workflow/architecture/testing constraints. - -They are not ordinary next-turn execution controls. - -They can be configured when: - -- creating a new thread; -- forking a thread; -- explicitly using **Resume with options…**. - -During normal operation they should be treated as foundational thread context rather than something casually changed between turns. - -## 14. Fork - -**Fork…** creates a new thread derived from an existing thread. - -Because this establishes a new thread boundary, the user may again modify: - -- Base Instructions; -- Developer Instructions; -- appropriate creation/lifetime properties such as temporary status. - -The resulting thread then uses the standard first/next-turn workflow for its mutable execution configuration. - -Figma should determine the best Fork interaction. - -## 15. Resume with options - -Normal resume is automatic. - -An explicit **Resume with options…** action is available as an advanced operation. It permits changing Base and Developer Instructions when resuming the same historical thread. - -This is deliberately different from ordinary Open/Resume and should be presented as an expert operation. - -## 16. Thread context menu - -The thread context menu should adapt to the current thread state. - -Its conceptual actions are: - -```text -Open -Rename… -Fork… - -Interrupt when applicable - -Resume with options… when applicable - -Archive / Unarchive -Delete… - -More - Copy Thread ID optional developer utility -``` - -Figma should determine grouping, separators, icons, wording refinements, and exact state-dependent presentation. - -The thread-list interaction design must explicitly define **normal, hover, selected, selected+hover, focus, context-menu-open, running, attention-required, archived, and disabled states**. Background changes for hovered rows and temporary surfaces must preserve clear state distinction in the light theme. - -### Open - -The normal operation. If necessary, CodexUI automatically resumes the thread using its existing context and settings. - -### Rename - -Changes the human-readable thread title independently of execution state. If no explicit name was supplied during creation, the initial useful name may be derived from the first turn. - -### Interrupt - -Visible when the thread has a running turn. This is especially important because multiple Codex threads may be running concurrently. The user should not need to switch to a thread merely to stop its active work. - -### Archive / Unarchive - -Archive is the normal non-destructive way to remove completed threads from the active working set. Archived threads remain discoverable and can be restored using Unarchive. Archive should generally be preferred over permanent deletion. - -### Delete - -Delete is destructive. It should be visually separated from ordinary actions and require appropriate confirmation. CodexUI should not silently interrupt and delete a running thread; state-dependent availability should make destructive behavior explicit. - -### Copy Thread ID - -An optional developer-oriented utility. It should not clutter the primary menu and may live under a secondary `More` section. - -## 17. Temporary threads - -Temporary / ephemeral status is a creation-time lifetime property. It changes whether the thread is persisted to normal history. - -It belongs to the thread-creation/fork experience rather than the upcoming-turn execution settings. - -The exact control and explanatory wording are left to Figma. - -## 18. Overall lifecycle - -```text - NEW THREAD - │ - ▼ - Thread creation dialog - ────────────────────── - optional name - Base Instructions - Developer Instructions - temporary/lifetime - │ - Create - ▼ - THREAD - │ - ▼ - FIRST TURN - ────────────────────── - prompt / input - - current execution values - Model - Reasoning - Answer style - Workspace - Access - Approval - Service tier - Reasoning summary - Collaboration mode - ... - │ - Start - ▼ - TURN 1 - │ - effective settings - stored/displayable - │ - ▼ - NEXT TURN - ────────────────────── - prompt - - inherited current values - Model - Reasoning - Answer style - Workspace - Access - Approval - Service tier - Reasoning summary - Collaboration mode - ... - │ - optionally modify - │ - Send - ▼ - TURN 2 - │ - ▼ - ... -``` - -Thread management is conceptually independent: - -```text - THREAD - │ - ┌────────────────┼────────────────┐ - │ │ │ - Open Fork Interrupt - │ │ if running - auto resume NEW THREAD - │ - foundational - instructions - editable again - - │ - ├── Resume with options - │ foundational instructions editable - │ - ├── Rename - ├── Archive / Unarchive - └── Delete -``` - -## 19. Phase 1 design principle for Figma - -These are **semantic and behavioral requirements, not a prescribed visual layout**. - -Figma has freedom over: - -- layout and hierarchy; -- compactness and density; -- icons and labels; -- progressive disclosure; -- dialog organization; -- whether execution settings appear as chips, selectors, a toolbar, expandable configuration area, or another appropriate interaction pattern. - -That freedom exists within the global requirements above: **the redesign is light-theme-first**, all interaction-state surfaces—especially hover backgrounds, menus, popovers, dialogs, and temporary frames—must maintain clear visual hierarchy and state distinction, and the center-pane conversation/composer split plus compact upward-expanding prompt behavior are invariant. - -The invariant mental model is: - -> **Thread creation establishes identity, lifetime, and foundational context. The upcoming-turn workflow exposes the one editable set of current execution settings. Changes apply to the upcoming and subsequent turns. Historical turns show their effective settings read-only. Thread management is provided through a state-aware context menu. The conversation scrolls independently above a spatially stable bottom turn/composer surface whose prompt editor starts compact and expands upward only when needed.** +Progress feedback is scoped to the operation it represents. Prompt +acknowledgment uses the pending card's animated highlight sweep. Thread creation +and long thread loading may receive dedicated scoped indicators, but no global +spinner or application-wide input lock is defined.