diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe09bb6..ba4f449 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,7 @@ jobs: cmake \ iproute2 \ libbluetooth-dev \ + libgit2-dev \ libmagic-dev \ libssl-dev \ ninja-build \ diff --git a/CMakeLists.txt b/CMakeLists.txt index a9a08fb..5e70ee8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -23,6 +23,8 @@ find_package( COMPONENTS net-rc-stream-legacy net-rc-stream-tls ) find_package(Qt6 REQUIRED COMPONENTS Widgets) +find_package(PkgConfig REQUIRED) +pkg_check_modules(LIBGIT2 REQUIRED IMPORTED_TARGET libgit2) find_package(Threads REQUIRED) set( @@ -37,6 +39,8 @@ set( src/codex/DiffViewer.h src/codex/FrontendSession.cpp src/codex/FrontendSession.h + src/codex/GitDiffProvider.cpp + src/codex/GitDiffProvider.h src/codex/FileSelectionDialog.cpp src/codex/FileSelectionDialog.h src/codex/MainWindow.cpp @@ -65,45 +69,38 @@ set( ) 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 + CODEXUI_SHELL_SOURCES + src/codex/ShellWidget.cpp + src/codex/ShellWidget.h + src/codex/middle/ComposerPane.cpp + src/codex/middle/ComposerPane.h + src/codex/middle/ConversationCards.cpp + src/codex/middle/ConversationCards.h + src/codex/middle/ConversationProjection.cpp + src/codex/middle/ConversationProjection.h + src/codex/middle/ConversationView.cpp + src/codex/middle/ConversationView.h + src/codex/middle/InspectorPane.cpp + src/codex/middle/InspectorPane.h + src/codex/middle/MiddleRegionWidget.cpp + src/codex/middle/MiddleRegionWidget.h + src/codex/middle/MiddleTypes.cpp + src/codex/middle/MiddleTypes.h + src/codex/middle/PromptCoordinator.cpp + src/codex/middle/PromptCoordinator.h + src/codex/middle/ThreadPane.cpp + src/codex/middle/ThreadPane.h ) qt_add_executable( codex-ui ${CODEXUI_CODEX_COMMON_SOURCES} - ${CODEXUI_GREENFIELD_MIDDLE_SOURCES} + ${CODEXUI_SHELL_SOURCES} src/codex/PendingRequestDialog.cpp src/codex/PendingRequestDialog.h src/codex/TurnSettingsWidget.cpp src/codex/TurnSettingsWidget.h ) -qt_add_executable( - codex-ui-harness - ${CODEXUI_CODEX_COMMON_SOURCES} - src/codex/WorkbenchWidget.cpp - src/codex/WorkbenchWidget.h -) - function(configure_codexui_target target) target_compile_features(${target} PRIVATE cxx_std_20) target_include_directories(${target} PRIVATE src) @@ -111,6 +108,7 @@ function(configure_codexui_target target) ${target} PRIVATE AISuite::OpenAICodex + PkgConfig::LIBGIT2 Qt6::Widgets Threads::Threads snodec::net-un-stream-legacy @@ -120,33 +118,24 @@ function(configure_codexui_target target) 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() + target_compile_definitions(codex-ui PRIVATE CODEXUI_CODEX_FRONTEND_TLS=1) + target_link_libraries( + codex-ui PRIVATE snodec::net-in-stream-tls snodec::net-in6-stream-tls + ) 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() + target_compile_definitions(codex-ui PRIVATE CODEXUI_CODEX_FRONTEND_RFCOMM=1) + target_link_libraries( + codex-ui PRIVATE snodec::net-rc-stream-legacy snodec::net-rc-stream-tls + ) 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() + target_compile_definitions(codex-ui PRIVATE CODEXUI_CODEX_FRONTEND_WEBSOCKET=1) + target_link_libraries(codex-ui PRIVATE snodec::http-client snodec::websocket-client) endif() if(BUILD_TESTING) @@ -207,65 +196,63 @@ if(BUILD_TESTING) ) add_executable( - 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 + codexui-conversation-projection-test + tests/codex/ConversationProjectionTest.cpp + src/codex/middle/ConversationProjection.cpp + src/codex/middle/ConversationProjection.h + src/codex/middle/MiddleTypes.cpp + src/codex/middle/MiddleTypes.h + src/codex/middle/PromptCoordinator.cpp + src/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 + codexui-conversation-projection-test PRIVATE cxx_std_20 ) + target_include_directories(codexui-conversation-projection-test PRIVATE src) target_link_libraries( - codexui-greenfield-projection-test PRIVATE Qt6::Widgets + codexui-conversation-projection-test PRIVATE Qt6::Widgets ) add_test( - NAME codexui-greenfield-projection - COMMAND codexui-greenfield-projection-test + NAME codexui-conversation-projection + COMMAND codexui-conversation-projection-test ) set_tests_properties( - codexui-greenfield-projection PROPERTIES TIMEOUT 10 + codexui-conversation-projection PROPERTIES TIMEOUT 10 ) 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 + codexui-conversation-cards-test + tests/codex/ConversationCardsTest.cpp + src/codex/middle/ConversationCards.cpp + src/codex/middle/ConversationCards.h + src/codex/middle/ConversationView.cpp + src/codex/middle/ConversationView.h + src/codex/middle/MiddleTypes.cpp + src/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 + codexui-conversation-cards-test PRIVATE cxx_std_20 ) + target_include_directories(codexui-conversation-cards-test PRIVATE src) target_link_libraries( - codexui-greenfield-middle-test PRIVATE Qt6::Widgets + codexui-conversation-cards-test PRIVATE Qt6::Widgets ) add_test( - NAME codexui-greenfield-middle - COMMAND codexui-greenfield-middle-test + NAME codexui-conversation-cards + COMMAND codexui-conversation-cards-test ) set_tests_properties( - codexui-greenfield-middle + codexui-conversation-cards PROPERTIES TIMEOUT 15 ENVIRONMENT "QT_QPA_PLATFORM=offscreen" ) qt_add_executable( - codexui-greenfield-layout-test - tests/codex/GreenfieldLayoutTest.cpp + codexui-application-layout-test + tests/codex/ApplicationLayoutTest.cpp src/codex/DiffViewer.cpp src/codex/DiffViewer.h + src/codex/GitDiffProvider.cpp + src/codex/GitDiffProvider.h src/codex/FileSelectionDialog.cpp src/codex/FileSelectionDialog.h src/codex/PresentationModel.cpp @@ -276,73 +263,95 @@ if(BUILD_TESTING) 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 + src/codex/ui/UiStyle.cpp + src/codex/ui/UiStyle.h + src/codex/middle/ComposerPane.cpp + src/codex/middle/ComposerPane.h + src/codex/middle/ConversationCards.cpp + src/codex/middle/ConversationCards.h + src/codex/middle/ConversationView.cpp + src/codex/middle/ConversationView.h + src/codex/middle/InspectorPane.cpp + src/codex/middle/InspectorPane.h + src/codex/middle/MiddleRegionWidget.cpp + src/codex/middle/MiddleRegionWidget.h + src/codex/middle/MiddleTypes.cpp + src/codex/middle/MiddleTypes.h + src/codex/middle/ThreadPane.cpp + src/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 + codexui-application-layout-test PRIVATE cxx_std_20 ) + target_include_directories(codexui-application-layout-test PRIVATE src) target_link_libraries( - codexui-greenfield-layout-test PRIVATE AISuite::OpenAICodex Qt6::Widgets + codexui-application-layout-test + PRIVATE AISuite::OpenAICodex PkgConfig::LIBGIT2 Qt6::Widgets ) add_test( - NAME codexui-greenfield-layout - COMMAND codexui-greenfield-layout-test + NAME codexui-application-layout + COMMAND codexui-application-layout-test ) set_tests_properties( - codexui-greenfield-layout + codexui-application-layout PROPERTIES TIMEOUT 15 ENVIRONMENT "QT_QPA_PLATFORM=offscreen" ) - set(CODEXUI_GREENFIELD_SHELL_TEST_SOURCES ${CODEXUI_CODEX_COMMON_SOURCES}) + qt_add_executable( + codexui-git-changes-live-test + tests/codex/GitChangesLiveTest.cpp + src/codex/DiffViewer.cpp + src/codex/DiffViewer.h + src/codex/GitDiffProvider.cpp + src/codex/GitDiffProvider.h + src/codex/ui/UiStyle.cpp + src/codex/ui/UiStyle.h + ) + target_compile_features(codexui-git-changes-live-test PRIVATE cxx_std_20) + target_include_directories(codexui-git-changes-live-test PRIVATE src) + target_link_libraries( + codexui-git-changes-live-test PRIVATE PkgConfig::LIBGIT2 Qt6::Widgets + ) + add_test( + NAME codexui-git-changes-live + COMMAND codexui-git-changes-live-test + ) + set_tests_properties( + codexui-git-changes-live + PROPERTIES TIMEOUT 30 ENVIRONMENT "QT_QPA_PLATFORM=offscreen" + ) + + set(CODEXUI_SHELL_TEST_SOURCES ${CODEXUI_CODEX_COMMON_SOURCES}) list( - REMOVE_ITEM CODEXUI_GREENFIELD_SHELL_TEST_SOURCES + REMOVE_ITEM CODEXUI_SHELL_TEST_SOURCES src/codex/main.cpp src/codex/MainWindow.cpp src/codex/MainWindow.h ) qt_add_executable( - codexui-greenfield-shell-test - tests/codex/GreenfieldShellIntegrationTest.cpp - ${CODEXUI_GREENFIELD_SHELL_TEST_SOURCES} - ${CODEXUI_GREENFIELD_MIDDLE_SOURCES} + codexui-shell-integration-test + tests/codex/ShellIntegrationTest.cpp + ${CODEXUI_SHELL_TEST_SOURCES} + ${CODEXUI_SHELL_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 - ) + configure_codexui_target(codexui-shell-integration-test) add_test( - NAME codexui-greenfield-shell - COMMAND codexui-greenfield-shell-test + NAME codexui-shell-integration + COMMAND codexui-shell-integration-test ) set_tests_properties( - codexui-greenfield-shell + codexui-shell-integration PROPERTIES TIMEOUT 20 ENVIRONMENT "QT_QPA_PLATFORM=offscreen" ) endif() install( - TARGETS codex-ui codex-ui-harness + TARGETS codex-ui RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} BUNDLE DESTINATION . ) diff --git a/README.md b/README.md index 480f8f2..836daf9 100644 --- a/README.md +++ b/README.md @@ -19,19 +19,18 @@ 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 +## Application -- `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. +`codex-ui` is the canonical visual application. Its production shell consumes +the normalized presentation protocol and model directly; there is no parallel +legacy UI or alternate application target. ## Build -Qt 6 Widgets, Threads, SNode.C `master`/HEAD, and an installed canonical -AISuite package exporting `AISuite::OpenAICodex` are required. +Qt 6 Widgets, Threads, libgit2 development files (discoverable as `libgit2` +through pkg-config), SNode.C `master`/HEAD, and an installed canonical AISuite +package exporting `AISuite::OpenAICodex` are required. On Debian and Ubuntu, +the libgit2 package is `libgit2-dev`. ```sh cmake -S . -B "${BUILD_DIR}" -G Ninja \ diff --git a/docs/codex-architecture.md b/docs/codex-architecture.md index f55d269..15735b4 100644 --- a/docs/codex-architecture.md +++ b/docs/codex-architecture.md @@ -287,7 +287,7 @@ reduced result payloads are: - `threads.list`: `threads`, `nextCursor`, and `backwardsCursor`, with `merge`; - `thread.read`: returned `thread`, with `replace` when no newer presentation event arrived after the read began, otherwise `merge` so a late snapshot - cannot erase newer live Plan, Agent, command, or Changes detail; + cannot erase newer live Plan, Agent, command, or turn-diff domain detail; - `thread.create`, `thread.resume`, and `thread.fork`: returned `thread`, with `merge`; - `thread.rename`, `thread.archive`, `thread.unarchive`, and `thread.delete`: @@ -429,8 +429,8 @@ 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 +does not erase retained live-only Plan, Agent, or turn-diff domain 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. @@ -484,8 +484,7 @@ 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. +currently being reviewed. ### 7.3 Message Attachments @@ -504,17 +503,45 @@ 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. +The Changes inspector is authoritative over the local Git worktrees associated +with the selected thread. It does not use app-server `turn.diff.changed` or +`fileChange` messages as review content. `ThreadPresentation` retains bounded, +deduplicated command working directories and changed-path hints from the +thread's authoritative items. The provider resolves each directory upward with +libgit2, deduplicates repository roots, validates ambiguous paths against the +worktree, index, and HEAD, and persists the resolved roots per thread. A path +that is currently changed ranks above the same clean tracked path; equal-rank +matches remain available together. It never performs a recursive downward +workspace search. + +Resolved roots are synchronously persisted in QSettings and loaded from either +the native string-list representation or the scalar representation used by the +INI backend for a single root. Consequently, restart hydration does not depend +on historical command items being present in `thread.read`. + +The repository selector defaults to All repositories when several candidates +match. Candidate paths containing a dot-prefixed directory are excluded by +default; the persistent Hidden option explicitly includes them. The provider +exposes Unstaged, Staged, and Since HEAD scopes. Untracked +content—including files created outside CodexUI—renames, copies, deletions, +type changes, conflicts, and binary metadata come from libgit2. A folder +outside Git remains a valid Codex workspace, but its Changes tab reports that +review requires a repository. + +The Inspector contains a compact unified preview with stable file selection, +addition/deletion counts, Copy, Open review, and file-double-click review. The +modeless Change Review window remains usable beside the conversation and offers +Unified or Side by side layout plus Compact or Expanded context. Preferences +persist across threads. Repository collection runs outside the UI thread, +superseded results are discarded, and rendered diff content is bounded to 16 +MiB with an explicit truncation state. Every returned file carries its resolved +absolute pathname. CodexUI watches existing changed files and their parent +directories, then debounces filesystem events into a fresh libgit2 snapshot. +Parent-directory watches keep deletion, recreation, rename, and atomic file +replacement consistent. A visible-only two-second refresh remains the safety +net for newly created files in previously unwatched nested directories and for +index-only changes. Files disappear from selection as soon as libgit2 reports +that they are clean again. ### 7.5 Conversation Projection and Prompt Admission @@ -617,9 +644,9 @@ 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. +Plan, Agents, and Requests content is read from retained per-thread +presentation snapshots. Changes is instead refreshed from the selected +thread's local Git worktree and is independent of protocol-frame retention. ## 8. Plans and Agents @@ -872,51 +899,20 @@ 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. +## 16. Application Presentation + +The production `ShellWidget` is the sole Qt consumer of the +`codexui.presentation` contract and `PresentationModel`. Conversation, Plan, +Agents, Changes, Requests, retained State, and bounded Protocol diagnostics are +integrated into that shell. Diagnostic presentation is telemetry only: it +cannot replay frames, hydrate state, supply deletion authority, or conceal a +missing app-server result. 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 +The canonical CodexUI implementation contains one complete visual shell. +`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: @@ -932,7 +928,6 @@ The implementation is divided into the following concrete components: | `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 | @@ -941,12 +936,13 @@ The implementation is divided into the following concrete components: | `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 | +| `InspectorPane` | Retained Plan, Agents, Requests, State, and Protocol presentation plus selected-workspace Git review | | `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 | +| `GitDiffProvider` | Asynchronous in-process libgit2 repository discovery and scoped diff snapshots | +| `DiffViewer` | Compact repository summary/preview and modeless unified or side-by-side review | | `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 | @@ -1004,22 +1000,20 @@ 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 build produces one application, `codex-ui`. It integrates the production +shell with `FrontendSession`, `ClientRuntime`, the socketpair, normalizer, +presentation protocol, and presentation model; no alternate UI target 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. +`AISuite::OpenAICodex`, Qt Widgets, Threads, libgit2 through pkg-config, and the +selected SNode.C client modules. Git review is performed through libgit2; the +application never launches a Git process. 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 @@ -1081,7 +1075,7 @@ 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 +Seven 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. @@ -1147,7 +1141,7 @@ instead of repeating both mechanisms in every case. #### Conversation Projection -`codexui-greenfield-projection-test` verifies the pure typed projection and +`codexui-conversation-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, @@ -1156,13 +1150,13 @@ Command execution output visibility. #### Middle-Region Behavior -`codexui-greenfield-middle-test` exercises the actual conversation widgets +`codexui-conversation-cards-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 +`codexui-application-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 @@ -1171,13 +1165,26 @@ transient card rasters only to prove that motion exists. #### Shell Integration -`codexui-greenfield-shell-test` drives the production `ShellWidget` and +`codexui-shell-integration-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. +#### Git Changes Integration + +`codexui-git-changes-live-test` uses production `DiffViewer`, +`GitDiffProvider`, QFileSystemWatcher, and libgit2 against a temporary real Git +repository. It performs filesystem writes rather than UI interaction. The test +verifies polling discovery of a manually created nested untracked file and +native watcher refresh after removal, content reversion, deletion restoration, +and atomic replacement. `codexui-application-layout-test` complements it with +in-process repository-resolution coverage for all scopes, duplicate candidates, +ambiguous and absolute paths, All and individual repository selection, hidden +repository exclusion/inclusion, stale hints/selections, and preference for an +actually changed path over an identical clean tracked path. + #### Explicit Exclusions The permanent automated suite does not include: @@ -1199,32 +1206,33 @@ 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: +The seven 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 + codexui-conversation-projection-test \ + codexui-conversation-cards-test \ + codexui-application-layout-test \ + codexui-git-changes-live-test \ + codexui-shell-integration-test ctest --test-dir "${BUILD_DIR}" --output-on-failure \ - -R '^codexui-(socketpair-contract|presentation-pipeline|greenfield-(projection|middle|layout|shell))$' + -R '^codexui-(socketpair-contract|presentation-pipeline|conversation-projection|conversation-cards|application-layout|git-changes-live|shell-integration)$' ``` -Each test has a 10-to-20-second CTest ceiling. Normal successful execution is +Each test has a 10-to-30-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 +## 18. Live Application Validation -The harness was exercised against one persistent real topology: +The application was exercised against one persistent real topology: ```text Codex app-server over IPv4 WebSocket <-> codex-bridge over IPv4 WebSocket - <-> CodexUI harness over IPv4 WebSocket + <-> CodexUI over IPv4 WebSocket ``` An independent `codex-bridge-client` observer remained connected to the same @@ -1346,9 +1354,7 @@ 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. +consume only `PresentationModel` and call only `FrontendSession`. The implemented shell contains the 64-pixel top bar, hideable work sidebar, thread list, conversation timeline and composer, hideable inspector, Plan, @@ -1357,16 +1363,17 @@ 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. +messages, plans, reasoning summaries, agent results, and authoritative user +messages are rendered with Qt Markdown parsing while embedded HTML is disabled. +The transitional local prompt, 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 +is never dumped to the shell, Info/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 diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index 1595603..ed6dcca 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -4,6 +4,11 @@ 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. +All Qt popup and context menus share one application-level visual contract, +including menus created automatically by text widgets. They use a compact +white rounded surface, neutral border, 30-pixel actions, neutral hover, +blue-tinted checked actions, muted disabled actions, and inset separators. + ## Conversation source and structure `PresentationModel` is the sole retained authoritative store for normalized UI @@ -31,8 +36,25 @@ bottom or is owned by the user. - 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. + authoritative removal still removes it. +- The sidebar sorts all visible rows by a user-selected criterion. `Recent` is + the default and uses the app-server's provider-defined `recencyAt` value, + newest first. `Created` uses `createdAt` newest first, and `Last changed` + uses `updatedAt` newest first. `Alphanumeric` sorts displayed titles + case-insensitively with natural number ordering, so 2 precedes 10 and titles + beginning with numbers precede other titles. Timestamp values that are not + available sort after timestamped threads. The directions are fixed; the UI + does not provide a separate ascending/descending control. +- Each visible thread is presented as a compact card. Its status indicator is + part of that card, and hover and selection strengthen the same card surface + instead of introducing a separate row treatment. The Sort and Transport + controls use the same centered chevron and compact text-to-indicator spacing + as the prompt settings. +- A left click selects a thread and changes the displayed conversation. A + right click opens actions for the pointed-to card without changing the + selected thread or displayed conversation. That card retains its hover + treatment until the non-blocking menu closes; dismissing the menu does not + replay the closing click into another control. - 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. @@ -42,8 +64,8 @@ bottom or is owned by the user. 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 + into the retained per-thread presentation, so live Plan and Agents state + cannot be erased by an incomplete reconstruction. Reload remains the explicit forced fresh-read action. ## Prompt submission and acknowledgment @@ -68,6 +90,10 @@ explicit error state. The composer is cleared immediately after local admission and remains enabled. Users may enter additional prompts while earlier prompts await acknowledgment. +Unsubmitted composer text and attachments form one shared local draft: ordinary +thread navigation retains them, and submission sends them to the thread that is +visibly selected at that moment. Explicit new-thread creation still starts with +a deliberately cleared composer. 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 @@ -88,6 +114,10 @@ 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. +Authoritative user-message text is rendered as Markdown through the same safe +`MarkdownNoHTML` path as agent messages. The locally admitted prompt remains a +plain-text transitional card until its authoritative item arrives. + ## Conversation scrolling The message view smoothly follows incoming content only while it is already at @@ -98,6 +128,9 @@ 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. +Scrollable Command output owns wheel and touchpad gestures while the pointer is +over it, including overscroll at either boundary; those gestures never chain to +the outer message view. 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, @@ -159,12 +192,16 @@ 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. +The command surface uses the same content-height behavior with its existing +90-pixel maximum. Trailing empty lines are omitted from both displayed texts. +Their wrapped content height is measured at the final viewport width during the +outer layout transaction. While the conversation follows its bottom, streaming +output growth holds the card bottom and metadata in place and expands upward. +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 @@ -172,9 +209,31 @@ 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. +domain authority. Plan, Agents, and Requests use retained per-thread +presentation snapshots. Changes instead resolves local Git repositories upward +from the selected thread's retained command working directories and refreshes +them asynchronously through libgit2. When several repositories match, All +repositories is the default and a selector can narrow the view. Resolution +considers only repositories reached through visible directory paths by +default. The persistent Hidden option also includes paths containing +dot-prefixed directories. When identical hinted paths occur in several +repositories, repositories where the path is currently changed are preferred +over clean tracked matches. Changes offers Unstaged, Staged, and Since HEAD +scopes; a thread without a resolvable +repository shows an explanatory unavailable state without preventing normal +work. Manual and Codex-created changes are treated identically. + +The Inspector shows a compact unified preview. Open review and double-clicking +a changed file open a modeless review window with Unified or Side by side +layout and Compact or Expanded context. These view preferences persist across +threads. The changed-file list ends with a compact footer containing the file +count and semantic green/red totals; a standard gray divider separates that +selection area from the preview. Diff scrollbars show proportional overview +marks using canonical green for additions, red for deletions, and blue for hunk +boundaries. Existing changed files and their parent directories are watched; +reverted or restored files disappear after libgit2 confirms they are clean, +while a short visible-only refresh discovers new untracked files and catches +index-only changes. ## Desktop identity diff --git a/src/codex/ConnectionDialog.cpp b/src/codex/ConnectionDialog.cpp index a746a48..ece1496 100644 --- a/src/codex/ConnectionDialog.cpp +++ b/src/codex/ConnectionDialog.cpp @@ -90,7 +90,7 @@ ConnectionDialog::ConnectionDialog(nlohmann::json settings, QWidget *parent) "meta"); root->addWidget(tlsNotice); errorLabel = dialogLabel({}, "meta"); - errorLabel->setStyleSheet(QStringLiteral("color:#b83a3a;")); + errorLabel->setStyleSheet(QStringLiteral("color:#982f3d;")); errorLabel->hide(); root->addWidget(errorLabel); root->addStretch(); diff --git a/src/codex/DiffViewer.cpp b/src/codex/DiffViewer.cpp index a42551f..f47e2eb 100644 --- a/src/codex/DiffViewer.cpp +++ b/src/codex/DiffViewer.cpp @@ -1,24 +1,104 @@ // SPDX-License-Identifier: LGPL-3.0-or-later OR MIT #include "codex/DiffViewer.h" +#include "codex/ui/UiStyle.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 + namespace codexui::codex { namespace { +constexpr int RepositoryRefreshDelayMs = 120; +constexpr int RepositoryPollingIntervalMs = 2000; + +struct DiffMark { + qreal position = 0; + QColor color; +}; + +class DiffScrollBar final : public QScrollBar { +public: + explicit DiffScrollBar(QWidget *parent = nullptr) + : QScrollBar(Qt::Vertical, parent) {} + + void setMarks(std::vector nextMarks) { + marks = std::move(nextMarks); + update(); + } + +protected: + void paintEvent(QPaintEvent *event) override { + QScrollBar::paintEvent(event); + if (marks.empty()) + return; + QStyleOptionSlider option; + initStyleOption(&option); + const QRect groove = style()->subControlRect( + QStyle::CC_ScrollBar, &option, QStyle::SC_ScrollBarGroove, this); + if (!groove.isValid()) + return; + QPainter painter(this); + painter.setPen(Qt::NoPen); + const int width = std::min(4, groove.width()); + for (const DiffMark &mark : marks) { + painter.setBrush(mark.color); + const int y = groove.top() + qRound( + mark.position * + std::max(0, groove.height() - 2)); + painter.drawRoundedRect(groove.right() - width + 1, y, width, 2, 1, 1); + } + } + +private: + std::vector marks; +}; + +class ChevronComboBox 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); + UiStyle::drawChevron( + this, indicator, option.state & QStyle::State_Enabled, + option.state & (QStyle::State_MouseOver | QStyle::State_HasFocus)); + } +}; + class DiffHighlighter final : public QSyntaxHighlighter { public: explicit DiffHighlighter(QTextDocument *document) @@ -37,11 +117,13 @@ class DiffHighlighter final : public QSyntaxHighlighter { format.setBackground(QColor(QStringLiteral("#e9f7f0"))); } else if (text.startsWith(QLatin1Char('-')) && !text.startsWith(QStringLiteral("---"))) { - format.setForeground(QColor(QStringLiteral("#9d2e2e"))); - format.setBackground(QColor(QStringLiteral("#fff1f1"))); + format.setForeground(QColor(QStringLiteral("#982f3d"))); + format.setBackground(QColor(QStringLiteral("#fff0f2"))); } else if (text.startsWith(QStringLiteral("diff --git")) || text.startsWith(QStringLiteral("---")) || - text.startsWith(QStringLiteral("+++"))) { + text.startsWith(QStringLiteral("+++")) || + text.startsWith(QStringLiteral("Binary files")) || + text.startsWith(QStringLiteral("GIT binary patch"))) { format.setForeground(QColor(QStringLiteral("#344054"))); format.setFontWeight(QFont::DemiBold); } else { @@ -55,211 +137,804 @@ QLabel *label(QString value, const char *kind) { auto *result = new QLabel(std::move(value)); result->setProperty("kind", kind); result->setWordWrap(true); + result->setTextInteractionFlags(Qt::TextSelectableByMouse); + return result; +} + +QPlainTextEdit *diffView(const QString &objectName) { + auto *view = new QPlainTextEdit; + view->setObjectName(objectName); + view->setProperty("kind", "infoViewer"); + view->setReadOnly(true); + view->setLineWrapMode(QPlainTextEdit::NoWrap); + view->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); + auto *scrollBar = new DiffScrollBar(view); + view->setVerticalScrollBar(scrollBar); + view->verticalScrollBar()->setProperty("kind", "infoViewer"); + new DiffHighlighter(view->document()); + QObject::connect(view, &QPlainTextEdit::textChanged, view, + [view, scrollBar] { + std::vector marks; + const int blockCount = view->document()->blockCount(); + for (QTextBlock block = view->document()->begin(); + block.isValid(); block = block.next()) { + const QString text = block.text(); + QColor color; + if (text.startsWith(QStringLiteral("@@"))) + color = QColor(QStringLiteral("#2f6feb")); + else if (text.startsWith(QLatin1Char('+')) && + !text.startsWith(QStringLiteral("+++"))) + color = QColor(QStringLiteral("#18865e")); + else if (text.startsWith(QLatin1Char('-')) && + !text.startsWith(QStringLiteral("---"))) + color = QColor(QStringLiteral("#c43d4d")); + if (!color.isValid()) + continue; + const qreal position = + blockCount > 1 + ? static_cast(block.blockNumber()) / + static_cast(blockCount - 1) + : 0; + marks.push_back({position, color}); + } + scrollBar->setMarks(std::move(marks)); + }); + return view; +} + +GitDiffScope scopeValue(const QComboBox *scope) { + return static_cast(scope->currentData().toInt()); +} + +QString scopeName(GitDiffScope scope) { + switch (scope) { + case GitDiffScope::Staged: + return QStringLiteral("Staged changes"); + case GitDiffScope::Uncommitted: + return QStringLiteral("All changes since HEAD"); + default: + return QStringLiteral("Unstaged changes"); + } +} + +QString repositoryName(const QString &root) { + const QString name = QFileInfo(QDir::cleanPath(root)).fileName(); + return name.isEmpty() ? root : name; +} + +QString fileTitle(const GitDiffFile &file, bool includeRepository = false) { + QString result = + !file.previousPath.isEmpty() && file.previousPath != file.path + ? QStringLiteral("%1 → %2").arg(file.previousPath, file.path) + : file.path; + if (includeRepository) + result = QStringLiteral("%1 / %2") + .arg(repositoryName(file.repositoryRoot), result); + return result; +} + +QString repositorySummary(const GitDiffSnapshot &snapshot) { + return snapshot.repositoryRoots.size() > 1 + ? QStringLiteral("%1 repositories") + .arg(snapshot.repositoryRoots.size()) + : snapshot.repositoryRoots.isEmpty() + ? QString{} + : snapshot.repositoryRoots.front(); +} + +QString settingsBase(const QString &threadId) { + return QStringLiteral("diff/threads/%1") + .arg(QString::fromLatin1(QCryptographicHash::hash( + threadId.toUtf8(), QCryptographicHash::Sha256) + .toHex())); +} + +QStringList stringListSetting(const QString &key) { + const QVariant stored = QSettings().value(key); + QStringList result = stored.toStringList(); + if (result.isEmpty()) { + const QString scalar = stored.toString(); + if (!scalar.isEmpty()) + result.push_back(scalar); + } + result.removeDuplicates(); 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; +QByteArray fingerprint(const GitDiffSnapshot &snapshot) { + QByteArray value = snapshot.repositoryRoot.toUtf8(); + value += '\0'; + value += snapshot.error.toUtf8(); + value += static_cast(snapshot.scope); + value += static_cast(snapshot.context); + value += snapshot.repository ? '\1' : '\0'; + value += snapshot.truncated ? '\1' : '\0'; + for (const GitDiffFile &file : snapshot.files) { + value += '\0'; + value += file.repositoryRoot.toUtf8(); + value += '\0'; + value += file.path.toUtf8(); + value += '\0'; + value += file.absolutePath.toUtf8(); + value += '\0'; + value += file.previousPath.toUtf8(); + value += '\0'; + value += file.status.toUtf8(); + value += '\0'; + value += file.patch.toUtf8(); + } + return QCryptographicHash::hash(value, QCryptographicHash::Sha256); } -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) { +struct SideBySideText { + QString left; + QString right; +}; + +QString sideLine(QChar marker, int number, const QString &content) { + return QStringLiteral("%1%2 │ %3") + .arg(marker) + .arg(number > 0 ? QString::number(number).rightJustified(6) + : QString(6, QLatin1Char(' '))) + .arg(content); +} + +SideBySideText sideBySide(const QString &patch) { + QStringList left; + QStringList right; + const QStringList lines = patch.split(QLatin1Char('\n')); + static const QRegularExpression hunk( + QStringLiteral(R"(^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@)")); + int oldLine = 0; + int newLine = 0; + for (qsizetype index = 0; index < lines.size();) { + const QString &line = lines[index]; + const QRegularExpressionMatch match = hunk.match(line); + if (match.hasMatch()) { + oldLine = match.captured(1).toInt(); + newLine = match.captured(2).toInt(); + left << line; + right << line; + ++index; + continue; + } + if (line.startsWith(QLatin1Char('-')) && + !line.startsWith(QStringLiteral("---"))) { + QStringList removed; + QStringList added; + while (index < lines.size() && + lines[index].startsWith(QLatin1Char('-')) && + !lines[index].startsWith(QStringLiteral("---"))) + removed << lines[index++].mid(1); + while (index < lines.size() && + lines[index].startsWith(QLatin1Char('+')) && + !lines[index].startsWith(QStringLiteral("+++"))) + added << lines[index++].mid(1); + const qsizetype count = std::max(removed.size(), added.size()); + for (qsizetype row = 0; row < count; ++row) { + const bool hasOld = row < removed.size(); + const bool hasNew = row < added.size(); + left << sideLine(hasOld ? QLatin1Char('-') : QLatin1Char(' '), + hasOld ? oldLine++ : 0, + hasOld ? removed[row] : QString{}); + right << sideLine(hasNew ? QLatin1Char('+') : QLatin1Char(' '), + hasNew ? newLine++ : 0, + hasNew ? added[row] : QString{}); + } + continue; + } if (line.startsWith(QLatin1Char('+')) && - !line.startsWith(QStringLiteral("+++"))) - ++additions; - else if (line.startsWith(QLatin1Char('-')) && - !line.startsWith(QStringLiteral("---"))) - ++deletions; + !line.startsWith(QStringLiteral("+++"))) { + left << sideLine(QLatin1Char(' '), 0, {}); + right << sideLine(QLatin1Char('+'), newLine++, line.mid(1)); + } else if (line.startsWith(QLatin1Char(' ')) && oldLine > 0 && + newLine > 0) { + left << sideLine(QLatin1Char(' '), oldLine++, line.mid(1)); + right << sideLine(QLatin1Char(' '), newLine++, line.mid(1)); + } else { + left << line; + right << line; + } + ++index; } + return {left.join(QLatin1Char('\n')), right.join(QLatin1Char('\n'))}; +} + +QPushButton *modeButton(const QString &text) { + auto *button = new QPushButton(text); + button->setCheckable(true); + button->setProperty("kind", "segment"); + button->setFixedHeight(30); + return button; } } // namespace +class GitDiffReviewWindow final : public QDialog { +public: + explicit GitDiffReviewWindow(QWidget *parent = nullptr) : QDialog(parent) { + setWindowTitle(QStringLiteral("Change Review")); + setAttribute(Qt::WA_DeleteOnClose); + setWindowModality(Qt::NonModal); + resize(1200, 780); + provider = new GitDiffProvider(this); + repositoryTimer = new QTimer(this); + repositoryTimer->setInterval(RepositoryPollingIntervalMs); + repositoryTimer->start(); + + auto *root = new QVBoxLayout(this); + root->setContentsMargins(14, 14, 14, 14); + root->setSpacing(10); + auto *header = new QHBoxLayout; + title = label(QStringLiteral("Change Review"), "heading"); + subtitle = label({}, "meta"); + auto *titles = new QVBoxLayout; + titles->setSpacing(1); + titles->addWidget(title); + titles->addWidget(subtitle); + header->addLayout(titles, 1); + + unified = modeButton(QStringLiteral("Unified")); + split = modeButton(QStringLiteral("Side by side")); + auto *layoutModes = new QButtonGroup(this); + layoutModes->setExclusive(true); + layoutModes->addButton(unified); + layoutModes->addButton(split); + header->addWidget(unified); + header->addWidget(split); + header->addSpacing(8); + compact = modeButton(QStringLiteral("Compact")); + expanded = modeButton(QStringLiteral("Expanded")); + auto *contextModes = new QButtonGroup(this); + contextModes->setExclusive(true); + contextModes->addButton(compact); + contextModes->addButton(expanded); + header->addWidget(compact); + header->addWidget(expanded); + root->addLayout(header); + + auto *body = new QSplitter; + reviewFiles = new QListWidget; + reviewFiles->setMinimumWidth(230); + reviewFiles->setMaximumWidth(420); + body->addWidget(reviewFiles); + views = new QStackedWidget; + unifiedView = diffView(QStringLiteral("codexReviewUnified")); + views->addWidget(unifiedView); + auto *sides = new QSplitter; + leftView = diffView(QStringLiteral("codexReviewBefore")); + rightView = diffView(QStringLiteral("codexReviewAfter")); + sides->addWidget(leftView); + sides->addWidget(rightView); + sides->setSizes({600, 600}); + views->addWidget(sides); + body->addWidget(views); + body->setStretchFactor(1, 1); + root->addWidget(body, 1); + + connect(reviewFiles, &QListWidget::currentRowChanged, this, + [this] { renderSelected(); }); + connect(unified, &QPushButton::clicked, this, [this] { + views->setCurrentIndex(0); + QSettings().setValue(QStringLiteral("diff/layout"), + QStringLiteral("unified")); + renderSelected(); + }); + connect(split, &QPushButton::clicked, this, [this] { + views->setCurrentIndex(1); + QSettings().setValue(QStringLiteral("diff/layout"), + QStringLiteral("side-by-side")); + renderSelected(); + }); + connect(compact, &QPushButton::clicked, this, [this] { + context = GitDiffContext::Compact; + QSettings().setValue(QStringLiteral("diff/context"), + QStringLiteral("compact")); + reload(); + }); + connect(expanded, &QPushButton::clicked, this, [this] { + context = GitDiffContext::Expanded; + QSettings().setValue(QStringLiteral("diff/context"), + QStringLiteral("expanded")); + reload(); + }); + connect(provider, &GitDiffProvider::loadingChanged, this, [this](bool value) { + if (value && snapshot.files.empty()) + subtitle->setText(QStringLiteral("Loading repository changes…")); + }); + connect(provider, &GitDiffProvider::snapshotReady, this, + [this](const GitDiffSnapshot &value) { apply(value); }); + connect(repositoryTimer, &QTimer::timeout, this, [this] { + if (isVisible()) + reload(); + }); + connect(leftView->verticalScrollBar(), &QScrollBar::valueChanged, + rightView->verticalScrollBar(), &QScrollBar::setValue); + connect(rightView->verticalScrollBar(), &QScrollBar::valueChanged, + leftView->verticalScrollBar(), &QScrollBar::setValue); + connect(leftView->horizontalScrollBar(), &QScrollBar::valueChanged, + rightView->horizontalScrollBar(), &QScrollBar::setValue); + connect(rightView->horizontalScrollBar(), &QScrollBar::valueChanged, + leftView->horizontalScrollBar(), &QScrollBar::setValue); + + const QSettings settings; + const bool side = settings.value(QStringLiteral("diff/layout"), + QStringLiteral("unified")) == + QStringLiteral("side-by-side"); + unified->setChecked(!side); + split->setChecked(side); + views->setCurrentIndex(side ? 1 : 0); + const bool full = settings.value(QStringLiteral("diff/context"), + QStringLiteral("compact")) == + QStringLiteral("expanded"); + compact->setChecked(!full); + expanded->setChecked(full); + context = full ? GitDiffContext::Expanded : GitDiffContext::Compact; + } + + void setSource(QString nextWorkspace, QStringList nextDirectories, + QStringList nextPaths, QString nextRepository, + bool nextIncludeHiddenRepositories, + GitDiffScope nextScope, QString preferredPath) { + workspace = std::move(nextWorkspace); + commandDirectories = std::move(nextDirectories); + changedPaths = std::move(nextPaths); + selectedRepository = std::move(nextRepository); + includeHiddenRepositories = nextIncludeHiddenRepositories; + scope = nextScope; + requestedPath = std::move(preferredPath); + reload(); + } + +private: + void reload() { + provider->request(workspace, commandDirectories, changedPaths, + selectedRepository, includeHiddenRepositories, scope, + context); + } + + void apply(const GitDiffSnapshot &value) { + const QByteArray nextFingerprint = fingerprint(value); + if (nextFingerprint == snapshotFingerprint) + return; + snapshotFingerprint = nextFingerprint; + snapshot = value; + subtitle->setText(value.error.isEmpty() + ? QStringLiteral("%1 | %2") + .arg(scopeName(value.scope), + repositorySummary(value)) + : value.error); + reviewFiles->clear(); + int selected = -1; + for (std::size_t index = 0; index < value.files.size(); ++index) { + const GitDiffFile &file = value.files[index]; + auto *item = new QListWidgetItem( + QStringLiteral("%1\n%2 +%3 −%4") + .arg(fileTitle(file, value.repositoryRoots.size() > 1), + file.status) + .arg(file.additions) + .arg(file.deletions)); + item->setToolTip(file.absolutePath); + reviewFiles->addItem(item); + if (file.absolutePath == requestedPath) + selected = static_cast(index); + } + if (!value.files.empty()) + reviewFiles->setCurrentRow(selected >= 0 ? selected : 0); + else { + unifiedView->setPlainText(value.error.isEmpty() + ? QStringLiteral("No file changes") + : value.error); + leftView->clear(); + rightView->clear(); + } + } + + void renderSelected() { + const int index = reviewFiles->currentRow(); + if (index < 0 || static_cast(index) >= snapshot.files.size()) + return; + const GitDiffFile &file = snapshot.files[static_cast(index)]; + requestedPath = file.absolutePath; + title->setText(fileTitle(file, snapshot.repositoryRoots.size() > 1)); + const QString content = file.patch.isEmpty() + ? QStringLiteral("No textual patch is available for this file.") + : file.patch; + unifiedView->setPlainText(content); + unifiedView->moveCursor(QTextCursor::Start); + const SideBySideText sides = sideBySide(content); + leftView->setPlainText(sides.left); + rightView->setPlainText(sides.right); + leftView->moveCursor(QTextCursor::Start); + rightView->moveCursor(QTextCursor::Start); + } + + GitDiffProvider *provider = nullptr; + GitDiffSnapshot snapshot; + QString workspace; + QStringList commandDirectories; + QStringList changedPaths; + QString selectedRepository; + bool includeHiddenRepositories = false; + QString requestedPath; + GitDiffScope scope = GitDiffScope::Unstaged; + GitDiffContext context = GitDiffContext::Compact; + QByteArray snapshotFingerprint; + QLabel *title = nullptr; + QLabel *subtitle = nullptr; + QListWidget *reviewFiles = nullptr; + QStackedWidget *views = nullptr; + QPlainTextEdit *unifiedView = nullptr; + QPlainTextEdit *leftView = nullptr; + QPlainTextEdit *rightView = nullptr; + QPushButton *unified = nullptr; + QPushButton *split = nullptr; + QPushButton *compact = nullptr; + QPushButton *expanded = nullptr; + QTimer *repositoryTimer = nullptr; +}; + DiffViewer::DiffViewer(QWidget *parent) : QWidget(parent) { + provider = new GitDiffProvider(this); + fileWatcher = new QFileSystemWatcher(this); + refreshTimer = new QTimer(this); + refreshTimer->setSingleShot(true); + refreshTimer->setInterval(RepositoryRefreshDelayMs); + repositoryTimer = new QTimer(this); + repositoryTimer->setInterval(RepositoryPollingIntervalMs); + repositoryTimer->start(); + auto *root = new QVBoxLayout(this); - root->setContentsMargins(10, 10, 10, 10); + root->setContentsMargins(0, 0, 0, 0); root->setSpacing(8); - auto *header = new QHBoxLayout; - summary = label(QStringLiteral("No file changes"), "title"); + auto *filters = new QHBoxLayout; + filters->setContentsMargins(10, 10, 10, 0); + filters->setSpacing(8); + repositories = new ChevronComboBox; + repositories->setObjectName(QStringLiteral("codexDiffRepository")); + repositories->setProperty("codexChevron", true); + repositories->addItem(QStringLiteral("Repository"), QString{}); + repositories->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed); + filters->addWidget(repositories, 1); + hiddenRepositories = new QPushButton(QStringLiteral("Hidden")); + hiddenRepositories->setObjectName( + QStringLiteral("codexDiffHiddenRepositories")); + hiddenRepositories->setProperty("kind", "segment"); + hiddenRepositories->setProperty("comboPeer", true); + hiddenRepositories->setCheckable(true); + hiddenRepositories->setChecked( + QSettings().value(QStringLiteral("diff/includeHiddenRepositories"), false) + .toBool()); + hiddenRepositories->setToolTip( + QStringLiteral("Also include hidden repositories")); + filters->addWidget(hiddenRepositories); + scope = new ChevronComboBox; + scope->setObjectName(QStringLiteral("codexDiffScope")); + scope->setProperty("codexChevron", true); + scope->addItem(QStringLiteral("Unstaged"), + static_cast(GitDiffScope::Unstaged)); + scope->addItem(QStringLiteral("Staged"), + static_cast(GitDiffScope::Staged)); + scope->addItem(QStringLiteral("Since HEAD"), + static_cast(GitDiffScope::Uncommitted)); + const int savedScope = + QSettings().value(QStringLiteral("diff/scope"), 0).toInt(); + scope->setCurrentIndex(std::clamp(savedScope, 0, scope->count() - 1)); + scope->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed); + filters->addWidget(scope, 1); + root->addLayout(filters); + + files = new QListWidget; + files->setObjectName(QStringLiteral("codexDiffFiles")); + files->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + files->setMaximumHeight(170); + auto *fileList = new QVBoxLayout; + fileList->setContentsMargins(10, 0, 10, 0); + fileList->addWidget(files); + root->addLayout(fileList); + + auto *fileSummary = new QHBoxLayout; + fileSummary->setContentsMargins(10, 0, 10, 0); + fileSummary->setSpacing(8); + summary = label(QStringLiteral("No changes"), "meta"); authority = label({}, "meta"); - auto *headerText = new QVBoxLayout; - headerText->setSpacing(1); - headerText->addWidget(summary); - headerText->addWidget(authority); - header->addLayout(headerText, 1); + authority->setWordWrap(false); + authority->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + truncationSummary = label({}, "attentionSection"); + additionSummary = label({}, "diffAdditionMeta"); + deletionSummary = label({}, "diffDeletionMeta"); + fileSummary->addWidget(summary); + fileSummary->addWidget(authority, 1); + fileSummary->addWidget(truncationSummary); + fileSummary->addWidget(additionSummary); + fileSummary->addWidget(deletionSummary); + root->addLayout(fileSummary); + + auto *previewDivider = new QFrame; + previewDivider->setProperty("kind", "standardDivider"); + previewDivider->setFixedHeight(1); + root->addWidget(previewDivider); + + auto *previewHeader = new QHBoxLayout; + previewHeader->setContentsMargins(10, 0, 10, 0); + selectedFile = label(QStringLiteral("Select a changed file"), "title"); + previewHeader->addWidget(selectedFile, 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); + reviewButton = new QPushButton(QStringLiteral("Open review")); + reviewButton->setProperty("comboPeer", true); + previewHeader->addWidget(copyButton); + previewHeader->addWidget(reviewButton); + root->addLayout(previewHeader); - 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 = diffView(QStringLiteral("codexDiffText")); diff->setPlaceholderText(QStringLiteral("Select a changed file.")); - new DiffHighlighter(diff->document()); - root->addWidget(diff, 1); + auto *diffArea = new QVBoxLayout; + diffArea->setContentsMargins(10, 0, 10, 10); + diffArea->addWidget(diff); + root->addLayout(diffArea, 1); + connect(refreshTimer, &QTimer::timeout, this, [this] { + provider->request(workspace, repositoryCandidates(), changedPaths, + selectedRepository, hiddenRepositories->isChecked(), + scopeValue(scope), GitDiffContext::Compact); + }); + connect(provider, &GitDiffProvider::loadingChanged, this, [this](bool loading) { + if (loading && snapshot.files.empty() && snapshot.error.isEmpty()) { + summary->setText(QStringLiteral("Loading changes…")); + } + }); + connect(provider, &GitDiffProvider::snapshotReady, this, + [this](const GitDiffSnapshot &value) { applySnapshot(value); }); + connect(fileWatcher, &QFileSystemWatcher::fileChanged, this, + [this](const QString &) { refreshRepository(); }); + connect(fileWatcher, &QFileSystemWatcher::directoryChanged, this, + [this](const QString &) { refreshRepository(); }); + connect(repositoryTimer, &QTimer::timeout, this, [this] { + if (isVisible()) + provider->request(workspace, repositoryCandidates(), changedPaths, + selectedRepository, hiddenRepositories->isChecked(), + scopeValue(scope), + GitDiffContext::Compact); + }); + connect(repositories, &QComboBox::currentIndexChanged, this, + [this](int) { + selectedRepository = repositories->currentData().toString(); + if (!threadId.isEmpty()) + QSettings().setValue(settingsBase(threadId) + + QStringLiteral("/selected"), + selectedRepository); + refreshRepository(); + }); + connect(hiddenRepositories, &QPushButton::toggled, this, [this](bool value) { + QSettings().setValue(QStringLiteral("diff/includeHiddenRepositories"), + value); + refreshRepository(); + }); + connect(scope, &QComboBox::currentIndexChanged, this, [this](int index) { + QSettings().setValue(QStringLiteral("diff/scope"), index); + refreshRepository(); + }); connect(files, &QListWidget::currentRowChanged, this, [this] { showSelectedFile(); }); + connect(files, &QListWidget::itemDoubleClicked, this, + [this](QListWidgetItem *) { openReview(); }); connect(copyButton, &QPushButton::clicked, this, [this] { if (!diff->toPlainText().isEmpty()) QApplication::clipboard()->setText(diff->toPlainText()); }); - connect(expandButton, &QPushButton::clicked, this, - [this] { showExpanded(); }); + connect(reviewButton, &QPushButton::clicked, this, + [this] { openReview(); }); copyButton->setEnabled(false); - expandButton->setEnabled(false); + reviewButton->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(); +void DiffViewer::setRepositoryContext(QString nextThreadId, + QString nextWorkspace, + QStringList nextCommandDirectories, + QStringList nextChangedPaths) { + if (!nextWorkspace.isEmpty()) + nextWorkspace = QDir::cleanPath(std::move(nextWorkspace)); + nextCommandDirectories.removeDuplicates(); + nextChangedPaths.removeDuplicates(); + if (threadId == nextThreadId && workspace == nextWorkspace && + commandDirectories == nextCommandDirectories && + changedPaths == nextChangedPaths) + return; + const bool changedThread = threadId != nextThreadId; + threadId = std::move(nextThreadId); + workspace = std::move(nextWorkspace); + commandDirectories = std::move(nextCommandDirectories); + changedPaths = std::move(nextChangedPaths); + if (changedThread) { + const QString base = settingsBase(threadId); + persistedRepositoryRoots = + stringListSetting(base + QStringLiteral("/roots")); + selectedRepository = + QSettings().value(base + QStringLiteral("/selected")).toString(); } - const QByteArray fingerprint = - QCryptographicHash::hash(fingerprintInput, QCryptographicHash::Sha256); - if (fingerprint == contentFingerprint) + snapshot = {}; + updateFileWatches(); + snapshotFingerprint.clear(); + files->clear(); + diff->clear(); + refreshRepository(); +} + +const GitDiffSnapshot &DiffViewer::currentSnapshot() const noexcept { + return snapshot; +} + +void DiffViewer::refreshRepository() { + refreshTimer->start(); + if (reviewWindow) + reviewWindow->setSource(workspace, repositoryCandidates(), changedPaths, + selectedRepository, + hiddenRepositories->isChecked(), scopeValue(scope), + selectedPath()); +} + +QStringList DiffViewer::repositoryCandidates() const { + QStringList result = commandDirectories; + result.append(persistedRepositoryRoots); + result.removeDuplicates(); + return result; +} + +QString DiffViewer::selectedPath() const { + const int index = files->currentRow(); + return index >= 0 && static_cast(index) < snapshot.files.size() + ? snapshot.files[static_cast(index)].absolutePath + : QString{}; +} + +void DiffViewer::applySnapshot(const GitDiffSnapshot &value) { + const QByteArray nextFingerprint = fingerprint(value); + if (nextFingerprint == snapshotFingerprint) { + snapshot = value; + updateFileWatches(); 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)); + } + snapshotFingerprint = nextFingerprint; + const QString previous = selectedPath(); + const int previousScroll = diff->verticalScrollBar()->value(); + snapshot = value; + updateFileWatches(); + if (!threadId.isEmpty() && !value.repositoryRoots.isEmpty()) { + persistedRepositoryRoots = value.repositoryRoots; + QSettings settings; + settings.setValue(settingsBase(threadId) + QStringLiteral("/roots"), + persistedRepositoryRoots); + settings.sync(); + } + { + const QSignalBlocker blocked(repositories); + repositories->clear(); + if (value.repositoryRoots.size() > 1) + repositories->addItem(QStringLiteral("All repositories"), QString{}); + for (const QString &root : value.repositoryRoots) { + repositories->addItem(repositoryName(root), root); + repositories->setItemData(repositories->count() - 1, root, + Qt::ToolTipRole); } + int selectedIndex = repositories->findData(selectedRepository); + if (selectedIndex < 0) + selectedIndex = 0; + repositories->setCurrentIndex(selectedIndex); + selectedRepository = repositories->currentData().toString(); } - files->clear(); int additions = 0; int deletions = 0; - for (const FileDiff &file : fileDiffs) { + int selected = -1; + for (std::size_t index = 0; index < value.files.size(); ++index) { + const GitDiffFile &file = value.files[index]; 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); + auto *item = new QListWidgetItem( + QStringLiteral("%1 %2 +%3 −%4") + .arg(fileTitle(file, value.repositoryRoots.size() > 1), file.status) + .arg(file.additions) + .arg(file.deletions)); + item->setToolTip(file.absolutePath); files->addItem(item); + if (file.absolutePath == previous) + selected = static_cast(index); } - 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()); + if (!value.error.isEmpty()) { + summary->setText(QStringLiteral("Changes unavailable")); + authority->setText(value.error); + authority->setToolTip(value.error); + truncationSummary->clear(); + additionSummary->clear(); + deletionSummary->clear(); + } else { + summary->setText( + value.files.empty() + ? QStringLiteral("No changes") + : value.files.size() == 1 + ? QStringLiteral("1 changed file") + : QStringLiteral("%1 changed files").arg(value.files.size())); + authority->clear(); + authority->setToolTip(QString{}); + truncationSummary->setText(value.truncated + ? QStringLiteral("Display truncated") + : QString{}); + additionSummary->setText(value.files.empty() + ? QString{} + : QStringLiteral("+%1").arg(additions)); + deletionSummary->setText(value.files.empty() + ? QString{} + : QStringLiteral("−%1").arg(deletions)); + } + if (!value.files.empty()) { + files->setCurrentRow(selected >= 0 ? selected : 0); + if (selected >= 0) + diff->verticalScrollBar()->setValue(previousScroll); + } else { + selectedFile->setText(QStringLiteral("Select a changed file")); + diff->setPlainText(value.error); + } + copyButton->setEnabled(!value.files.empty()); + reviewButton->setEnabled(!value.files.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'); +void DiffViewer::updateFileWatches() { + QStringList desired; + for (const GitDiffFile &file : snapshot.files) { + const QFileInfo info(file.absolutePath); + if (info.exists()) + desired.push_back(info.absoluteFilePath()); + const QString parent = info.absolutePath(); + if (!parent.isEmpty() && QFileInfo(parent).isDir()) + desired.push_back(parent); } - 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)); + desired.removeDuplicates(); + const QStringList existing = fileWatcher->files() + fileWatcher->directories(); + QStringList removed; + for (const QString &path : existing) { + if (!desired.contains(path)) + removed.push_back(path); } - return result; + if (!removed.isEmpty()) + fileWatcher->removePaths(removed); + QStringList added; + for (const QString &path : desired) { + if (!existing.contains(path)) + added.push_back(path); + } + if (!added.isEmpty()) + fileWatcher->addPaths(added); } void DiffViewer::showSelectedFile() { const int index = files->currentRow(); - if (index < 0 || static_cast(index) >= fileDiffs.size()) { + if (index < 0 || static_cast(index) >= snapshot.files.size()) { + selectedFile->setText(QStringLiteral("Select a changed file")); diff->clear(); return; } - diff->setPlainText(fileDiffs[static_cast(index)].content); + const GitDiffFile &file = snapshot.files[static_cast(index)]; + selectedFile->setText( + fileTitle(file, snapshot.repositoryRoots.size() > 1)); + diff->setPlainText(file.patch.isEmpty() + ? QStringLiteral("No textual patch is available for this file.") + : file.patch); diff->moveCursor(QTextCursor::Start); } -void DiffViewer::showExpanded() { - const int index = files->currentRow(); - if (index < 0 || static_cast(index) >= fileDiffs.size()) +void DiffViewer::openReview() { + if (selectedPath().isEmpty()) 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(); + if (!reviewWindow) + reviewWindow = new GitDiffReviewWindow(window()); + reviewWindow->setSource(workspace, repositoryCandidates(), changedPaths, + selectedRepository, hiddenRepositories->isChecked(), + scopeValue(scope), selectedPath()); + reviewWindow->show(); + reviewWindow->raise(); + reviewWindow->activateWindow(); } } // namespace codexui::codex diff --git a/src/codex/DiffViewer.h b/src/codex/DiffViewer.h index 1d14686..0f3f20b 100644 --- a/src/codex/DiffViewer.h +++ b/src/codex/DiffViewer.h @@ -3,52 +3,68 @@ #ifndef CODEXUI_CODEX_DIFFVIEWER_H #define CODEXUI_CODEX_DIFFVIEWER_H -#include -#include +#include "codex/GitDiffProvider.h" -#include +#include +#include +#include +class QComboBox; +class QFileSystemWatcher; class QLabel; class QListWidget; class QPlainTextEdit; class QPushButton; +class QTimer; namespace codexui::codex { -struct DiffFilePresentation { - QString path; - QString kind; - QString diff; -}; +class GitDiffReviewWindow; class DiffViewer final : public QWidget { public: explicit DiffViewer(QWidget *parent = nullptr); - void setChanges(QString liveDiff, - std::vector retainedChanges); + void setRepositoryContext(QString threadId, QString workspace, + QStringList commandDirectories, + QStringList changedPaths); + void refreshRepository(); + [[nodiscard]] const GitDiffSnapshot ¤tSnapshot() const noexcept; private: - struct FileDiff { - QString path; - QString kind; - QString content; - int additions = 0; - int deletions = 0; - }; - - static std::vector parseUnifiedDiff(const QString &diff); + void applySnapshot(const GitDiffSnapshot &snapshot); void showSelectedFile(); - void showExpanded(); + void openReview(); + void updateFileWatches(); + [[nodiscard]] QString selectedPath() const; + [[nodiscard]] QStringList repositoryCandidates() const; + GitDiffProvider *provider = nullptr; + QFileSystemWatcher *fileWatcher = nullptr; + QTimer *refreshTimer = nullptr; + QTimer *repositoryTimer = nullptr; + QString workspace; + QString threadId; + QStringList commandDirectories; + QStringList changedPaths; + QStringList persistedRepositoryRoots; + QString selectedRepository; + GitDiffSnapshot snapshot; + QByteArray snapshotFingerprint; + QComboBox *scope = nullptr; + QComboBox *repositories = nullptr; + QPushButton *hiddenRepositories = nullptr; QLabel *summary = nullptr; QLabel *authority = nullptr; + QLabel *truncationSummary = nullptr; + QLabel *additionSummary = nullptr; + QLabel *deletionSummary = nullptr; + QLabel *selectedFile = nullptr; QListWidget *files = nullptr; QPlainTextEdit *diff = nullptr; QPushButton *copyButton = nullptr; - QPushButton *expandButton = nullptr; - std::vector fileDiffs; - QByteArray contentFingerprint; + QPushButton *reviewButton = nullptr; + QPointer reviewWindow; }; } // namespace codexui::codex diff --git a/src/codex/FileSelectionDialog.cpp b/src/codex/FileSelectionDialog.cpp index 77ca9cc..d2baf81 100644 --- a/src/codex/FileSelectionDialog.cpp +++ b/src/codex/FileSelectionDialog.cpp @@ -143,7 +143,7 @@ FileSelectionDialog::FileSelectionDialog( } errorLabel = dialogLabel({}, "meta"); - errorLabel->setStyleSheet(QStringLiteral("color:#b83a3a;")); + errorLabel->setStyleSheet(QStringLiteral("color:#982f3d;")); errorLabel->hide(); root->addWidget(errorLabel); diff --git a/src/codex/GitDiffProvider.cpp b/src/codex/GitDiffProvider.cpp new file mode 100644 index 0000000..dda32f1 --- /dev/null +++ b/src/codex/GitDiffProvider.cpp @@ -0,0 +1,422 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/GitDiffProvider.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace codexui::codex { +namespace { + +constexpr std::size_t MaximumDiffBytes = 16U * 1024U * 1024U; + +void ensureLibGit() { + static std::once_flag initialized; + std::call_once(initialized, [] { git_libgit2_init(); }); +} + +template +using GitPointer = std::unique_ptr; + +QString gitError(const QString &fallback) { + const git_error *error = git_error_last(); + return error && error->message ? QString::fromUtf8(error->message) : fallback; +} + +QString text(const char *value) { + return value ? QString::fromUtf8(value) : QString{}; +} + +QString statusName(git_delta_t status) { + switch (status) { + case GIT_DELTA_ADDED: + return QStringLiteral("Added"); + case GIT_DELTA_DELETED: + return QStringLiteral("Deleted"); + case GIT_DELTA_RENAMED: + return QStringLiteral("Renamed"); + case GIT_DELTA_COPIED: + return QStringLiteral("Copied"); + case GIT_DELTA_UNTRACKED: + return QStringLiteral("Untracked"); + case GIT_DELTA_TYPECHANGE: + return QStringLiteral("Type changed"); + case GIT_DELTA_UNREADABLE: + return QStringLiteral("Unreadable"); + case GIT_DELTA_CONFLICTED: + return QStringLiteral("Conflict"); + default: + return QStringLiteral("Modified"); + } +} + +QString discoverRoot(const QString &directory) { + if (directory.isEmpty()) + return {}; + QString start = directory; + if (QFileInfo(start).isFile()) + start = QFileInfo(start).absolutePath(); + git_buf discovered = GIT_BUF_INIT; + const QByteArray encoded = QFile::encodeName(start); + if (git_repository_discover(&discovered, encoded.constData(), 0, nullptr) < + 0) { + git_buf_dispose(&discovered); + return {}; + } + git_repository *raw = nullptr; + const int opened = git_repository_open(&raw, discovered.ptr); + git_buf_dispose(&discovered); + if (opened < 0) + return {}; + GitPointer repository(raw, + git_repository_free); + if (git_repository_is_bare(repository.get())) + return {}; + return QDir::cleanPath(text(git_repository_workdir(repository.get()))); +} + +GitPointer headTree(git_repository *repository, + QString &error) { + git_reference *rawReference = nullptr; + const int headResult = git_repository_head(&rawReference, repository); + GitPointer reference(rawReference, + git_reference_free); + if (headResult == GIT_EUNBORNBRANCH || headResult == GIT_ENOTFOUND) + return {nullptr, git_tree_free}; + if (headResult < 0) { + error = gitError(QStringLiteral("Unable to read repository HEAD.")); + return {nullptr, git_tree_free}; + } + git_object *rawObject = nullptr; + if (git_reference_peel(&rawObject, reference.get(), GIT_OBJECT_TREE) < 0) { + error = gitError(QStringLiteral("Unable to read the HEAD tree.")); + return {nullptr, git_tree_free}; + } + return {reinterpret_cast(rawObject), git_tree_free}; +} + +QString normalizedHint(QString path) { + path = QDir::fromNativeSeparators(std::move(path)); + if (path.startsWith(QStringLiteral("a/")) || + path.startsWith(QStringLiteral("b/"))) + path.remove(0, 2); + return QDir::cleanPath(path); +} + +bool containsHiddenDirectory(const QString &path) { + const QStringList parts = QDir::fromNativeSeparators(path).split( + QLatin1Char('/'), Qt::SkipEmptyParts); + return std::any_of(parts.begin(), parts.end(), [](const QString &part) { + return part.size() > 1 && part.startsWith(QLatin1Char('.')); + }); +} + +int repositoryPathScore(git_repository *repository, const QString &root, + QString path) { + path = normalizedHint(std::move(path)); + if (QDir::isAbsolutePath(path)) { + path = QDir(root).relativeFilePath(path); + if (path == QStringLiteral("..") || path.startsWith(QStringLiteral("../"))) + return 0; + } + const QByteArray encoded = QFile::encodeName(path); + unsigned int status = 0; + if (git_status_file(&status, repository, encoded.constData()) == 0) + return status == GIT_STATUS_CURRENT ? 1 : 2; + git_index *rawIndex = nullptr; + if (git_repository_index(&rawIndex, repository) == 0) { + GitPointer index(rawIndex, git_index_free); + if (git_index_get_bypath(index.get(), encoded.constData(), 0)) + return 1; + } + QString error; + GitPointer tree = headTree(repository, error); + if (!tree) + return 0; + git_tree_entry *entry = nullptr; + const bool found = + git_tree_entry_bypath(&entry, tree.get(), encoded.constData()) == 0; + git_tree_entry_free(entry); + return found ? 1 : 0; +} + +std::vector rootHintScores(const QString &root, + const QStringList &directories, + const QStringList &paths) { + std::vector scores(static_cast(paths.size()), 0); + git_repository *raw = nullptr; + const QByteArray encodedRoot = QFile::encodeName(root); + if (git_repository_open(&raw, encodedRoot.constData()) < 0) + return scores; + GitPointer repository(raw, + git_repository_free); + for (qsizetype index = 0; index < paths.size(); ++index) { + const QString &path = paths[index]; + int score = repositoryPathScore(repository.get(), root, path); + for (const QString &directory : directories) { + if (score == 2) + break; + const QString absolute = QDir(directory).absoluteFilePath(path); + score = std::max( + score, repositoryPathScore(repository.get(), root, absolute)); + } + scores[static_cast(index)] = score; + } + return scores; +} + +bool appendRepository(GitDiffSnapshot &snapshot, const QString &root, + GitDiffScope scope, GitDiffContext context, + const std::shared_ptr> &clock, + std::uint64_t generation, std::size_t &retainedBytes) { + git_repository *rawRepository = nullptr; + const QByteArray encodedRoot = QFile::encodeName(root); + if (git_repository_open(&rawRepository, encodedRoot.constData()) < 0) + return false; + GitPointer repository(rawRepository, + git_repository_free); + git_diff_options options = GIT_DIFF_OPTIONS_INIT; + options.flags = GIT_DIFF_INCLUDE_UNTRACKED | + GIT_DIFF_RECURSE_UNTRACKED_DIRS | + GIT_DIFF_SHOW_UNTRACKED_CONTENT | + GIT_DIFF_INCLUDE_TYPECHANGE | + GIT_DIFF_INCLUDE_TYPECHANGE_TREES | + GIT_DIFF_INCLUDE_UNREADABLE; + options.context_lines = + context == GitDiffContext::Compact + ? 3 + : std::numeric_limits::max(); + options.interhunk_lines = context == GitDiffContext::Compact ? 0 : 3; + + QString treeError; + GitPointer tree = + headTree(repository.get(), treeError); + if (!treeError.isEmpty()) { + snapshot.error = treeError; + return false; + } + git_diff *rawDiff = nullptr; + int result = 0; + if (scope == GitDiffScope::Unstaged) + result = git_diff_index_to_workdir(&rawDiff, repository.get(), nullptr, + &options); + else if (scope == GitDiffScope::Staged) + result = git_diff_tree_to_index(&rawDiff, repository.get(), tree.get(), + nullptr, &options); + else + result = git_diff_tree_to_workdir_with_index( + &rawDiff, repository.get(), tree.get(), &options); + if (result < 0) { + snapshot.error = gitError(QStringLiteral("Unable to calculate Git changes.")); + return false; + } + GitPointer diff(rawDiff, git_diff_free); + git_diff_find_options findOptions = GIT_DIFF_FIND_OPTIONS_INIT; + findOptions.flags = GIT_DIFF_FIND_RENAMES | GIT_DIFF_FIND_COPIES | + GIT_DIFF_FIND_FOR_UNTRACKED; + git_diff_find_similar(diff.get(), &findOptions); + + const std::size_t count = git_diff_num_deltas(diff.get()); + for (std::size_t index = 0; index < count; ++index) { + if (clock->load() != generation) + return false; + const git_diff_delta *delta = git_diff_get_delta(diff.get(), index); + if (!delta || delta->status == GIT_DELTA_UNMODIFIED || + delta->status == GIT_DELTA_IGNORED) + continue; + GitDiffFile file; + file.repositoryRoot = root; + file.path = text(delta->new_file.path); + if (file.path.isEmpty()) + file.path = text(delta->old_file.path); + file.absolutePath = QDir(root).absoluteFilePath(file.path); + file.previousPath = text(delta->old_file.path); + if (file.previousPath == file.path) + file.previousPath.clear(); + file.status = statusName(delta->status); + file.binary = (delta->flags & GIT_DIFF_FLAG_BINARY) != 0; + git_patch *rawPatch = nullptr; + const int patchResult = git_patch_from_diff(&rawPatch, diff.get(), index); + GitPointer patch(rawPatch, git_patch_free); + if (patchResult == 0 && patch) { + std::size_t additions = 0; + std::size_t deletions = 0; + git_patch_line_stats(nullptr, &additions, &deletions, patch.get()); + file.additions = static_cast(std::min( + additions, static_cast(std::numeric_limits::max()))); + file.deletions = static_cast(std::min( + deletions, static_cast(std::numeric_limits::max()))); + git_buf rendered = GIT_BUF_INIT; + if (git_patch_to_buf(&rendered, patch.get()) == 0) { + if (retainedBytes + rendered.size <= MaximumDiffBytes) { + file.patch = QString::fromUtf8( + rendered.ptr, static_cast(rendered.size)); + retainedBytes += rendered.size; + } else { + snapshot.truncated = true; + file.patch = QStringLiteral( + "Diff omitted because the review exceeds the 16 MiB display limit."); + } + } + git_buf_dispose(&rendered); + } + snapshot.files.push_back(std::move(file)); + } + return true; +} + +GitDiffSnapshot collect(QString workspace, QStringList directories, + QStringList paths, QString selectedRepository, + bool includeHiddenRepositories, GitDiffScope scope, + GitDiffContext context, + const std::shared_ptr> &clock, + std::uint64_t generation) { + GitDiffSnapshot snapshot; + snapshot.workspace = workspace.isEmpty() + ? QString{} + : QDir::cleanPath(std::move(workspace)); + snapshot.scope = scope; + snapshot.context = context; + if (!snapshot.workspace.isEmpty()) + directories.prepend(snapshot.workspace); + directories.removeDuplicates(); + + QStringList roots; + QHash rootDirectories; + for (const QString &directory : directories) { + if (clock->load() != generation) + return snapshot; + if (!includeHiddenRepositories && containsHiddenDirectory(directory)) + continue; + const QString root = discoverRoot(directory); + if (root.isEmpty() || + (!includeHiddenRepositories && containsHiddenDirectory(root))) + continue; + rootDirectories[root].push_back(directory); + if (!roots.contains(root)) + roots.push_back(root); + } + if (roots.isEmpty()) { + snapshot.error = snapshot.workspace.isEmpty() + ? QStringLiteral("Select a thread to inspect changes.") + : QStringLiteral("Change review requires a Git repository."); + return snapshot; + } + + QStringList matched; + QHash> hintScores; + for (const QString &root : roots) + hintScores.insert(root, + rootHintScores(root, rootDirectories[root], paths)); + for (qsizetype pathIndex = 0; pathIndex < paths.size(); ++pathIndex) { + QStringList pathMatches; + int bestScore = 0; + for (const QString &root : roots) { + const int score = + hintScores[root][static_cast(pathIndex)]; + if (score > bestScore) { + bestScore = score; + pathMatches.clear(); + } + if (score != 0 && score == bestScore) + pathMatches.push_back(root); + } + for (const QString &root : pathMatches) { + if (!matched.contains(root)) + matched.push_back(root); + } + } + if (!matched.isEmpty()) + roots = std::move(matched); + std::sort(roots.begin(), roots.end(), [](const QString &left, + const QString &right) { + return QString::localeAwareCompare(left, right) < 0; + }); + snapshot.repositoryRoots = roots; + snapshot.repository = true; + + if (!selectedRepository.isEmpty() && roots.contains(selectedRepository)) + roots = {selectedRepository}; + snapshot.repositoryRoot = roots.size() == 1 ? roots.front() : QString{}; + std::size_t retainedBytes = 0; + for (const QString &root : roots) { + if (clock->load() != generation) + return snapshot; + appendRepository(snapshot, root, scope, context, clock, generation, + retainedBytes); + } + std::sort(snapshot.files.begin(), snapshot.files.end(), + [](const GitDiffFile &left, const GitDiffFile &right) { + if (left.repositoryRoot != right.repositoryRoot) + return QString::localeAwareCompare(left.repositoryRoot, + right.repositoryRoot) < 0; + return QString::localeAwareCompare(left.path, right.path) < 0; + }); + return snapshot; +} + +} // namespace + +GitDiffProvider::GitDiffProvider(QObject *parent) + : QObject(parent), + generation(std::make_shared>(0)) { + ensureLibGit(); +} + +GitDiffProvider::~GitDiffProvider() { cancel(); } + +void GitDiffProvider::cancel() { + generation->fetch_add(1); + emit loadingChanged(false); +} + +void GitDiffProvider::request(QString workspace, + QStringList candidateDirectories, + QStringList changedPaths, + QString selectedRepository, + bool includeHiddenRepositories, + GitDiffScope scope, + GitDiffContext context) { + const std::uint64_t requested = generation->fetch_add(1) + 1; + const auto clock = generation; + const QPointer receiver(this); + emit loadingChanged(true); + QThreadPool::globalInstance()->start( + [receiver, clock, requested, workspace = std::move(workspace), + candidateDirectories = std::move(candidateDirectories), + changedPaths = std::move(changedPaths), + selectedRepository = std::move(selectedRepository), + includeHiddenRepositories, scope, + context]() mutable { + GitDiffSnapshot snapshot = collect( + std::move(workspace), std::move(candidateDirectories), + std::move(changedPaths), std::move(selectedRepository), + includeHiddenRepositories, scope, context, clock, requested); + if (clock->load() != requested) + return; + QMetaObject::invokeMethod( + QCoreApplication::instance(), + [receiver, clock, requested, snapshot = std::move(snapshot)]() { + if (!receiver || clock->load() != requested) + return; + emit receiver->loadingChanged(false); + emit receiver->snapshotReady(snapshot); + }, + Qt::QueuedConnection); + }); +} + +} // namespace codexui::codex diff --git a/src/codex/GitDiffProvider.h b/src/codex/GitDiffProvider.h new file mode 100644 index 0000000..2a19203 --- /dev/null +++ b/src/codex/GitDiffProvider.h @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_GITDIFFPROVIDER_H +#define CODEXUI_CODEX_GITDIFFPROVIDER_H + +#include +#include +#include + +#include +#include +#include +#include + +namespace codexui::codex { + +enum class GitDiffScope { Unstaged, Staged, Uncommitted }; +enum class GitDiffContext { Compact, Expanded }; + +struct GitDiffFile { + QString repositoryRoot; + QString path; + QString absolutePath; + QString previousPath; + QString status; + QString patch; + int additions = 0; + int deletions = 0; + bool binary = false; +}; + +struct GitDiffSnapshot { + QString workspace; + QString repositoryRoot; + QStringList repositoryRoots; + QString error; + GitDiffScope scope = GitDiffScope::Unstaged; + GitDiffContext context = GitDiffContext::Compact; + std::vector files; + bool repository = false; + bool truncated = false; +}; + +class GitDiffProvider final : public QObject { + Q_OBJECT + +public: + explicit GitDiffProvider(QObject *parent = nullptr); + ~GitDiffProvider() override; + + void request(QString workspace, QStringList candidateDirectories, + QStringList changedPaths, QString selectedRepository, + bool includeHiddenRepositories, GitDiffScope scope, + GitDiffContext context); + void cancel(); + +signals: + void loadingChanged(bool loading); + void snapshotReady(const codexui::codex::GitDiffSnapshot &snapshot); + +private: + std::shared_ptr> generation; +}; + +} // namespace codexui::codex + +#endif // CODEXUI_CODEX_GITDIFFPROVIDER_H diff --git a/src/codex/MainWindow.cpp b/src/codex/MainWindow.cpp index b98a37f..a580053 100644 --- a/src/codex/MainWindow.cpp +++ b/src/codex/MainWindow.cpp @@ -2,11 +2,7 @@ #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" @@ -16,11 +12,7 @@ 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); @@ -28,11 +20,7 @@ MainWindow::MainWindow(FrontendSession &session, QWidget *parent) 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/NewThreadDialog.cpp b/src/codex/NewThreadDialog.cpp index 0341826..4637f13 100644 --- a/src/codex/NewThreadDialog.cpp +++ b/src/codex/NewThreadDialog.cpp @@ -115,7 +115,7 @@ NewThreadDialog::NewThreadDialog(QString initialWorkspace, QWidget *parent) root->addWidget(scroll, 1); errorLabel = label({}, "meta"); - errorLabel->setStyleSheet(QStringLiteral("color:#b83a3a;")); + errorLabel->setStyleSheet(QStringLiteral("color:#982f3d;")); errorLabel->hide(); root->addWidget(errorLabel); diff --git a/src/codex/PresentationModel.cpp b/src/codex/PresentationModel.cpp index 8d103e4..9d47421 100644 --- a/src/codex/PresentationModel.cpp +++ b/src/codex/PresentationModel.cpp @@ -39,6 +39,15 @@ bool boolValue(const nlohmann::json &object, const char *key, : fallback; } +void updateTimestamp(const nlohmann::json &object, const char *key, + std::optional &target) { + if (!object.is_object()) + return; + const auto iterator = object.find(key); + if (iterator != object.end() && iterator->is_number_integer()) + target = iterator->get(); +} + std::string statusValue(const nlohmann::json &value) { if (value.is_string()) return value.get(); @@ -51,6 +60,30 @@ std::string requestKey(const nlohmann::json &value) { return value.is_null() ? std::string{} : value.dump(); } +void appendUnique(std::vector &values, const std::string &value, + std::size_t maximum) { + if (value.empty() || + std::find(values.begin(), values.end(), value) != values.end()) + return; + if (values.size() == maximum) + values.erase(values.begin()); + values.push_back(value); +} + +void retainRepositoryHints(ThreadPresentation &thread, + const nlohmann::json &item) { + const std::string type = stringValue(item, "type"); + if (type == "commandExecution") + appendUnique(thread.commandCwds, stringValue(item, "cwd"), 64); + if (type != "fileChange") + return; + const auto changes = item.find("changes"); + if (changes == item.end() || !changes->is_array()) + return; + for (const auto &change : *changes) + appendUnique(thread.changedPaths, stringValue(change, "path"), 512); +} + bool isSpawnActivity(const nlohmann::json &activity) { const std::string type = stringValue(activity, "type"); if (type == "subAgentActivity") @@ -436,9 +469,11 @@ void PresentationModel::applyValidatedEvent(const nlohmann::json &event) { return; } if (type == "conversation.file-change.patch-replaced") { - if (ItemPresentation *item = findItem(scope)) + if (ItemPresentation *item = findItem(scope)) { item->raw["changes"] = memberValue(data, "changes", nlohmann::json::array()); + retainRepositoryHints(thread, item->raw); + } return; } if (type == "conversation.mcp.progress") { @@ -585,6 +620,9 @@ ThreadPresentation &PresentationModel::upsertThread(const nlohmann::json &raw, const auto status = raw.find("status"); if (status != raw.end()) result.status = statusValue(*status); + updateTimestamp(raw, "createdAt", result.createdAt); + updateTimestamp(raw, "updatedAt", result.updatedAt); + updateTimestamp(raw, "recencyAt", result.recencyAt); result.archived = boolValue(raw, "archived", result.archived); const auto turns = raw.find("turns"); @@ -594,6 +632,8 @@ ThreadPresentation &PresentationModel::upsertThread(const nlohmann::json &raw, result.turns.clear(); result.agentOrder.clear(); result.agents.clear(); + result.commandCwds.clear(); + result.changedPaths.clear(); } for (const auto &turn : *turns) upsertTurn(result, turn, replaceTurns); @@ -655,6 +695,7 @@ ItemPresentation &PresentationModel::upsertItem(ThreadPresentation &thread, mergePreservingCompleteness(result.raw, raw); } const std::string type = stringValue(result.raw, "type"); + retainRepositoryHints(thread, result.raw); if (type == "subAgentActivity" || type == "collabAgentToolCall") { upsertAgentActivity( thread, diff --git a/src/codex/PresentationModel.h b/src/codex/PresentationModel.h index 0477add..bfa0bf9 100644 --- a/src/codex/PresentationModel.h +++ b/src/codex/PresentationModel.h @@ -44,6 +44,11 @@ struct ThreadPresentation { std::string preview; std::string cwd; std::string status; + std::optional createdAt; + std::optional updatedAt; + std::optional recencyAt; + std::vector commandCwds; + std::vector changedPaths; std::vector turnOrder; std::unordered_map turns; nlohmann::json raw = nlohmann::json::object(); diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index f3a7c7c..218550e 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -3,414 +3,57 @@ #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/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 "codex/ui/UiStyle.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 #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; -}; +constexpr auto DraftThreadId = "draft:new-thread"; QString text(const std::string &value) { return QString::fromUtf8(value.data(), static_cast(value.size())); @@ -419,10 +62,9 @@ QString text(const std::string &value) { 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{}; + const auto found = object.find(key); + return found != object.end() && found->is_string() ? found->get() + : std::string{}; } QString displayStatus(const std::string &status) { @@ -432,105 +74,7 @@ QString displayStatus(const std::string &status) { 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 {}; + return status.empty() ? QStringLiteral("Unknown") : text(status); } std::string safeMessage(const nlohmann::json &value) { @@ -555,216 +99,162 @@ bool isThreadNotFoundResult(const nlohmann::json &result) { 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; +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)); } -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; +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; +} - 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 *statusDot() { + auto *dot = new QFrame; + dot->setFixedSize(10, 10); + dot->setStyleSheet(QStringLiteral("background:#98a2b3;border-radius:5px;")); + return dot; } -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; +std::string recoveryKey(const std::string &threadId, + std::uint64_t submissionId) { + return threadId + ':' + std::to_string(submissionId); } } // namespace -ShellWidget::ShellWidget(FrontendSession &session, QWidget *parent) - : QWidget(parent), session(session) { - setObjectName(QStringLiteral("workbench")); - auto *root = new QVBoxLayout(this); +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 scheduleRender(); + 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; + std::uint64_t observedProviderGeneration = 0; + QByteArray settingsSnapshot; + QByteArray statusSnapshot; + bool renderScheduled = false; + + 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("applicationShell")); + auto *root = new QVBoxLayout(owner); root->setContentsMargins(0, 0, 0, 0); root->setSpacing(0); @@ -777,6 +267,7 @@ ShellWidget::ShellWidget(FrontendSession &session, QWidget *parent) 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); @@ -790,36 +281,45 @@ ShellWidget::ShellWidget(FrontendSession &session, QWidget *parent) 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(); - connect(requestButton, &QPushButton::clicked, this, [this] { - inspector->show(); - restoreInspectorButton->hide(); - inspectorTabs->setCurrentIndex(3); - }); - topLayout->addStretch(); - connectionStatusDot = makeStatusDot(); + 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 = new UiStyle::ChevronToolButton; + connectionButton->setObjectName(QStringLiteral("transportButton")); connectionButton->setText(QStringLiteral("Connection")); connectionButton->setProperty("kind", "subtle"); + connectionButton->setProperty("codexChevron", true); connectionButton->setPopupMode(QToolButton::InstantPopup); connectionButton->setFixedHeight(32); auto *connectionMenu = new QMenu(connectionButton); - connectionMenu->addAction(QStringLiteral("Configure..."), this, [this] { + 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, this); + ConnectionDialog dialog(model.connection().settings, owner); if (dialog.exec() != QDialog::Accepted) return; - this->session.configureConnection( - dialog.selection(), [this](const nlohmann::json &result) { - if (result.value("ok", false)) + 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())); @@ -829,31 +329,14 @@ ShellWidget::ShellWidget(FrontendSession &session, QWidget *parent) }); }); connectionMenu->addSeparator(); - connectAction = - connectionMenu->addAction(QStringLiteral("Connect"), this, - [this] { this->session.connectTransport(); }); + connectAction = connectionMenu->addAction( + QStringLiteral("Connect"), owner, [this] { session.connectTransport(); }); disconnectAction = - connectionMenu->addAction(QStringLiteral("Disconnect"), this, [this] { - this->session.disconnectTransport(); - }); + connectionMenu->addAction(QStringLiteral("Disconnect"), owner, + [this] { session.disconnectTransport(); }); reconnectAction = connectionMenu->addAction( - QStringLiteral("Reconnect"), this, [this] { this->session.reconnect(); }); + QStringLiteral("Reconnect"), owner, [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); @@ -863,466 +346,14 @@ ShellWidget::ShellWidget(FrontendSession &session, QWidget *parent) 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); + 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->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); @@ -1336,143 +367,125 @@ ShellWidget::ShellWidget(FrontendSession &session, QWidget *parent) 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(); }); +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)); - session.setEventHandler( - [this](const nlohmann::json &event) { handleEvent(event); }); - refresh(); -} + 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); + }); -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); + 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::scheduleComposerLayout() { - if (composerLayoutRefreshPending) - return; - composerLayoutRefreshPending = true; - QTimer::singleShot(0, this, [this] { - composerLayoutRefreshPending = false; - refreshComposerLayout(); - }); +void ShellWidget::Impl::showNotice(QString message, bool error) { + middleRegion->showNotice(std::move(message), error); } -void ShellWidget::refreshComposerLayout() { - if (!composerBody || !composerGrid || composerBody->width() <= 0) - return; +void ShellWidget::Impl::handleEvent(const nlohmann::json &event) { + middleRegion->inspector().appendProtocolFrame(event); - 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; + 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); - 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); + const ConnectionPresentation &connection = model.connection(); + if (connection.generation != observedConnectionGeneration) { + observedConnectionGeneration = connection.generation; + hydration.clear(); + readRevisions.clear(); + operationReadyThreads.clear(); + dispatchScheduledThreads.clear(); } - 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(); + if (connection.providerGeneration != observedProviderGeneration) { + observedProviderGeneration = connection.providerGeneration; + hydration.clear(); + readRevisions.clear(); operationReadyThreads.clear(); + dispatchScheduledThreads.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; - }); + const nlohmann::json scope = event.value("scope", nlohmann::json::object()); + const std::string eventThreadId = stringValue(scope, "threadId"); + if (kind == "event" && type == "connection.provider" && + stringValue(data, "state") == "disconnected") { + hydration.clear(); + readRevisions.clear(); + operationReadyThreads.clear(); + dispatchScheduledThreads.clear(); } - if (kind == "result" && !event.value("ok", false) && - !recoveringThreadNotFound) { - const nlohmann::json error = event.value("error", nlohmann::json::object()); - const std::string message = safeMessage(error); + + 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") { @@ -1484,1480 +497,235 @@ void ShellWidget::handleEvent(const nlohmann::json &event) { } 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; + 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 (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); + 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); } - } 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; + session.listPermissionProfiles( + {{"cwd", QDir::currentPath().toStdString()}}); } - 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 (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(); } - 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)); + } else if (!eventThreadId.empty()) { + if (const ThreadPresentation *thread = model.thread(eventThreadId)) { + prompts.reconcile(eventThreadId, *thread); + prompts.compactResolved(eventThreadId, + QDateTime::currentMSecsSinceEpoch()); + } + } else if (kind == "event" && type == "connection.provider" && + stringValue(data, "state") == "ready") { + session.listThreads(); + session.listModels(); + readThread(selectedThreadId, true); } - 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); + hydrateHistoricalAgents(); + scheduleRender(); } -void ShellWidget::scheduleAcknowledgementCompletion(const std::string &threadId, - PendingPrompt &submission) { - if (submission.completionRefreshScheduled || - submission.status != PendingPromptStatus::Acknowledged) +void ShellWidget::Impl::scheduleRender() { + if (renderScheduled) 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()) + renderScheduled = true; + const auto token = alive; + // A streamed response may deliver many deltas in one display interval. + // Reconcile once per frame instead of rebuilding rich text and layout for + // every transport chunk. + QTimer::singleShot(16, Qt::PreciseTimer, owner, [this, token] { + if (!*token) return; - pending->completionRefreshScheduled = false; - if (threadId == selectedThreadId) { - conversationRebuildPending = true; - scheduleRefresh(RefreshConversation); - } + renderScheduled = false; + render(); }); } -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::Impl::render() { + middleRegion->threads().refresh(model, selectedThreadId); + renderConversation(); + middleRegion->inspector().refresh(model, selectedThreadId); + refreshSettings(); + refreshStatus(); } -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]; +void ShellWidget::Impl::renderConversation() { 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; + 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) { - 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; + if (turn != thread->turns.end()) + authoritativeCount += turn->second.itemOrder.size(); } - claimed.insert(matchedId); - submission->materializedIdentity = matchedId; - promptAnchorKeys[matchedId] = - pendingPromptAnchorKey(threadId, submission->id); - if (now - submission->acknowledgedAtMilliseconds < - AcknowledgementTransitionMilliseconds) { - scheduleAcknowledgementCompletion(threadId, *submission); - ++submission; - continue; + } + 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); } - submission = prompts->second.erase(submission); + } else if (newThreadIntent) { + identity = DraftThreadId; + canonical["cwd"] = (newThreadWorkspace.isEmpty() ? QDir::currentPath() + : newThreadWorkspace) + .toStdString(); + } else { + canonical["cwd"] = QDir::currentPath().toStdString(); } - if (prompts->second.empty()) - pendingPrompts.erase(prompts); + 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::refreshStatus() { +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 dotToolTip; + QString dotTip; if (connection.connected) { - dotStyle = QStringLiteral("background:#23845a;border-radius:4px;"); - dotToolTip = QStringLiteral("Connected"); + dotStyle = QStringLiteral("background:#18865e;border-radius:5px;"); + dotTip = QStringLiteral("Connected"); } else if (connection.retrying) { - dotStyle = QStringLiteral("background:#d98e1c;border-radius:4px;"); - dotToolTip = QStringLiteral("Disconnected, retrying"); + dotStyle = QStringLiteral("background:#a85d0c;border-radius:5px;"); + dotTip = QStringLiteral("Disconnected, retrying"); } else { - dotStyle = QStringLiteral("background:#b83a3a;border-radius:4px;"); - dotToolTip = QStringLiteral("Disconnected"); + dotStyle = QStringLiteral("background:#c43d4d;border-radius:5px;"); + dotTip = QStringLiteral("Disconnected"); } connectionStatusDot->setStyleSheet(dotStyle); - connectionStatusDot->setToolTip(dotToolTip); + connectionStatusDot->setToolTip(dotTip); QString selectedTransport; const std::string selectedKey = stringValue(connection.settings, "selected"); const nlohmann::json available = @@ -2985,29 +753,21 @@ void ShellWidget::refreshStatus() { ? QStringLiteral("Release control") : QStringLiteral("Claim control")); controllerButton->setEnabled(connection.connected); - const std::size_t pending = model.pendingRequestCount(selectedThreadId); + + 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); - approveButton->parentWidget()->setVisible(pending != 0); + middleRegion->composer().setAttentionVisible(selectedPending != 0); - const ThreadPresentation *thread = model.thread(selectedThreadId); + QString workspace = QStringLiteral("No workspace"); if (thread) { - const QString workspace = text(thread->cwd); - workspaceBreadcrumb->setToolTip(workspace); - workspaceBreadcrumb->setText(workspaceBreadcrumb->fontMetrics().elidedText( - workspace, Qt::ElideMiddle, workspaceBreadcrumb->maximumWidth())); + workspace = text(thread->cwd); 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") @@ -3015,158 +775,167 @@ void ShellWidget::refreshStatus() { .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 + 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")); } - 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); + 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::refreshStateInspector() { - if (!stateView || !infoTabs || inspectorTabs->currentIndex() != 4 || - infoTabs->currentIndex() != 0) +void ShellWidget::Impl::hydrateHistoricalAgents() { + const ThreadPresentation *thread = model.thread(selectedThreadId); + if (!thread) 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) + "]"; + 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); } - stateView->setPlainText(text(rendered)); } -void ShellWidget::selectThread(std::string threadId) { - stopConversationScrollAnimation(); - conversationSmoothScrollFloor = 0; - conversationPausedAnchorValid = false; - ++conversationScrollSettlementRevision; - conversationSmoothFollowRequested = false; - if (threadId != selectedThreadId) - conversationItemLimit = 80; +void ShellWidget::Impl::selectThread(std::string threadId) { + if (threadId.empty()) + return; + if (threadId == selectedThreadId) { + ensureThreadHydrated(threadId); + return; + } selectedThreadId = std::move(threadId); - localNewThreadIntent = false; - newThreadDraftOptions = nlohmann::json::object(); - newThreadDraftName.clear(); - newThreadDraftWorkspace.clear(); - conversationRebuildPending = true; - resetComposer(); + newThreadIntent = false; + newThreadOptions = nlohmann::json::object(); + newThreadName.clear(); + newThreadWorkspace.clear(); + historyWindows.try_emplace(selectedThreadId); ensureThreadHydrated(selectedThreadId); - scheduleRefresh(); + render(); } -void ShellWidget::beginNewThread() { +void ShellWidget::Impl::beginNewThread() { if (newThreadCreationInFlight) { showNotice(QStringLiteral("The current new thread is still being created."), false); return; } - NewThreadDialog dialog( - text(turnSettings->workspace(QDir::currentPath().toStdString())), this); + 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(); - newThreadPendingPrompts.clear(); + prompts.clearThread(DraftThreadId); selectedThreadId.clear(); - localNewThreadIntent = true; - newThreadDraftName = draft.name; - newThreadDraftWorkspace = draft.workspace; - newThreadDraftOptions = nlohmann::json::object(); + newThreadIntent = true; + newThreadName = draft.name; + newThreadWorkspace = draft.workspace; + newThreadOptions = nlohmann::json::object(); if (!draft.baseInstructions.isEmpty()) - newThreadDraftOptions["baseInstructions"] = - draft.baseInstructions.toStdString(); + newThreadOptions["baseInstructions"] = draft.baseInstructions.toStdString(); if (!draft.developerInstructions.isEmpty()) - newThreadDraftOptions["developerInstructions"] = + newThreadOptions["developerInstructions"] = draft.developerInstructions.toStdString(); if (draft.ephemeral) - newThreadDraftOptions["ephemeral"] = true; - turnSettings->setWorkspace(draft.workspace); - conversationItemLimit = 80; - conversationRebuildPending = true; - threadList->clearSelection(); - resetComposer(); - promptEditor->setFocus(); - scheduleRefresh(); + newThreadOptions["ephemeral"] = true; + settingsSnapshot.clear(); + middleRegion->composer().clearDraft(); + middleRegion->composer().turnSettings()->setWorkspace(draft.workspace); + middleRegion->composer().promptEditor()->setFocus(); + render(); } -void ShellWidget::requestThreads() { session.listThreads(); } - -void ShellWidget::requestModels() { session.listModels(); } - -void ShellWidget::readThread(const std::string &threadId) { - if (threadId.empty()) +void ShellWidget::Impl::readThread(const std::string &threadId, bool forced) { + if (threadId.empty() || resumeInFlightThreads.contains(threadId)) return; - threadHydration[threadId] = ThreadHydrationState::ReadInFlight; - session.readThread(threadId); + 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::ensureThreadHydrated(const std::string &threadId) { - if (threadId.empty() || threadIsHydrated(threadId)) +void ShellWidget::Impl::ensureThreadHydrated(const std::string &threadId) { + if (threadId.empty() || threadIsHydrated(threadId) || + !model.connection().connected) return; - const auto state = threadHydration.find(threadId); - if (state != threadHydration.end() && - state->second == ThreadHydrationState::ReadInFlight) + const auto found = hydration.find(threadId); + if (found != hydration.end() && found->second == Hydration::InFlight) 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::Impl::threadIsHydrated(const std::string &threadId) const { + const auto found = hydration.find(threadId); + return found != hydration.end() && found->second == Hydration::Hydrated; } -bool ShellWidget::threadRequiresResume(const std::string &threadId) const { +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::renameThread(const std::string &threadId) { +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(this, QStringLiteral("Rename thread"), + QInputDialog::getText(owner, QStringLiteral("Rename thread"), QStringLiteral("Name"), QLineEdit::Normal, text(thread->title), &accepted) .trimmed(); @@ -3174,23 +943,24 @@ void ShellWidget::renameThread(const std::string &threadId) { session.renameThread(threadId, name.toStdString()); } -void ShellWidget::forkThread(const std::string &threadId) { +void ShellWidget::Impl::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); - }); + 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::toggleThreadArchive(const std::string &threadId) { +void ShellWidget::Impl::toggleThreadArchive(const std::string &threadId) { const ThreadPresentation *thread = model.thread(threadId); if (!thread) return; @@ -3200,373 +970,401 @@ void ShellWidget::toggleThreadArchive(const std::string &threadId) { session.archiveThread(threadId); } -void ShellWidget::deleteThread(const std::string &threadId) { +void ShellWidget::Impl::deleteThread(const std::string &threadId) { if (threadId.empty()) return; - if (QMessageBox::question(this, QStringLiteral("Delete thread"), + if (QMessageBox::question(owner, QStringLiteral("Delete thread"), QStringLiteral("Delete the selected thread?"), QMessageBox::Yes | QMessageBox::Cancel, - QMessageBox::Cancel) == QMessageBox::Yes) { + 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)) { +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; + return false; } - selectThread(visibleThreadId); + selectThread(visiblySelected); } - 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; + 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 (!localNewThreadIntent) { - showNotice(QStringLiteral("No destination thread is selected. Your " - "message was not sent; select a thread or use " - "New thread.")); - promptEditor->setFocus(); - return; + + 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; + } } - newThreadPendingPrompts.push_back(std::move(submission)); - resetComposer(); - conversationSmoothFollowRequested = true; - conversationRebuildPending = true; - scheduleRefresh(RefreshConversation); - startThreadForPendingPrompts(); + + 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::startThreadForPendingPrompts() { - if (newThreadCreationInFlight || newThreadPendingPrompts.empty()) +void ShellWidget::Impl::startThreadForDraft() { + if (newThreadCreationInFlight || prompts.submissions(DraftThreadId).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); }); - }); + 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::dispatchNextPrompt(const std::string &threadId) { - const auto prompts = pendingPrompts.find(threadId); - if (prompts == pendingPrompts.end()) +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 (std::any_of(prompts->second.begin(), prompts->second.end(), - [](const PendingPrompt &candidate) { - return candidate.status == PendingPromptStatus::Awaiting && - candidate.dispatched; - })) + if (prompts.hasInFlight(threadId)) + return; + if (threadRequiresResume(threadId)) { + resumePromptQueue(threadId); 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()) + } + if (!dispatchScheduledThreads.insert(threadId).second) return; - next->dispatched = true; - submitPromptToThread(threadId, next->id, next->prompt.toStdString(), - next->turnOptions, next->attachments); + + // 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::submitPromptToThread( - std::string threadId, std::uint64_t submissionId, std::string prompt, - nlohmann::json options, std::vector attachments) { +void ShellWidget::Impl::dispatchPrompt(middle::PromptDispatch dispatch) { nlohmann::json input = nlohmann::json::array({{{"type", "text"}, - {"text", std::move(prompt)}, + {"text", dispatch.prompt.toStdString()}, {"text_elements", nlohmann::json::array()}}}); - for (const AttachmentDraft &attachment : attachments) { - if (attachment.mimeType.startsWith(QStringLiteral("image/"))) { + 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/"))) { + else if (attachment.mimeType.startsWith(QStringLiteral("audio/"))) input.push_back( {{"type", "localAudio"}, {"path", attachment.path.toStdString()}}); - } else { + 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); - } + + 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 (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)(); - }); - }); + 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 { - (*sendPrompt)(); + 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::attemptPromptThreadRecovery(const std::string &threadId, +bool ShellWidget::Impl::attemptThreadRecovery(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()) + const std::string key = recoveryKey(threadId, submissionId); + if (!promptRecoveryAttempted.insert(key).second) 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) + if (!prompts.requeue(threadId, submissionId)) return false; - - submission->readinessRetryAttempted = true; - threadHydration[threadId] = ThreadHydrationState::NotHydrated; + hydration[threadId] = Hydration::NotHydrated; operationReadyThreads.erase(threadId); + render(); + resumeInFlightThreads.insert(threadId); + const auto token = alive; session.resumeThread( threadId, nlohmann::json::object(), - [this, threadId, submissionId](const nlohmann::json &resumeResult) { + [this, token, threadId](const nlohmann::json &resumeResult) { + if (!*token) + return; + resumeInFlightThreads.erase(threadId); if (!resumeResult.value("ok", false)) { - completePromptSubmission(threadId, submissionId, resumeResult); + 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; } - 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); - }); + hydration[threadId] = Hydration::Hydrated; + operationReadyThreads.insert(threadId); + QTimer::singleShot(0, owner, + [this, threadId] { 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) +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; - attachmentDrafts = dialog.selectedAttachments(); - ++attachmentRevision; - refreshAttachments(); + 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::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::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::interruptActiveTurn() { - const auto turnId = model.activeTurnId(selectedThreadId); - if (!turnId) - return; - session.interruptTurn(selectedThreadId, *turnId); +void ShellWidget::Impl::interruptTurn() { + const auto turn = model.activeTurnId(selectedThreadId); + if (turn) + session.interruptTurn(selectedThreadId, *turn); } -void ShellWidget::respondToFirstPending(bool approve) { +void ShellWidget::Impl::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; - }); + 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::reviewPending(const std::string &requestKey) { +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, this); + const auto response = PendingRequestDialog::present(request->second, owner); if (!response) return; session.respondToServerRequest(nlohmann::json::parse(requestKey), response->result, response->error); } -void ShellWidget::rejectPending(const std::string &requestKey) { +void ShellWidget::Impl::rejectPending(const std::string &requestKey) { const auto request = model.pendingRequestPresentations().find(requestKey); if (request == model.pendingRequestPresentations().end()) return; @@ -3577,4 +1375,20 @@ void ShellWidget::rejectPending(const std::string &requestKey) { 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/codex/ShellWidget.h b/src/codex/ShellWidget.h index f111677..be9cdf1 100644 --- a/src/codex/ShellWidget.h +++ b/src/codex/ShellWidget.h @@ -3,277 +3,33 @@ #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; -} +#include namespace codexui::codex { class FrontendSession; -class DiffViewer; -class ShellWidgetScrollTest; -class TurnSettingsWidget; +// Production shell backed by the 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: - 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; + struct Impl; + std::unique_ptr impl; }; } // namespace codexui::codex -#endif // CODEXUI_CODEX_SHELLWIDGET_H +#endif diff --git a/src/codex/TurnSettingsWidget.cpp b/src/codex/TurnSettingsWidget.cpp index 446c20c..f57bc05 100644 --- a/src/codex/TurnSettingsWidget.cpp +++ b/src/codex/TurnSettingsWidget.cpp @@ -3,6 +3,7 @@ #include "codex/TurnSettingsWidget.h" #include "codex/FileSelectionDialog.h" +#include "codex/ui/UiStyle.h" #include #include @@ -13,8 +14,6 @@ #include #include #include -#include -#include #include #include #include @@ -33,29 +32,6 @@ 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 { @@ -65,9 +41,9 @@ class CompactComboBox final : public QComboBox { 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)); + UiStyle::drawChevron( + this, indicator, option.state & QStyle::State_Enabled, + option.state & (QStyle::State_MouseOver | QStyle::State_HasFocus)); } }; @@ -87,9 +63,9 @@ class ChevronMenuButton final : public QPushButton { 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)); + UiStyle::drawChevron( + this, indicator, option.state & QStyle::State_Enabled, + option.state & (QStyle::State_MouseOver | QStyle::State_HasFocus)); } }; diff --git a/src/codex/WorkbenchWidget.cpp b/src/codex/WorkbenchWidget.cpp deleted file mode 100644 index ef02770..0000000 --- a/src/codex/WorkbenchWidget.cpp +++ /dev/null @@ -1,1001 +0,0 @@ -// 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 deleted file mode 100644 index d6c1e48..0000000 --- a/src/codex/WorkbenchWidget.h +++ /dev/null @@ -1,104 +0,0 @@ -// 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/greenfield/codex/middle/ComposerPane.cpp b/src/codex/middle/ComposerPane.cpp similarity index 97% rename from src/greenfield/codex/middle/ComposerPane.cpp rename to src/codex/middle/ComposerPane.cpp index 8afcda6..e4f8d2d 100644 --- a/src/greenfield/codex/middle/ComposerPane.cpp +++ b/src/codex/middle/ComposerPane.cpp @@ -73,7 +73,7 @@ ComposerPane::ComposerPane(QWidget *anchor) root->setSpacing(0); attention_ = new QFrame(this); - attention_->setProperty("kind", "amberBadge"); + attention_->setProperty("kind", "orangeBadge"); auto *attentionLayout = new QHBoxLayout(attention_); attentionLayout->setContentsMargins(10, 6, 10, 6); attentionLayout->addWidget(makeLabel( @@ -336,11 +336,7 @@ void ComposerPane::refreshAttachments() { 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;}")); + remove->setProperty("kind", "destructiveCompact"); connect(remove, &QPushButton::clicked, this, [this, index] { if (index >= attachments_.size()) return; diff --git a/src/greenfield/codex/middle/ComposerPane.h b/src/codex/middle/ComposerPane.h similarity index 94% rename from src/greenfield/codex/middle/ComposerPane.h rename to src/codex/middle/ComposerPane.h index 6f31952..d6ab838 100644 --- a/src/greenfield/codex/middle/ComposerPane.h +++ b/src/codex/middle/ComposerPane.h @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later OR MIT -#ifndef CODEXUI_GREENFIELD_CODEX_MIDDLE_COMPOSERPANE_H -#define CODEXUI_GREENFIELD_CODEX_MIDDLE_COMPOSERPANE_H +#ifndef CODEXUI_CODEX_MIDDLE_COMPOSERPANE_H +#define CODEXUI_CODEX_MIDDLE_COMPOSERPANE_H #include "codex/FileSelectionDialog.h" @@ -106,4 +106,4 @@ class ComposerPane final : public QWidget { } // namespace middle } // namespace codexui::codex -#endif // CODEXUI_GREENFIELD_CODEX_MIDDLE_COMPOSERPANE_H +#endif // CODEXUI_CODEX_MIDDLE_COMPOSERPANE_H diff --git a/src/greenfield/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp similarity index 86% rename from src/greenfield/codex/middle/ConversationCards.cpp rename to src/codex/middle/ConversationCards.cpp index 544dfc3..7b9cf40 100644 --- a/src/greenfield/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -26,6 +26,8 @@ namespace codexui::codex::middle { namespace { constexpr int MaximumCommandOutputHeight = 220; +constexpr int MaximumCommandTextHeight = 90; +constexpr int CommandTextPadding = 7; constexpr int PendingAnimationIntervalMilliseconds = 32; constexpr qint64 PendingHalfCycleMilliseconds = 850; @@ -175,20 +177,72 @@ bool presentationEquals(const VisibleCardData &left, } // namespace -CommandOutputView::CommandOutputView(const QString &output, QWidget *parent) - : QPlainTextEdit(parent) { +ContentSizedTextView::ContentSizedTextView(int maximumContentHeight, + QWidget *parent) + : QTextEdit(parent) { setReadOnly(true); + setAcceptRichText(false); setMinimumHeight(0); - setMaximumHeight(MaximumCommandOutputHeight); - setLineWrapMode(QPlainTextEdit::WidgetWidth); + setMaximumHeight(maximumContentHeight); + setLineWrapMode(QTextEdit::WidgetWidth); setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + document()->setDocumentMargin(CommandTextPadding); +} + +bool ContentSizedTextView::setContent(const QString &content) { + if (toPlainText() == content) + return false; + setPlainText(content); + measureAtCurrentWidth(true); + return true; +} + +QSize ContentSizedTextView::sizeHint() const { + QSize result = QTextEdit::sizeHint(); + result.setHeight(preferredHeight_); + return result; +} + +QSize ContentSizedTextView::minimumSizeHint() const { + QSize result = QTextEdit::minimumSizeHint(); + result.setHeight(0); + return result; +} + +void ContentSizedTextView::resizeEvent(QResizeEvent *event) { + QTextEdit::resizeEvent(event); + // Wrapping is authoritative only after QTextEdit has assigned its + // viewport width. Propagate a changed hint immediately so a multiline view + // cannot remain at an earlier one-line height with a premature scrollbar. + measureAtCurrentWidth(true); +} + +void ContentSizedTextView::measureAtCurrentWidth(bool notifyParent) { + const QString content = toPlainText(); + int wantedHeight = 0; + if (!content.isEmpty()) { + const int frame = 2 * frameWidth(); + document()->setTextWidth(std::max(1, viewport()->width())); + wantedHeight = + frame + static_cast(std::ceil(document()->size().height())); + } + wantedHeight = std::clamp(wantedHeight, 0, maximumHeight()); + if (wantedHeight == preferredHeight_) + return; + preferredHeight_ = wantedHeight; + if (notifyParent) + updateGeometry(); +} + +CommandOutputView::CommandOutputView(const QString &output, QWidget *parent) + : ContentSizedTextView(MaximumCommandOutputHeight, parent) { setProperty("kind", "code"); setObjectName(QStringLiteral("commandOutputView")); setStyleSheet(QStringLiteral( - "background:#111827;color:#e5e7eb;border-radius:6px;padding:7px;" - "font-family:monospace;")); + "QTextEdit#commandOutputView{background:#111827;color:#e5e7eb;" + "border-radius:6px;}")); connect(verticalScrollBar(), &QScrollBar::valueChanged, this, [this](int value) { @@ -214,22 +268,23 @@ bool CommandOutputView::followsLatest() const noexcept { } bool CommandOutputView::setOutput(const QString &output) { - if (currentOutput_ == output) + const QString displayOutput = trimTrailingEmptyLines(output); + if (currentOutput_ == displayOutput) return false; const bool retainedFollow = followsLatest_; const int retainedValue = preservedScrollValue_; - const bool appendOnly = !currentOutput_.isEmpty() && - output.startsWith(currentOutput_); + const bool appendOnly = + !currentOutput_.isEmpty() && displayOutput.startsWith(currentOutput_); programmaticScroll_ = true; if (appendOnly) { QTextCursor cursor = textCursor(); cursor.movePosition(QTextCursor::End); - cursor.insertText(output.sliced(currentOutput_.size())); + cursor.insertText(displayOutput.sliced(currentOutput_.size())); } else { - setPlainText(output); + setPlainText(displayOutput); } - currentOutput_ = output; + currentOutput_ = displayOutput; followsLatest_ = retainedFollow; preservedScrollValue_ = retainedValue; programmaticScroll_ = false; @@ -247,26 +302,6 @@ void CommandOutputView::restoreScrollState(const ScrollState &state) { 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() @@ -274,40 +309,34 @@ void CommandOutputView::wheelEvent(QWheelEvent *event) { if (bar->maximum() <= bar->minimum() || (delta > 0 && bar->value() <= bar->minimum()) || (delta < 0 && bar->value() >= bar->maximum())) { - event->ignore(); + event->accept(); return; } if (delta > 0) followsLatest_ = false; - QPlainTextEdit::wheelEvent(event); + QTextEdit::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 bool wasProgrammatic = programmaticScroll_; + programmaticScroll_ = true; + if (followsLatest_) { + QTextCursor cursor = textCursor(); + cursor.movePosition(QTextCursor::End); + setTextCursor(cursor); + ensureCursorVisible(); + } 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; @@ -356,7 +385,7 @@ class ConversationCard::Impl final { case CardKind::UserMessage: owner->setProperty("messageRole", "user"); title = makeLabel(QStringLiteral("You"), "title", owner); - body = makeLabel({}, "body", owner); + body = makeMarkdownLabel({}, owner); layout->addWidget(title); layout->addWidget(body); break; @@ -369,17 +398,12 @@ class ConversationCard::Impl final { 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 = new ContentSizedTextView(MaximumCommandTextHeight, owner); 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;")); + "QTextEdit#commandTextView{background:#f8fafc;" + "border:1px solid #d7dee8;border-radius:6px;}")); output = new CommandOutputView({}, owner); output->hide(); metadata = makeLabel({}, "meta", owner); @@ -448,24 +472,27 @@ class ConversationCard::Impl final { switch (data.kind) { case CardKind::UserMessage: { const auto &message = std::get(data.payload); - setVisibleText(body, message.text); + setVisibleMarkdown(body, message.text); break; } case CardKind::AgentMessage: { const auto &message = std::get(data.payload); title->setText(message.finalAnswer ? QStringLiteral("Codex") : QStringLiteral("Codex activity")); + layout->setContentsMargins(12, message.finalAnswer ? 10 : 8, 12, + message.finalAnswer ? 10 : 8); 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); + const QString displayCommand = trimTrailingEmptyLines(execution.command); + command->setContent(displayCommand); + command->setVisible(!displayCommand.isEmpty()); + const QString displayOutput = trimTrailingEmptyLines(execution.output); + const bool visibleOutput = terminalOutputHasVisibleText(displayOutput); if (visibleOutput) { - output->setOutput(execution.output); + output->setOutput(displayOutput); output->show(); } else { output->hide(); @@ -534,7 +561,7 @@ class ConversationCard::Impl final { const bool failed = prompt->state == PromptState::Failed; const QString foreground = waiting || transitioning ? QStringLiteral("#536b8f") - : failed ? QStringLiteral("#9b2c2c") + : failed ? QStringLiteral("#982f3d") : QStringLiteral("#1d2633"); const QString style = QStringLiteral("background:transparent;color:%1;").arg(foreground); @@ -585,7 +612,7 @@ class ConversationCard::Impl final { QLabel *body = nullptr; QLabel *metadata = nullptr; QLabel *detail = nullptr; - QPlainTextEdit *command = nullptr; + ContentSizedTextView *command = nullptr; CommandOutputView *output = nullptr; QTimer *animationTimer = nullptr; }; @@ -638,11 +665,11 @@ void ConversationCard::paintEvent(QPaintEvent *event) { const bool failed = prompt->state == PromptState::Failed; const QColor background = waiting || transitioning ? QColor(QStringLiteral("#dbe7f8")) - : failed ? QColor(QStringLiteral("#fff1f1")) + : failed ? QColor(QStringLiteral("#fff0f2")) : QColor(QStringLiteral("#eaf2ff")); const QColor border = waiting || transitioning ? QColor(QStringLiteral("#9eb9df")) - : failed ? QColor(QStringLiteral("#e5a3a3")) + : failed ? QColor(QStringLiteral("#efb8c0")) : QColor(QStringLiteral("#bfd3f9")); painter.setBrush(background); painter.setPen(QPen(border, 1.0)); diff --git a/src/greenfield/codex/middle/ConversationCards.h b/src/codex/middle/ConversationCards.h similarity index 83% rename from src/greenfield/codex/middle/ConversationCards.h rename to src/codex/middle/ConversationCards.h index 1cad853..a2e542e 100644 --- a/src/greenfield/codex/middle/ConversationCards.h +++ b/src/codex/middle/ConversationCards.h @@ -1,12 +1,12 @@ // SPDX-License-Identifier: LGPL-3.0-or-later OR MIT -#ifndef CODEXUI_GREENFIELD_CODEX_MIDDLE_CONVERSATIONCARDS_H -#define CODEXUI_GREENFIELD_CODEX_MIDDLE_CONVERSATIONCARDS_H +#ifndef CODEXUI_CODEX_MIDDLE_CONVERSATIONCARDS_H +#define CODEXUI_CODEX_MIDDLE_CONVERSATIONCARDS_H #include "codex/middle/MiddleTypes.h" #include -#include +#include #include #include @@ -20,7 +20,24 @@ class QWheelEvent; namespace codexui::codex::middle { -class CommandOutputView final : public QPlainTextEdit { +class ContentSizedTextView : public QTextEdit { +public: + explicit ContentSizedTextView(int maximumContentHeight, + QWidget *parent = nullptr); + + bool setContent(const QString &content); + QSize sizeHint() const override; + QSize minimumSizeHint() const override; + +protected: + void resizeEvent(QResizeEvent *event) override; + void measureAtCurrentWidth(bool notifyParent); + +private: + int preferredHeight_ = 0; +}; + +class CommandOutputView final : public ContentSizedTextView { public: struct ScrollState { bool followsLatest = true; @@ -39,15 +56,10 @@ class CommandOutputView final : public QPlainTextEdit { 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; @@ -55,7 +67,6 @@ class CommandOutputView final : public QPlainTextEdit { bool programmaticScroll_ = false; bool settlingScroll_ = false; int preservedScrollValue_ = 0; - int preferredHeight_ = 0; QString currentOutput_; }; @@ -90,4 +101,4 @@ createConversationCard(const VisibleCardData &data, QWidget *parent = nullptr); } // namespace codexui::codex::middle -#endif // CODEXUI_GREENFIELD_CODEX_MIDDLE_CONVERSATIONCARDS_H +#endif // CODEXUI_CODEX_MIDDLE_CONVERSATIONCARDS_H diff --git a/src/greenfield/codex/middle/ConversationProjection.cpp b/src/codex/middle/ConversationProjection.cpp similarity index 100% rename from src/greenfield/codex/middle/ConversationProjection.cpp rename to src/codex/middle/ConversationProjection.cpp diff --git a/src/greenfield/codex/middle/ConversationProjection.h b/src/codex/middle/ConversationProjection.h similarity index 86% rename from src/greenfield/codex/middle/ConversationProjection.h rename to src/codex/middle/ConversationProjection.h index 9cfe024..e42f663 100644 --- a/src/greenfield/codex/middle/ConversationProjection.h +++ b/src/codex/middle/ConversationProjection.h @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later OR MIT -#ifndef CODEXUI_GREENFIELD_CODEX_MIDDLE_CONVERSATIONPROJECTION_H -#define CODEXUI_GREENFIELD_CODEX_MIDDLE_CONVERSATIONPROJECTION_H +#ifndef CODEXUI_CODEX_MIDDLE_CONVERSATIONPROJECTION_H +#define CODEXUI_CODEX_MIDDLE_CONVERSATIONPROJECTION_H #include "codex/PresentationModel.h" #include "codex/middle/MiddleTypes.h" @@ -39,4 +39,4 @@ class ConversationProjection final { } // namespace codexui::codex::middle -#endif // CODEXUI_GREENFIELD_CODEX_MIDDLE_CONVERSATIONPROJECTION_H +#endif // CODEXUI_CODEX_MIDDLE_CONVERSATIONPROJECTION_H diff --git a/src/greenfield/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp similarity index 96% rename from src/greenfield/codex/middle/ConversationView.cpp rename to src/codex/middle/ConversationView.cpp index 824b5c1..06cc602 100644 --- a/src/greenfield/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -9,10 +9,11 @@ #include #include #include +#include #include #include -#include #include +#include #include #include #include @@ -216,6 +217,19 @@ bool ConversationView::reconcile(const ConversationSnapshot &snapshot) { } } const bool follow = mode_ == Mode::Following; + const auto visibleOutputFootprint = [this] { + int height = 0; + for (const auto &[key, card] : cards_) { + static_cast(key); + auto *output = + dynamic_cast(card->findChild( + QStringLiteral("commandOutputView"))); + if (output && !output->isHidden()) + height += output->height(); + } + return height; + }; + const int outputFootprintBefore = visibleOutputFootprint(); stopFollowingAnimation(); applying_ = true; @@ -355,10 +369,11 @@ bool ConversationView::reconcile(const ConversationSnapshot &snapshot) { snapshot_ = snapshot; recomputeGeometry(); + const bool outputGrew = visibleOutputFootprint() > outputFootprintBefore; for (const auto &[card, state] : commandOutputRestorations) card->restoreCommandOutputScrollState(state); if (follow) { - if (switchedThread) { + if (switchedThread || outputGrew) { setScrollValue(verticalScrollBar()->maximum()); } else { // Reflow above the viewport must preserve the same painted card/pixel @@ -374,7 +389,7 @@ bool ConversationView::reconcile(const ConversationSnapshot &snapshot) { viewport()->setUpdatesEnabled(true); viewport()->update(); - if (follow && !switchedThread) { + if (follow && !switchedThread && !outputGrew) { const int stableValue = verticalScrollBar()->value(); if (verticalScrollBar()->maximum() > stableValue + 3) animateToBottom(stableValue); @@ -640,8 +655,7 @@ bool ConversationView::applyWheel(QWheelEvent *event) { // 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()); + 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()); diff --git a/src/greenfield/codex/middle/ConversationView.h b/src/codex/middle/ConversationView.h similarity index 95% rename from src/greenfield/codex/middle/ConversationView.h rename to src/codex/middle/ConversationView.h index 8c01ad5..b72daa5 100644 --- a/src/greenfield/codex/middle/ConversationView.h +++ b/src/codex/middle/ConversationView.h @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later OR MIT -#ifndef CODEXUI_GREENFIELD_CODEX_MIDDLE_CONVERSATIONVIEW_H -#define CODEXUI_GREENFIELD_CODEX_MIDDLE_CONVERSATIONVIEW_H +#ifndef CODEXUI_CODEX_MIDDLE_CONVERSATIONVIEW_H +#define CODEXUI_CODEX_MIDDLE_CONVERSATIONVIEW_H #include "codex/middle/ConversationCards.h" @@ -126,4 +126,4 @@ class ConversationView final : public QAbstractScrollArea { } // namespace codexui::codex::middle -#endif // CODEXUI_GREENFIELD_CODEX_MIDDLE_CONVERSATIONVIEW_H +#endif // CODEXUI_CODEX_MIDDLE_CONVERSATIONVIEW_H diff --git a/src/greenfield/codex/middle/InspectorPane.cpp b/src/codex/middle/InspectorPane.cpp similarity index 81% rename from src/greenfield/codex/middle/InspectorPane.cpp rename to src/codex/middle/InspectorPane.cpp index d22ece8..27da816 100644 --- a/src/greenfield/codex/middle/InspectorPane.cpp +++ b/src/codex/middle/InspectorPane.cpp @@ -4,6 +4,7 @@ #include "codex/DiffViewer.h" #include "codex/PresentationModel.h" +#include "codex/ui/UiStyle.h" #include #include @@ -12,6 +13,8 @@ #include #include #include +#include +#include #include #include #include @@ -26,11 +29,22 @@ namespace codexui::codex::middle { namespace { constexpr int MaximumProtocolLines = 2000; +constexpr int InfoChoicePage = 0; +constexpr int StatePage = 1; +constexpr int ProtocolPage = 2; QString text(const std::string &value) { return QString::fromUtf8(value.data(), static_cast(value.size())); } +QStringList texts(const std::vector &values) { + QStringList result; + result.reserve(static_cast(values.size())); + for (const std::string &value : values) + result.push_back(text(value)); + return result; +} + QString joinedStrings(const nlohmann::json &value) { if (!value.is_array()) return {}; @@ -104,6 +118,23 @@ QByteArray bytes(const nlohmann::json &value) { static_cast(serialized.size())); } +class InfoChoiceButton final : public 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 QRect indicator(contents.right() - 18, contents.top(), 18, + contents.height()); + UiStyle::drawChevron( + this, indicator, option.state & QStyle::State_Enabled, + option.state & (QStyle::State_MouseOver | QStyle::State_HasFocus), + UiStyle::ChevronDirection::Right); + } +}; + QFrame *agentFrame(const AgentPresentation &agent) { auto *frame = new QFrame; frame->setProperty("kind", "raised"); @@ -145,6 +176,47 @@ QFrame *agentFrame(const AgentPresentation &agent) { return frame; } +QPushButton *infoChoice(const QString &title, const QString &description) { + auto *button = new InfoChoiceButton; + button->setProperty("kind", "infoChoice"); + button->setMinimumHeight(64); + button->setCursor(Qt::PointingHandCursor); + + auto *layout = new QHBoxLayout(button); + layout->setContentsMargins(12, 9, 30, 9); + layout->setSpacing(8); + auto *copy = new QVBoxLayout; + copy->setSpacing(2); + auto *titleLabel = makeLabel(title, "title"); + auto *descriptionLabel = makeLabel(description, "meta"); + titleLabel->setAttribute(Qt::WA_TransparentForMouseEvents); + descriptionLabel->setAttribute(Qt::WA_TransparentForMouseEvents); + titleLabel->setTextInteractionFlags(Qt::NoTextInteraction); + descriptionLabel->setTextInteractionFlags(Qt::NoTextInteraction); + copy->addWidget(titleLabel); + copy->addWidget(descriptionLabel); + layout->addLayout(copy, 1); + return button; +} + +QWidget *infoDetail(const QString &title, QWidget *content, + QPushButton **backButton) { + auto *page = new QWidget; + auto *layout = new QVBoxLayout(page); + layout->setContentsMargins(8, 8, 8, 8); + layout->setSpacing(8); + auto *heading = new QHBoxLayout; + *backButton = new QPushButton(QStringLiteral("‹ Info")); + (*backButton)->setProperty("kind", "subtle"); + (*backButton)->setFixedHeight(28); + heading->addWidget(*backButton); + heading->addStretch(); + heading->addWidget(makeLabel(title, "title")); + layout->addLayout(heading); + layout->addWidget(content, 1); + return page; +} + struct ScrollPosition { bool followsTail = true; int value = 0; @@ -173,7 +245,11 @@ InspectorPane::InspectorPane(QWidget *parent) : QFrame(parent) { outer->setContentsMargins(18, 14, 20, 0); outer->setSpacing(0); auto *heading = new QHBoxLayout; - heading->addWidget(makeLabel(QStringLiteral("INSPECTOR"), "section")); + heading->addStrut(24); + auto *sectionTitle = makeLabel(QStringLiteral("INSPECTOR"), "panelHeader"); + sectionTitle->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); + sectionTitle->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Preferred); + heading->addWidget(sectionTitle); heading->addStretch(); auto *hide = new QPushButton(QStringLiteral("Hide")); hide->setProperty("kind", "subtle"); @@ -184,7 +260,11 @@ InspectorPane::InspectorPane(QWidget *parent) : QFrame(parent) { }); heading->addWidget(hide); outer->addLayout(heading); - outer->addSpacing(7); + auto *headerDivider = new QFrame; + headerDivider->setProperty("kind", "standardDivider"); + headerDivider->setFixedHeight(1); + outer->addWidget(headerDivider); + outer->addSpacing(8); inspectorTabs = new QTabWidget; inspectorTabs->setDocumentMode(true); @@ -204,15 +284,14 @@ InspectorPane::InspectorPane(QWidget *parent) : QFrame(parent) { const auto makeScroll = [](QWidget *content) { auto *scroll = new QScrollArea; + scroll->setProperty("kind", "inspectorScroll"); + scroll->setFrameShape(QFrame::NoFrame); 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"); @@ -221,11 +300,9 @@ InspectorPane::InspectorPane(QWidget *parent) : QFrame(parent) { 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->setContentsMargins(0, 0, 0, 0); protocolLayout->setSpacing(6); protocolLog = new QPlainTextEdit; protocolLog->setObjectName(QStringLiteral("protocolInfoLog")); @@ -250,27 +327,54 @@ InspectorPane::InspectorPane(QWidget *parent) : QFrame(parent) { 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")); + infoStack = new QStackedWidget; + infoStack->setObjectName(QStringLiteral("infoStack")); + auto *choices = new QWidget; + auto *choicesLayout = new QVBoxLayout(choices); + choicesLayout->setContentsMargins(8, 8, 8, 8); + choicesLayout->setSpacing(8); + auto *stateChoice = infoChoice( + QStringLiteral("State"), QStringLiteral("Current application state")); + stateChoice->setObjectName(QStringLiteral("stateInfoChoice")); + auto *protocolChoice = infoChoice( + QStringLiteral("Protocol"), QStringLiteral("App-server protocol messages")); + protocolChoice->setObjectName(QStringLiteral("protocolInfoChoice")); + choicesLayout->addWidget(stateChoice); + choicesLayout->addWidget(protocolChoice); + choicesLayout->addStretch(); + + QPushButton *stateBack = nullptr; + QPushButton *protocolBack = nullptr; + infoStack->addWidget(choices); + infoStack->addWidget(infoDetail(QStringLiteral("State"), stateView, + &stateBack)); + infoStack->addWidget(infoDetail(QStringLiteral("Protocol"), protocolContent, + &protocolBack)); + connect(stateChoice, &QPushButton::clicked, this, [this] { + infoStack->setCurrentIndex(StatePage); + refreshCurrentTab(); + }); + connect(protocolChoice, &QPushButton::clicked, this, [this] { + infoStack->setCurrentIndex(ProtocolPage); + showProtocolTail(); + refreshCurrentTab(); + }); + const auto showInfoChoices = [this] { + infoStack->setCurrentIndex(InfoChoicePage); + }; + connect(stateBack, &QPushButton::clicked, this, showInfoChoices); + connect(protocolBack, &QPushButton::clicked, this, showInfoChoices); 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")); + inspectorTabs->addTab(infoStack, 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) { @@ -307,9 +411,9 @@ void InspectorPane::refreshCurrentTab() { refreshRequests(); break; case 4: - if (infoTabs->currentIndex() == 0) + if (infoStack->currentIndex() == StatePage) refreshState(); - else { + else if (infoStack->currentIndex() == ProtocolPage) { showProtocolTail(); refreshProtocolStats(); } @@ -374,9 +478,10 @@ void InspectorPane::refreshPlan() { for (const auto &step : planTurn->plan.value("steps", nlohmann::json::array())) { auto *row = new QFrame; - row->setProperty("kind", "summary"); + row->setProperty("kind", "raised"); auto *layout = new QVBoxLayout(row); - layout->setContentsMargins(9, 7, 9, 7); + layout->setContentsMargins(12, 10, 12, 10); + layout->setSpacing(6); layout->addWidget(makeLabel(text(stringValue(step, "step")))); layout->addWidget( makeLabel(displayStatus(stringValue(step, "status")), "meta")); @@ -446,57 +551,11 @@ void InspectorPane::refreshAgents() { 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)); + diffViewer->setRepositoryContext( + text(currentThreadId), thread ? text(thread->cwd) : QString{}, + thread ? texts(thread->commandCwds) : QStringList{}, + thread ? texts(thread->changedPaths) : QStringList{}); + diffViewer->refreshRepository(); } void InspectorPane::refreshRequests() { @@ -524,10 +583,10 @@ void InspectorPane::refreshRequests() { for (const auto &[id, request] : currentModel->pendingRequestPresentations()) { auto *frame = new QFrame; - frame->setProperty("kind", "summary"); + frame->setProperty("kind", "raised"); auto *layout = new QVBoxLayout(frame); - layout->setContentsMargins(9, 7, 9, 7); - layout->setSpacing(5); + layout->setContentsMargins(12, 10, 12, 10); + layout->setSpacing(6); layout->addWidget(makeLabel(text(request.kind), "title")); QString threadContext = text(request.threadId); if (const ThreadPresentation *thread = diff --git a/src/greenfield/codex/middle/InspectorPane.h b/src/codex/middle/InspectorPane.h similarity index 93% rename from src/greenfield/codex/middle/InspectorPane.h rename to src/codex/middle/InspectorPane.h index b50dc7a..34757ea 100644 --- a/src/greenfield/codex/middle/InspectorPane.h +++ b/src/codex/middle/InspectorPane.h @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later OR MIT -#ifndef CODEXUI_GREENFIELD_CODEX_MIDDLE_INSPECTORPANE_H -#define CODEXUI_GREENFIELD_CODEX_MIDDLE_INSPECTORPANE_H +#ifndef CODEXUI_CODEX_MIDDLE_INSPECTORPANE_H +#define CODEXUI_CODEX_MIDDLE_INSPECTORPANE_H #include #include @@ -16,6 +16,7 @@ class QLabel; class QPlainTextEdit; +class QStackedWidget; class QTabWidget; class QVBoxLayout; @@ -61,7 +62,7 @@ class InspectorPane final : public QFrame { std::function hideAction; QTabWidget *inspectorTabs = nullptr; - QTabWidget *infoTabs = nullptr; + QStackedWidget *infoStack = nullptr; QWidget *planContent = nullptr; QVBoxLayout *planLayout = nullptr; QWidget *agentsContent = nullptr; @@ -75,7 +76,6 @@ class InspectorPane final : public QFrame { QByteArray planSnapshot; QByteArray agentsSnapshot; - QByteArray changesSnapshot; QByteArray requestsSnapshot; QByteArray stateSnapshot; QByteArray protocolStatsSnapshot; diff --git a/src/greenfield/codex/middle/MiddleRegionWidget.cpp b/src/codex/middle/MiddleRegionWidget.cpp similarity index 89% rename from src/greenfield/codex/middle/MiddleRegionWidget.cpp rename to src/codex/middle/MiddleRegionWidget.cpp index 9292d10..814dd8b 100644 --- a/src/greenfield/codex/middle/MiddleRegionWidget.cpp +++ b/src/codex/middle/MiddleRegionWidget.cpp @@ -3,6 +3,7 @@ #include "codex/middle/MiddleRegionWidget.h" #include "codex/middle/ComposerPane.h" +#include "codex/middle/ConversationCards.h" #include "codex/middle/ConversationView.h" #include "codex/middle/InspectorPane.h" #include "codex/middle/ThreadPane.h" @@ -83,15 +84,16 @@ MiddleRegionWidget::MiddleRegionWidget(QWidget *parent) : QWidget(parent) { 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->addStrut(24); + auto *sectionTitle = + makeLabel(QStringLiteral("CONVERSATION"), "panelHeader"); + sectionTitle->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); + sectionTitle->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Preferred); + context->addWidget(sectionTitle); context->addStretch(); center->addLayout(context); - center->addSpacing(2); + center->addWidget(divider()); + center->addSpacing(8); conversationTitle = makeLabel(QStringLiteral("No synchronized thread"), "heading"); conversationMetadata = makeLabel({}, "meta"); @@ -104,11 +106,11 @@ MiddleRegionWidget::MiddleRegionWidget(QWidget *parent) : QWidget(parent) { noticeBar = new QFrame; noticeBar->setStyleSheet(QStringLiteral( - "background:#fff4f2;border:1px solid #efc2bc;border-radius:6px;")); + "background:#fff0f2;border:1px solid #efb8c0;border-radius:6px;")); auto *noticeLayout = new QHBoxLayout(noticeBar); noticeLayout->setContentsMargins(10, 6, 8, 6); noticeLabel = makeLabel({}, "meta"); - noticeLabel->setStyleSheet(QStringLiteral("color:#9d2e2e;")); + noticeLabel->setStyleSheet(QStringLiteral("color:#982f3d;")); auto *dismiss = new QPushButton(QStringLiteral("Dismiss")); dismiss->setProperty("kind", "subtle"); dismiss->setFixedHeight(28); @@ -167,12 +169,12 @@ void MiddleRegionWidget::showNotice(QString message, bool error) { return; noticeLabel->setText(std::move(message)); noticeBar->setStyleSheet( - error ? QStringLiteral("background:#fff4f2;border:1px solid #efc2bc;" + error ? QStringLiteral("background:#fff0f2;border:1px solid #efb8c0;" "border-radius:6px;") - : QStringLiteral("background:#fff8e8;border:1px solid #e5c77d;" + : QStringLiteral("background:#fff6df;border:1px solid #e5c77d;" "border-radius:6px;")); - noticeLabel->setStyleSheet(error ? QStringLiteral("color:#9d2e2e;") - : QStringLiteral("color:#8a5a00;")); + noticeLabel->setStyleSheet(error ? QStringLiteral("color:#982f3d;") + : QStringLiteral("color:#8a5208;")); noticeBar->show(); } @@ -232,6 +234,8 @@ bool MiddleRegionWidget::routeScrollEvent(QObject *watched, QEvent *event) { ancestor = ancestor->parentWidget()) { if (auto *nested = qobject_cast(ancestor); nested && nested != conversationView) { + if (dynamic_cast(nested)) + return false; if (canConsume(nested, verticalIntent(wheel))) return false; break; @@ -244,6 +248,8 @@ bool MiddleRegionWidget::routeScrollEvent(QObject *watched, QEvent *event) { ancestor && ancestor != conversationRegion; ancestor = ancestor->parentWidget()) { if (auto *nested = qobject_cast(ancestor)) { + if (dynamic_cast(nested)) + return false; if (canConsume(nested, verticalIntent(wheel))) return false; break; diff --git a/src/greenfield/codex/middle/MiddleRegionWidget.h b/src/codex/middle/MiddleRegionWidget.h similarity index 94% rename from src/greenfield/codex/middle/MiddleRegionWidget.h rename to src/codex/middle/MiddleRegionWidget.h index 391ea7f..1648573 100644 --- a/src/greenfield/codex/middle/MiddleRegionWidget.h +++ b/src/codex/middle/MiddleRegionWidget.h @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later OR MIT -#ifndef CODEXUI_GREENFIELD_CODEX_MIDDLE_MIDDLEREGIONWIDGET_H -#define CODEXUI_GREENFIELD_CODEX_MIDDLE_MIDDLEREGIONWIDGET_H +#ifndef CODEXUI_CODEX_MIDDLE_MIDDLEREGIONWIDGET_H +#define CODEXUI_CODEX_MIDDLE_MIDDLEREGIONWIDGET_H #include diff --git a/src/greenfield/codex/middle/MiddleTypes.cpp b/src/codex/middle/MiddleTypes.cpp similarity index 82% rename from src/greenfield/codex/middle/MiddleTypes.cpp rename to src/codex/middle/MiddleTypes.cpp index 614e719..9dc9556 100644 --- a/src/greenfield/codex/middle/MiddleTypes.cpp +++ b/src/codex/middle/MiddleTypes.cpp @@ -92,6 +92,33 @@ bool terminalOutputHasVisibleText(QStringView output) { return false; } +QString trimTrailingEmptyLines(QStringView text) { + qsizetype end = text.size(); + while (end > 0) { + while (end > 0 && (text[end - 1] == QLatin1Char('\n') || + text[end - 1] == QLatin1Char('\r'))) + --end; + if (end == 0) + break; + + qsizetype lineStart = end; + while (lineStart > 0 && text[lineStart - 1] != QLatin1Char('\n') && + text[lineStart - 1] != QLatin1Char('\r')) + --lineStart; + bool emptyLine = true; + for (qsizetype index = lineStart; index < end; ++index) { + if (!text[index].isSpace()) { + emptyLine = false; + break; + } + } + if (!emptyLine) + break; + end = lineStart; + } + return text.first(end).toString(); +} + std::vector ConversationSnapshot::cardKeys() const { std::vector result; for (const TurnSection §ion : sections) diff --git a/src/greenfield/codex/middle/MiddleTypes.h b/src/codex/middle/MiddleTypes.h similarity index 96% rename from src/greenfield/codex/middle/MiddleTypes.h rename to src/codex/middle/MiddleTypes.h index 220dee0..5faf0f9 100644 --- a/src/greenfield/codex/middle/MiddleTypes.h +++ b/src/codex/middle/MiddleTypes.h @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later OR MIT -#ifndef CODEXUI_GREENFIELD_CODEX_MIDDLE_MIDDLETYPES_H -#define CODEXUI_GREENFIELD_CODEX_MIDDLE_MIDDLETYPES_H +#ifndef CODEXUI_CODEX_MIDDLE_MIDDLETYPES_H +#define CODEXUI_CODEX_MIDDLE_MIDDLETYPES_H #include @@ -43,6 +43,7 @@ using CardKey = std::variant; [[nodiscard]] std::string stableKey(const CardKey &key); [[nodiscard]] bool terminalOutputHasVisibleText(QStringView output); +[[nodiscard]] QString trimTrailingEmptyLines(QStringView text); enum class PromptState { Queued, InFlight, Accepted, Failed }; @@ -184,4 +185,4 @@ struct ConversationSnapshot { } // namespace codexui::codex::middle -#endif // CODEXUI_GREENFIELD_CODEX_MIDDLE_MIDDLETYPES_H +#endif // CODEXUI_CODEX_MIDDLE_MIDDLETYPES_H diff --git a/src/greenfield/codex/middle/PromptCoordinator.cpp b/src/codex/middle/PromptCoordinator.cpp similarity index 100% rename from src/greenfield/codex/middle/PromptCoordinator.cpp rename to src/codex/middle/PromptCoordinator.cpp diff --git a/src/greenfield/codex/middle/PromptCoordinator.h b/src/codex/middle/PromptCoordinator.h similarity index 96% rename from src/greenfield/codex/middle/PromptCoordinator.h rename to src/codex/middle/PromptCoordinator.h index 3c52802..59e6837 100644 --- a/src/greenfield/codex/middle/PromptCoordinator.h +++ b/src/codex/middle/PromptCoordinator.h @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later OR MIT -#ifndef CODEXUI_GREENFIELD_CODEX_MIDDLE_PROMPTCOORDINATOR_H -#define CODEXUI_GREENFIELD_CODEX_MIDDLE_PROMPTCOORDINATOR_H +#ifndef CODEXUI_CODEX_MIDDLE_PROMPTCOORDINATOR_H +#define CODEXUI_CODEX_MIDDLE_PROMPTCOORDINATOR_H #include "codex/FileSelectionDialog.h" #include "codex/PresentationModel.h" @@ -116,4 +116,4 @@ class PromptCoordinator final { } // namespace codexui::codex::middle -#endif // CODEXUI_GREENFIELD_CODEX_MIDDLE_PROMPTCOORDINATOR_H +#endif // CODEXUI_CODEX_MIDDLE_PROMPTCOORDINATOR_H diff --git a/src/greenfield/codex/middle/ThreadPane.cpp b/src/codex/middle/ThreadPane.cpp similarity index 52% rename from src/greenfield/codex/middle/ThreadPane.cpp rename to src/codex/middle/ThreadPane.cpp index 7d89773..b23beb3 100644 --- a/src/greenfield/codex/middle/ThreadPane.cpp +++ b/src/codex/middle/ThreadPane.cpp @@ -3,13 +3,20 @@ #include "codex/middle/ThreadPane.h" #include "codex/PresentationModel.h" +#include "codex/ui/UiStyle.h" #include +#include +#include #include +#include #include #include #include +#include #include +#include +#include #include #include @@ -19,6 +26,36 @@ namespace codexui::codex::middle { namespace { +constexpr int ContextMenuRole = Qt::UserRole + 1; + +class ThreadListWidget final : public QListWidget { +protected: + QItemSelectionModel::SelectionFlags + selectionCommand(const QModelIndex &index, + const QEvent *event = nullptr) const override { + if (event && (event->type() == QEvent::MouseButtonPress || + event->type() == QEvent::MouseButtonRelease)) { + const auto *mouse = static_cast(event); + if (mouse->button() == Qt::RightButton) + return QItemSelectionModel::NoUpdate; + } + return QListWidget::selectionCommand(index, event); + } +}; + +class ThreadItemDelegate final : public QStyledItemDelegate { +public: + using QStyledItemDelegate::QStyledItemDelegate; + + void paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const override { + QStyleOptionViewItem effective = option; + if (index.data(ContextMenuRole).toBool()) + effective.state |= QStyle::State_MouseOver; + QStyledItemDelegate::paint(painter, effective, index); + } +}; + QString text(const std::string &value) { return QString::fromUtf8(value.data(), static_cast(value.size())); } @@ -47,7 +84,7 @@ QLabel *makeLabel(QString value, const char *kind = "body") { QFrame *statusDot() { auto *dot = new QFrame; dot->setObjectName(QStringLiteral("threadStatusDot")); - dot->setFixedSize(8, 8); + dot->setFixedSize(10, 10); return dot; } @@ -63,15 +100,15 @@ void updateRow(QWidget *row, const ThreadPresentation &thread, titleText.prepend(QStringLiteral("! ")); title->setText(titleText); status->setText(displayStatus(thread.status)); - QString color = QStringLiteral("#98a2b3"); + QString color = QStringLiteral("#cacccf"); if (requestCount != 0) - color = QStringLiteral("#a76812"); + color = QStringLiteral("#a85d0c"); else if (thread.status == "active" || thread.status == "inProgress") color = QStringLiteral("#2f6feb"); else if (thread.status == "failed" || thread.status == "systemError") - color = QStringLiteral("#b83a3a"); + color = QStringLiteral("#c43d4d"); dot->setStyleSheet( - QStringLiteral("background:%1;border-radius:4px;").arg(color)); + QStringLiteral("background:%1;border-radius:5px;").arg(color)); } QWidget *createRow() { @@ -96,6 +133,15 @@ QWidget *createRow() { return row; } +std::optional timestampFor(const ThreadPresentation &thread, + ThreadPane::SortCriterion criterion) { + if (criterion == ThreadPane::SortCriterion::Created) + return thread.createdAt; + if (criterion == ThreadPane::SortCriterion::LastChanged) + return thread.updatedAt; + return thread.recencyAt; +} + } // namespace ThreadPane::ThreadPane(QWidget *parent) : QFrame(parent) { @@ -107,8 +153,12 @@ ThreadPane::ThreadPane(QWidget *parent) : QFrame(parent) { 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->setContentsMargins(8, 0, 6, 0); + header->addStrut(24); + auto *sectionTitle = makeLabel(QStringLiteral("THREADS"), "panelHeader"); + sectionTitle->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); + sectionTitle->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Preferred); + header->addWidget(sectionTitle); header->addStretch(); auto *hide = new QPushButton(QStringLiteral("Hide")); hide->setProperty("kind", "subtle"); @@ -119,6 +169,11 @@ ThreadPane::ThreadPane(QWidget *parent) : QFrame(parent) { }); header->addWidget(hide); layout->addLayout(header); + auto *headerDivider = new QFrame; + headerDivider->setProperty("kind", "standardDivider"); + headerDivider->setFixedHeight(1); + layout->addWidget(headerDivider); + layout->addSpacing(8); auto *create = new QPushButton(QStringLiteral("+ New thread")); create->setFixedHeight(36); @@ -146,21 +201,52 @@ ThreadPane::ThreadPane(QWidget *parent) : QFrame(parent) { }); toolbar->addWidget(refresh); toolbar->addStretch(); + sortButton = new UiStyle::ChevronToolButton; + sortButton->setObjectName(QStringLiteral("threadSortButton")); + sortButton->setProperty("kind", "subtle"); + sortButton->setProperty("codexChevron", true); + sortButton->setPopupMode(QToolButton::InstantPopup); + sortButton->setFixedHeight(28); + auto *sortMenu = new QMenu(sortButton); + auto *sortGroup = new QActionGroup(sortMenu); + sortGroup->setExclusive(true); + const auto addSortAction = [this, sortMenu, sortGroup](QString label, + SortCriterion value) { + QAction *action = sortMenu->addAction(std::move(label)); + action->setCheckable(true); + sortGroup->addAction(action); + connect(action, &QAction::triggered, this, + [this, value] { setSortCriterion(value); }); + return action; + }; + addSortAction(QStringLiteral("Alphanumeric"), SortCriterion::Alphanumeric); + addSortAction(QStringLiteral("Created"), SortCriterion::Created); + addSortAction(QStringLiteral("Last changed"), SortCriterion::LastChanged); + QAction *recent = + addSortAction(QStringLiteral("Recent"), SortCriterion::Recency); + recent->setChecked(true); + sortButton->setMenu(sortMenu); + sortButton->setToolTip(QStringLiteral("Sort threads")); + updateSortButton(); + toolbar->addWidget(sortButton); layout->addLayout(toolbar); - list = new QListWidget; + list = new ThreadListWidget; list->setObjectName(QStringLiteral("threadList")); + list->setItemDelegate(new ThreadItemDelegate(list)); 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;" + "QListWidget#threadList::item{min-height:30px;background:#ffffff;" + "border:1px solid #d7dee8;border-radius:8px;margin:3px 0;" "padding:2px 8px;color:#344054;}" - "QListWidget#threadList::item:hover{background:#eef3fa;}" + "QListWidget#threadList::item:hover{background:#f1f5fb;" + "border-color:#b9c4d2;}" "QListWidget#threadList::item:selected{background:#e5eeff;" - "color:#1d2633;font-weight:600;}")); + "border-color:#bfd3f9;color:#1d2633;font-weight:600;}")); connect(list, &QListWidget::itemSelectionChanged, this, [this] { if (actions.select) { const std::string id = visiblySelectedThreadId(); @@ -175,9 +261,103 @@ ThreadPane::ThreadPane(QWidget *parent) : QFrame(parent) { void ThreadPane::setActions(Actions next) { actions = std::move(next); } +void ThreadPane::setSortCriterion(SortCriterion criterion) { + if (sortCriterion == criterion) + return; + sortCriterion = criterion; + updateSortButton(); + visibleSnapshot.clear(); + if (currentModel) + refresh(*currentModel, projectedSelectedThreadId); +} + +ThreadPane::SortCriterion ThreadPane::currentSortCriterion() const noexcept { + return sortCriterion; +} + +void ThreadPane::updateSortButton() { + if (!sortButton) + return; + QString label; + switch (sortCriterion) { + case SortCriterion::Alphanumeric: + label = QStringLiteral("A–Z"); + break; + case SortCriterion::Created: + label = QStringLiteral("Created"); + break; + case SortCriterion::LastChanged: + label = QStringLiteral("Changed"); + break; + case SortCriterion::Recency: + label = QStringLiteral("Recent"); + break; + } + sortButton->setText(QStringLiteral("Sort: %1").arg(label)); + for (QAction *action : sortButton->menu()->actions()) + action->setChecked(action->text() == + (sortCriterion == SortCriterion::Alphanumeric + ? QStringLiteral("Alphanumeric") + : sortCriterion == SortCriterion::Created + ? QStringLiteral("Created") + : sortCriterion == SortCriterion::LastChanged + ? QStringLiteral("Last changed") + : QStringLiteral("Recent"))); +} + +void ThreadPane::sortVisibleThreads(std::vector &ids, + const PresentationModel &model) const { + QCollator collator(QLocale::system().language() == QLocale::C + ? QLocale(QLocale::English) + : QLocale::system()); + collator.setCaseSensitivity(Qt::CaseInsensitive); + collator.setIgnorePunctuation(true); + collator.setNumericMode(true); + std::sort(ids.begin(), ids.end(), + [&](const std::string &leftId, const std::string &rightId) { + const ThreadPresentation *left = model.thread(leftId); + const ThreadPresentation *right = model.thread(rightId); + if (!left || !right) + return leftId < rightId; + if (sortCriterion == SortCriterion::Alphanumeric) { + const QString leftTitle = text(left->title).trimmed(); + const QString rightTitle = text(right->title).trimmed(); + const bool leftStartsWithNumber = + !leftTitle.isEmpty() && leftTitle.front().isDigit(); + const bool rightStartsWithNumber = + !rightTitle.isEmpty() && rightTitle.front().isDigit(); + if (leftStartsWithNumber != rightStartsWithNumber) + return leftStartsWithNumber; + const int comparison = collator.compare(leftTitle, rightTitle); + if (comparison != 0) + return comparison < 0; + } else { + const auto leftTimestamp = timestampFor(*left, sortCriterion); + const auto rightTimestamp = timestampFor(*right, sortCriterion); + if (leftTimestamp != rightTimestamp) { + if (!leftTimestamp) + return false; + if (!rightTimestamp) + return true; + return *leftTimestamp > *rightTimestamp; + } + } + return leftId < rightId; + }); +} + +void ThreadPane::setContextHighlight(const std::string &threadId, + bool highlighted) { + const auto found = rows.find(threadId); + if (found == rows.end()) + return; + found->second->setData(ContextMenuRole, highlighted); +} + void ThreadPane::refresh(const PresentationModel &model, const std::string &selectedThreadId) { currentModel = &model; + projectedSelectedThreadId = selectedThreadId; const std::vector &authoritativeOrder = model.threadOrder(); std::erase_if(retainedVisibleThreads, [&](const std::string &id) { return !model.thread(id) || @@ -195,6 +375,7 @@ void ThreadPane::refresh(const PresentationModel &model, std::vector visibleOrder = retainedVisibleThreads; visibleOrder.insert(visibleOrder.end(), authoritativeOrder.begin(), authoritativeOrder.end()); + sortVisibleThreads(visibleOrder, model); nlohmann::json visible = nlohmann::json::array(); for (const std::string &id : visibleOrder) { const ThreadPresentation *thread = model.thread(id); @@ -206,8 +387,10 @@ void ThreadPane::refresh(const PresentationModel &model, {"status", thread->status}, {"pending", model.pendingRequestCount(id)}}); } - const std::string serialized = - nlohmann::json{{"selected", selectedThreadId}, {"rows", visible}}.dump(); + const std::string serialized = nlohmann::json{ + {"selected", selectedThreadId}, + {"sort", static_cast(sortCriterion)}, + {"rows", visible}}.dump(); const QByteArray next(serialized.data(), static_cast(serialized.size())); if (next == visibleSnapshot) @@ -231,7 +414,7 @@ void ThreadPane::refresh(const PresentationModel &model, const auto found = rows.find(id); if (found == rows.end()) { item = new QListWidgetItem; - item->setSizeHint(QSize(0, 48)); + item->setSizeHint(QSize(0, 54)); item->setData(Qt::UserRole, text(id)); list->insertItem(wantedIndex, item); list->setItemWidget(item, createRow()); @@ -251,6 +434,8 @@ void ThreadPane::refresh(const PresentationModel &model, } item->setToolTip(text(thread->cwd)); updateRow(list->itemWidget(item), *thread, model.pendingRequestCount(id)); + if (id == contextThreadId) + setContextHighlight(id, true); if (id == selectedThreadId) list->setCurrentItem(item); ++wantedIndex; @@ -282,38 +467,52 @@ void ThreadPane::showContextMenu(const QPoint &position) { const ThreadPresentation *thread = currentModel->thread(id); if (!thread) return; - QMenu menu(list); - menu.addAction(QStringLiteral("Reload"), this, [this, id] { + if (contextMenu) + contextMenu->close(); + contextThreadId = id; + setContextHighlight(contextThreadId, true); + auto *menu = new QMenu(list); + contextMenu = menu; + connect(menu, &QMenu::aboutToHide, this, [this, menu] { + if (contextMenu == menu) { + setContextHighlight(contextThreadId, false); + contextThreadId.clear(); + contextMenu = nullptr; + } + menu->deleteLater(); + }); + 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] { + QAction *rename = menu->addAction(QStringLiteral("Rename"), this, [this, id] { if (actions.rename) actions.rename(id); }); - QAction *fork = menu.addAction(QStringLiteral("Fork"), this, [this, 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); - }); + 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)); + menu->popup(list->viewport()->mapToGlobal(position)); } } // namespace codexui::codex::middle diff --git a/src/greenfield/codex/middle/ThreadPane.h b/src/codex/middle/ThreadPane.h similarity index 65% rename from src/greenfield/codex/middle/ThreadPane.h rename to src/codex/middle/ThreadPane.h index 2f7ba18..9e56d36 100644 --- a/src/greenfield/codex/middle/ThreadPane.h +++ b/src/codex/middle/ThreadPane.h @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later OR MIT -#ifndef CODEXUI_GREENFIELD_CODEX_MIDDLE_THREADPANE_H -#define CODEXUI_GREENFIELD_CODEX_MIDDLE_THREADPANE_H +#ifndef CODEXUI_CODEX_MIDDLE_THREADPANE_H +#define CODEXUI_CODEX_MIDDLE_THREADPANE_H #include #include @@ -13,6 +13,8 @@ class QListWidget; class QListWidgetItem; +class QMenu; +class QToolButton; namespace codexui::codex { class PresentationModel; @@ -21,6 +23,8 @@ namespace middle { class ThreadPane final : public QFrame { public: + enum class SortCriterion { Alphanumeric, Created, LastChanged, Recency }; + struct Actions { std::function newThread; std::function refresh; @@ -38,16 +42,27 @@ class ThreadPane final : public QFrame { void setActions(Actions actions); void refresh(const PresentationModel &model, const std::string &selectedThreadId); + void setSortCriterion(SortCriterion criterion); + [[nodiscard]] SortCriterion currentSortCriterion() const noexcept; [[nodiscard]] std::string visiblySelectedThreadId() const; private: + void updateSortButton(); + void sortVisibleThreads(std::vector &ids, + const PresentationModel &model) const; + void setContextHighlight(const std::string &threadId, bool highlighted); void showContextMenu(const QPoint &position); const PresentationModel *currentModel = nullptr; Actions actions; + SortCriterion sortCriterion = SortCriterion::Recency; + QToolButton *sortButton = nullptr; QListWidget *list = nullptr; std::unordered_map rows; std::vector retainedVisibleThreads; + std::string projectedSelectedThreadId; + std::string contextThreadId; + QMenu *contextMenu = nullptr; QByteArray visibleSnapshot; }; diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp index 0e27578..4f6867e 100644 --- a/src/codex/ui/UiStyle.cpp +++ b/src/codex/ui/UiStyle.cpp @@ -4,11 +4,62 @@ #include #include +#include +#include +#include +#include +#include +#include #include namespace codexui::UiStyle { +void drawChevron(QWidget *widget, const QRect &indicator, bool enabled, + bool highlighted, ChevronDirection direction) { + if (!indicator.isValid() || indicator.isEmpty()) + return; + const QPointF center = indicator.center(); + QPainterPath chevron; + if (direction == ChevronDirection::Right) { + chevron.moveTo(center.x() - 1.5, center.y() - 3.5); + chevron.lineTo(center.x() + 2.0, center.y()); + chevron.lineTo(center.x() - 1.5, center.y() + 3.5); + } else { + 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); +} + +void ChevronToolButton::paintEvent(QPaintEvent *event) { + QToolButton::paintEvent(event); + QStyleOptionToolButton option; + initStyleOption(&option); + const QRect contents = + style()->subElementRect(QStyle::SE_ToolButtonLayoutItem, &option, this); + const int indicatorWidth = + style()->pixelMetric(QStyle::PM_MenuButtonIndicator, &option, this); + const 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 applicationStyleSheet() { const qreal configuredSize = QFontInfo(QApplication::font()).pointSizeF(); const qreal baseSize = configuredSize > 0.0 ? configuredSize : 10.0; @@ -16,6 +67,7 @@ QString applicationStyleSheet() { 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 panelHeader = QString::number(baseSize + 1.0, 'f', 1); const QString heading = QString::number(baseSize + 3.0, 'f', 1); return QStringLiteral(R"QSS( @@ -23,7 +75,7 @@ QString applicationStyleSheet() { color: #1d2633; font-size: %1pt; } - QMainWindow, QWidget#workbench { background: #f6f8fb; } + QMainWindow, QWidget#applicationShell { background: #f6f8fb; } QLabel { background: transparent; font-weight: 400; } QLabel[kind="muted"] { color: #667085; font-size: %1pt; } QLabel[kind="section"] { @@ -31,8 +83,13 @@ QString applicationStyleSheet() { font-size: %3pt; font-weight: 600; } + QLabel[kind="panelHeader"] { + color: #475467; + font-size: %5pt; + font-weight: 700; + } QLabel[kind="attentionSection"] { - color: #a76812; + color: #a85d0c; font-size: %1pt; font-weight: 600; } @@ -43,6 +100,8 @@ QString applicationStyleSheet() { QLabel[kind="body"] { font-size: %2pt; } QLabel[kind="meta"] { color: #667085; font-size: %1pt; } QLabel[kind="small"] { color: #667085; font-size: %1pt; } + QLabel[kind="diffAdditionMeta"] { color: #176b45; font-size: %1pt; font-weight: 600; } + QLabel[kind="diffDeletionMeta"] { color: #982f3d; font-size: %1pt; font-weight: 600; } QPushButton, QToolButton { background: #ffffff; border: 1px solid #d7dee8; @@ -51,6 +110,7 @@ QString applicationStyleSheet() { font-size: %1pt; font-weight: 600; } + QPushButton[comboPeer="true"] { min-height: 30px; max-height: 30px; } 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; } @@ -59,7 +119,7 @@ QString applicationStyleSheet() { 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"] { background: #fff6df; border-color: #e5c77d; color: #8a5208; } 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; } @@ -70,6 +130,27 @@ QString applicationStyleSheet() { background: transparent; border-color: transparent; } + QPushButton[kind="infoChoice"] { + background: #ffffff; + border: 1px solid #d7dee8; + border-radius: 10px; + padding: 0; + text-align: left; + } + QPushButton[kind="infoChoice"]:hover { background: #f8fafc; border-color: #b9c4d2; } + QPushButton[kind="infoChoice"]:pressed { background: #f1f5fb; border-color: #9eabbc; } + QPushButton[kind="segment"] { + background: #ffffff; + border-color: #d7dee8; + border-radius: 7px; + padding: 0 10px; + } + QPushButton[kind="segment"]:checked { + background: #e5eeff; + border-color: #bfd3f9; + color: #1d2633; + } + QPushButton[kind="segment"]:hover:!checked { background: #f1f5fb; } QToolButton[kind="composerAction"] { background: #ffffff; border: 1px solid #d7dee8; @@ -78,10 +159,19 @@ QString applicationStyleSheet() { } 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[kind="success"] { background: #18865e; border-color: #18865e; color: white; } + QPushButton[kind="success"]:hover { background: #14734f; border-color: #14734f; } + QPushButton[kind="success"]:pressed { background: #105f41; border-color: #105f41; } + QPushButton[kind="destructive"], QPushButton[kind="stop"] { background: #c43d4d; border-color: #c43d4d; color: white; } + QPushButton[kind="destructive"]:hover, QPushButton[kind="stop"]:hover { background: #aa3342; border-color: #aa3342; } + QPushButton[kind="destructive"]:pressed, QPushButton[kind="stop"]:pressed { background: #8f2b38; border-color: #8f2b38; } + QPushButton[kind="destructiveCompact"] { background: #c43d4d; border: 0; color: white; border-radius: 4px; padding: 0; font-weight: 700; } + QPushButton[kind="destructiveCompact"]:hover { background: #aa3342; } + QPushButton[kind="destructiveCompact"]:pressed { background: #8f2b38; } + QPushButton[codexChevron="true"] { padding-right: 20px; } QPushButton[codexChevron="true"]::menu-indicator { image: none; width: 0; } + QToolButton[codexChevron="true"] { padding-right: 20px; } + QToolButton[codexChevron="true"]::menu-indicator { image: none; width: 0; } QPushButton[changed="true"] { background: #e5eeff; color: #2f6feb; @@ -92,12 +182,13 @@ QString applicationStyleSheet() { 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="standardDivider"] { background: #d7dee8; border: none; } + QFrame[kind="greenBadge"] { background: #e9f7f0; border: 1px solid #a9d8c1; 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="orangeBadge"] { 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 { + QPlainTextEdit, QTextEdit { background: transparent; border: 0; color: #1d2633; @@ -108,6 +199,7 @@ QString applicationStyleSheet() { } QPlainTextEdit[empty="true"] { color: #98a2b3; } QPlainTextEdit[kind="code"], QPlainTextEdit[kind="command"], + QTextEdit[kind="code"], QTextEdit[kind="command"], QPlainTextEdit[kind="infoViewer"] { font-family: monospace; font-size: %1pt; @@ -215,6 +307,11 @@ QString applicationStyleSheet() { QDialog { background: #ffffff; } QScrollArea { background: #f6f8fb; border: 0; } QTabWidget QScrollArea { background: #fbfcfe; } + QScrollArea[kind="inspectorScroll"], + QScrollArea[kind="inspectorScroll"] > QWidget > QWidget { + background: transparent; + border: 0; + } QDialog QScrollArea { background: #ffffff; } QScrollArea > QWidget > QWidget { background: transparent; } QAbstractScrollArea::corner { background: transparent; border: 0; } @@ -334,14 +431,38 @@ QString applicationStyleSheet() { 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 { + background: #ffffff; + color: #1d2633; + border: 1px solid #d7dee8; + border-radius: 8px; + padding: 4px; + } + QMenu::item { + min-height: 30px; + padding: 0 24px 0 10px; + border-radius: 5px; + font-weight: 400; + } + QMenu::item:selected { + background: #f1f5fb; + color: #1d2633; + } + QMenu::item:checked { + background: #e5eeff; + color: #285fca; + } + QMenu::item:checked:selected { background: #d8e7ff; } QMenu::item:disabled { color: #98a2b3; } - QMenu::separator { height: 1px; background: #d7dee8; margin: 5px 8px; } + QMenu::item:disabled:selected { background: transparent; } + QMenu::separator { + height: 1px; + background: #d7dee8; + margin: 4px 8px; + } QToolTip { background: #ffffff; color: #1d2633; border: 1px solid #b9c4d2; padding: 5px; } )QSS") - .arg(compact, standard, section, heading); + .arg(compact, standard, section, heading, panelHeader); } } // namespace codexui::UiStyle diff --git a/src/codex/ui/UiStyle.h b/src/codex/ui/UiStyle.h index c18df86..81eea51 100644 --- a/src/codex/ui/UiStyle.h +++ b/src/codex/ui/UiStyle.h @@ -4,6 +4,11 @@ #define CODEXUI_UI_UISTYLE_H #include +#include + +class QPaintEvent; +class QRect; +class QWidget; namespace codexui::UiStyle { @@ -18,17 +23,45 @@ inline constexpr auto primary = "#1d2633"; inline constexpr auto secondary = "#667085"; inline constexpr auto placeholder = "#98a2b3"; inline constexpr auto blue = "#2f6feb"; +inline constexpr auto blueHover = "#285fca"; inline constexpr auto blueSelected = "#e5eeff"; inline constexpr auto blueBorder = "#bfd3f9"; inline constexpr auto hover = "#f1f5fb"; -inline constexpr auto green = "#23845a"; -inline constexpr auto amberSurface = "#fff6df"; -inline constexpr auto amberBorder = "#e5c77d"; -inline constexpr auto amber = "#a76812"; -inline constexpr auto destructive = "#b83a3a"; +inline constexpr auto green = "#18865e"; +inline constexpr auto greenHover = "#14734f"; +inline constexpr auto greenPressed = "#105f41"; +inline constexpr auto greenSurface = "#e9f7f0"; +inline constexpr auto greenBorder = "#a9d8c1"; +inline constexpr auto greenText = "#176b45"; +inline constexpr auto orange = "#a85d0c"; +inline constexpr auto orangeHover = "#8e4d09"; +inline constexpr auto orangePressed = "#743e07"; +inline constexpr auto orangeSurface = "#fff6df"; +inline constexpr auto orangeSurfaceHover = "#ffefc4"; +inline constexpr auto orangeBorder = "#e5c77d"; +inline constexpr auto orangeBorderStrong = "#d5ad50"; +inline constexpr auto orangeText = "#8a5208"; +inline constexpr auto red = "#c43d4d"; +inline constexpr auto redHover = "#aa3342"; +inline constexpr auto redPressed = "#8f2b38"; +inline constexpr auto redSurface = "#fff0f2"; +inline constexpr auto redBorder = "#efb8c0"; +inline constexpr auto redText = "#982f3d"; inline constexpr auto purple = "#6941c6"; QString applicationStyleSheet(); +enum class ChevronDirection { Down, Right }; +void drawChevron(QWidget *widget, const QRect &indicator, bool enabled, + bool highlighted, + ChevronDirection direction = ChevronDirection::Down); + +class ChevronToolButton final : public QToolButton { +public: + using QToolButton::QToolButton; + +protected: + void paintEvent(QPaintEvent *event) override; +}; } // namespace codexui::UiStyle diff --git a/src/greenfield/codex/ShellWidget.cpp b/src/greenfield/codex/ShellWidget.cpp deleted file mode 100644 index 2ba5344..0000000 --- a/src/greenfield/codex/ShellWidget.cpp +++ /dev/null @@ -1,1392 +0,0 @@ -// 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 scheduleRender(); - 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; - std::uint64_t observedProviderGeneration = 0; - QByteArray settingsSnapshot; - QByteArray statusSnapshot; - bool renderScheduled = false; - - 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(); - } - if (connection.providerGeneration != observedProviderGeneration) { - observedProviderGeneration = connection.providerGeneration; - 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 == "event" && type == "connection.provider" && - stringValue(data, "state") == "disconnected") { - hydration.clear(); - readRevisions.clear(); - operationReadyThreads.clear(); - dispatchScheduledThreads.clear(); - } - - 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()); - } - } else if (kind == "event" && type == "connection.provider" && - stringValue(data, "state") == "ready") { - session.listThreads(); - session.listModels(); - readThread(selectedThreadId, true); - } - - hydrateHistoricalAgents(); - scheduleRender(); -} - -void ShellWidget::Impl::scheduleRender() { - if (renderScheduled) - return; - renderScheduled = true; - const auto token = alive; - // A streamed response may deliver many deltas in one display interval. - // Reconcile once per frame instead of rebuilding rich text and layout for - // every transport chunk. - QTimer::singleShot(16, Qt::PreciseTimer, owner, [this, token] { - if (!*token) - return; - renderScheduled = false; - 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 deleted file mode 100644 index b400a45..0000000 --- a/src/greenfield/codex/ShellWidget.h +++ /dev/null @@ -1,35 +0,0 @@ -// 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/tests/codex/GreenfieldLayoutTest.cpp b/tests/codex/ApplicationLayoutTest.cpp similarity index 51% rename from tests/codex/GreenfieldLayoutTest.cpp rename to tests/codex/ApplicationLayoutTest.cpp index 11ff93d..2847191 100644 --- a/tests/codex/GreenfieldLayoutTest.cpp +++ b/tests/codex/ApplicationLayoutTest.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: LGPL-3.0-or-later OR MIT +#include "codex/GitDiffProvider.h" #include "codex/PresentationModel.h" #include "codex/PresentationProtocol.h" #include "codex/middle/ComposerPane.h" @@ -9,24 +10,35 @@ #include "codex/middle/MiddleRegionWidget.h" #include "codex/middle/ThreadPane.h" #include "codex/ui/ExpandingPromptEditor.h" +#include "codex/ui/UiStyle.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 namespace codexui::codex::middle { namespace { @@ -38,6 +50,43 @@ bool expect(bool condition, const char *message) { return false; } +bool commitPath(git_repository *repository, const char *path) { + git_index *index = nullptr; + if (git_repository_index(&index, repository) < 0) + return false; + const bool indexed = git_index_add_bypath(index, path) == 0 && + git_index_write(index) == 0; + git_oid treeId{}; + const bool wroteTree = indexed && git_index_write_tree(&treeId, index) == 0; + git_index_free(index); + if (!wroteTree) + return false; + git_tree *tree = nullptr; + git_signature *signature = nullptr; + if (git_tree_lookup(&tree, repository, &treeId) < 0 || + git_signature_now(&signature, "CodexUI Test", "codexui@example.invalid") < + 0) { + git_tree_free(tree); + git_signature_free(signature); + return false; + } + git_oid commitId{}; + git_reference *head = nullptr; + git_commit *parent = nullptr; + if (git_repository_head(&head, repository) == 0) + git_commit_lookup(&parent, repository, git_reference_target(head)); + const git_commit *parents[] = {parent}; + const bool committed = + git_commit_create(&commitId, repository, "HEAD", signature, signature, + nullptr, "path baseline", tree, parent ? 1 : 0, + parent ? parents : nullptr) == 0; + git_commit_free(parent); + git_reference_free(head); + git_signature_free(signature); + git_tree_free(tree); + return committed; +} + bool hasLabelContaining(const QWidget &root, const QString &text) { for (const QLabel *label : root.findChildren()) { if (label->text().contains(text)) @@ -88,6 +137,19 @@ QWheelEvent wheelFor(QWidget *target, int pixelDelta) { Qt::ScrollUpdate, false); } +std::vector threadOrder(const ThreadPane &pane) { + const auto *list = + pane.findChild(QStringLiteral("threadList")); + std::vector result; + if (!list) + return result; + result.reserve(static_cast(list->count())); + for (int row = 0; row < list->count(); ++row) + result.push_back( + list->item(row)->data(Qt::UserRole).toString().toStdString()); + return result; +} + bool testOverlayGeometryAndRegionRouting() { MiddleRegionWidget region; bool result = @@ -216,16 +278,26 @@ bool testThreadSelectionProjection() { row ? row->findChild(QStringLiteral("threadTitle")) : nullptr; auto *status = row ? row->findChild(QStringLiteral("threadStatus")) : nullptr; + auto *dot = + row ? row->findChild(QStringLiteral("threadStatusDot")) + : nullptr; auto *rowLayout = row ? qobject_cast(row->layout()) : nullptr; + auto *sortButton = + pane.findChild(QStringLiteral("threadSortButton")); result &= expect( - selected && selected->sizeHint().height() == 48 && rowLayout && + selected && selected->sizeHint().height() == 54 && rowLayout && rowLayout->contentsMargins() == QMargins(5, 2, 5, 2) && - rowLayout->spacing() == 8 && title && status && + rowLayout->spacing() == 8 && title && status && dot && + dot->size() == QSize(10, 10) && rowLayout->indexOf(dot) >= 0 && + sortButton && + dynamic_cast(sortButton) && + sortButton->property("codexChevron").toBool() && 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"); + "thread cards keep their status dot and shared chevron styling inside " + "the UI contract"); pane.refresh(model, "thread-a"); bool retainedSupplement = false; if (list) { @@ -254,6 +326,92 @@ bool testThreadSelectionProjection() { return result; } +bool testThreadAlphanumericSort() { + PresentationModel model; + model.applyEvent(presentation::result( + 1, 1, "threads.list", "alpha-threads", true, + {{"threads", + nlohmann::json::array({{{"id", "alpha"}, {"name", "Alpha"}}, + {{"id", "ten"}, {"name", "10 Release"}}, + {{"id", "two"}, {"name", "2 Review"}}, + {{"id", "one"}, {"name", "1 Setup"}}, + {{"id", "beta"}, {"name", "beta"}}})}}, + presentation::Authority::Merge)); + ThreadPane pane; + pane.setSortCriterion(ThreadPane::SortCriterion::Alphanumeric); + pane.refresh(model, "two"); + const std::vector order = threadOrder(pane); + const bool correct = order == std::vector( + {"one", "two", "ten", "alpha", "beta"}) && + pane.visiblySelectedThreadId() == "two"; + if (!correct) { + std::cerr << "Observed alphanumeric order:"; + for (const std::string &id : order) + std::cerr << ' ' << id; + std::cerr << "; selected=" << pane.visiblySelectedThreadId() << '\n'; + } + return expect(correct, + "Alphanumeric sorting is natural and preserves selection"); +} + +bool testThreadCreatedSort() { + PresentationModel model; + model.applyEvent(presentation::result( + 1, 1, "threads.list", "created-threads", true, + {{"threads", + nlohmann::json::array({{{"id", "old"}, {"createdAt", 10}}, + {{"id", "missing"}}, + {{"id", "new"}, {"createdAt", 30}}, + {{"id", "middle"}, {"createdAt", 20}}})}}, + presentation::Authority::Merge)); + ThreadPane pane; + pane.setSortCriterion(ThreadPane::SortCriterion::Created); + pane.refresh(model, {}); + return expect(threadOrder(pane) == std::vector( + {"new", "middle", "old", "missing"}), + "Created sorting is newest first with missing values last"); +} + +bool testThreadLastChangedSort() { + PresentationModel model; + model.applyEvent(presentation::result( + 1, 1, "threads.list", "changed-threads", true, + {{"threads", + nlohmann::json::array({{{"id", "first"}, {"updatedAt", 20}}, + {{"id", "second"}, {"updatedAt", 10}}, + {{"id", "third"}, {"updatedAt", 30}}})}}, + presentation::Authority::Merge)); + model.applyEvent(presentation::event( + 2, 1, "thread.upsert", + {{"thread", {{"id", "first"}, {"name", "Renamed"}}}}, + presentation::Authority::Merge, {{"threadId", "first"}})); + ThreadPane pane; + pane.setSortCriterion(ThreadPane::SortCriterion::LastChanged); + pane.refresh(model, {}); + return expect(threadOrder(pane) == + std::vector({"third", "first", "second"}), + "Last changed sorting uses retained updated timestamps"); +} + +bool testThreadRecencySort() { + PresentationModel model; + model.applyEvent(presentation::result( + 1, 1, "threads.list", "recent-threads", true, + {{"threads", + nlohmann::json::array({{{"id", "older"}, {"recencyAt", 10}}, + {{"id", "recent"}, {"recencyAt", 30}}, + {{"id", "middle"}, {"recencyAt", 20}}})}}, + presentation::Authority::Merge)); + ThreadPane pane; + pane.refresh(model, "older"); + return expect( + pane.currentSortCriterion() == ThreadPane::SortCriterion::Recency && + threadOrder(pane) == + std::vector({"recent", "middle", "older"}) && + pane.visiblySelectedThreadId() == "older", + "Recent is the default and preserves selection"); +} + bool testThreadRowReorderOwnership() { PresentationModel model; model.applyEvent(presentation::event( @@ -264,35 +422,62 @@ bool testThreadRowReorderOwnership() { presentation::Authority::Merge, {{"threadId", "thread-b"}})); ThreadPane pane; + int selectedByUser = 0; + ThreadPane::Actions actions; + actions.select = [&](const std::string &) { ++selectedByUser; }; + pane.setActions(std::move(actions)); + pane.setSortCriterion(ThreadPane::SortCriterion::Alphanumeric); pane.resize(320, 500); pane.show(); pane.refresh(model, "thread-a"); spin(20); auto *list = pane.findChild(QStringLiteral("threadList")); QListWidgetItem *threadA = nullptr; + QListWidgetItem *threadB = 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; + } else if (list->item(row)->data(Qt::UserRole).toString() == + QStringLiteral("thread-b")) { + threadB = list->item(row); } } } - bool result = expect(list && threadA, + bool result = expect(list && threadA && threadB, "the stable thread row exists before list reordering"); - if (!list || !threadA) + if (!list || !threadA || !threadB) return false; - QPointer originalRow = list->itemWidget(threadA); + const QPoint rightClickPosition = list->visualItemRect(threadB).center(); + QMouseEvent rightClick(QEvent::MouseButtonPress, rightClickPosition, + list->viewport()->mapToGlobal(rightClickPosition), + Qt::RightButton, Qt::RightButton, Qt::NoModifier); + QApplication::sendEvent(list->viewport(), &rightClick); + QContextMenuEvent contextMenuEvent( + QContextMenuEvent::Mouse, rightClickPosition, + list->viewport()->mapToGlobal(rightClickPosition)); + QApplication::sendEvent(list->viewport(), &contextMenuEvent); + result &= expect(pane.visiblySelectedThreadId() == "thread-a" && + selectedByUser == 0 && + threadB->data(Qt::UserRole + 1).toBool(), + "right-click highlights row actions without selecting a " + "thread"); + if (QWidget *popup = QApplication::activePopupWidget()) + popup->close(); + spin(); + result &= expect(!threadB->data(Qt::UserRole + 1).toBool(), + "closing row actions clears the native context hover"); + QPointer originalRow = list->itemWidget(threadB); model.applyEvent(presentation::result( 3, 1, "threads.list", "reordered-threads", true, {{"threads", - nlohmann::json::array({{{"id", "thread-a"}, {"name", "A"}}, + nlohmann::json::array({{{"id", "thread-a"}, {"name", "Z"}}, {{"id", "thread-b"}, {"name", "B"}}})}}, presentation::Authority::Replace)); pane.refresh(model, "thread-a"); - QPointer movedRow = list->itemWidget(threadA); + QPointer movedRow = list->itemWidget(threadB); result &= expect(originalRow && movedRow && originalRow != movedRow, "moving an item never reattaches its deferred-delete row"); if (!originalRow || !movedRow || originalRow == movedRow) @@ -301,7 +486,7 @@ bool testThreadRowReorderOwnership() { QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); spin(20); result &= expect(originalRow.isNull() && movedRow && - list->itemWidget(threadA) == movedRow, + list->itemWidget(threadB) == movedRow, "deferred deletion cannot invalidate the moved thread row"); list->setCurrentItem(threadA); list->viewport()->repaint(); @@ -351,11 +536,11 @@ bool testNestedCommandScrollOwnership() { 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() < + QWheelEvent boundary = wheelFor(commandOutput, 120); + result &= expect(!region.routeScrollEvent(commandOutput, &boundary) && + region.conversation().verticalScrollBar()->value() == outerBefore, - "nested output hands input to the message view at its edge"); + "nested output retains input at its scroll boundary"); return result; } @@ -366,19 +551,21 @@ bool testInfoViewerLayout() { PresentationModel model; inspector.refresh(model, {}); inspector.tabs()->setCurrentIndex(4); - auto *infoTabs = - inspector.findChild(QStringLiteral("infoTabs")); + auto *infoStack = + inspector.findChild(QStringLiteral("infoStack")); + auto *protocolChoice = inspector.findChild( + QStringLiteral("protocolInfoChoice")); 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) + bool result = expect(infoStack && protocolChoice && protocol && state && statistics, + "Info exposes State and Protocol through choice navigation"); + if (!infoStack || !protocolChoice || !protocol || !state || !statistics) return false; - infoTabs->setCurrentIndex(1); + protocolChoice->click(); inspector.appendProtocolFrame( {{"kind", "event"}, {"type", "conversation.item.upsert"}, @@ -435,13 +622,13 @@ bool testInfoViewerLayout() { result &= expect(protocolScroll->value() == pausedValue, "a visible Protocol append preserves a user-paused position"); - infoTabs->setCurrentIndex(0); + infoStack->setCurrentIndex(0); inspector.appendProtocolFrame({{"kind", "event"}, {"type", "protocol.test.hidden-append"}, {"sequence", 92}, {"generation", 1}, {"authority", "app-server"}}); - infoTabs->setCurrentIndex(1); + protocolChoice->click(); spin(20); result &= expect(protocolScroll->value() == pausedValue, @@ -511,6 +698,211 @@ bool testInspectorDetailParity() { return result; } +bool testGitDiffScopes() { + QTemporaryDir repositoryDirectory; + if (!expect(repositoryDirectory.isValid(), + "Git diff test creates a temporary workspace")) + return false; + GitDiffProvider provider; + git_repository *repository = nullptr; + if (!expect(git_repository_init(&repository, + repositoryDirectory.path().toUtf8().constData(), + 0) == 0, + "Git diff test initializes an in-process repository")) + return false; + QFile file(repositoryDirectory.filePath(QStringLiteral("notes.txt"))); + if (!expect(file.open(QIODevice::WriteOnly | QIODevice::Truncate), + "Git diff test creates an untracked file")) { + git_repository_free(repository); + return false; + } + file.write("first line\nsecond line\n"); + file.close(); + + GitDiffSnapshot received; + bool ready = false; + QObject::connect(&provider, &GitDiffProvider::snapshotReady, + [&received, &ready](const GitDiffSnapshot &snapshot) { + received = snapshot; + ready = true; + }); + const auto request = [&](const QString &workspace, + const QStringList &directories, + const QStringList &paths, + const QString &selectedRepository, + GitDiffScope scope, + bool includeHiddenRepositories = false) { + ready = false; + provider.request(workspace, directories, paths, selectedRepository, + includeHiddenRepositories, scope, GitDiffContext::Compact); + QElapsedTimer timeout; + timeout.start(); + while (!ready && timeout.elapsed() < 3000) + spin(1); + return ready; + }; + + bool result = expect(request(repositoryDirectory.path(), {}, {}, {}, + GitDiffScope::Unstaged) && + received.repository && received.error.isEmpty() && + received.files.size() == 1 && + received.files.front().status == + QStringLiteral("Untracked") && + received.files.front().patch.contains( + QStringLiteral("+first line")), + "Unstaged scope includes untracked file content"); + + git_index *index = nullptr; + if (git_repository_index(&index, repository) == 0) { + git_index_add_bypath(index, "notes.txt"); + git_index_write(index); + git_index_free(index); + } + result &= expect(request(repositoryDirectory.path(), {}, {}, {}, + GitDiffScope::Staged) && + received.files.size() == 1 && + received.files.front().status == + QStringLiteral("Added"), + "Staged scope compares the index with HEAD"); + result &= expect(request(repositoryDirectory.path(), {}, {}, {}, + GitDiffScope::Uncommitted) && + received.files.size() == 1 && + received.files.front().patch.contains( + QStringLiteral("+second line")), + "Since-HEAD scope combines index and worktree state"); + + QTemporaryDir ordinaryDirectory; + result &= expect(ordinaryDirectory.isValid() && + request(ordinaryDirectory.path(), {}, {}, {}, + GitDiffScope::Unstaged) && + !received.repository && + received.error.contains(QStringLiteral("Git repository")), + "ordinary folders expose an explicit non-repository state"); + + QTemporaryDir multiWorkspace; + const QString firstRoot = multiWorkspace.filePath(QStringLiteral("first")); + const QString secondRoot = multiWorkspace.filePath(QStringLiteral("second")); + const QString hiddenRoot = + multiWorkspace.filePath(QStringLiteral(".hidden/repository")); + git_repository *firstRepository = nullptr; + git_repository *secondRepository = nullptr; + git_repository *hiddenRepository = nullptr; + git_repository_init(&firstRepository, firstRoot.toUtf8().constData(), 0); + git_repository_init(&secondRepository, secondRoot.toUtf8().constData(), 0); + QDir().mkpath(hiddenRoot); + git_repository_init(&hiddenRepository, hiddenRoot.toUtf8().constData(), 0); + for (const QString &root : {firstRoot, secondRoot, hiddenRoot}) { + QFile shared(QDir(root).filePath(QStringLiteral("shared.txt"))); + if (shared.open(QIODevice::WriteOnly | QIODevice::Truncate)) + shared.write("shared path\n"); + } + QFile firstOnly(QDir(firstRoot).filePath(QStringLiteral("first-only.txt"))); + if (firstOnly.open(QIODevice::WriteOnly | QIODevice::Truncate)) + firstOnly.write("first repository\n"); + firstOnly.close(); + result &= expect( + request(multiWorkspace.path(), + {firstRoot, firstRoot, hiddenRoot, secondRoot}, + {QStringLiteral("shared.txt")}, {}, GitDiffScope::Unstaged) && + received.repositoryRoots.size() == 2 && received.files.size() == 3 && + !received.repositoryRoots.contains(QDir::cleanPath(hiddenRoot)), + "duplicate directories are deduplicated, hidden roots are excluded, and ambiguous paths retain visible matches"); + result &= expect( + request(multiWorkspace.path(), {firstRoot, secondRoot, hiddenRoot}, + {QStringLiteral("shared.txt")}, {}, GitDiffScope::Unstaged, + true) && + received.repositoryRoots.size() == 3 && received.files.size() == 4 && + received.repositoryRoots.contains(QDir::cleanPath(hiddenRoot)), + "the explicit hidden-repository option includes hidden candidates"); + result &= expect( + request(multiWorkspace.path(), {firstRoot, secondRoot}, + {QStringLiteral("shared.txt")}, firstRoot, + GitDiffScope::Unstaged) && + received.repositoryRoots.size() == 2 && received.files.size() == 2 && + received.files.front().repositoryRoot == QDir::cleanPath(firstRoot), + "repository selection filters files without losing the candidate set"); + result &= expect( + request(multiWorkspace.path(), {firstRoot, secondRoot}, + {QStringLiteral("first-only.txt")}, {}, + GitDiffScope::Unstaged) && + received.repositoryRoots == QStringList{QDir::cleanPath(firstRoot)} && + received.files.size() == 2, + "a unique relative path resolves one repository and includes all of its changes"); + result &= expect( + request(multiWorkspace.path(), {firstRoot, secondRoot}, + {QDir(secondRoot).filePath(QStringLiteral("shared.txt"))}, {}, + GitDiffScope::Unstaged) && + received.repositoryRoots == + QStringList{QDir::cleanPath(secondRoot)} && + received.files.size() == 1, + "an absolute path resolves only its owning repository"); + result &= expect( + request(multiWorkspace.path(), {firstRoot, secondRoot}, + {QStringLiteral("not-applied-yet.txt")}, + QStringLiteral("/stale/repository"), GitDiffScope::Unstaged) && + received.repositoryRoots.size() == 2 && received.files.size() == 3, + "an unmatched early path and stale selection safely fall back to all candidate repositories"); + const QString priorityPath = QStringLiteral("priority.txt"); + QFile firstPriority(QDir(firstRoot).filePath(priorityPath)); + QFile secondPriority(QDir(secondRoot).filePath(priorityPath)); + const bool priorityFiles = + firstPriority.open(QIODevice::WriteOnly | QIODevice::Truncate) && + firstPriority.write("baseline\n") > 0; + firstPriority.close(); + const bool secondPriorityFile = + secondPriority.open(QIODevice::WriteOnly | QIODevice::Truncate) && + secondPriority.write("baseline\n") > 0; + secondPriority.close(); + const bool priorityCommitted = + priorityFiles && secondPriorityFile && + commitPath(firstRepository, "priority.txt") && + commitPath(secondRepository, "priority.txt"); + if (firstPriority.open(QIODevice::WriteOnly | QIODevice::Truncate)) + firstPriority.write("changed\n"); + firstPriority.close(); + result &= expect( + priorityCommitted && + request(multiWorkspace.path(), {firstRoot, secondRoot}, + {priorityPath}, {}, GitDiffScope::Unstaged) && + received.repositoryRoots == QStringList{QDir::cleanPath(firstRoot)} && + received.files.size() == 3 && + std::any_of(received.files.begin(), received.files.end(), + [&](const GitDiffFile &file) { + return file.path == priorityPath && + file.status == QStringLiteral("Modified"); + }), + "a currently changed path is preferred over the same clean tracked path"); + const QString secondCleanPath = QStringLiteral("second-clean.txt"); + QFile secondClean(QDir(secondRoot).filePath(secondCleanPath)); + const bool secondCleanCreated = + secondClean.open(QIODevice::WriteOnly | QIODevice::Truncate) && + secondClean.write("clean unique path\n") > 0; + secondClean.close(); + result &= expect( + secondCleanCreated && commitPath(secondRepository, "second-clean.txt") && + request(multiWorkspace.path(), {firstRoot, secondRoot}, + {priorityPath, secondCleanPath}, {}, + GitDiffScope::Unstaged) && + received.repositoryRoots.size() == 2 && received.files.size() == 4, + "changed-file preference is applied independently for every hinted path"); + QFile::remove(QDir(firstRoot).filePath(priorityPath)); + result &= expect( + request(multiWorkspace.path(), {firstRoot, secondRoot}, {priorityPath}, + {}, GitDiffScope::Unstaged) && + received.repositoryRoots == QStringList{QDir::cleanPath(firstRoot)} && + std::any_of(received.files.begin(), received.files.end(), + [&](const GitDiffFile &file) { + return file.path == priorityPath && + file.status == QStringLiteral("Deleted"); + }), + "a deleted path is resolved from Git state and preferred over a clean tracked match"); + git_repository_free(firstRepository); + git_repository_free(secondRepository); + git_repository_free(hiddenRepository); + git_repository_free(repository); + return result; +} + } // namespace } // namespace codexui::codex::middle @@ -519,11 +911,16 @@ int main(int argc, char **argv) { using namespace codexui::codex::middle; bool result = testOverlayGeometryAndRegionRouting(); result &= testThreadSelectionProjection(); + result &= testThreadAlphanumericSort(); + result &= testThreadCreatedSort(); + result &= testThreadLastChangedSort(); + result &= testThreadRecencySort(); result &= testThreadRowReorderOwnership(); result &= testNestedCommandScrollOwnership(); result &= testInfoViewerLayout(); result &= testInspectorDetailParity(); + result &= testGitDiffScopes(); if (result) - std::cout << "Greenfield layout tests passed\n"; + std::cout << "Application layout tests passed\n"; return result ? 0 : 1; } diff --git a/tests/codex/GreenfieldMiddleTest.cpp b/tests/codex/ConversationCardsTest.cpp similarity index 82% rename from tests/codex/GreenfieldMiddleTest.cpp rename to tests/codex/ConversationCardsTest.cpp index 1e99ffc..e55da45 100644 --- a/tests/codex/GreenfieldMiddleTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -8,8 +8,11 @@ #include #include #include +#include #include #include +#include +#include #include #include @@ -345,14 +348,15 @@ bool testMutableCardsAndCommandOutput() { TurnSection section{"turn:cards", "turn", {}}; section.cards = { {AuthoritativeItemKey{thread, "turn", "user"}, CardKind::UserMessage, - thread, "turn", "user", UserMessageData{QStringLiteral("hello")}}, + thread, "turn", "user", + UserMessageData{QStringLiteral("hello **Markdown**")}}, {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"), + CommandExecutionData{QStringLiteral("printf test\n\n \t"), + QStringLiteral(" \n\t"), QStringLiteral("inProgress"), {}, std::nullopt}}, @@ -397,10 +401,29 @@ bool testMutableCardsAndCommandOutput() { auto *commandCard = identities[stableKey( CardKey{AuthoritativeItemKey{thread, "turn", "command"}})]; auto *output = dynamic_cast( - commandCard->findChild( + commandCard->findChild( QStringLiteral("commandOutputView"))); + auto *commandText = dynamic_cast( + commandCard->findChild( + QStringLiteral("commandTextView"))); bool result = expect(output && output->isHidden(), - "control-only command output has no black surface"); + "empty-line command output has no black surface"); + auto *userCard = identities[stableKey( + CardKey{AuthoritativeItemKey{thread, "turn", "user"}})]; + const auto userLabels = userCard->findChildren(); + result &= expect( + std::ranges::any_of(userLabels, [](QLabel *label) { + return label->property("markdownSource").toString() == + QStringLiteral("hello **Markdown**") && + label->textFormat() == Qt::RichText; + }), + "authoritative user messages use the shared Markdown renderer"); + result &= expect( + commandText && + commandText->toPlainText() == QStringLiteral("printf test") && + commandText->height() < commandText->maximumHeight() && + commandText->verticalScrollBarPolicy() == Qt::ScrollBarAsNeeded, + "short command text trims empty lines and uses its content height"); auto &cards = snapshot.sections.front().cards; std::get(cards[0].payload).text += @@ -408,7 +431,8 @@ bool testMutableCardsAndCommandOutput() { std::get(cards[1].payload).text += QStringLiteral(" updated"); auto &command = std::get(cards[2].payload); - command.output = QString(120, QLatin1Char('x')) + QStringLiteral("\nvisible"); + command.output = + QString(120, QLatin1Char('x')) + QStringLiteral("\nvisible\n\n \t"); command.status = QStringLiteral("completed"); std::get(cards[3].payload).resultText = QStringLiteral("result"); @@ -438,8 +462,9 @@ bool testMutableCardsAndCommandOutput() { result &= expect(!output->isHidden() && output->minimumHeight() == 0 && output->maximumHeight() == 220 && + output->toPlainText().endsWith(QStringLiteral("visible")) && output->verticalScrollBarPolicy() == Qt::ScrollBarAsNeeded, - "visible command output grows from zero with the 220px cap"); + "visible output trims empty lines and grows with the 220px cap"); QString longOutput; for (int line = 0; line < 80; ++line) @@ -519,7 +544,7 @@ bool testInitialCommandGeometrySettlement() { "initial visible command output is inserted"); ConversationCard *commandCard = card(view, stableKey(command.key)); auto *outputView = commandCard ? dynamic_cast( - commandCard->findChild( + commandCard->findChild( QStringLiteral("commandOutputView"))) : nullptr; result &= expect(commandCard && outputView && !outputView->isHidden() && @@ -537,6 +562,89 @@ bool testInitialCommandGeometrySettlement() { outputView->height() == immediateOutputHeight && outputView->sizeHint().height() == immediateHint, "initial wrapped output has no delayed geometry settlement"); + + const int glyphWidth = + std::max(1, outputView->fontMetrics().horizontalAdvance(QLatin1Char('W'))); + const int charactersPerLine = + std::max(1, outputView->viewport()->width() / glyphWidth); + auto &execution = std::get( + snapshot.sections.front().cards.front().payload); + execution.output = QString(charactersPerLine + 1, QLatin1Char('W')); + result &= expect(view.reconcile(snapshot), + "single logical output line changes to two visual lines"); + spin(); + const QTextBlock wrappedBlock = outputView->document()->firstBlock(); + result &= expect( + wrappedBlock.layout() && wrappedBlock.layout()->lineCount() == 2 && + outputView->verticalScrollBar()->maximum() == 0 && + outputView->viewport()->height() >= + static_cast(std::ceil(outputView->document()->size().height())), + "two visual output lines are fully visible without inner scrolling"); + return result; +} + +bool testBottomAnchoredCommandOutputGrowth() { + const std::string thread = "bottom-anchored-output"; + ConversationSnapshot snapshot = conversation(thread, 14); + VisibleCardData command{ + AuthoritativeItemKey{thread, "turn-2", "live-command"}, + CardKind::CommandExecution, + thread, + "turn-2", + "live-command", + CommandExecutionData{QStringLiteral("run live command"), + {}, + QStringLiteral("inProgress"), + {}, + std::nullopt}}; + snapshot.sections.back().cards.push_back(command); + + ConversationView view; + view.resize(620, 360); + view.show(); + view.reconcile(snapshot); + spin(); + ConversationCard *commandCard = card(view, stableKey(command.key)); + auto *metadata = + commandCard + ? commandCard->findChild(QStringLiteral("commandMetadata")) + : nullptr; + auto *output = commandCard ? dynamic_cast( + commandCard->findChild( + QStringLiteral("commandOutputView"))) + : nullptr; + bool result = expect(commandCard && metadata && output && + output->isHidden() && view.isAtBottom(), + "live command starts with a hidden zero-line output"); + if (!commandCard || !metadata || !output) + return false; + const int metadataBottomBefore = + metadata->mapTo(view.viewport(), QPoint(0, metadata->height())).y(); + + auto &live = std::get( + snapshot.sections.back().cards.back().payload); + live.output = QStringLiteral( + "first wrapped output line with enough words to use real width\n" + "second output line\nthird output line\n\n"); + result &= expect(view.reconcile(snapshot), "live output becomes visible"); + const int metadataBottomAfter = + metadata->mapTo(view.viewport(), QPoint(0, metadata->height())).y(); + result &= expect(!output->isHidden() && output->height() > 2 * 20 && + output->height() == output->sizeHint().height() && + metadataBottomAfter == metadataBottomBefore && + view.isAtBottom(), + "multiline output takes its needed height and grows upward"); + + QString cappedOutput; + for (int line = 0; line < 80; ++line) + cappedOutput += QStringLiteral("scrollable line %1\n").arg(line); + live.output = cappedOutput; + result &= expect(view.reconcile(snapshot), "live output reaches its cap"); + result &= expect( + output->height() == 220 && output->verticalScrollBar()->maximum() > 0 && + metadata->mapTo(view.viewport(), QPoint(0, metadata->height())).y() == + metadataBottomBefore, + "capped output keeps its scrollbar and fixed card bottom"); return result; } @@ -567,7 +675,7 @@ bool testCommandOutputStateAcrossNavigation() { ConversationCard *commandCard = card(view, stableKey(command.key)); auto *initialOutput = commandCard ? dynamic_cast( - commandCard->findChild( + commandCard->findChild( QStringLiteral("commandOutputView"))) : nullptr; bool result = @@ -588,7 +696,7 @@ bool testCommandOutputStateAcrossNavigation() { commandCard = card(view, stableKey(command.key)); auto *restoredOutput = commandCard ? dynamic_cast( - commandCard->findChild( + commandCard->findChild( QStringLiteral("commandOutputView"))) : nullptr; result &= @@ -645,9 +753,10 @@ int main(int argc, char **argv) { result &= testPromptAdmissionFollowOwnership(); result &= testMutableCardsAndCommandOutput(); result &= testInitialCommandGeometrySettlement(); + result &= testBottomAnchoredCommandOutputGrowth(); result &= testCommandOutputStateAcrossNavigation(); result &= testPendingPromptAnimation(); if (result) - std::cout << "Greenfield middle-region tests passed\n"; + std::cout << "Conversation card tests passed\n"; return result ? 0 : 1; } diff --git a/tests/codex/GreenfieldProjectionTest.cpp b/tests/codex/ConversationProjectionTest.cpp similarity index 95% rename from tests/codex/GreenfieldProjectionTest.cpp rename to tests/codex/ConversationProjectionTest.cpp index 188e49a..f75f1f8 100644 --- a/tests/codex/GreenfieldProjectionTest.cpp +++ b/tests/codex/ConversationProjectionTest.cpp @@ -348,6 +348,17 @@ bool testCommandOutputVisibility() { result &= expect( terminalOutputHasVisibleText(QStringView{QStringLiteral("done\n")}), "printable command output is visible"); + result &= expect(trimTrailingEmptyLines(QStringView{ + QStringLiteral("first\nsecond\n\n \t\r\n")}) == + QStringLiteral("first\nsecond"), + "trailing empty terminal lines are removed"); + result &= expect(trimTrailingEmptyLines( + QStringView{QStringLiteral(" meaningful spacing ")}) == + QStringLiteral(" meaningful spacing "), + "spacing on a non-empty final line is retained"); + result &= expect( + trimTrailingEmptyLines(QStringView{QStringLiteral(" \t\r\n")}).isEmpty(), + "an entirely empty-line display normalizes to zero lines"); return result; } @@ -363,6 +374,6 @@ int main() { result &= testAnchoredDuplicatePrompts(); result &= testCommandOutputVisibility(); if (result) - std::cout << "Greenfield projection tests passed\n"; + std::cout << "Conversation projection tests passed\n"; return result ? 0 : 1; } diff --git a/tests/codex/ConversationScrollTest.cpp b/tests/codex/ConversationScrollTest.cpp deleted file mode 100644 index 8d5ca28..0000000 --- a/tests/codex/ConversationScrollTest.cpp +++ /dev/null @@ -1,519 +0,0 @@ -// 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/GitChangesLiveTest.cpp b/tests/codex/GitChangesLiveTest.cpp new file mode 100644 index 0000000..b21108f --- /dev/null +++ b/tests/codex/GitChangesLiveTest.cpp @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/DiffViewer.h" +#include "codex/GitDiffProvider.h" +#include "codex/ui/UiStyle.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +namespace { + +using codexui::codex::DiffViewer; +using codexui::codex::GitDiffFile; +using codexui::codex::GitDiffSnapshot; + +bool expect(bool condition, const char *message) { + if (condition) + return true; + std::cerr << "FAILED: " << message << '\n'; + return false; +} + +bool writeFile(const QString &path, const QByteArray &contents) { + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) + return false; + return file.write(contents) == contents.size(); +} + +bool replaceFile(const QString &path, const QByteArray &contents) { + QSaveFile file(path); + if (!file.open(QIODevice::WriteOnly) || + file.write(contents) != contents.size()) + return false; + return file.commit(); +} + +bool waitFor(const std::function &condition, int timeoutMs) { + QElapsedTimer timer; + timer.start(); + while (!condition() && timer.elapsed() < timeoutMs) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + QThread::msleep(2); + } + return condition(); +} + +bool createInitialCommit(git_repository *repository, const QString &root) { + if (!writeFile(QDir(root).filePath(QStringLiteral("tracked.txt")), + QByteArray("original\n"))) + return false; + git_index *index = nullptr; + if (git_repository_index(&index, repository) < 0) + return false; + const bool indexed = git_index_add_bypath(index, "tracked.txt") == 0 && + git_index_write(index) == 0; + git_oid treeId{}; + const bool wroteTree = indexed && git_index_write_tree(&treeId, index) == 0; + git_index_free(index); + if (!wroteTree) + return false; + git_tree *tree = nullptr; + git_signature *signature = nullptr; + if (git_tree_lookup(&tree, repository, &treeId) < 0 || + git_signature_now(&signature, "CodexUI Test", "codexui@example.invalid") < + 0) { + git_tree_free(tree); + git_signature_free(signature); + return false; + } + git_oid commitId{}; + const bool committed = + git_commit_create(&commitId, repository, "HEAD", signature, signature, + nullptr, "initial", tree, 0, nullptr) == 0; + git_signature_free(signature); + git_tree_free(tree); + return committed; +} + +bool hasFile(const GitDiffSnapshot &snapshot, const QString &path, + const QString &status = {}) { + for (const GitDiffFile &file : snapshot.files) { + if (file.path == path && (status.isEmpty() || file.status == status)) + return true; + } + return false; +} + +bool testLiveWorkingTreeChanges() { + QTemporaryDir directory; + if (!expect(directory.isValid(), "creates a temporary repository")) + return false; + git_repository *repository = nullptr; + if (!expect(git_repository_init(&repository, + directory.path().toUtf8().constData(), 0) == + 0, + "initializes a repository with libgit2")) + return false; + if (!expect(createInitialCommit(repository, directory.path()), + "creates an initial commit with libgit2")) { + git_repository_free(repository); + return false; + } + + DiffViewer viewer; + viewer.resize(700, 500); + viewer.show(); + const QString threadId = + QStringLiteral("live-%1").arg(directory.path()); + viewer.setRepositoryContext( + threadId, directory.path(), {directory.path()}, {}); + bool result = expect( + waitFor([&] { return viewer.currentSnapshot().repository; }, 1500) && + viewer.currentSnapshot().files.empty(), + "starts from the clean real working tree"); + + const QString manual = + directory.filePath(QStringLiteral("nested/manual.txt")); + QDir().mkpath(QFileInfo(manual).absolutePath()); + result &= expect(writeFile(manual, QByteArray("created by hand\n")) && + waitFor( + [&] { + return hasFile(viewer.currentSnapshot(), + QStringLiteral("nested/manual.txt"), + QStringLiteral("Untracked")); + }, + 3500), + "discovers a manually created untracked file"); + for (QTimer *timer : viewer.findChildren()) { + if (!timer->isSingleShot()) + timer->stop(); + } + result &= expect(QFile::remove(manual) && + waitFor( + [&] { + return !hasFile(viewer.currentSnapshot(), + QStringLiteral("nested/manual.txt")); + }, + 1500), + "removes a reverted untracked file after a filesystem event"); + + const QString tracked = + directory.filePath(QStringLiteral("tracked.txt")); + const bool modified = writeFile(tracked, QByteArray("modified\n")); + viewer.refreshRepository(); + result &= expect(modified && + waitFor( + [&] { + return hasFile(viewer.currentSnapshot(), + QStringLiteral("tracked.txt"), + QStringLiteral("Modified")); + }, + 3500), + "discovers a modified tracked file"); + result &= expect(writeFile(tracked, QByteArray("original\n")) && + waitFor( + [&] { + return !hasFile(viewer.currentSnapshot(), + QStringLiteral("tracked.txt")); + }, + 1500), + "removes a content reversion after a filesystem event"); + + const bool atomicallyModified = + replaceFile(tracked, QByteArray("atomic modification\n")); + viewer.refreshRepository(); + result &= expect(atomicallyModified && + waitFor( + [&] { + return hasFile(viewer.currentSnapshot(), + QStringLiteral("tracked.txt"), + QStringLiteral("Modified")); + }, + 1500), + "refreshes after an atomic file replacement"); + result &= expect(replaceFile(tracked, QByteArray("original\n")) && + waitFor( + [&] { + return !hasFile(viewer.currentSnapshot(), + QStringLiteral("tracked.txt")); + }, + 1500), + "re-registers watches and removes an atomic reversion"); + + const bool deleted = QFile::remove(tracked); + viewer.refreshRepository(); + result &= expect(deleted && + waitFor( + [&] { + return hasFile(viewer.currentSnapshot(), + QStringLiteral("tracked.txt"), + QStringLiteral("Deleted")); + }, + 1500), + "represents a deleted tracked file consistently"); + result &= expect(writeFile(tracked, QByteArray("original\n")) && + waitFor( + [&] { + return !hasFile(viewer.currentSnapshot(), + QStringLiteral("tracked.txt")); + }, + 1500), + "removes a restored deletion after a directory event"); + + DiffViewer restartedViewer; + restartedViewer.resize(700, 500); + restartedViewer.show(); + restartedViewer.setRepositoryContext( + threadId, QFileInfo(directory.path()).absolutePath(), {}, {}); + result &= expect( + waitFor( + [&] { + return restartedViewer.currentSnapshot().repositoryRoots == + QStringList{QDir::cleanPath(directory.path())}; + }, + 1500), + "restores a one-repository thread from persisted resolution after viewer recreation"); + + git_repository_free(repository); + return result; +} + +} // namespace + +int main(int argc, char **argv) { + QApplication application(argc, argv); + git_libgit2_init(); + const bool result = testLiveWorkingTreeChanges(); + git_libgit2_shutdown(); + return result ? 0 : 1; +} diff --git a/tests/codex/PresentationPipelineTest.cpp b/tests/codex/PresentationPipelineTest.cpp index 30dd8a4..50393b6 100644 --- a/tests/codex/PresentationPipelineTest.cpp +++ b/tests/codex/PresentationPipelineTest.cpp @@ -205,6 +205,44 @@ int main() { "incomplete thread reads preserve live plan and inspector state"); passed &= expect(!model.activeTurnId("thread-1").has_value(), "completed stream leaves no active turn"); + + PresentationModel hydratedModel; + ProtocolNormalizer hydratedNormalizer( + [&](const nlohmann::json &frame) { + hydratedModel.applyEvent(frame); + return true; + }); + hydratedNormalizer.transportEvent("connected"); + hydratedNormalizer.operationResult( + "thread.read", "repository-hints", {{"threadId", "repository-thread"}}, + {{"id", "repository-hints"}, + {"result", + {{"thread", + {{"id", "repository-thread"}, + {"cwd", "/workspace"}, + {"turns", + nlohmann::json::array( + {{{"id", "repository-turn"}, + {"items", + nlohmann::json::array( + {{{"id", "repository-command"}, + {"type", "commandExecution"}, + {"cwd", "/workspace/project/src"}}, + {{"id", "repository-change"}, + {"type", "fileChange"}, + {"changes", + nlohmann::json::array( + {{{"path", "lib/example.cpp"}}, + {{"path", "removed.txt"}}})}}})}}})}}}}}}); + const auto *repositoryThread = hydratedModel.thread("repository-thread"); + passed &= expect( + repositoryThread != nullptr && + repositoryThread->commandCwds == + std::vector{"/workspace/project/src"} && + repositoryThread->changedPaths == + std::vector{"lib/example.cpp", "removed.txt"}, + "authoritative thread hydration retains compact repository hints"); + normalizer.bridgeEvent({{"kind", "bridge.provider"}, {"state", "disconnected"}, {"providerGeneration", std::uint64_t{1}}, diff --git a/tests/codex/GreenfieldShellIntegrationTest.cpp b/tests/codex/ShellIntegrationTest.cpp similarity index 97% rename from tests/codex/GreenfieldShellIntegrationTest.cpp rename to tests/codex/ShellIntegrationTest.cpp index 0659bed..1199d86 100644 --- a/tests/codex/GreenfieldShellIntegrationTest.cpp +++ b/tests/codex/ShellIntegrationTest.cpp @@ -10,6 +10,7 @@ #include "codex/middle/ConversationCards.h" #include "codex/middle/ConversationView.h" #include "codex/ui/ExpandingPromptEditor.h" +#include "codex/ui/UiStyle.h" #include #include @@ -19,6 +20,7 @@ #include #include #include +#include #include #include @@ -273,6 +275,14 @@ bool runShellFlow(FrontendSession &session, PresentationPeer &peer) { spin(10); bool result = true; + auto *transportButton = + shell.findChild(QStringLiteral("transportButton")); + result &= expect( + transportButton && + dynamic_cast(transportButton) && + transportButton->property("codexChevron").toBool(), + "transport and thread sorting use the canonical compact chevron button"); + auto *conversation = dynamic_cast( shell.findChild(QStringLiteral("conversationScroll"))); result &= expect(conversation, "the shell owns the conversation viewport"); @@ -357,8 +367,12 @@ bool runShellFlow(FrontendSession &session, PresentationPeer &peer) { expect(beforeAck && beforeAck->state == middle::PromptState::InFlight, "materialization alone cannot acknowledge A1"); + editor->setPlainText(QStringLiteral("unsent shared draft")); result &= expect(selectThread(list, "thread-b"), "B can be selected while A remains active"); + result &= expect(editor->toPlainText() == + QStringLiteral("unsent shared draft"), + "thread navigation retains the shared composer draft"); const auto readB = peer.waitFor("thread.read", "thread-b"); result &= expect(readB.has_value(), "selecting B requests its own hydration"); if (!readB) @@ -721,6 +735,6 @@ int main(int argc, char **argv) { codexui::codex::FrontendSessionTestPeer::takeClientDescriptor(session)); const bool result = codexui::codex::runShellFlow(session, peer); if (result) - std::cout << "Greenfield shell integration test passed\n"; + std::cout << "Shell integration test passed\n"; return result ? 0 : 1; } diff --git a/ui-review/UI-INVENTORY.md b/ui-review/UI-INVENTORY.md index eb81ce2..034417b 100644 --- a/ui-review/UI-INVENTORY.md +++ b/ui-review/UI-INVENTORY.md @@ -69,7 +69,8 @@ - **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 +- **Changes:** multi-repository selector, per-file unified diff with + addition/deletion counts, live filesystem refresh, copy, and expanded viewing. - **Requests:** typed approval and input requests with explicit resolution. - **Info / State:** retained normalized presentation domains. diff --git a/ui-review/UX-DESIGN-DECISIONS.md b/ui-review/UX-DESIGN-DECISIONS.md index 6638ff8..3d05167 100644 --- a/ui-review/UX-DESIGN-DECISIONS.md +++ b/ui-review/UX-DESIGN-DECISIONS.md @@ -4,14 +4,69 @@ This document records the implemented CodexUI visual and interaction contract. ## Visual system -- CodexUI uses a light theme with neutral application surfaces and restrained - blue, green, amber, and red state colors. +- CodexUI uses a light theme with neutral application surfaces and four opaque + semantic color families. Blue remains the unchanged primary-action reference; + green, orange, and red use matching interaction steps and comparable + white-text contrast. - 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 three primary panels use one prominent neutral 24 px header row: + `THREADS`, `CONVERSATION`, and `INSPECTOR`, followed by a standard-intensity + divider and an 8 px content gap. Accent-filled labels are reserved for + interactive or selected states. Panel headers are one typographic level + below the active thread title so structure never competes with content. + +Canonical application typography is derived from the platform/application +base font size `B`; fixed absolute point sizes are not used for UI chrome. + +| Level | Size | Canonical roles | +|---|---:|---| +| Compact | `B - 1 pt` | Metadata, tabs, buttons, table headers, code and diff text | +| Standard | `B` | Body text, controls, editors, list content | +| Structural | `B + 1 pt` | Panel headers, section labels, subordinate brand labels | +| Content heading | `B + 3 pt` | Active thread title and primary in-panel headings | + +Weight and color may distinguish roles that share a size. In particular, +uppercase panel headers use Structural size with bold weight and a stronger +neutral color; the mixed-case active thread title uses Content heading size +with semibold weight. + +Markdown is authored content rather than application chrome. Its semantic +heading levels intentionally retain Qt's native relative rich-text sizes and +are not mapped to the canonical application scale. + +| Family | Primary | Hover | Pressed | Soft surface | Border | Surface text | +|---|---|---|---|---|---|---| +| Blue | `#2f6feb` | `#285fca` | existing blue behavior | `#e5eeff` | `#bfd3f9` | `#285fca` | +| Green | `#18865e` | `#14734f` | `#105f41` | `#e9f7f0` | `#a9d8c1` | `#176b45` | +| Orange | `#a85d0c` | `#8e4d09` | `#743e07` | `#fff6df` | `#e5c77d` | `#8a5208` | +| Red | `#c43d4d` | `#aa3342` | `#8f2b38` | `#fff0f2` | `#efb8c0` | `#982f3d` | + +Neutral separators and borders use three canonical intensity steps: + +| Intensity | Color | Role | +|---|---|---| +| Soft | `#eef1f5` | Subordinate internal separation | +| Standard | `#d7dee8` | Ordinary dividers and card/control borders | +| Strong | `#b9c4d2` | Hover, emphasis, and stronger structural separation | + +Ordinary one-pixel lines use Standard. Soft is reserved for deliberately +subordinate structure, while Strong must communicate interaction or hierarchy +rather than decorate a normal boundary. + +Filled semantic buttons use white text and the primary, hover, and pressed +steps without opacity changes. Their primary contrast against white ranges +from 4.55:1 to 5.09:1. Blue denotes primary action or active work, green +denotes success or connection, orange denotes warning or attention, and red +denotes failure, stop, removal, or another destructive action. Activity dots +use the same primary colors at 10 pixels so their state remains legible without +creating a separate indicator palette. The existing gray palette is unchanged; +only inactive thread dots use the lighter, less saturated `#cacccf` so active +blue threads retain clear visual priority. ## Application layout @@ -72,11 +127,38 @@ pauses when the user scrolls upward. ## Inspector -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. +The Inspector contains the peer primary tabs Plan, Agents, Changes, Requests, +and Info. Primary tabs use the shared full-size application typography and are +never nested. Info presents State and Protocol as raised choice rows with +chevrons; selecting one drills into its viewer, with an explicit back action to +the choices. This expresses hierarchy through navigation rather than smaller +text. Both viewers use application scrollbars. In Protocol, the log expands +above a statistics summary placed at the bottom. + +Plan steps, agents, and pending requests are peer records and therefore use the +same raised card surface, border, radius, and internal spacing. Summary surfaces +are reserved for subordinate content within a record. Inspector scroll areas +are frameless and transparent so the panel background remains continuous. +Plan, Agents, and Requests retain their last visible per-thread presentation +across thread and tab navigation. + +Changes reflects the local Git worktrees resolved from the selected thread's +retained command directories, never a patch reconstructed from conversation +messages. When several repositories match, the compact Inspector surface +defaults to All repositories and offers a repository selector beside scope, +file summary/list, and unified preview. Repository-qualified file labels remove +ambiguity. Copy and Open review belong to the selected-file preview; +double-clicking a file also opens review. The modeless review window provides +Unified or Side by side layout and Compact or Expanded context without blocking +conversation use. Manual filesystem changes use the same libgit2 authority as +Codex changes; filesystem watches and a short safety refresh remove clean files +and discover new untracked files. The compact and review scrollbars provide an +overview ruler: canonical green marks additions, red marks deletions, and blue +marks hunk boundaries. The file list owns a compact muted footer with semantic +addition/deletion totals, followed by a standard gray divider before the selected +file preview. The divider spans the full tab page and aligns with the tab +underline, while adjacent content retains its normal inset. Repository and +scope are not repeated outside their controls. ## Desktop integration