diff --git a/CMakeLists.txt b/CMakeLists.txt index a5d0b44..0641fd2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -29,6 +29,7 @@ find_package(Threads REQUIRED) set( CODEXUI_CODEX_COMMON_SOURCES + src/codex/AttachmentDraft.h src/codex/ClientRuntime.cpp src/codex/ClientRuntime.h src/codex/Configuration.cpp @@ -47,13 +48,18 @@ set( src/codex/MainWindow.h src/codex/NewThreadDialog.cpp src/codex/NewThreadDialog.h + src/codex/PendingRequestPolicy.cpp + src/codex/PendingRequestPolicy.h src/codex/PresentationModel.cpp src/codex/PresentationModel.h + src/codex/PresentationClient.h src/codex/PresentationStatus.h src/codex/PresentationProtocol.cpp src/codex/PresentationProtocol.h src/codex/ProtocolNormalizer.cpp src/codex/ProtocolNormalizer.h + src/codex/UiSession.cpp + src/codex/UiSession.h src/codex/ipc/QtSocketPairEndpoint.cpp src/codex/ipc/QtSocketPairEndpoint.h src/codex/ipc/SNodeSocketPairEndpoint.cpp @@ -67,6 +73,9 @@ set( src/codex/ui/BrandMark.h src/codex/ui/UiStyle.cpp src/codex/ui/UiStyle.h + src/codex/ui/UiViewProjection.cpp + src/codex/ui/UiViewProjection.h + src/codex/ui/UiViewState.h ) set( @@ -203,6 +212,55 @@ if(BUILD_TESTING) codexui-presentation-pipeline PROPERTIES TIMEOUT 10 ) + add_executable( + codexui-pending-request-policy-test + tests/codex/PendingRequestPolicyTest.cpp + src/codex/PendingRequestPolicy.cpp + src/codex/PendingRequestPolicy.h + ) + target_compile_features( + codexui-pending-request-policy-test PRIVATE cxx_std_20 + ) + target_include_directories( + codexui-pending-request-policy-test PRIVATE src + ) + add_test( + NAME codexui-pending-request-policy + COMMAND codexui-pending-request-policy-test + ) + set_tests_properties( + codexui-pending-request-policy PROPERTIES TIMEOUT 10 + ) + + add_executable( + codexui-ui-session-test + tests/codex/UiSessionTest.cpp + src/codex/UiSession.cpp + src/codex/UiSession.h + src/codex/PendingRequestPolicy.cpp + src/codex/PendingRequestPolicy.h + src/codex/PresentationClient.h + src/codex/PresentationModel.cpp + src/codex/PresentationModel.h + src/codex/PresentationProtocol.cpp + src/codex/PresentationProtocol.h + src/codex/PresentationStatus.h + src/codex/AttachmentDraft.h + src/codex/ui/UiViewProjection.cpp + src/codex/ui/UiViewProjection.h + src/codex/ui/UiViewState.h + 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-ui-session-test PRIVATE cxx_std_20) + target_include_directories(codexui-ui-session-test PRIVATE src) + add_test(NAME codexui-ui-session COMMAND codexui-ui-session-test) + set_tests_properties(codexui-ui-session PROPERTIES TIMEOUT 10) + add_executable( codexui-conversation-projection-test tests/codex/ConversationProjectionTest.cpp @@ -212,14 +270,12 @@ if(BUILD_TESTING) src/codex/middle/MiddleTypes.h src/codex/middle/PromptCoordinator.cpp src/codex/middle/PromptCoordinator.h + src/codex/AttachmentDraft.h ) target_compile_features( codexui-conversation-projection-test PRIVATE cxx_std_20 ) target_include_directories(codexui-conversation-projection-test PRIVATE src) - target_link_libraries( - codexui-conversation-projection-test PRIVATE Qt6::Widgets - ) add_test( NAME codexui-conversation-projection COMMAND codexui-conversation-projection-test @@ -275,6 +331,9 @@ if(BUILD_TESTING) src/codex/ui/ExpandingPromptEditor.h src/codex/ui/UiStyle.cpp src/codex/ui/UiStyle.h + src/codex/ui/UiViewProjection.cpp + src/codex/ui/UiViewProjection.h + src/codex/ui/UiViewState.h src/codex/middle/ComposerPane.cpp src/codex/middle/ComposerPane.h src/codex/middle/ConversationCards.cpp diff --git a/README.md b/README.md index 2190d90..2db31f2 100644 --- a/README.md +++ b/README.md @@ -15,16 +15,20 @@ SNode.C client thread <-> Codex app-server ``` -The Qt thread owns widgets and `PresentationModel`. The SNode.C thread owns the -event loop, selected transport, `AISuite::OpenAICodex` frontend proxy SDK, -native protocol normalization, and connection/controller telemetry. They -exchange only bounded `codexui.presentation` JSONL commands and events. +The Qt thread owns widgets plus a toolkit-neutral `UiSession`, which owns the +`PresentationModel` and UI/UX state machine. Widgets exchange only semantic +intents and value snapshots with that boundary. `FrontendSession` adapts its +generic presentation client to the unchanged socketpair. The SNode.C thread +owns the event loop, selected transport, `AISuite::OpenAICodex` frontend proxy +SDK, native protocol normalization, and connection/controller telemetry. The +threads exchange only bounded `codexui.presentation` JSONL commands and events. ## Applications -`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. +`codex-ui` is the canonical visual application. Its production shell renders +the neutral `UiSessionView` API and sends semantic intents; it does not consume +`PresentationModel` directly. There is no parallel legacy UI or alternate +application target. `CodexWebUI` is the browser presentation. It uses the framework-neutral `@snodec/codex-frontend` SDK from AISuite, connects directly to the bridge over diff --git a/docs/codex-architecture.md b/docs/codex-architecture.md index d94c1ee..1cc5c08 100644 --- a/docs/codex-architecture.md +++ b/docs/codex-architecture.md @@ -7,10 +7,15 @@ CodexUI is a remote frontend for `codex-bridge`. It uses the AISuite without introducing another backend, protocol authority, or retained semantic store. -The architecture has three explicit boundaries: +The architecture keeps the existing transport boundaries and adds an explicit +in-process renderer boundary: ```text -Qt presentation +Qt widgets and dialogs + <-> semantic intents, neutral snapshots, notices, and narrow effects +UiSession (toolkit-neutral C++ UI/UX logic; on the Qt thread today) + <-> PresentationClient actions/results and normalized presentation frames +FrontendSession Qt/socketpair adapter <-> normalized UI command/event protocol SNode.C client runtime + codex frontend proxy SDK <-> slim codex-bridge envelope over a selected SNode.C transport @@ -35,10 +40,10 @@ a protocol object and is unrelated to these execution threads. Qt GUI thread SNode.C client thread +------------------------+ +---------------------------+ | Qt application loop | | SNode.C event loop | - | widgets | | selected client transport | - | presentation model | | ClientConnection | - | normalized UI events | | frontend proxy SDK | - | user interaction | | protocol normalizer | + | concrete widgets | | selected client transport | + | UiSession | | ClientConnection | + | PresentationModel | | frontend proxy SDK | + | FrontendSession | | protocol normalizer | +-----------+------------+ +-------------+-------------+ | | | bounded full-duplex Unix socketpair | @@ -74,17 +79,57 @@ required. The Qt thread exclusively owns: - `QApplication`, the Qt event loop, and all GUI objects; -- selected thread, selected tab, scroll, expansion, draft, and focus state; -- the `PresentationModel`, which is the sole retained authoritative store for - normalized presentation state; -- rendering and user-action translation; -- correlation of normalized UI operation results with UI intents. - -Only the Qt thread may mutate Qt objects or presentation state. It performs no -bridge transport, app-server framing, JSON-RPC correlation, or typed app-server -decoding. - -### 3.2 SNode.C Client Thread +- `UiSession`, including selected-thread intent, new-thread intent, prompt + admission and queues, hydration/recovery state, pending-request eligibility, + and the `PresentationModel`; +- concrete-only selected tab, scroll, expansion, composer-form draft, dialog, + focus, geometry, and paint state; +- `FrontendSession`, the Qt endpoint adapter and normalized operation-result + correlation; +- projection of neutral snapshots into widgets and translation of gestures + into semantic `UiSession` calls. + +Only the Qt thread may mutate Qt objects or `UiSession` today. `UiSession` has +no Qt types and does not require the Qt event loop; keeping it on this thread is +the current threading model, not a toolkit dependency. The Qt side performs no +bridge transport, app-server framing, native JSON-RPC correlation, or typed +app-server decoding. + +### 3.2 Renderer/Logic API + +`UiSession` is the authoritative UI/UX state owner. Its public surface is +deliberately small and protocol-complete: + +- renderer input consists of semantic calls such as select, submit, reload, + interrupt, resolve request, or configure connection; +- normalized `codexui.presentation` frames enter through + `onPresentationFrame`, and transport activity enters through the stable + thread identity only; +- rendering reads one aggregate `UiSessionView` containing toolkit-neutral + thread, conversation, inspector, settings, connection, request, and + optimistic-thread snapshots; +- one-shot notices and narrow effects cover only concrete work such as clearing + or focusing the composer and preserving its local-admission scroll behavior; +- an optional read-only frame observer feeds the bounded Protocol diagnostic + without giving that renderer state or replay authority; +- absolute wakeups let the existing Qt timer drive deferred dispatch and visual + acknowledgment transitions without introducing another scheduler. + +Downward communication uses the value-type `PresentationClient`: generic +correlated `execute(action, data, completion)`, fire-and-forget +`send(action, data)`, and `respond(requestId, result, error)`. It contains no Qt, +socket, thread, or inheritance contract. `FrontendSession` supplies those three +functions over the unchanged Qt socketpair endpoint. Consequently another UI +toolkit can consume the same C++ UI/UX logic by rendering the snapshots and +supplying an equivalent presentation-protocol adapter; it does not need to +inherit from or instantiate a Qt widget. + +This separation is not a new live protocol or execution architecture. The +class still runs on the GUI thread, the bounded socketpair remains the sole +cross-thread queue, and protocol version 1 is unchanged. It makes a later move +of the neutral logic possible without making that move part of this refactor. + +### 3.3 SNode.C Client Thread The SNode.C thread exclusively owns: @@ -278,10 +323,10 @@ The implemented typed action catalog additionally covers: feedback upload, and Windows sandbox setup/readiness. Every action is dispatched through its generated AISuite codex operation type. -Qt sends semantic presentation action names and typed `data`; native app-server -method names do not cross the regular socketpair contract. `initialize` and -`initialized` are deliberately absent because the bridge owns the one shared -provider handshake. +`UiSession` sends semantic presentation action names and typed `data` through +`PresentationClient` and `FrontendSession`; native app-server method names do +not cross the regular socketpair contract. `initialize` and `initialized` are +deliberately absent because the bridge owns the one shared provider handshake. Commands are asynchronous. No Qt call blocks waiting for SNode.C. Unsupported correlated actions receive one `result` with `ok:false` and a structured error. @@ -388,12 +433,14 @@ identity requires a new major version. ## 6. Presentation Authority and Reduction -Qt owns `PresentationModel`, the sole retained authoritative store for -normalized presentation state. Widgets and projections read from it; they do -not retain competing copies of thread, turn, item, plan, agent, request, or -global-domain state. The app-server remains the semantic and persistence -authority, so the model is not a persistence layer or substitute for -app-server history. +`UiSession` owns `PresentationModel`, the sole retained authoritative store for +normalized presentation state. Only neutral projection code inside the logic +boundary reads it. Qt widgets consume value snapshots and do not retain +competing copies of thread, turn, item, plan, agent, request, or global-domain +state. Widget-local scroll, expansion, sorting, focus, and paint caches are +presentation mechanics, not another semantic store. The app-server remains the +semantic and persistence authority, so the model is not a persistence layer or +substitute for app-server history. Presentation reduction follows these rules: @@ -445,8 +492,8 @@ explicit forced fresh-read operation. ### 7.1 Upcoming-Turn Settings -The real shell has a codex-native upcoming-turn settings surface backed by the -normalized `PresentationModel`. Its primary controls are: +The real shell has a codex-native upcoming-turn settings surface populated from +the neutral `UiSession` settings snapshot. Its primary controls are: - model and model-constrained reasoning effort; - sandbox access and the sandbox-native network choice; @@ -599,11 +646,12 @@ that they are clean again. ### 7.5 Conversation Projection and Prompt Admission -The selected conversation is a pure projection of `PresentationModel` plus -client-local prompt admissions. Its one structural grouping level is the -app-server turn: each retained turn contributes one transparent section, and -its items remain in exact server order. A turn is identified only by its stable -turn ID; CodexUI does not infer a turn boundary from a user-message card. +The selected conversation snapshot is a pure projection inside `UiSession` of +`PresentationModel` plus client-local prompt admissions. Its one structural +grouping level is the app-server turn: each retained turn contributes one +transparent section, and its items remain in exact server order. A turn is +identified only by its stable turn ID; CodexUI does not infer a turn boundary +from a user-message card. Authoritative cards use the stable `(threadId, turnId, itemId)` identity. Locally admitted cards use a process-wide submission identity that remains @@ -981,13 +1029,13 @@ depends on the member's presence. ## 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. +The production `ShellWidget` is a concrete renderer of `UiSessionView`; it does +not consume `PresentationModel` or branch on protocol operations. Conversation, +Plan, Agents, Changes, Requests, retained State, and bounded Protocol +diagnostics are integrated into that shell. The raw Protocol log is a bounded +renderer-local diagnostic view of frames also delivered to `UiSession`; it has +no reduction, replay, hydration, or deletion authority. The shell retains no +parallel semantic state authority. ## 17. Implemented Components and APIs @@ -1003,12 +1051,15 @@ The implementation is divided into the following concrete components: | `SocketPair` | Movable RAII owner for the unnamed nonblocking `AF_UNIX` socketpair | | `QtSocketPairEndpoint` | Qt-thread descriptor adapter using `QSocketNotifier`, bounded reads, and bounded writes | | `SNodeSocketPairEndpoint` | SNode.C-thread descriptor adapter using `ReadEventReceiver` and `WriteEventReceiver`, bounded reads, and bounded writes | -| `FrontendSession` | Qt-side asynchronous command facade, correlation registry, lifecycle owner, and socketpair JSONL endpoint | +| `PresentationClient` | Toolkit-neutral value API for generic execute, send, and server-request response operations | +| `FrontendSession` | Qt-side `PresentationClient` adapter, correlation registry, lifecycle owner, and socketpair JSONL endpoint | | `ClientRuntime` | SNode.C-thread application graph, selected transport, frontend proxy SDK dispatch, reconnect, and shutdown | | `ProtocolNormalizer` | Native app-server/bridge input to `codexui.presentation` result/event conversion | | `PresentationProtocol` | Frame construction, validation, authority, sequence, generation, and scope utilities | -| `PresentationModel` | Qt-owned stable-ID reducer for threads, turns, items, plans, agents, requests, global domains, and telemetry | -| `ShellWidget` | Product shell and protocol/application coordinator; owns stable selection, hydration, recovery, and command dispatch | +| `PresentationModel` | Toolkit-neutral stable-ID reducer for threads, turns, items, plans, agents, requests, global domains, and telemetry; owned by `UiSession` | +| `UiSession` | Toolkit-neutral UI/UX owner for semantic intents, selection, hydration, recovery, prompt queues, pending eligibility, projections, notices, and effects | +| `UiViewState` / `UiViewProjection` | Renderer-facing neutral snapshot DTOs and pure model projection | +| `ShellWidget` | Thin Qt product-shell adapter for dialogs, gestures, snapshot rendering, focus, and pane composition | | `MiddleRegionWidget` | Three-pane visual composition and center-region wheel routing | | `ThreadPane` | Stable-ID thread-list projection and thread actions | | `ConversationProjection` | Pure thread-to-turn-to-card projection over `PresentationModel` and local prompts | @@ -1027,16 +1078,14 @@ The implementation is divided into the following concrete components: | `MainWindow` | Top-level Qt window ownership only | | `BrandMark` and desktop resources | Shared visual mark and the consistent `codex-ui` executable/application/window/icon identity | -### 17.1 FrontendSession API +### 17.1 Presentation Client and FrontendSession APIs -`FrontendSession` is the normal Qt-side entry point. It provides asynchronous -methods for thread discovery/read/create/resume/fork/rename/archive/delete, -model and environment discovery, turn start/steer/interrupt, controller -claim/release, transport connect/disconnect/reconnect/configure, raw diagnostic -send, and typed server-request -resolution. Every correlated method returns a presentation correlation ID and -optionally invokes a Qt-thread response callback. It never blocks the GUI -thread or exposes a transport socket. +`PresentationClient` is the normal UI-logic entry point. Its three generic +functions cover correlated operations, uncorrelated commands, and typed +server-request responses. `FrontendSession::presentationClient()` binds them to +the existing Qt endpoint. Every correlated operation returns a presentation +correlation ID and optionally invokes a GUI-thread response callback. Neither +API blocks the GUI thread or exposes a transport socket. The generic operation method: @@ -1046,17 +1095,30 @@ request(std::string operation, ResponseHandler handler = {}) ``` -supports the complete generated AISuite operation catalog without adding one -Qt facade method per rarely used operation. Frequently used UI actions have -narrow named methods such as `listThreads()`, `readThread()`, `startTurn()`, -`steerTurn()`, `configureConnection()`, and `respondToServerRequest()`. +supports the complete generated AISuite operation catalog. Existing narrow +`FrontendSession` convenience methods remain adapters, but `UiSession` depends +only on `PresentationClient` and therefore does not mirror the catalog as a Qt +facade. Lifecycle is explicit: `start()` creates the endpoint/runtime graph, `shutdown()` requests orderly asynchronous termination, and `wait()` joins the SNode.C thread. `setEventHandler()` receives normalized frames and `setRuntimeStoppedHandler()` reports terminal worker shutdown. -### 17.2 Normalizer and reducer APIs +### 17.2 UiSession API + +`UiSession` accepts normalized frames and semantic renderer intents. It owns +the presentation reducer and prompt coordinator and publishes one aggregate +`UiSessionView`. Thread and Inspector panes accept their neutral snapshot DTOs; +conversation cards accept neutral middle-layer values. The concrete shell has +no `PresentationModel` include. + +The change callback requests a coalesced render on the current GUI loop. The +absolute wakeup callback maps deferred prompt dispatch and acknowledgment +deadlines onto `QTimer` without giving `UiSession` a Qt dependency. This is an +adapter seam, not another queue or event loop. + +### 17.3 Normalizer and reducer APIs `ProtocolNormalizer` accepts transport lifecycle, bridge telemetry, typed server notifications, server requests, raw inbound observation, operation @@ -1071,7 +1133,7 @@ telemetry, and pending-request presentation records. Internal upsert helpers preserve complete fields across partial events, correlate child-agent threads, and apply explicit merge/replace/remove authority. -### 17.3 Transport availability +### 17.4 Transport availability The executable always builds Unix, IPv4, and IPv6 JSONL clients. TLS, RFCOMM, WebSocket, and WSS clients are compiled when their SNode.C targets are @@ -1095,7 +1157,7 @@ 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 +### 17.5 Shell settings and pending-request APIs `TurnSettingsWidget` owns only an upcoming-turn draft. The shell supplies fresh provider context and catalogs through: @@ -1127,26 +1189,29 @@ encoder resolves the catalog entry marked `isDefault` and sends its concrete model ID. Until that fresh catalog is available, CodexUI omits the otherwise explicit collaboration object rather than constructing an invalid one. -`PendingRequestDialog::present()` accepts one generation-preserving -`PendingRequestPresentation` and returns either no value when the user closes -the dialog or a `PendingRequestResponse` containing exactly one native result -or JSON-RPC error. `negativeResponse()` constructs the family-specific explicit -decline used by the Requests surface. The caller resolves through -`FrontendSession::respondToServerRequest()` with the stable connection -generation and request ID; the dialog never mutates presentation state itself. - -`ShellWidget` is the sole visual command adapter. It translates selection, -composer, settings, controller, thread-management, and request-review actions -into `FrontendSession` calls. Agent messages, plan text, reasoning summaries, -and agent results pass through `QTextDocument::setMarkdown()` with +`PendingRequestDialog::present()` accepts one neutral, generation-preserving +`PendingRequestDescriptor` and returns either no value when the user closes the +dialog or a `PendingRequestResponse` containing exactly one native result or +JSON-RPC error. `PendingRequestPolicy` owns family-specific positive, negative, +and form-submission response shaping. `UiSession::resolvePending()` revalidates +the descriptor against current generation, identity, kind, thread, and raw +request before responding through `PresentationClient`; the dialog never +mutates presentation state itself. + +`ShellWidget` is the sole native visual adapter. It translates selection, +composer, settings, controller, thread-management, and request-review gestures +into semantic `UiSession` calls and renders snapshots/effects. Agent messages, +plan text, reasoning summaries, and agent results pass through +`QTextDocument::setMarkdown()` with `MarkdownNoHTML`; user prompts, commands, and command output remain literal. Its custom dialogs return transient value objects and never mutate the -presentation model directly. The composer owns attachment drafts; the -connection dialog edits only the SNode.C runtime selection; and `DiffViewer` +presentation model directly. The composer owns its editable attachment draft; +the neutral logic owns admitted attachment values. The connection dialog edits +only the SNode.C runtime selection, and `DiffViewer` is a read-only consumer of normalized model domains and retained provider items. -### 17.5 Essential Automated Architecture Tests +### 17.6 Essential Automated Architecture Tests The permanent automated-test policy protects architectural boundaries rather than individual fixes, widget details, or lines of implementation. A defect @@ -1155,7 +1220,7 @@ codex suite only when it validates a boundary whose failure would undermine the application architecture independently of the particular symptom that revealed it. -Seven focused CTest executables form the essential suite. They use production +Nine 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. @@ -1199,7 +1264,7 @@ representative native app-server and bridge records -> ProtocolNormalizer -> codexui.presentation v1 frames -> PresentationModel::applyEvent() - -> coherent Qt-owned presentation state + -> coherent toolkit-neutral presentation state ``` The representative lifecycle includes connection and controller publication, @@ -1219,6 +1284,16 @@ socketpair itself is independently covered by the first test. This keeps a failure attributable to either inter-thread transport or semantic reduction instead of repeating both mechanisms in every case. +#### UI Session Boundary + +`codexui-ui-session-test` supplies a fake value-type `PresentationClient` to +the production, Qt-free `UiSession`. It verifies provider hydration, selection +hydration, settings resume, aggregate snapshots, deferred exact prompt +dispatch, pending-request eligibility and stale-response rejection, new-thread +effects, and change/wakeup callbacks. `codexui-pending-request-policy-test` +separately verifies every typed native response shape. Neither executable links +Qt. + #### Conversation Projection `codexui-conversation-projection-test` verifies the pure typed projection and @@ -1436,7 +1511,9 @@ 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`. +consume only neutral view values and call only semantic `UiSession` intents. +`UiSession` alone consumes normalized frames, owns `PresentationModel`, and +talks downward through `PresentationClient`. The implemented shell contains the 64-pixel top bar, hideable work sidebar, thread list, conversation timeline and composer, hideable inspector, Plan, @@ -1474,7 +1551,8 @@ contract requires a separately reviewed change. 2. `codex-bridge` is a thin multi-client router with telemetry, not a cache. 3. The codex frontend SDK is a typed proxy, not a frontend state store. 4. SNode.C owns transport, SDK execution, protocol decoding, and normalization. -5. Qt owns widgets, interaction, selection, and transient presentation state. +5. Qt owns concrete widgets and renderer mechanics; toolkit-neutral + `UiSession` owns semantic UI/UX state on the Qt thread today. 6. Only normalized commands/events form the regular inter-thread contract. 7. Cross-thread work is asynchronous and bounded. 8. Partial omission is not deletion authority. @@ -1503,9 +1581,9 @@ contract requires a separately reviewed change. one hidden layout transaction and one scroll settlement. 22. Conversation hierarchy has exactly one semantic grouping level: stable app-server turns containing stable server-ordered items. -23. `PresentationModel` is the only retained normalized presentation store; - conversation and inspector views are keyed projections, not parallel state - authorities. +23. `UiSession` exclusively owns `PresentationModel`, the only retained + normalized presentation store; conversation and inspector views are value + projections, not parallel state authorities. ## 22. Resolved Presentation Decisions diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index e980d0c..a5a1032 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -11,10 +11,12 @@ blue-tinted checked actions, muted disabled actions, and inset separators. ## Conversation source and structure -`PresentationModel` is the sole retained authoritative store for normalized UI -state. The message view is a projection of its selected thread plus -client-local prompt admissions; cards and inspectors do not maintain a second -domain store. +`UiSession` owns the sole retained `PresentationModel` for normalized UI state +and projects toolkit-neutral snapshots. The concrete message view consumes its +selected-thread snapshot plus client-local prompt admissions; cards and +inspectors do not access the model or maintain a second domain store. Qt keeps +only renderer mechanics such as scroll anchors, folding, expansion, focus, +geometry, and the editable composer form. The conversation has one semantic grouping level: an app-server turn contains its items in server order. When a turn has a prompt, its first You card is the @@ -41,6 +43,14 @@ bottom or is owned by the user. ## Thread identity and prompt routing - The selected thread is identified by its stable app-server thread ID. +- The Conversation heading reports `Last activity` from one monotonic + presentation timestamp. After hydration it starts at the greater of the + app-server's `updatedAt` and optional `recencyAt`. During the live session, + meaningful thread-scoped protocol traffic in either direction advances it + immediately. Selection-driven `thread/read` and `thread/resume` hydration, + global connection traffic, and catalog traffic do not count as activity. + This local value never replaces the authoritative timestamps used for thread + sorting and is not persisted by CodexUI. - 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. diff --git a/src/codex/AttachmentDraft.h b/src/codex/AttachmentDraft.h new file mode 100644 index 0000000..78d2a19 --- /dev/null +++ b/src/codex/AttachmentDraft.h @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_ATTACHMENTDRAFT_H +#define CODEXUI_CODEX_ATTACHMENTDRAFT_H + +#include +#include + +namespace codexui::codex { + +// Renderer-neutral description of a locally selected attachment. Paths and +// names are UTF-8; selecting and opening files remains the renderer's job. +struct AttachmentDraft { + std::string path; + std::string name; + std::string mimeType; + std::int64_t size = 0; + + bool operator==(const AttachmentDraft &) const = default; +}; + +} // namespace codexui::codex + +#endif // CODEXUI_CODEX_ATTACHMENTDRAFT_H diff --git a/src/codex/FileSelectionDialog.cpp b/src/codex/FileSelectionDialog.cpp index 22fa6f9..0d645df 100644 --- a/src/codex/FileSelectionDialog.cpp +++ b/src/codex/FileSelectionDialog.cpp @@ -18,12 +18,20 @@ #include #include +#include +#include namespace codexui::codex { namespace { constexpr int MaximumAttachments = 16; +QString text(std::string_view value) { + return QString::fromUtf8(value.data(), static_cast(value.size())); +} + +std::string utf8(const QString &value) { return value.toUtf8().toStdString(); } + QLabel *dialogLabel(QString text, const char *kind) { auto *label = new QLabel(std::move(text)); label->setProperty("kind", kind); @@ -132,12 +140,12 @@ FileSelectionDialog::FileSelectionDialog( for (const AttachmentDraft &attachment : initialAttachments) { auto *item = new QListWidgetItem( QStringLiteral("%1 | %2") - .arg(attachment.name, readableSize(attachment.size))); - item->setData(Qt::UserRole, attachment.path); - item->setData(Qt::UserRole + 1, attachment.mimeType); + .arg(text(attachment.name), readableSize(attachment.size))); + item->setData(Qt::UserRole, text(attachment.path)); + item->setData(Qt::UserRole + 1, text(attachment.mimeType)); item->setData(Qt::UserRole + 2, QVariant::fromValue(attachment.size)); - item->setToolTip(attachment.path); + item->setToolTip(text(attachment.path)); attachments->addItem(item); } } @@ -214,10 +222,10 @@ std::vector FileSelectionDialog::selectedAttachments() const { result.reserve(static_cast(attachments->count())); for (int index = 0; index < attachments->count(); ++index) { const QListWidgetItem *item = attachments->item(index); - result.push_back(AttachmentDraft{ - item->data(Qt::UserRole).toString(), - QFileInfo(item->data(Qt::UserRole).toString()).fileName(), - item->data(Qt::UserRole + 1).toString(), + const QString path = item->data(Qt::UserRole).toString(); + result.push_back( + AttachmentDraft{utf8(path), utf8(QFileInfo(path).fileName()), + utf8(item->data(Qt::UserRole + 1).toString()), item->data(Qt::UserRole + 2).toLongLong()}); } return result; diff --git a/src/codex/FileSelectionDialog.h b/src/codex/FileSelectionDialog.h index 25fbe90..aad428c 100644 --- a/src/codex/FileSelectionDialog.h +++ b/src/codex/FileSelectionDialog.h @@ -3,10 +3,11 @@ #ifndef CODEXUI_CODEX_FILESELECTIONDIALOG_H #define CODEXUI_CODEX_FILESELECTIONDIALOG_H +#include "codex/AttachmentDraft.h" + #include #include -#include #include #include @@ -19,13 +20,6 @@ class QTreeView; namespace codexui::codex { -struct AttachmentDraft { - QString path; - QString name; - QString mimeType; - std::int64_t size = 0; -}; - class FileSelectionDialog final : public QDialog { public: enum class Mode { Workspace, Attachments }; diff --git a/src/codex/FrontendSession.cpp b/src/codex/FrontendSession.cpp index 9ccb786..d07b273 100644 --- a/src/codex/FrontendSession.cpp +++ b/src/codex/FrontendSession.cpp @@ -26,6 +26,7 @@ namespace { constexpr std::size_t MaximumFrameBytes = 64U * 1024U * 1024U; constexpr std::size_t MaximumWriteQueueBytes = 128U * 1024U * 1024U; +constexpr std::size_t MaximumOutstandingRequests = 4096; } // namespace @@ -108,7 +109,6 @@ void FrontendSession::shutdown() { QTimer::singleShot(750, &acknowledgementLoop, &QEventLoop::quit); acknowledgementLoop.exec(); // A timeout must not leave a callback capturing the completed nested loop. - pending.erase(requestId); outstanding.erase(requestId); } else if (started) { static_cast(sendMessage(presentation::command("runtime.shutdown"))); @@ -128,36 +128,74 @@ void FrontendSession::setEventHandler(EventHandler handler) { eventHandler = std::move(handler); } +void FrontendSession::setActivityHandler(ActivityHandler handler) { + activityHandler = std::move(handler); +} + void FrontendSession::setRuntimeStoppedHandler(RuntimeStoppedHandler handler) { runtimeStoppedHandler = std::move(handler); } +PresentationClient FrontendSession::presentationClient() { + return PresentationClient{ + [this](std::string action, nlohmann::json data, + PresentationClient::Completion completion) { + return request(std::move(action), std::move(data), + std::move(completion)); + }, + [this](std::string action, nlohmann::json data) { + return sendMessage( + presentation::command(std::move(action), std::move(data))); + }, + [this](nlohmann::json requestId, nlohmann::json result, + nlohmann::json error) { + return respondToServerRequest(std::move(requestId), std::move(result), + std::move(error)); + }}; +} + std::string FrontendSession::request(std::string operation, nlohmann::json parameters, ResponseHandler handler) { const std::string requestId = "ui-request-" + std::to_string(nextOperation++); - outstanding.insert(requestId); - if (handler) - pending.emplace(requestId, std::move(handler)); - if (!sendMessage(presentation::command(std::move(operation), - std::move(parameters), requestId))) { - const auto iterator = pending.find(requestId); - if (iterator != pending.end()) { - ResponseHandler failed = std::move(iterator->second); - pending.erase(iterator); + const std::string threadId = + presentation::stringMember(parameters, "threadId"); + const std::string action = operation; + if (outstanding.size() >= MaximumOutstandingRequests) { + if (handler) { try { - failed({{"protocol", presentation::ProtocolName}, - {"version", presentation::ProtocolVersion}, - {"kind", "result"}, - {"correlationId", requestId}, - {"ok", false}, - {"error", - {{"code", -32020}, - {"message", "CodexUI IPC rejected operation"}}}}); + handler(presentation::result( + 0, activeGeneration, action, requestId, false, + {{"code", -32021}, + {"message", "CodexUI has too many outstanding operations"}})); + } catch (...) { + } + } + return requestId; + } + outstanding.emplace( + requestId, OutstandingRequest{action, threadId, std::move(handler)}); + const bool sent = sendMessage(presentation::command( + std::move(operation), std::move(parameters), requestId)); + if (sent && !threadId.empty() && + !presentation::isThreadHydrationAction(action) && activityHandler) + activityHandler(threadId); + if (!sent) { + const auto iterator = outstanding.find(requestId); + if (iterator != outstanding.end()) { + ResponseHandler failed = std::move(iterator->second.completion); + const std::string failedAction = std::move(iterator->second.action); + outstanding.erase(iterator); + if (!failed) + return requestId; + try { + failed(presentation::result( + 0, activeGeneration, failedAction, requestId, false, + {{"code", -32020}, + {"message", "CodexUI IPC rejected operation"}})); } catch (...) { } } - outstanding.erase(requestId); } return requestId; } @@ -411,17 +449,24 @@ void FrontendSession::receiveMessage(nlohmann::json message) { if (presentation::stringMember(message, "kind") == "result") { const std::string requestId = presentation::stringMember(message, "correlationId"); - if (outstanding.erase(requestId) == 0) + const auto iterator = outstanding.find(requestId); + if (iterator == outstanding.end()) + return; + if (presentation::stringMember(message, "action") != + iterator->second.action) { + terminalFailure("presentation result action does not match its request"); return; - const auto iterator = pending.find(requestId); - if (iterator != pending.end()) { - ResponseHandler handler = std::move(iterator->second); - pending.erase(iterator); - if (handler) { - try { - handler(message); - } catch (...) { - } + } + if (!iterator->second.threadId.empty() && + !presentation::isThreadHydrationAction(iterator->second.action) && + activityHandler) + activityHandler(iterator->second.threadId); + ResponseHandler handler = std::move(iterator->second.completion); + outstanding.erase(iterator); + if (handler) { + try { + handler(message); + } catch (...) { } } } @@ -483,22 +528,18 @@ void FrontendSession::terminalFailure(std::string message) { void FrontendSession::failAllPending(int code, std::string message, bool transient) noexcept { + auto failed = std::move(outstanding); outstanding.clear(); - auto failed = std::move(pending); - pending.clear(); - for (auto &[correlationId, handler] : failed) { + for (auto &[correlationId, request] : failed) { + ResponseHandler &handler = request.completion; if (!handler) continue; try { nlohmann::json error{{"code", code}, {"message", message}}; if (transient) error["transient"] = true; - handler({{"protocol", presentation::ProtocolName}, - {"version", presentation::ProtocolVersion}, - {"kind", "result"}, - {"correlationId", correlationId}, - {"ok", false}, - {"error", std::move(error)}}); + handler(presentation::result(0, activeGeneration, request.action, + correlationId, false, std::move(error))); } catch (...) { } } diff --git a/src/codex/FrontendSession.h b/src/codex/FrontendSession.h index 45e0d3a..0c43681 100644 --- a/src/codex/FrontendSession.h +++ b/src/codex/FrontendSession.h @@ -3,6 +3,8 @@ #ifndef CODEXUI_CODEX_FRONTENDSESSION_H #define CODEXUI_CODEX_FRONTENDSESSION_H +#include "codex/PresentationClient.h" + #include #include @@ -11,7 +13,6 @@ #include #include #include -#include namespace ai::openai::codex::protocol { class JsonLineFramer; @@ -29,6 +30,7 @@ class FrontendSessionTestPeer; class FrontendSession final { public: using EventHandler = std::function; + using ActivityHandler = std::function; using ResponseHandler = std::function; using RuntimeStoppedHandler = std::function; @@ -42,8 +44,14 @@ class FrontendSession final { void wait(); void shutdown(); void setEventHandler(EventHandler handler); + void setActivityHandler(ActivityHandler handler); void setRuntimeStoppedHandler(RuntimeStoppedHandler handler); + // Returns the slim, toolkit-neutral command API consumed by UI logic. + // FrontendSession continues to own the current Qt endpoint, socketpair, and + // SNode.C thread exactly as before. + [[nodiscard]] PresentationClient presentationClient(); + std::string request(std::string operation, nlohmann::json parameters, ResponseHandler handler = {}); std::string listThreads(nlohmann::json options = nlohmann::json::object(), @@ -111,6 +119,12 @@ class FrontendSession final { private: friend class FrontendSessionTestPeer; + struct OutstandingRequest { + std::string action; + std::string threadId; + ResponseHandler completion; + }; + bool sendMessage(const nlohmann::json &message); void receiveMessage(nlohmann::json message); void reportLocalError(std::string message); @@ -124,9 +138,9 @@ class FrontendSession final { std::thread clientThread; int clientDescriptor = -1; std::uint64_t nextOperation = 1; - std::unordered_map pending; - std::unordered_set outstanding; + std::unordered_map outstanding; EventHandler eventHandler; + ActivityHandler activityHandler; RuntimeStoppedHandler runtimeStoppedHandler; bool started = false; bool stopping = false; diff --git a/src/codex/PendingRequestDialog.cpp b/src/codex/PendingRequestDialog.cpp index 3be3417..abba42d 100644 --- a/src/codex/PendingRequestDialog.cpp +++ b/src/codex/PendingRequestDialog.cpp @@ -47,30 +47,6 @@ QLabel *wrapped(QString value, const char *kind = "body") { return label; } -QString titleFor(const std::string &kind) { - if (kind == "command-approval") - return QStringLiteral("Command approval"); - if (kind == "file-change-approval") - return QStringLiteral("File-change approval"); - if (kind == "user-input") - return QStringLiteral("Codex needs input"); - if (kind == "mcp-elicitation") - return QStringLiteral("MCP server request"); - if (kind == "permissions-approval") - return QStringLiteral("Permission request"); - if (kind == "dynamic-tool-call") - return QStringLiteral("Dynamic tool request"); - if (kind == "authentication-refresh") - return QStringLiteral("Authentication refresh"); - if (kind == "attestation") - return QStringLiteral("Attestation request"); - if (kind == "legacy-patch-approval") - return QStringLiteral("Legacy patch approval"); - if (kind == "legacy-command-approval") - return QStringLiteral("Legacy command approval"); - return QStringLiteral("Unsupported Codex request"); -} - void addDetail(QVBoxLayout *layout, const QString &label, const std::string &value) { if (!value.empty()) @@ -147,10 +123,6 @@ void addChoice(QComboBox *combo, const QString &label, const char *value) { combo->addItem(label, QString::fromLatin1(value)); } -nlohmann::json jsonRpcError(std::string message) { - return {{"code", -32601}, {"message", std::move(message)}}; -} - struct QuestionEditor { std::string id; std::vector> choices; @@ -160,16 +132,18 @@ struct QuestionEditor { } // namespace std::optional -PendingRequestDialog::present(const PendingRequestPresentation &request, +PendingRequestDialog::present(const PendingRequestDescriptor &request, QWidget *parent) { QDialog dialog(parent); - dialog.setWindowTitle(titleFor(request.kind)); + const QString dialogTitle = + text(PendingRequestPolicy::dialogTitle(request.kind)); + dialog.setWindowTitle(dialogTitle); dialog.setModal(true); dialog.resize(620, 560); auto *root = new QVBoxLayout(&dialog); root->setContentsMargins(18, 16, 18, 16); root->setSpacing(10); - root->addWidget(wrapped(titleFor(request.kind), "heading")); + root->addWidget(wrapped(dialogTitle, "heading")); root->addWidget(wrapped(QStringLiteral("Thread %1 | request %2") .arg(text(request.threadId), text(request.id)), "meta")); @@ -388,96 +362,26 @@ PendingRequestDialog::present(const PendingRequestPresentation &request, if (dialog.exec() != QDialog::Accepted) return std::nullopt; - PendingRequestResponse response; + std::string selectedDecision; if (request.kind == "command-approval" || request.kind == "file-change-approval") { - response.result = { - {"decision", decision->currentData().toString().toStdString()}}; + selectedDecision = decision->currentData().toString().toStdString(); } else if (request.kind == "user-input") { - response.result = {{"answers", std::move(acceptedAnswers)}}; - } else if (request.kind == "mcp-elicitation") { - const std::string action = decision->currentData().toString().toStdString(); - response.result = {{"action", action}, - {"content", std::move(acceptedStructuredContent)}, - {"_meta", nullptr}}; - } else if (request.kind == "permissions-approval") { - const std::string scope = decision->currentData().toString().toStdString(); - if (scope == "decline") { - response.error = jsonRpcError("Permission request declined by user"); - } else { - response.result = { - {"permissions", raw.value("permissions", nlohmann::json::object())}, - {"scope", scope}}; - } - } else if (request.kind == "legacy-patch-approval" || - request.kind == "legacy-command-approval") { - const std::string selected = - decision->currentData().toString().toStdString(); - if (selected == "approved" || selected == "approved_for_session") - response.result = {{"decision", selected}}; - else if (selected == "denied") - response.result = { - {"decision", {{"denied", {{"rejection", "Denied by user"}}}}}}; - else - response.result = {{"decision", "abort"}}; - } else if (request.kind == "dynamic-tool-call") { - response.result = { - {"contentItems", - nlohmann::json::array( - {{{"type", "inputText"}, - {"text", "CodexUI does not provide this dynamic tool"}}})}, - {"success", false}}; - } else { - response.error = - jsonRpcError("CodexUI does not support this server request"); - } - return response; -} - -PendingRequestResponse PendingRequestDialog::negativeResponse( - const PendingRequestPresentation &request) { - PendingRequestResponse response; - if (request.kind == "command-approval" || - request.kind == "file-change-approval") { - response.result = {{"decision", "decline"}}; + return PendingRequestPolicy::responseForSubmission( + request.kind, raw, {}, std::move(acceptedAnswers)); } else if (request.kind == "mcp-elicitation") { - response.result = { - {"action", "decline"}, {"content", nullptr}, {"_meta", nullptr}}; - } else if (request.kind == "legacy-patch-approval" || - request.kind == "legacy-command-approval") { - response.result = { - {"decision", {{"denied", {{"rejection", "Denied by user"}}}}}}; - } else if (request.kind == "dynamic-tool-call") { - response.result = { - {"contentItems", - nlohmann::json::array( - {{{"type", "inputText"}, {"text", "Request declined by user"}}})}, - {"success", false}}; - } else { - response.error = jsonRpcError("Request declined by user"); - } - return response; -} - -PendingRequestResponse PendingRequestDialog::positiveResponse( - const PendingRequestPresentation &request) { - PendingRequestResponse response; - if (request.kind == "command-approval" || - request.kind == "file-change-approval") { - response.result = {{"decision", "accept"}}; + selectedDecision = decision->currentData().toString().toStdString(); + return PendingRequestPolicy::responseForSubmission( + request.kind, raw, std::move(selectedDecision), + std::move(acceptedStructuredContent)); } else if (request.kind == "permissions-approval") { - response.result = { - {"permissions", - request.raw.value("permissions", nlohmann::json::object())}, - {"scope", "turn"}}; + selectedDecision = decision->currentData().toString().toStdString(); } else if (request.kind == "legacy-patch-approval" || request.kind == "legacy-command-approval") { - response.result = {{"decision", "approved"}}; - } else { - response.error = - jsonRpcError("CodexUI cannot directly approve this server request"); + selectedDecision = decision->currentData().toString().toStdString(); } - return response; + return PendingRequestPolicy::responseForSubmission( + request.kind, raw, std::move(selectedDecision)); } } // namespace codexui::codex diff --git a/src/codex/PendingRequestDialog.h b/src/codex/PendingRequestDialog.h index 41c7662..b4cf333 100644 --- a/src/codex/PendingRequestDialog.h +++ b/src/codex/PendingRequestDialog.h @@ -3,9 +3,7 @@ #ifndef CODEXUI_CODEX_PENDINGREQUESTDIALOG_H #define CODEXUI_CODEX_PENDINGREQUESTDIALOG_H -#include "codex/PresentationModel.h" - -#include +#include "codex/PendingRequestPolicy.h" #include @@ -13,21 +11,10 @@ class QWidget; namespace codexui::codex { -struct PendingRequestResponse { - nlohmann::json result = nlohmann::json::object(); - nlohmann::json error = nullptr; -}; - class PendingRequestDialog final { public: [[nodiscard]] static std::optional - present(const PendingRequestPresentation &request, QWidget *parent); - - [[nodiscard]] static PendingRequestResponse - negativeResponse(const PendingRequestPresentation &request); - - [[nodiscard]] static PendingRequestResponse - positiveResponse(const PendingRequestPresentation &request); + present(const PendingRequestDescriptor &request, QWidget *parent); }; } // namespace codexui::codex diff --git a/src/codex/PendingRequestPolicy.cpp b/src/codex/PendingRequestPolicy.cpp new file mode 100644 index 0000000..a45602c --- /dev/null +++ b/src/codex/PendingRequestPolicy.cpp @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/PendingRequestPolicy.h" + +#include +#include + +namespace codexui::codex { +namespace { + +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{}; +} + +nlohmann::json memberValue(const nlohmann::json &object, const char *key, + nlohmann::json fallback) { + if (!object.is_object()) + return fallback; + const auto found = object.find(key); + return found == object.end() ? fallback : *found; +} + +nlohmann::json jsonRpcError(std::string message) { + return {{"code", -32601}, {"message", std::move(message)}}; +} + +void appendDetail(std::vector &parts, std::string label, + std::string value) { + if (!value.empty()) + parts.push_back(std::move(label) + value); +} + +std::string joinDetails(const std::vector &parts) { + std::string result; + for (const std::string &part : parts) { + if (!result.empty()) + result += " | "; + result += part; + } + return result; +} + +} // namespace + +std::string PendingRequestPolicy::title(std::string_view kind) { + if (kind == "command-approval") + return "Command approval requested"; + if (kind == "file-change-approval") + return "File-change approval requested"; + if (kind == "permissions-approval") + return "Permission request"; + if (kind == "user-input") + return "Codex needs input"; + if (kind == "mcp-elicitation") + return "MCP server request"; + if (kind == "legacy-patch-approval") + return "Legacy patch approval"; + if (kind == "legacy-command-approval") + return "Legacy command approval"; + return "Codex request needs attention"; +} + +std::string PendingRequestPolicy::dialogTitle(std::string_view kind) { + if (kind == "command-approval") + return "Command approval"; + if (kind == "file-change-approval") + return "File-change approval"; + if (kind == "user-input") + return "Codex needs input"; + if (kind == "mcp-elicitation") + return "MCP server request"; + if (kind == "permissions-approval") + return "Permission request"; + if (kind == "dynamic-tool-call") + return "Dynamic tool request"; + if (kind == "authentication-refresh") + return "Authentication refresh"; + if (kind == "attestation") + return "Attestation request"; + if (kind == "legacy-patch-approval") + return "Legacy patch approval"; + if (kind == "legacy-command-approval") + return "Legacy command approval"; + return "Unsupported Codex request"; +} + +std::string PendingRequestPolicy::detail(std::string_view requestId, + std::string_view threadId, + const nlohmann::json &request) { + std::vector parts; + appendDetail(parts, "Command: ", stringValue(request, "command")); + appendDetail(parts, "Reason: ", stringValue(request, "reason")); + appendDetail(parts, {}, stringValue(request, "message")); + appendDetail(parts, "Directory: ", stringValue(request, "cwd")); + appendDetail(parts, "Grant root: ", stringValue(request, "grantRoot")); + + if (request.is_object()) { + const auto permissions = request.find("permissions"); + if (permissions != request.end() && !permissions->is_null()) + parts.push_back("Permissions: " + permissions->dump(0)); + const auto questions = request.find("questions"); + if (questions != request.end() && questions->is_array()) + parts.push_back(std::to_string(questions->size()) + " questions"); + } + + if (!parts.empty()) + return joinDetails(parts); + return "Request " + std::string(requestId) + " for thread " + + std::string(threadId); +} + +bool PendingRequestPolicy::supportsDirectAccept( + std::string_view kind) noexcept { + return kind == "command-approval" || kind == "file-change-approval" || + kind == "permissions-approval" || kind == "legacy-patch-approval" || + kind == "legacy-command-approval"; +} + +std::string PendingRequestPolicy::directAcceptLabel(std::string_view kind) { + return kind == "permissions-approval" ? "Allow this turn" : "Accept"; +} + +PendingRequestResponse PendingRequestPolicy::responseForSubmission( + std::string_view kind, const nlohmann::json &request, std::string decision, + nlohmann::json input) { + PendingRequestResponse response; + if (kind == "command-approval" || kind == "file-change-approval") { + response.result = {{"decision", std::move(decision)}}; + } else if (kind == "user-input") { + response.result = {{"answers", std::move(input)}}; + } else if (kind == "mcp-elicitation") { + const bool acceptsContent = decision == "accept"; + response.result = {{"action", std::move(decision)}, + {"content", acceptsContent ? std::move(input) + : nlohmann::json(nullptr)}, + {"_meta", nullptr}}; + } else if (kind == "permissions-approval") { + if (decision == "decline") { + response.error = jsonRpcError("Permission request declined by user"); + } else { + response.result = {{"permissions", memberValue(request, "permissions", + nlohmann::json::object())}, + {"scope", std::move(decision)}}; + } + } else if (kind == "legacy-patch-approval" || + kind == "legacy-command-approval") { + if (decision == "approved" || decision == "approved_for_session") + response.result = {{"decision", std::move(decision)}}; + else if (decision == "denied") + response.result = { + {"decision", {{"denied", {{"rejection", "Denied by user"}}}}}}; + else + response.result = {{"decision", "abort"}}; + } else if (kind == "dynamic-tool-call") { + response.result = { + {"contentItems", + nlohmann::json::array( + {{{"type", "inputText"}, + {"text", "CodexUI does not provide this dynamic tool"}}})}, + {"success", false}}; + } else { + response.error = + jsonRpcError("CodexUI does not support this server request"); + } + return response; +} + +PendingRequestResponse +PendingRequestPolicy::negativeResponse(std::string_view kind, + const nlohmann::json &request) { + if (kind == "command-approval" || kind == "file-change-approval") + return responseForSubmission(kind, request, "decline"); + if (kind == "mcp-elicitation") + return responseForSubmission(kind, request, "decline"); + if (kind == "legacy-patch-approval" || kind == "legacy-command-approval") + return responseForSubmission(kind, request, "denied"); + if (kind == "dynamic-tool-call") { + PendingRequestResponse response; + response.result = { + {"contentItems", + nlohmann::json::array( + {{{"type", "inputText"}, {"text", "Request declined by user"}}})}, + {"success", false}}; + return response; + } + + PendingRequestResponse response; + response.error = jsonRpcError("Request declined by user"); + return response; +} + +PendingRequestResponse +PendingRequestPolicy::positiveResponse(std::string_view kind, + const nlohmann::json &request) { + if (kind == "command-approval" || kind == "file-change-approval") + return responseForSubmission(kind, request, "accept"); + if (kind == "permissions-approval") + return responseForSubmission(kind, request, "turn"); + if (kind == "legacy-patch-approval" || kind == "legacy-command-approval") + return responseForSubmission(kind, request, "approved"); + + PendingRequestResponse response; + response.error = + jsonRpcError("CodexUI cannot directly approve this server request"); + return response; +} + +} // namespace codexui::codex diff --git a/src/codex/PendingRequestPolicy.h b/src/codex/PendingRequestPolicy.h new file mode 100644 index 0000000..ddf6384 --- /dev/null +++ b/src/codex/PendingRequestPolicy.h @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_PENDINGREQUESTPOLICY_H +#define CODEXUI_CODEX_PENDINGREQUESTPOLICY_H + +#include + +#include +#include +#include + +namespace codexui::codex { + +struct PendingRequestDescriptor { + std::string id; + std::string kind; + std::string threadId; + std::uint64_t generation = 0; + nlohmann::json raw = nlohmann::json::object(); + + bool operator==(const PendingRequestDescriptor &) const = default; +}; + +struct PendingRequestResponse { + nlohmann::json result = nlohmann::json::object(); + nlohmann::json error = nullptr; +}; + +class PendingRequestPolicy final { +public: + PendingRequestPolicy() = delete; + + [[nodiscard]] static std::string title(std::string_view kind); + [[nodiscard]] static std::string dialogTitle(std::string_view kind); + [[nodiscard]] static std::string detail(std::string_view requestId, + std::string_view threadId, + const nlohmann::json &request); + + [[nodiscard]] static bool + supportsDirectAccept(std::string_view kind) noexcept; + [[nodiscard]] static std::string directAcceptLabel(std::string_view kind); + + [[nodiscard]] static PendingRequestResponse + responseForSubmission(std::string_view kind, const nlohmann::json &request, + std::string decision = {}, + nlohmann::json input = nullptr); + [[nodiscard]] static PendingRequestResponse + negativeResponse(std::string_view kind, const nlohmann::json &request); + [[nodiscard]] static PendingRequestResponse + positiveResponse(std::string_view kind, const nlohmann::json &request); +}; + +} // namespace codexui::codex + +#endif // CODEXUI_CODEX_PENDINGREQUESTPOLICY_H diff --git a/src/codex/PresentationClient.h b/src/codex/PresentationClient.h new file mode 100644 index 0000000..47f2c6e --- /dev/null +++ b/src/codex/PresentationClient.h @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_PRESENTATIONCLIENT_H +#define CODEXUI_CODEX_PRESENTATIONCLIENT_H + +#include + +#include +#include +#include + +namespace codexui::codex { + +// Toolkit-neutral, protocol-complete command side of the presentation +// boundary. It deliberately contains no transport or lifecycle ownership: +// FrontendSession remains the Qt/socketpair adapter and supplies these calls. +// A different renderer can supply the same three functions without inheriting +// from a Qt type or mirroring FrontendSession's convenience methods. +class PresentationClient final { +public: + using Completion = std::function; + using Request = std::function; + using Command = + std::function; + using ServerResponse = std::function; + + PresentationClient() = default; + PresentationClient(Request request, Command command, + ServerResponse serverResponse) + : request_(std::move(request)), command_(std::move(command)), + serverResponse_(std::move(serverResponse)) {} + + [[nodiscard]] explicit operator bool() const noexcept { + return static_cast(request_) && static_cast(command_) && + static_cast(serverResponse_); + } + + std::string execute(std::string action, nlohmann::json data, + Completion completion = {}) const { + return request_ ? request_(std::move(action), std::move(data), + std::move(completion)) + : std::string{}; + } + + bool send(std::string action, + nlohmann::json data = nlohmann::json::object()) const { + return command_ && command_(std::move(action), std::move(data)); + } + + bool respond(nlohmann::json requestId, nlohmann::json result, + nlohmann::json error = nullptr) const { + return serverResponse_ && serverResponse_( + std::move(requestId), std::move(result), + std::move(error)); + } + +private: + Request request_; + Command command_; + ServerResponse serverResponse_; +}; + +} // namespace codexui::codex + +#endif // CODEXUI_CODEX_PRESENTATIONCLIENT_H diff --git a/src/codex/PresentationModel.cpp b/src/codex/PresentationModel.cpp index 892a763..0db7ccf 100644 --- a/src/codex/PresentationModel.cpp +++ b/src/codex/PresentationModel.cpp @@ -379,6 +379,16 @@ void PresentationModel::applyEvent(const nlohmann::json &event) noexcept { } } +void PresentationModel::noteThreadActivity(const std::string &threadId, + std::int64_t timestamp) noexcept { + const auto iterator = threads.find(threadId); + if (iterator == threads.end()) + return; + std::optional &activity = iterator->second.lastActivityAt; + if (!activity || timestamp > *activity) + activity = timestamp; +} + void PresentationModel::applyValidatedEvent(const nlohmann::json &event) { if (!presentation::isPresentationFrame(event)) return; @@ -852,6 +862,10 @@ ThreadPresentation &PresentationModel::upsertThread(const nlohmann::json &raw, updateTimestamp(raw, "createdAt", result.createdAt); updateTimestamp(raw, "updatedAt", result.updatedAt); updateTimestamp(raw, "recencyAt", result.recencyAt); + if (result.updatedAt) + noteThreadActivity(id, *result.updatedAt); + if (result.recencyAt) + noteThreadActivity(id, *result.recencyAt); result.archived = boolValue(raw, "archived", result.archived); const auto turns = raw.find("turns"); diff --git a/src/codex/PresentationModel.h b/src/codex/PresentationModel.h index 4ecccbd..d17755f 100644 --- a/src/codex/PresentationModel.h +++ b/src/codex/PresentationModel.h @@ -62,6 +62,7 @@ struct ThreadPresentation { std::optional createdAt; std::optional updatedAt; std::optional recencyAt; + std::optional lastActivityAt; std::vector commandCwds; std::vector changedPaths; std::vector turnOrder; @@ -109,6 +110,8 @@ struct TelemetryPresentation { class PresentationModel final { public: void applyEvent(const nlohmann::json &event) noexcept; + void noteThreadActivity(const std::string &threadId, + std::int64_t timestamp) noexcept; [[nodiscard]] const std::vector &threadOrder() const noexcept; [[nodiscard]] const ThreadPresentation * diff --git a/src/codex/PresentationProtocol.h b/src/codex/PresentationProtocol.h index 1d0d59c..2fd80cb 100644 --- a/src/codex/PresentationProtocol.h +++ b/src/codex/PresentationProtocol.h @@ -14,6 +14,11 @@ namespace codexui::codex::presentation { inline constexpr std::string_view ProtocolName = "codexui.presentation"; inline constexpr std::uint32_t ProtocolVersion = 1; +[[nodiscard]] inline constexpr bool +isThreadHydrationAction(std::string_view action) noexcept { + return action == "thread.read" || action == "thread.resume"; +} + enum class Authority { None, Merge, diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index f8abc19..44bcbaf 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -7,15 +7,13 @@ #include "codex/FrontendSession.h" #include "codex/NewThreadDialog.h" #include "codex/PendingRequestDialog.h" -#include "codex/PresentationModel.h" -#include "codex/PresentationStatus.h" +#include "codex/PendingRequestPolicy.h" #include "codex/TurnSettingsWidget.h" +#include "codex/UiSession.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" @@ -33,7 +31,6 @@ #include #include #include -#include #include #include #include @@ -43,14 +40,13 @@ #include #include -#include +#include #include +#include #include #include -#include #include -#include -#include +#include #include namespace codexui::codex { @@ -62,120 +58,39 @@ QString text(std::string_view 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{}; +std::string utf8(const QString &value) { + return value.toUtf8().toStdString(); } -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{}; +QString lastActivityText(std::int64_t timestamp) { + const QDateTime activity = + QDateTime::fromSecsSinceEpoch(timestamp).toLocalTime(); + const QDateTime now = QDateTime::currentDateTime(); + const QString formatted = activity.date() == now.date() + ? activity.toString(QStringLiteral("HH:mm:ss")) + : activity.toString( + QStringLiteral("yyyy-MM-dd HH:mm:ss")); + return QStringLiteral("Last activity: %1").arg(formatted); } -QString requestTitle(const PendingRequestPresentation &request) { - if (request.kind == "command-approval") - return QStringLiteral("Command approval requested"); - if (request.kind == "file-change-approval") - return QStringLiteral("File-change approval requested"); - if (request.kind == "permissions-approval") - return QStringLiteral("Permission request"); - if (request.kind == "user-input") - return QStringLiteral("Codex needs input"); - if (request.kind == "mcp-elicitation") - return QStringLiteral("MCP server request"); - if (request.kind == "legacy-patch-approval") - return QStringLiteral("Legacy patch approval"); - if (request.kind == "legacy-command-approval") - return QStringLiteral("Legacy command approval"); - return QStringLiteral("Codex request needs attention"); -} - -QString requestDetail(const PendingRequestPresentation &request) { - const auto field = [&request](const char *key) { - return text(stringValue(request.raw, key)); - }; - QStringList parts; - const QString command = field("command"); - if (!command.isEmpty()) - parts << QStringLiteral("Command: %1").arg(command); - const QString reason = field("reason"); - if (!reason.isEmpty()) - parts << QStringLiteral("Reason: %1").arg(reason); - const QString message = field("message"); - if (!message.isEmpty()) - parts << message; - const QString cwd = field("cwd"); - if (!cwd.isEmpty()) - parts << QStringLiteral("Directory: %1").arg(cwd); - const QString grantRoot = field("grantRoot"); - if (!grantRoot.isEmpty()) - parts << QStringLiteral("Grant root: %1").arg(grantRoot); - const auto permissions = request.raw.find("permissions"); - if (permissions != request.raw.end() && !permissions->is_null()) - parts << QStringLiteral("Permissions: %1") - .arg(text(permissions->dump(0))); - const auto questions = request.raw.find("questions"); - if (questions != request.raw.end() && questions->is_array()) - parts << QStringLiteral("%1 questions") - .arg(static_cast(questions->size())); - if (parts.isEmpty()) - return QStringLiteral("Request %1 for thread %2") - .arg(text(request.id), text(request.threadId)); - return parts.join(QStringLiteral(" | ")); -} - -bool requestSupportsDirectAccept(const PendingRequestPresentation &request) { - return request.kind == "command-approval" || - request.kind == "file-change-approval" || - request.kind == "permissions-approval" || - request.kind == "legacy-patch-approval" || - request.kind == "legacy-command-approval"; -} - -QString directAcceptLabel(const PendingRequestPresentation &request) { - if (request.kind == "permissions-approval") - return QStringLiteral("Allow this turn"); - return QStringLiteral("Accept"); -} - -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")); -} - -bool isTransientCancellation(const nlohmann::json &result) { - return !result.value("ok", false) && - result.value("error", nlohmann::json::object()) - .value("transient", false); +const ui::ThreadListRow *findThread(const ui::ThreadListRow &row, + std::string_view id) { + if (row.id == id) + return &row; + for (const ui::ThreadListRow &child : row.children) { + if (const ui::ThreadListRow *found = findThread(child, id)) + return found; + } + return nullptr; } -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)); +const ui::ThreadListRow *findThread(const ui::ThreadListSnapshot &snapshot, + std::string_view id) { + for (const ui::ThreadListRow &root : snapshot.roots) { + if (const ui::ThreadListRow *found = findThread(root, id)) + return found; + } + return nullptr; } QLabel *makeLabel(QString value, const char *kind = "body") { @@ -241,158 +156,80 @@ QFrame *statusDot() { } // namespace struct ShellWidget::Impl final { - enum class Hydration { NotHydrated, InFlight, Hydrated, Failed }; - enum class SettingsHydration { - Unknown, - WaitingForRead, - InFlight, - Hydrated, - Failed - }; - struct ThreadRuntimeState { - Hydration hydration = Hydration::NotHydrated; - SettingsHydration settingsHydration = SettingsHydration::Unknown; - std::uint64_t readRevision = 0; - bool operationReady = false; - bool resumeInFlight = false; - bool dispatchScheduled = false; - std::unordered_set recoveryAttemptedSubmissions; - - void resetForConnection() noexcept { - hydration = Hydration::NotHydrated; - settingsHydration = SettingsHydration::Unknown; - readRevision = 0; - operationReady = false; - resumeInFlight = false; - dispatchScheduled = false; - } - }; - struct SettingsUiSnapshot { - std::string identity; - nlohmann::json canonical; - nlohmann::json modelCatalog; - nlohmann::json permissionProfiles; - std::uint64_t settingsRevision = 0; - nlohmann::json settingsUpdate; - - bool operator==(const SettingsUiSnapshot &) const = default; - }; - struct StatusUiSnapshot { - bool connected = false; - bool retrying = false; - std::string role; - std::string providerState; - QString selectedTransport; - QString workspace; - bool active = false; - std::size_t selectedPending = 0; - std::size_t totalPending = 0; - bool selectedRequestActionable = false; - - bool operator==(const StatusUiSnapshot &) const = default; - }; - 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)) { + : owner(owner), session(session), + uiSession(session.presentationClient(), utf8(QDir::currentPath())), + alive(std::make_shared(true)) { buildUi(); connectUi(); const auto token = alive; - session.setEventHandler([this, token](const nlohmann::json &event) { + uiSession.setChangedHandler([this, token] { + if (*token) + scheduleRender(); + }); + uiSession.setWakeupHandler([this, token](std::int64_t atMilliseconds) { + if (*token) + scheduleLogicWakeup(atMilliseconds); + }); + uiSession.setProtocolFrameObserver( + [this, token](const nlohmann::json &frame) { + if (*token) + middleRegion->inspector().appendProtocolFrame(frame); + }); + session.setEventHandler([this, token](const nlohmann::json &frame) { + if (*token) + uiSession.onPresentationFrame(frame); + }); + session.setActivityHandler([this, token](const std::string &threadId) { if (*token) - handleEvent(event); + uiSession.noteThreadActivity(threadId); }); render(); } ~Impl() { *alive = false; + uiSession.setChangedHandler({}); + uiSession.setWakeupHandler({}); + uiSession.setProtocolFrameObserver({}); session.setEventHandler({}); - qApp->removeEventFilter(owner); + session.setActivityHandler({}); + if (qApp) + qApp->removeEventFilter(owner); } void buildUi(); void connectUi(); - void handleEvent(const nlohmann::json &event); + void scheduleLogicWakeup(std::int64_t atMilliseconds); void scheduleRender(); void render(); - void renderConversation(); - void refreshSettings(); - void refreshStatus(); - void hydrateHistoricalChildren(const std::string &parentThreadId, - bool retryFailed = false); + void renderStatus(const UiSessionView &view); + void synchronizeOptimisticThread( + const std::optional &optimistic); void showNotice(QString message, bool error = true); - void resetRuntimeForConnection(); - [[nodiscard]] bool providerReady() const; - [[nodiscard]] bool canControlProvider() const; - void hydrateProvider(); - - void selectThread(std::string threadId); - void beginNewThread(); - void readThread(const std::string &threadId, bool forced = false); - void ensureThreadHydrated(const std::string &threadId); - void hydrateThreadForSelection(const std::string &threadId); - void ensureThreadSettingsHydrated(const std::string &threadId); - void resumeThreadForSettings(const std::string &threadId); - 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 beginNewThreadDialog(); + void renameThreadDialog(const std::string &threadId); + void confirmDeleteThread(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 acceptPending(const std::string &requestKey); void rejectPending(const std::string &requestKey); void respondToFirstPending(bool approve); - [[nodiscard]] bool - isPendingActionable(const std::string &requestKey) const; - void resolvePending(PendingRequestPresentation request, - std::uint64_t providerGeneration, - PendingRequestResponse response); + [[nodiscard]] const UiPendingRequestView * + pendingRequest(const std::string &requestKey) const; ShellWidget *owner = nullptr; FrontendSession &session; - PresentationModel model; - middle::PromptCoordinator prompts; + UiSession uiSession; 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 runtimeByThread; - std::unordered_set resolvingRequests; - std::unordered_set staleReadResultCorrelations; - std::uint64_t nextReadRevision = 1; - std::unordered_map historyWindows; - std::uint64_t observedConnectionGeneration = 0; - std::uint64_t observedProviderGeneration = 0; - std::optional settingsSnapshot; - std::optional statusSnapshot; + const UiSessionView *renderedView = nullptr; + std::optional settingsSnapshot; + std::optional statusSnapshot; + std::optional attentionSnapshot; + std::optional optimisticSnapshot; + std::optional scheduledLogicWakeup; bool renderScheduled = false; middle::MiddleRegionWidget *middleRegion = nullptr; @@ -466,34 +303,27 @@ void ShellWidget::Impl::buildUi() { connectionButton->setFixedHeight(32); auto *connectionMenu = new QMenu(connectionButton); connectionMenu->addAction(QStringLiteral("Configure..."), owner, [this] { - if (!model.connection().settings.is_object() || - model.connection().settings.empty()) { + if (!renderedView || + !renderedView->status.connectionSettings.is_object() || + renderedView->status.connectionSettings.empty()) { showNotice(QStringLiteral("Connection settings are not available yet.")); return; } - ConnectionDialog dialog(model.connection().settings, owner); + ConnectionDialog dialog(renderedView->status.connectionSettings, 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)); - }); + uiSession.configureConnection(dialog.selection()); }); connectionMenu->addSeparator(); connectAction = connectionMenu->addAction( - QStringLiteral("Connect"), owner, [this] { session.connectTransport(); }); + QStringLiteral("Connect"), owner, + [this] { uiSession.connectTransport(); }); disconnectAction = connectionMenu->addAction(QStringLiteral("Disconnect"), owner, - [this] { session.disconnectTransport(); }); + [this] { uiSession.disconnectTransport(); }); reconnectAction = connectionMenu->addAction( - QStringLiteral("Reconnect"), owner, [this] { session.reconnect(); }); + QStringLiteral("Reconnect"), owner, + [this] { uiSession.reconnectTransport(); }); connectionButton->setMenu(connectionMenu); auto *connectionControl = new QWidget; auto *connectionLayout = new QHBoxLayout(connectionControl); @@ -550,31 +380,28 @@ void ShellWidget::Impl::buildUi() { void ShellWidget::Impl::connectUi() { middle::ThreadPane::Actions threadActions; - threadActions.newThread = [this] { beginNewThread(); }; - threadActions.refresh = [this] { - if (providerReady()) - session.listThreads(); - }; + threadActions.newThread = [this] { beginNewThreadDialog(); }; + threadActions.refresh = [this] { uiSession.refreshThreads(); }; threadActions.hide = [this] { middleRegion->showSidebar(false); }; threadActions.select = [this](const std::string &id) { - if (id == DraftThreadId && newThreadIntent) { + if (id == DraftThreadId && renderedView && + renderedView->newThreadIntent) { render(); return; } - if (id != selectedThreadId) - selectThread(id); - }; - threadActions.reload = [this](const std::string &id) { - runtimeByThread[id].settingsHydration = SettingsHydration::Unknown; - readThread(id, true); - ensureThreadSettingsHydrated(id); + uiSession.selectThread(id); }; - threadActions.rename = [this](const std::string &id) { renameThread(id); }; - threadActions.fork = [this](const std::string &id) { forkThread(id); }; + threadActions.reload = + [this](const std::string &id) { uiSession.reloadThread(id); }; + threadActions.rename = + [this](const std::string &id) { renameThreadDialog(id); }; + threadActions.fork = + [this](const std::string &id) { uiSession.forkThread(id); }; threadActions.toggleArchive = [this](const std::string &id) { - toggleThreadArchive(id); + uiSession.toggleThreadArchive(id); }; - threadActions.remove = [this](const std::string &id) { deleteThread(id); }; + threadActions.remove = + [this](const std::string &id) { confirmDeleteThread(id); }; middleRegion->threads().setActions(std::move(threadActions)); middle::ComposerPane::Actions composerActions; @@ -582,29 +409,19 @@ void ShellWidget::Impl::connectUi() { std::vector attachments) { return submitPrompt(std::move(prompt), std::move(attachments)); }; - composerActions.stop = [this] { interruptTurn(); }; + composerActions.stop = [this] { uiSession.interruptTurn(); }; composerActions.attach = [this] { chooseAttachments(); }; composerActions.accept = [this] { respondToFirstPending(true); }; 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->conversation().setLoadMoreAction( + [this] { uiSession.loadEarlierConversation(); }); middleRegion->inspector().setRequestActions( [this](const std::string &id) { reviewPending(id); }, [this](const std::string &id) { acceptPending(id); }, - [this](const std::string &id) { rejectPending(id); }, - [this](const std::string &id) { return isPendingActionable(id); }); + [this](const std::string &id) { rejectPending(id); }); middleRegion->setPaneVisibilityAction( [this](bool sidebarVisible, bool inspectorVisible) { restoreSidebarButton->setVisible(!sidebarVisible); @@ -619,12 +436,8 @@ void ShellWidget::Impl::connectUi() { middleRegion->showInspector(true); middleRegion->inspector().tabs()->setCurrentIndex(3); }); - connect(controllerButton, &QPushButton::clicked, owner, [this] { - if (model.connection().role == "controller") - session.releaseController(); - else - session.claimController(); - }); + connect(controllerButton, &QPushButton::clicked, owner, + [this] { uiSession.toggleController(); }); qApp->installEventFilter(owner); } @@ -632,138 +445,25 @@ void ShellWidget::Impl::showNotice(QString message, bool error) { middleRegion->showNotice(std::move(message), error); } -void ShellWidget::Impl::resetRuntimeForConnection() { - resolvingRequests.clear(); - for (auto &[threadId, runtime] : runtimeByThread) { - static_cast(threadId); - runtime.resetForConnection(); - } -} - -bool ShellWidget::Impl::providerReady() const { - const ConnectionPresentation &connection = model.connection(); - return connection.connected && connection.providerState == "ready"; -} - -bool ShellWidget::Impl::canControlProvider() const { - return providerReady() && model.connection().role == "controller"; -} - -void ShellWidget::Impl::hydrateProvider() { - if (!providerReady()) +void ShellWidget::Impl::scheduleLogicWakeup(std::int64_t atMilliseconds) { + if (scheduledLogicWakeup && *scheduledLogicWakeup <= atMilliseconds) return; - session.listThreads(); - session.listModels(); - ensureThreadHydrated(selectedThreadId); - ensureThreadSettingsHydrated(selectedThreadId); - for (const std::string &threadId : prompts.queuedThreadIds()) { - if (threadId == DraftThreadId) { - if (newThreadIntent) - startThreadForDraft(); - } else { - dispatchNextPrompt(threadId); - } - } - session.listPermissionProfiles( - {{"cwd", QDir::currentPath().toStdString()}}); -} - -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; - resetRuntimeForConnection(); - } - if (connection.providerGeneration != observedProviderGeneration) { - observedProviderGeneration = connection.providerGeneration; - resetRuntimeForConnection(); - } - - 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 == "pending-request.removed") { - const auto requestId = scope.find("requestId"); - if (requestId != scope.end() && !requestId->is_null()) - resolvingRequests.erase(requestId->dump()); - } - if (kind == "event" && type == "connection.provider" && - stringValue(data, "state") == "disconnected") { - resetRuntimeForConnection(); - } - - 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.provider" && - stringValue(data, "state") == "ready") || - (type == "connection.bridge" && - stringValue(data, "state") == "opened" && providerReady()))) - hydrateProvider(); - if (kind == "event" && type == "connection.controller" && - providerReady() && model.connection().role == "controller") { - ensureThreadSettingsHydrated(selectedThreadId); - for (const std::string &threadId : prompts.queuedThreadIds()) - dispatchNextPrompt(threadId); - } - - if (type == "thread.removed" && !eventThreadId.empty()) { - prompts.clearThread(eventThreadId); - runtimeByThread.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, - QDateTime::currentMSecsSinceEpoch()); - } - } - - if (!staleReadResult && kind == "result" && action == "thread.read" && - event.value("ok", false)) - hydrateHistoricalChildren(eventThreadId); - else if (kind == "event" && type == "agents.activity.upsert") - hydrateHistoricalChildren(eventThreadId); - scheduleRender(); + scheduledLogicWakeup = atMilliseconds; + const std::int64_t now = QDateTime::currentMSecsSinceEpoch(); + const std::int64_t requestedDelay = + atMilliseconds > now ? atMilliseconds - now : 0; + const int delay = + requestedDelay > std::numeric_limits::max() + ? std::numeric_limits::max() + : static_cast(requestedDelay); + const auto token = alive; + QTimer::singleShot(delay, Qt::PreciseTimer, owner, + [this, token, atMilliseconds] { + if (!*token || scheduledLogicWakeup != atMilliseconds) + return; + scheduledLogicWakeup.reset(); + uiSession.tick(); + }); } void ShellWidget::Impl::scheduleRender() { @@ -771,9 +471,8 @@ void ShellWidget::Impl::scheduleRender() { 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. + // Stream deltas can arrive in bursts. Reconcile the toolkit once per display + // interval while UiSession still observes every presentation frame. QTimer::singleShot(16, Qt::PreciseTimer, owner, [this, token] { if (!*token) return; @@ -782,179 +481,129 @@ void ShellWidget::Impl::scheduleRender() { }); } -void ShellWidget::Impl::render() { - const std::string visibleThreadId = - selectedThreadId.empty() && newThreadIntent - ? std::string(DraftThreadId) - : selectedThreadId; - middleRegion->threads().refresh(model, visibleThreadId); - renderConversation(); - middleRegion->inspector().refresh(model, selectedThreadId); - refreshSettings(); - refreshStatus(); -} +void ShellWidget::Impl::synchronizeOptimisticThread( + const std::optional &optimistic) { + if (optimisticSnapshot == optimistic) + return; -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; - middle::AuthoritativeItemIndex authoritativeItems = - middle::indexAuthoritativeItems(projectionId, thread); - if (thread) - prompts.reconcile(selectedThreadId, authoritativeItems, now); - const auto submissions = prompts.submissions(projectionId); - const std::size_t authoritativeCount = authoritativeItems.ordered.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; + if (!optimistic) { + if (optimisticSnapshot) { + const std::string id = optimisticSnapshot->threadId.empty() + ? optimisticSnapshot->key + : optimisticSnapshot->threadId; + middleRegion->threads().confirmOptimisticThread(id); + } + optimisticSnapshot.reset(); + return; } - history.lastAuthoritativeCount = authoritativeCount; - middle::ConversationSnapshot snapshot = - middle::ConversationProjection::project( - authoritativeItems, thread, submissions, history.effective, now); - snapshot.activeTurnId = model.activeTurnId(selectedThreadId); - 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(" | ") + - text(classifyStatus(thread->status).text)); - } else if (newThreadIntent) { - middleRegion->setThreadHeading(QStringLiteral("New thread"), - newThreadWorkspace.isEmpty() - ? QDir::currentPath() - : newThreadWorkspace); - } else { - middleRegion->setThreadHeading(QStringLiteral("Select a thread"), {}); + if (!optimisticSnapshot || optimisticSnapshot->key != optimistic->key) { + if (optimisticSnapshot) { + const std::string previousId = optimisticSnapshot->threadId.empty() + ? optimisticSnapshot->key + : optimisticSnapshot->threadId; + middleRegion->threads().confirmOptimisticThread(previousId); + } + middleRegion->threads().beginOptimisticThread( + optimistic->key, optimistic->title, optimistic->workspace); } -} -void ShellWidget::Impl::refreshSettings() { - nlohmann::json canonical = nlohmann::json::object(); - nlohmann::json settingsUpdate = nlohmann::json::object(); - std::uint64_t settingsRevision = 0; - std::string identity = "no-thread"; - if (const ThreadPresentation *thread = model.thread(selectedThreadId)) { - identity = thread->id; - settingsUpdate = thread->latestSettingsUpdate; - settingsRevision = thread->settingsRevision; - 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"]; - if (update.contains("effort")) - canonical.erase("reasoningEffort"); - if (update.contains("sandboxPolicy")) - canonical.erase("sandbox"); - canonical.merge_patch(update); - } - } else if (newThreadIntent) { - identity = DraftThreadId; - canonical["cwd"] = (newThreadWorkspace.isEmpty() ? QDir::currentPath() - : newThreadWorkspace) - .toStdString(); - } else { - canonical["cwd"] = QDir::currentPath().toStdString(); + std::string renderedId = optimistic->key; + if (!optimistic->threadId.empty()) { + middleRegion->threads().promoteOptimisticThread(optimistic->key, + optimistic->threadId); + renderedId = optimistic->threadId; } - nlohmann::json profiles = nlohmann::json::array(); - const auto found = - model.globalDomains().find("operation.permission-profiles.list"); - if (found != model.globalDomains().end()) - profiles = found->second; - SettingsUiSnapshot next{std::move(identity), - std::move(canonical), - model.modelCatalog(), - std::move(profiles), - settingsRevision, - std::move(settingsUpdate)}; - if (settingsSnapshot && *settingsSnapshot == next) - return; - settingsSnapshot = std::move(next); - const SettingsUiSnapshot &snapshot = *settingsSnapshot; - middleRegion->composer().turnSettings()->setContext( - snapshot.identity, snapshot.canonical, snapshot.modelCatalog, - snapshot.permissionProfiles, snapshot.settingsRevision, - snapshot.settingsUpdate); + if (optimistic->phase == UiOptimisticThreadPhase::Failed) + middleRegion->threads().failOptimisticThread(renderedId); + else if (optimistic->phase == UiOptimisticThreadPhase::Confirmed) + middleRegion->threads().confirmOptimisticThread(renderedId); + optimisticSnapshot = optimistic; } -void ShellWidget::Impl::refreshStatus() { - const ConnectionPresentation &connection = model.connection(); - const ThreadPresentation *thread = model.thread(selectedThreadId); - const bool active = model.activeTurnId(selectedThreadId).has_value(); - const std::size_t selectedPending = static_cast(std::count_if( - model.pendingRequestPresentations().begin(), - model.pendingRequestPresentations().end(), - [this](const auto &entry) { - return entry.second.threadId == selectedThreadId; - })); - const std::size_t totalPending = model.pendingRequestCount(); - const auto selectedRequest = std::ranges::find_if( - model.pendingRequestPresentations(), [this](const auto &entry) { - return entry.second.threadId == selectedThreadId; - }); - const bool selectedRequestActionable = - selectedRequest != model.pendingRequestPresentations().end() && - isPendingActionable(selectedRequest->first); - 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; - } +void ShellWidget::Impl::render() { + bool focusComposer = false; + for (const UiEffect effect : uiSession.takeEffects()) { + switch (effect) { + case UiEffect::ClearComposerDraft: + middleRegion->composer().clearDraft(); + break; + case UiEffect::FocusComposer: + focusComposer = true; + break; + case UiEffect::PrepareLocalPromptAdmission: + middleRegion->conversation().prepareForLocalPromptAdmission(); + break; } } - QString workspace = QStringLiteral("No workspace"); - if (thread) { - workspace = text(thread->cwd); - } else if (newThreadIntent) { - workspace = text(middleRegion->composer().turnSettings()->workspace( - QDir::currentPath().toStdString())); + for (UiNotice ¬ice : uiSession.takeNotices()) + showNotice(text(notice.message), notice.error); + + const std::string fallbackWorkspace = utf8(QDir::currentPath()); + const std::string draftWorkspace = + middleRegion->composer().turnSettings()->workspace(fallbackWorkspace); + const std::string conversationKey = uiSession.conversationKey(); + const bool following = + middleRegion->conversation().modeForThread(conversationKey) == + middle::ConversationView::Mode::Following; + const UiSessionView &view = + uiSession.refreshView(following, draftWorkspace); + renderedView = &view; + + synchronizeOptimisticThread(view.optimisticThread); + middleRegion->threads().refresh(view.threads); + + middleRegion->conversation().setEmptyMessage( + text(view.conversation.emptyMessage)); + middleRegion->conversation().reconcile(view.conversation.snapshot); + if (view.conversation.mode == UiConversationMode::Thread) { + QStringList metadata; + if (!view.conversation.workspace.empty()) + metadata << text(view.conversation.workspace); + if (!view.conversation.status.empty()) + metadata << text(view.conversation.status); + const QString activity = view.conversation.lastActivityAt + ? lastActivityText( + *view.conversation.lastActivityAt) + : QString{}; + middleRegion->setThreadHeading( + text(view.conversation.title), + metadata.join(QStringLiteral(" | ")), activity); + } else if (view.conversation.mode == UiConversationMode::NewThread) { + middleRegion->setThreadHeading(text(view.conversation.title), + text(view.conversation.workspace)); + } else { + middleRegion->setThreadHeading(text(view.conversation.title), {}); + } + + middleRegion->inspector().refresh(view.inspector); + if (!settingsSnapshot || *settingsSnapshot != view.settings) { + settingsSnapshot = view.settings; + middleRegion->composer().turnSettings()->setContext( + view.settings.identity, view.settings.canonical, + view.settings.modelCatalog, view.settings.permissionProfiles, + view.settings.settingsRevision, view.settings.settingsUpdate); } - StatusUiSnapshot next{connection.connected, - connection.retrying, - connection.role, - connection.providerState, - std::move(selectedTransport), - std::move(workspace), - active, - selectedPending, - totalPending, - selectedRequestActionable}; - if (statusSnapshot && *statusSnapshot == next) + renderStatus(view); + + if (focusComposer) + middleRegion->composer().promptEditor()->setFocus(); +} + +void ShellWidget::Impl::renderStatus(const UiSessionView &view) { + if (statusSnapshot && *statusSnapshot == view.status && + attentionSnapshot == view.selectedPendingRequest) return; - statusSnapshot = std::move(next); - const StatusUiSnapshot &snapshot = *statusSnapshot; + statusSnapshot = view.status; + attentionSnapshot = view.selectedPendingRequest; + const UiStatusView &status = view.status; + QString dotStyle; QString dotTip; - if (snapshot.connected) { + if (status.connected) { dotStyle = QStringLiteral("background:#18865e;border-radius:5px;"); dotTip = QStringLiteral("Connected"); - } else if (snapshot.retrying) { + } else if (status.retrying) { dotStyle = QStringLiteral("background:#a85d0c;border-radius:5px;"); dotTip = QStringLiteral("Disconnected, retrying"); } else { @@ -963,291 +612,88 @@ void ShellWidget::Impl::refreshStatus() { } connectionStatusDot->setStyleSheet(dotStyle); connectionStatusDot->setToolTip(dotTip); - connectionButton->setText(snapshot.selectedTransport.isEmpty() - ? QStringLiteral("Connection") - : snapshot.selectedTransport); + connectionButton->setText( + status.selectedTransport.empty() ? QStringLiteral("Connection") + : text(status.selectedTransport)); connectionButton->setToolTip( - snapshot.connected ? QStringLiteral("Connected bridge transport") - : QStringLiteral("Disconnected bridge transport")); - connectAction->setEnabled(!snapshot.connected); - disconnectAction->setEnabled(snapshot.connected); - reconnectAction->setEnabled(snapshot.connected); - controllerButton->setText(snapshot.role == "controller" + status.connected ? QStringLiteral("Connected bridge transport") + : QStringLiteral("Disconnected bridge transport")); + connectAction->setEnabled(!status.connected); + disconnectAction->setEnabled(status.connected); + reconnectAction->setEnabled(status.connected); + controllerButton->setText(status.role == "controller" ? QStringLiteral("Release control") : QStringLiteral("Claim control")); - controllerButton->setEnabled(snapshot.connected); + controllerButton->setEnabled(status.connected); requestButton->setText( QStringLiteral("Requests (%1)") - .arg(static_cast(snapshot.totalPending))); - requestButton->setVisible(snapshot.totalPending != 0); - if (selectedRequest != model.pendingRequestPresentations().end()) { - const PendingRequestPresentation &request = selectedRequest->second; + .arg(static_cast(status.totalPending))); + requestButton->setVisible(status.totalPending != 0); + if (view.selectedPendingRequest) { + const UiPendingRequestView &request = *view.selectedPendingRequest; middleRegion->composer().setAttentionRequest( - requestTitle(request), requestDetail(request), - requestSupportsDirectAccept(request), directAcceptLabel(request)); + text(request.title), text(request.detail), request.supportsDirectAccept, + text(request.directAcceptLabel)); } - middleRegion->composer().setAttentionVisible(selectedRequest != - model.pendingRequestPresentations().end()); + middleRegion->composer().setAttentionVisible( + view.selectedPendingRequest.has_value()); middleRegion->composer().setAttentionEnabled( - snapshot.selectedRequestActionable); + view.selectedPendingRequest && view.selectedPendingRequest->actionable); QString globalStatus = QStringLiteral("Ready"); QString globalTone = QStringLiteral("success"); - if (snapshot.retrying) { + if (status.retrying) { globalStatus = QStringLiteral("Reconnecting"); globalTone = QStringLiteral("warning"); - } else if (!snapshot.connected) { + } else if (!status.connected) { globalStatus = QStringLiteral("Offline"); globalTone = QStringLiteral("danger"); - } else if (snapshot.providerState != "ready") { - globalStatus = snapshot.providerState.empty() + } else if (status.providerState != "ready") { + globalStatus = status.providerState.empty() ? QStringLiteral("Waiting for provider") : QStringLiteral("Provider unavailable"); - globalTone = snapshot.providerState.empty() ? QStringLiteral("warning") - : QStringLiteral("danger"); - } else if (snapshot.totalPending != 0) { + globalTone = status.providerState.empty() ? QStringLiteral("warning") + : QStringLiteral("danger"); + } else if (status.totalPending != 0) { globalStatus = QStringLiteral("Attention required"); globalTone = QStringLiteral("warning"); } setStatusTone(globalStatusDot, globalStatusLabel, globalTone); setStatusLabelText(globalStatusLabel, globalStatus); - workspaceBreadcrumb->setToolTip(snapshot.workspace); - workspaceBreadcrumb->setText(workspaceBreadcrumb->fontMetrics().elidedText( - snapshot.workspace, Qt::ElideMiddle, - workspaceBreadcrumb->maximumWidth())); - - const bool canSubmit = snapshot.connected && snapshot.providerState == "ready" && - snapshot.role == "controller"; - middleRegion->composer().setActiveTurn(snapshot.active); - middleRegion->composer().setCanSubmit(canSubmit); - middleRegion->composer().setSettingsEnabled(canSubmit && !snapshot.active); -} -void ShellWidget::Impl::hydrateHistoricalChildren( - const std::string &parentThreadId, bool retryFailed) { - const ThreadPresentation *thread = model.thread(parentThreadId); - if (!thread) - return; - for (const std::string &childThreadId : thread->childThreadOrder) { - const ChildThreadOwnership *ownership = - model.childOwnership(childThreadId); - if (!ownership || ownership->parentThreadId != parentThreadId) - continue; - const auto agent = thread->agents.find(ownership->agentId); - if (agent == thread->agents.end() || - !isActiveStatus(agent->second.status)) - continue; - const auto runtime = runtimeByThread.find(childThreadId); - const bool failed = runtime != runtimeByThread.end() && - runtime->second.hydration == Hydration::Failed; - // Background activity never retries a failed read. Explicit navigation - // supplies a new bounded retry boundary without creating a retry loop. - if (!failed || retryFailed) - readThread(childThreadId, failed); - } -} + const QString workspace = text(status.workspace); + workspaceBreadcrumb->setToolTip(workspace); + workspaceBreadcrumb->setText( + workspaceBreadcrumb->fontMetrics().elidedText( + workspace, Qt::ElideMiddle, workspaceBreadcrumb->maximumWidth())); -void ShellWidget::Impl::selectThread(std::string threadId) { - if (threadId.empty()) - return; - if (threadId == selectedThreadId) { - hydrateThreadForSelection(threadId); - return; - } - if (middleRegion->threads().isOptimisticThread(DraftThreadId) && - prompts.submissions(DraftThreadId).empty()) - middleRegion->threads().confirmOptimisticThread(DraftThreadId); - selectedThreadId = std::move(threadId); - newThreadIntent = false; - newThreadOptions = nlohmann::json::object(); - newThreadName.clear(); - newThreadWorkspace.clear(); - historyWindows.try_emplace(selectedThreadId); - hydrateThreadForSelection(selectedThreadId); - render(); + middleRegion->composer().setActiveTurn(status.activeTurn); + middleRegion->composer().setCanSubmit(status.canSubmit); + middleRegion->composer().setSettingsEnabled(status.canEditSettings); } -void ShellWidget::Impl::beginNewThread() { - if (newThreadCreationInFlight) { - showNotice(QStringLiteral("The current new thread is still being created."), - false); - return; - } +void ShellWidget::Impl::beginNewThreadDialog() { + const QString fallback = QDir::currentPath(); const QString initial = - text(middleRegion->composer().turnSettings()->workspace( - QDir::currentPath().toStdString())); + text(middleRegion->composer().turnSettings()->workspace(utf8(fallback))); 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; - middleRegion->threads().beginOptimisticThread( - DraftThreadId, - draft.name.isEmpty() ? std::string("New thread") - : draft.name.toStdString(), - draft.workspace.toStdString()); - 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.reset(); - 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() || !providerReady()) - return; - ThreadRuntimeState &runtime = runtimeByThread[threadId]; - if (runtime.resumeInFlight) - return; - if (!forced) { - if (runtime.hydration == Hydration::InFlight || - runtime.hydration == Hydration::Hydrated || - runtime.hydration == Hydration::Failed) - return; - } - runtime.hydration = Hydration::InFlight; - const auto token = alive; - const std::uint64_t revision = nextReadRevision++; - runtime.readRevision = revision; - session.readThread(threadId, [this, token, threadId, - revision](const nlohmann::json &result) { - if (!*token) - return; - const auto current = runtimeByThread.find(threadId); - if (current == runtimeByThread.end() || - current->second.readRevision != revision) { - const std::string correlationId = stringValue(result, "correlationId"); - if (!correlationId.empty()) - staleReadResultCorrelations.insert(correlationId); - return; - } - ThreadRuntimeState &runtime = current->second; - if (result.value("ok", false)) { - runtime.hydration = Hydration::Hydrated; - if (runtime.settingsHydration == SettingsHydration::WaitingForRead) - resumeThreadForSettings(threadId); - QTimer::singleShot(0, owner, - [this, threadId] { dispatchNextPrompt(threadId); }); - return; - } - if (isTransientCancellation(result)) { - runtime.hydration = Hydration::NotHydrated; - if (runtime.settingsHydration == SettingsHydration::WaitingForRead) - runtime.settingsHydration = SettingsHydration::Unknown; - 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. - runtime.hydration = Hydration::Failed; - if (runtime.settingsHydration == SettingsHydration::WaitingForRead) - runtime.settingsHydration = SettingsHydration::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::ensureThreadSettingsHydrated( - const std::string &threadId) { - if (threadId.empty() || !canControlProvider()) - return; - ThreadRuntimeState &runtime = runtimeByThread[threadId]; - if (runtime.settingsHydration == SettingsHydration::WaitingForRead || - runtime.settingsHydration == SettingsHydration::InFlight || - runtime.settingsHydration == SettingsHydration::Hydrated) - return; - if (runtime.hydration != Hydration::Hydrated) { - runtime.settingsHydration = SettingsHydration::WaitingForRead; - ensureThreadHydrated(threadId); - return; - } - resumeThreadForSettings(threadId); + uiSession.beginNewThread( + {utf8(draft.workspace), utf8(draft.name), + utf8(draft.baseInstructions), utf8(draft.developerInstructions), + draft.ephemeral}); } -void ShellWidget::Impl::resumeThreadForSettings( +void ShellWidget::Impl::renameThreadDialog( const std::string &threadId) { - ThreadRuntimeState &runtime = runtimeByThread[threadId]; - if (runtime.resumeInFlight) + if (!renderedView || !renderedView->threads.canControl) return; - runtime.settingsHydration = SettingsHydration::InFlight; - const auto token = alive; - session.resumeThread( - threadId, {{"excludeTurns", true}}, - [this, token, threadId](const nlohmann::json &result) { - if (!*token) - return; - const auto found = runtimeByThread.find(threadId); - if (found == runtimeByThread.end()) - return; - ThreadRuntimeState &runtime = found->second; - if (!result.value("ok", false)) { - if (isTransientCancellation(result)) { - runtime.settingsHydration = SettingsHydration::Unknown; - return; - } - runtime.settingsHydration = SettingsHydration::Failed; - if (selectedThreadId == threadId) { - const std::string message = - safeMessage(result.value("error", nlohmann::json::object())); - showNotice(text(message.empty() - ? std::string("Thread settings refresh failed") - : message)); - } - } else { - runtime.settingsHydration = SettingsHydration::Hydrated; - runtime.hydration = Hydration::Hydrated; - runtime.operationReady = true; - } - QTimer::singleShot(0, owner, - [this, threadId] { dispatchNextPrompt(threadId); }); - }); -} - -void ShellWidget::Impl::ensureThreadHydrated(const std::string &threadId) { - if (threadId.empty() || !model.connection().connected) - return; - const auto found = runtimeByThread.find(threadId); - if (found != runtimeByThread.end() && - (found->second.hydration == Hydration::Hydrated || - found->second.hydration == Hydration::InFlight)) - return; - readThread(threadId); -} - -void ShellWidget::Impl::hydrateThreadForSelection( - const std::string &threadId) { - const auto runtime = runtimeByThread.find(threadId); - if (runtime != runtimeByThread.end() && - runtime->second.hydration == Hydration::Failed) - readThread(threadId, true); - else - ensureThreadHydrated(threadId); - ensureThreadSettingsHydrated(threadId); - hydrateHistoricalChildren(threadId, true); -} - -void ShellWidget::Impl::renameThread(const std::string &threadId) { - if (!canControlProvider()) - return; - const ThreadPresentation *thread = model.thread(threadId); + const ui::ThreadListRow *thread = + findThread(renderedView->threads, threadId); if (!thread) return; bool accepted = false; @@ -1257,580 +703,111 @@ void ShellWidget::Impl::renameThread(const std::string &threadId) { 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() || !canControlProvider()) - 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) { - if (!canControlProvider()) - return; - const ThreadPresentation *thread = model.thread(threadId); - if (!thread) - return; - if (thread->archived) - session.unarchiveThread(threadId); - else - session.archiveThread(threadId); + uiSession.renameThread(threadId, utf8(name)); } -void ShellWidget::Impl::deleteThread(const std::string &threadId) { - if (threadId.empty() || !canControlProvider()) +void ShellWidget::Impl::confirmDeleteThread( + const std::string &threadId) { + if (threadId.empty() || !renderedView || + !renderedView->threads.canControl) return; if (QMessageBox::question(owner, QStringLiteral("Delete thread"), QStringLiteral("Delete the selected thread?"), QMessageBox::Yes | QMessageBox::Cancel, QMessageBox::Cancel) == QMessageBox::Yes) - session.deleteThread(threadId); + uiSession.deleteThread(threadId); } -bool ShellWidget::Impl::submitPrompt(QString prompt, - std::vector attachments) { +bool ShellWidget::Impl::submitPrompt( + QString prompt, std::vector attachments) { prompt = prompt.trimmed(); if (prompt.isEmpty()) return false; - if (!canControlProvider()) { - showNotice(QStringLiteral( - "Codex is not ready for a controlled turn. Your message was not sent.")); - return false; - } - prompt = middle::promptWithFileLinks(std::move(prompt), attachments); - const std::string visiblySelected = + TurnSettingsWidget *settings = + middleRegion->composer().turnSettings(); + const std::string visibleThreadId = middleRegion->threads().visiblySelectedThreadId(); - const bool selectedNewThreadDraft = - visiblySelected == DraftThreadId && newThreadIntent; - if (!visiblySelected.empty() && visiblySelected != selectedThreadId && - !selectedNewThreadDraft) { - 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; - } else if (!thread) { - showNotice(QStringLiteral("The selected thread is no longer available. " - "Your message was not sent.")); - return false; - } - - if (destination != DraftThreadId) { - const auto runtime = runtimeByThread.find(destination); - if (runtime != runtimeByThread.end() && - runtime->second.hydration == 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); - - if (destination == DraftThreadId) - middleRegion->threads().beginOptimisticThread( - DraftThreadId, - newThreadName.isEmpty() ? std::string("New thread") - : newThreadName.toStdString(), - newThreadWorkspace.toStdString()); - else - middleRegion->threads().promotePromptedThread(destination); - - // 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 (!canControlProvider() || 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)) { - if (isTransientCancellation(result)) { - render(); - return; - } - 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)); - middleRegion->threads().failOptimisticThread(DraftThreadId); - 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)); - middleRegion->threads().failOptimisticThread(DraftThreadId); - showNotice(error); - render(); - return; - } - - if (!prompts.reassignThread(DraftThreadId, threadId)) { - middleRegion->threads().failOptimisticThread(DraftThreadId); - showNotice(QStringLiteral("Could not attach the draft prompts to " - "the created thread.")); - render(); - return; - } - ThreadRuntimeState &runtime = runtimeByThread[threadId]; - runtime.hydration = Hydration::Hydrated; - runtime.settingsHydration = SettingsHydration::Hydrated; - runtime.operationReady = true; - middleRegion->threads().promoteOptimisticThread(DraftThreadId, threadId); - const bool viewingDraft = selectedThreadId.empty() && newThreadIntent; - if (viewingDraft) { - selectedThreadId = threadId; - newThreadIntent = false; - } - newThreadOptions = nlohmann::json::object(); - newThreadName.clear(); - newThreadWorkspace.clear(); - settingsSnapshot.reset(); - 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() || !canControlProvider()) - return; - auto runtime = runtimeByThread.find(threadId); - if (runtime != runtimeByThread.end() && - runtime->second.settingsHydration == SettingsHydration::InFlight) - 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 (runtime != runtimeByThread.end() && runtime->second.resumeInFlight) - return; - if (runtime == runtimeByThread.end() || - runtime->second.hydration != Hydration::Hydrated) { - ensureThreadHydrated(threadId); - return; - } - if (prompts.hasInFlight(threadId)) - return; - const ThreadPresentation *thread = model.thread(threadId); - if (!runtime->second.operationReady && thread && - thread->status == "notLoaded") { - resumePromptQueue(threadId); - return; - } - if (runtime->second.dispatchScheduled) - return; - runtime->second.dispatchScheduled = true; - - // 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] { - const auto runtime = runtimeByThread.find(threadId); - if (runtime == runtimeByThread.end()) - return; - runtime->second.dispatchScheduled = false; - if (observedConnectionGeneration != generation) - return; - if (!canControlProvider() || runtime->second.resumeInFlight) - return; - const ThreadPresentation *thread = model.thread(threadId); - if (runtime->second.hydration != Hydration::Hydrated || - (!runtime->second.operationReady && thread && - thread->status == "notLoaded")) { - 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()}}); - } - - 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) { - ThreadRuntimeState &runtime = runtimeByThread[threadId]; - if (runtime.resumeInFlight || !canControlProvider()) - return; - runtime.resumeInFlight = true; - const auto token = alive; - session.resumeThread( - threadId, nlohmann::json::object(), - [this, token, threadId](const nlohmann::json &result) { - if (!*token) - return; - const auto found = runtimeByThread.find(threadId); - if (found == runtimeByThread.end()) - return; - ThreadRuntimeState &runtime = found->second; - runtime.resumeInFlight = false; - if (!result.value("ok", false)) { - if (isTransientCancellation(result)) { - runtime.hydration = Hydration::NotHydrated; - runtime.settingsHydration = SettingsHydration::Unknown; - runtime.operationReady = false; - return; - } - runtime.settingsHydration = SettingsHydration::Failed; - 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; - } - runtime.hydration = Hydration::Hydrated; - runtime.settingsHydration = SettingsHydration::Hydrated; - runtime.operationReady = true; - 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 (isTransientCancellation(result)) { - if (prompts.requeue(threadId, submissionId)) { - if (auto runtime = runtimeByThread.find(threadId); - runtime != runtimeByThread.end()) { - runtime->second.hydration = Hydration::NotHydrated; - runtime->second.operationReady = false; - } - render(); - } - return; - } - if (attemptThreadRecovery(threadId, submissionId, result)) - return; - const auto runtime = runtimeByThread.find(threadId); - if (runtime != runtimeByThread.end()) - runtime->second.recoveryAttemptedSubmissions.erase(submissionId); - if (result.value("ok", false)) { - if (runtime != runtimeByThread.end()) - runtime->second.operationReady = true; - static_cast(prompts.acknowledge(threadId, submissionId, - resultTurnId(result), - QDateTime::currentMSecsSinceEpoch())); - scheduleAcceptedTransition(threadId, submissionId); - middleRegion->threads().confirmOptimisticThread(threadId); - } 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)); - middleRegion->threads().failOptimisticThread(threadId); - 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 auto found = runtimeByThread.find(threadId); - if (found == runtimeByThread.end()) - return false; - ThreadRuntimeState &runtime = found->second; - if (!runtime.recoveryAttemptedSubmissions.insert(submissionId).second) - return false; - if (!prompts.requeue(threadId, submissionId)) - return false; - runtime.hydration = Hydration::NotHydrated; - runtime.operationReady = false; - render(); - runtime.resumeInFlight = true; - const auto token = alive; - session.resumeThread( - threadId, nlohmann::json::object(), - [this, token, threadId](const nlohmann::json &resumeResult) { - if (!*token) - return; - const auto found = runtimeByThread.find(threadId); - if (found == runtimeByThread.end()) - return; - ThreadRuntimeState &runtime = found->second; - runtime.resumeInFlight = false; - if (!resumeResult.value("ok", false)) { - if (isTransientCancellation(resumeResult)) { - runtime.hydration = Hydration::NotHydrated; - runtime.settingsHydration = SettingsHydration::Unknown; - runtime.operationReady = false; - return; - } - runtime.settingsHydration = SettingsHydration::Failed; - 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; - } - runtime.hydration = Hydration::Hydrated; - runtime.settingsHydration = SettingsHydration::Hydrated; - runtime.operationReady = true; - 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, now); - render(); - }); + UiPromptDraft draft; + draft.text = utf8(prompt); + draft.attachments = std::move(attachments); + draft.turnStartOptions = settings->turnStartOptions(); + draft.threadStartOptions = settings->threadStartOptions(); + draft.workspace = settings->workspace(utf8(QDir::currentPath())); + draft.visiblySelectedThreadId = visibleThreadId; + const bool admitted = uiSession.submitPrompt(std::move(draft)); + if (admitted && !visibleThreadId.empty() && + visibleThreadId != DraftThreadId) + middleRegion->threads().promotePromptedThread(visibleThreadId); + return admitted; } void ShellWidget::Impl::chooseAttachments() { const QString initial = text(middleRegion->composer().turnSettings()->workspace( - QDir::currentPath().toStdString())); + utf8(QDir::currentPath()))); 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); +const UiPendingRequestView *ShellWidget::Impl::pendingRequest( + const std::string &requestKey) const { + if (!renderedView) + return nullptr; + for (const UiPendingRequestView &request : renderedView->pendingRequests) { + if (request.id == requestKey) + return &request; + } + return nullptr; } 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()) + if (!renderedView || !renderedView->selectedPendingRequest) return; + const std::string id = renderedView->selectedPendingRequest->id; if (approve) - acceptPending(request->first); + acceptPending(id); else - rejectPending(request->first); -} - -bool ShellWidget::Impl::isPendingActionable( - const std::string &requestKey) const { - const auto request = model.pendingRequestPresentations().find(requestKey); - return canControlProvider() && - request != model.pendingRequestPresentations().end() && - request->second.generation == model.connection().generation && - !resolvingRequests.contains(requestKey); -} - -void ShellWidget::Impl::resolvePending( - PendingRequestPresentation request, std::uint64_t providerGeneration, - PendingRequestResponse response) { - const auto current = model.pendingRequestPresentations().find(request.id); - if (!canControlProvider() || providerGeneration != observedProviderGeneration || - current == model.pendingRequestPresentations().end() || - current->second.generation != request.generation || - current->second.kind != request.kind || - current->second.threadId != request.threadId || - current->second.raw != request.raw || - resolvingRequests.contains(request.id)) { - showNotice(QStringLiteral("The pending request is no longer actionable."), - false); - return; - } - const nlohmann::json nativeId = - nlohmann::json::parse(request.id, nullptr, false); - if (nativeId.is_discarded()) { - showNotice(QStringLiteral("The pending request has an invalid identity.")); - return; - } - resolvingRequests.insert(request.id); - if (!session.respondToServerRequest(nativeId, std::move(response.result), - std::move(response.error))) { - resolvingRequests.erase(request.id); - showNotice(QStringLiteral("The pending response could not be sent.")); - } - scheduleRender(); + rejectPending(id); } void ShellWidget::Impl::reviewPending(const std::string &requestKey) { - const auto request = model.pendingRequestPresentations().find(requestKey); - if (!isPendingActionable(requestKey) || - request == model.pendingRequestPresentations().end()) + const UiPendingRequestView *current = pendingRequest(requestKey); + if (!current || !current->actionable) return; - const PendingRequestPresentation presented = request->second; - const std::uint64_t providerGeneration = observedProviderGeneration; + const UiPendingRequestView request = *current; + const PendingRequestDescriptor presented{ + request.id, request.kind, request.threadId, request.generation, + request.raw}; const auto response = PendingRequestDialog::present(presented, owner); - if (!response) - return; - resolvePending(presented, providerGeneration, *response); + if (response) + static_cast( + uiSession.resolvePending(request, std::move(*response))); } void ShellWidget::Impl::acceptPending(const std::string &requestKey) { - const auto request = model.pendingRequestPresentations().find(requestKey); - if (!isPendingActionable(requestKey) || - request == model.pendingRequestPresentations().end()) + const UiPendingRequestView *current = pendingRequest(requestKey); + if (!current || !current->actionable) return; - if (!requestSupportsDirectAccept(request->second)) { + const UiPendingRequestView request = *current; + if (!request.supportsDirectAccept) { reviewPending(requestKey); return; } - const PendingRequestPresentation presented = request->second; - resolvePending(presented, observedProviderGeneration, - PendingRequestDialog::positiveResponse(presented)); + static_cast(uiSession.resolvePending( + request, + PendingRequestPolicy::positiveResponse(request.kind, request.raw))); } void ShellWidget::Impl::rejectPending(const std::string &requestKey) { - const auto request = model.pendingRequestPresentations().find(requestKey); - if (!isPendingActionable(requestKey) || - request == model.pendingRequestPresentations().end()) + const UiPendingRequestView *current = pendingRequest(requestKey); + if (!current || !current->actionable) return; - const PendingRequestPresentation presented = request->second; - resolvePending(presented, observedProviderGeneration, - PendingRequestDialog::negativeResponse(presented)); + const UiPendingRequestView request = *current; + static_cast(uiSession.resolvePending( + request, + PendingRequestPolicy::negativeResponse(request.kind, request.raw))); } ShellWidget::ShellWidget(FrontendSession &session, QWidget *parent) diff --git a/src/codex/UiSession.cpp b/src/codex/UiSession.cpp new file mode 100644 index 0000000..4312586 --- /dev/null +++ b/src/codex/UiSession.cpp @@ -0,0 +1,1350 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/UiSession.h" + +#include "codex/PresentationModel.h" +#include "codex/PresentationProtocol.h" +#include "codex/PresentationStatus.h" +#include "codex/middle/ConversationProjection.h" +#include "codex/middle/PromptCoordinator.h" +#include "codex/ui/UiViewProjection.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace codexui::codex { +namespace { + +constexpr std::string_view DraftThreadId = "draft:new-thread"; + +std::int64_t systemClockMilliseconds() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +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{}; +} + +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; + std::string message = + safeMessage(result.value("error", nlohmann::json::object())); + std::ranges::transform(message, message.begin(), [](unsigned char value) { + return static_cast(std::tolower(value)); + }); + return message.find("thread") != std::string::npos && + message.find("not found") != std::string::npos; +} + +bool isTransientCancellation(const nlohmann::json &result) { + return !result.value("ok", false) && + result.value("error", nlohmann::json::object()) + .value("transient", false); +} + +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)}; +} + +std::string trimAscii(std::string value) { + const auto whitespace = [](unsigned char character) { + return character == ' ' || character == '\t' || character == '\n' || + character == '\r' || character == '\f' || character == '\v'; + }; + const auto first = std::ranges::find_if_not(value, whitespace); + if (first == value.end()) + return {}; + const auto last = std::find_if_not(value.rbegin(), value.rend(), whitespace); + return std::string(first, last.base()); +} + +} // namespace + +class UiSession::Impl final { +public: + enum class Hydration { NotHydrated, InFlight, Hydrated, Failed }; + enum class SettingsHydration { + Unknown, + WaitingForRead, + InFlight, + Hydrated, + Failed, + }; + + struct ThreadRuntimeState { + Hydration hydration = Hydration::NotHydrated; + SettingsHydration settingsHydration = SettingsHydration::Unknown; + std::uint64_t readRevision = 0; + bool operationReady = false; + bool resumeInFlight = false; + std::unordered_set recoveryAttemptedSubmissions; + + void resetForConnection() noexcept { + hydration = Hydration::NotHydrated; + settingsHydration = SettingsHydration::Unknown; + readRevision = 0; + operationReady = false; + resumeInFlight = false; + } + }; + + struct HistoryWindow { + std::size_t requested = + middle::ConversationProjection::DefaultAuthoritativeItemLimit; + std::size_t effective = + middle::ConversationProjection::DefaultAuthoritativeItemLimit; + std::size_t lastAuthoritativeCount = 0; + }; + + Impl(PresentationClient client, std::string defaultWorkspace, + UiSession::Clock clock) + : client(std::move(client)), + defaultWorkspace(std::move(defaultWorkspace)), + clock(clock ? std::move(clock) : UiSession::Clock{ + systemClockMilliseconds}), + alive(std::make_shared(true)) {} + + ~Impl() { *alive = false; } + + [[nodiscard]] std::int64_t now() const { return clock(); } + [[nodiscard]] std::int64_t nowSeconds() const { return now() / 1000; } + + void changed() { + if (changedHandler) + changedHandler(); + } + + void showNotice(std::string message, bool error = true) { + if (message.empty()) + return; + notices.push_back({nextNoticeId++, std::move(message), error}); + changed(); + } + + void scheduleWakeup(std::int64_t atMilliseconds) { + if (!nextWakeupAt || atMilliseconds < *nextWakeupAt) { + nextWakeupAt = atMilliseconds; + if (wakeupHandler) + wakeupHandler(atMilliseconds); + } + } + + [[nodiscard]] bool providerReady() const { + const ConnectionPresentation &connection = model.connection(); + return connection.connected && connection.providerState == "ready"; + } + + [[nodiscard]] bool canControlProvider() const { + return providerReady() && model.connection().role == "controller"; + } + + void resetRuntimeForConnection() { + resolvingRequests.clear(); + deferredPromptDispatch.clear(); + for (auto &[threadId, runtime] : runtimeByThread) { + static_cast(threadId); + runtime.resetForConnection(); + } + } + + void hydrateProvider() { + if (!providerReady()) + return; + client.execute("threads.list", nlohmann::json::object()); + client.execute("models.list", nlohmann::json::object()); + ensureThreadHydrated(selectedThreadId); + ensureThreadSettingsHydrated(selectedThreadId); + for (const std::string &threadId : prompts.queuedThreadIds()) { + if (threadId == DraftThreadId) { + if (newThreadIntent) + startThreadForDraft(); + } else { + schedulePromptDispatch(threadId); + } + } + client.execute("permission-profiles.list", {{"cwd", defaultWorkspace}}); + } + + void onPresentationFrame(const nlohmann::json &event) { + if (protocolFrameObserver) + protocolFrameObserver(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; + resetRuntimeForConnection(); + } + if (connection.providerGeneration != observedProviderGeneration) { + observedProviderGeneration = connection.providerGeneration; + resetRuntimeForConnection(); + } + + 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"); + const bool hydrationResult = + kind == "result" && presentation::isThreadHydrationAction(action); + if (!eventThreadId.empty() && !hydrationResult) + model.noteThreadActivity(eventThreadId, nowSeconds()); + if (kind == "event" && type == "pending-request.removed") { + const auto requestId = scope.find("requestId"); + if (requestId != scope.end() && !requestId->is_null()) + resolvingRequests.erase(requestId->dump()); + } + if (kind == "event" && type == "connection.provider" && + stringValue(data, "state") == "disconnected") + resetRuntimeForConnection(); + + 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(message.empty() ? "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(message, stringValue(data, "severity") == "error"); + } else if (kind == "event" && type == "system.diagnostic") { + const std::string message = safeMessage(data); + if (!message.empty()) + showNotice("Protocol diagnostic: " + 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() ? "Codex bridge disconnected" : detail); + } + + if (kind == "event" && + ((type == "connection.provider" && + stringValue(data, "state") == "ready") || + (type == "connection.bridge" && + stringValue(data, "state") == "opened" && providerReady()))) + hydrateProvider(); + if (kind == "event" && type == "connection.controller" && + providerReady() && model.connection().role == "controller") { + ensureThreadSettingsHydrated(selectedThreadId); + for (const std::string &threadId : prompts.queuedThreadIds()) + schedulePromptDispatch(threadId); + } + + if (type == "thread.removed" && !eventThreadId.empty()) { + prompts.clearThread(eventThreadId); + runtimeByThread.erase(eventThreadId); + historyWindows.erase(eventThreadId); + if (selectedThreadId == eventThreadId) { + selectedThreadId.clear(); + effects.push_back(UiEffect::ClearComposerDraft); + } + } else if (!eventThreadId.empty()) { + if (const ThreadPresentation *thread = model.thread(eventThreadId)) + prompts.reconcile(eventThreadId, *thread, now()); + } + + if (!staleReadResult && kind == "result" && action == "thread.read" && + event.value("ok", false)) + hydrateHistoricalChildren(eventThreadId); + else if (kind == "event" && type == "agents.activity.upsert") + hydrateHistoricalChildren(eventThreadId); + + changed(); + } + + void noteThreadActivity(const std::string &threadId) { + model.noteThreadActivity(threadId, nowSeconds()); + changed(); + } + + void hydrateHistoricalChildren(const std::string &parentThreadId, + bool retryFailed = false) { + const ThreadPresentation *thread = model.thread(parentThreadId); + if (!thread) + return; + for (const std::string &childThreadId : thread->childThreadOrder) { + const ChildThreadOwnership *ownership = + model.childOwnership(childThreadId); + if (!ownership || ownership->parentThreadId != parentThreadId) + continue; + const auto agent = thread->agents.find(ownership->agentId); + if (agent == thread->agents.end() || + !isActiveStatus(agent->second.status)) + continue; + const auto runtime = runtimeByThread.find(childThreadId); + const bool failed = runtime != runtimeByThread.end() && + runtime->second.hydration == Hydration::Failed; + if (!failed || retryFailed) + readThread(childThreadId, failed); + } + } + + void selectThread(std::string threadId) { + if (threadId.empty()) + return; + if (threadId == selectedThreadId) { + hydrateThreadForSelection(threadId); + return; + } + if (optimisticThread && optimisticThread->key == DraftThreadId && + prompts.submissions(std::string(DraftThreadId)).empty()) + optimisticThread.reset(); + selectedThreadId = std::move(threadId); + newThreadIntent = false; + newThreadOptions = nlohmann::json::object(); + newThreadName.clear(); + newThreadWorkspace.clear(); + historyWindows.try_emplace(selectedThreadId); + hydrateThreadForSelection(selectedThreadId); + changed(); + } + + void beginNewThread(UiNewThreadDraft draft) { + if (newThreadCreationInFlight) { + showNotice("The current new thread is still being created.", false); + return; + } + prompts.clearThread(std::string(DraftThreadId)); + selectedThreadId.clear(); + newThreadIntent = true; + newThreadName = std::move(draft.name); + newThreadWorkspace = draft.workspace.empty() + ? defaultWorkspace + : std::move(draft.workspace); + newThreadOptions = nlohmann::json::object(); + if (!draft.baseInstructions.empty()) + newThreadOptions["baseInstructions"] = + std::move(draft.baseInstructions); + if (!draft.developerInstructions.empty()) + newThreadOptions["developerInstructions"] = + std::move(draft.developerInstructions); + if (draft.ephemeral) + newThreadOptions["ephemeral"] = true; + optimisticThread = UiOptimisticThreadView{ + std::string(DraftThreadId), {}, + newThreadName.empty() ? "New thread" : newThreadName, + newThreadWorkspace, UiOptimisticThreadPhase::Awaiting}; + effects.push_back(UiEffect::ClearComposerDraft); + effects.push_back(UiEffect::FocusComposer); + changed(); + } + + void readThread(const std::string &threadId, bool forced = false) { + if (threadId.empty() || !providerReady()) + return; + ThreadRuntimeState &runtime = runtimeByThread[threadId]; + if (runtime.resumeInFlight) + return; + if (!forced && (runtime.hydration == Hydration::InFlight || + runtime.hydration == Hydration::Hydrated || + runtime.hydration == Hydration::Failed)) + return; + runtime.hydration = Hydration::InFlight; + const auto token = alive; + const std::uint64_t revision = nextReadRevision++; + runtime.readRevision = revision; + client.execute( + "thread.read", {{"threadId", threadId}, {"includeTurns", true}}, + [this, token, threadId, + revision](const nlohmann::json &result) { + if (!*token) + return; + const auto current = runtimeByThread.find(threadId); + if (current == runtimeByThread.end() || + current->second.readRevision != revision) { + const std::string correlationId = + stringValue(result, "correlationId"); + if (!correlationId.empty()) + staleReadResultCorrelations.insert(correlationId); + return; + } + ThreadRuntimeState &runtime = current->second; + if (result.value("ok", false)) { + runtime.hydration = Hydration::Hydrated; + if (runtime.settingsHydration == + SettingsHydration::WaitingForRead) + resumeThreadForSettings(threadId); + schedulePromptDispatch(threadId); + return; + } + if (isTransientCancellation(result)) { + runtime.hydration = Hydration::NotHydrated; + if (runtime.settingsHydration == + SettingsHydration::WaitingForRead) + runtime.settingsHydration = SettingsHydration::Unknown; + return; + } + runtime.hydration = Hydration::Failed; + if (runtime.settingsHydration == SettingsHydration::WaitingForRead) + runtime.settingsHydration = SettingsHydration::Failed; + const std::string message = + safeMessage(result.value("error", nlohmann::json::object())); + const std::string displayed = + message.empty() ? "Thread loading failed" : message; + static_cast(prompts.failQueued(threadId, displayed)); + showNotice(displayed); + }); + } + + void ensureThreadSettingsHydrated(const std::string &threadId) { + if (threadId.empty() || !canControlProvider()) + return; + ThreadRuntimeState &runtime = runtimeByThread[threadId]; + if (runtime.settingsHydration == SettingsHydration::WaitingForRead || + runtime.settingsHydration == SettingsHydration::InFlight || + runtime.settingsHydration == SettingsHydration::Hydrated) + return; + if (runtime.hydration != Hydration::Hydrated) { + runtime.settingsHydration = SettingsHydration::WaitingForRead; + ensureThreadHydrated(threadId); + return; + } + resumeThreadForSettings(threadId); + } + + void resumeThreadForSettings(const std::string &threadId) { + ThreadRuntimeState &runtime = runtimeByThread[threadId]; + if (runtime.resumeInFlight) + return; + runtime.settingsHydration = SettingsHydration::InFlight; + const auto token = alive; + client.execute( + "thread.resume", {{"threadId", threadId}, {"excludeTurns", true}}, + [this, token, threadId](const nlohmann::json &result) { + if (!*token) + return; + const auto found = runtimeByThread.find(threadId); + if (found == runtimeByThread.end()) + return; + ThreadRuntimeState &runtime = found->second; + if (!result.value("ok", false)) { + if (isTransientCancellation(result)) { + runtime.settingsHydration = SettingsHydration::Unknown; + return; + } + runtime.settingsHydration = SettingsHydration::Failed; + if (selectedThreadId == threadId) { + const std::string message = safeMessage( + result.value("error", nlohmann::json::object())); + showNotice(message.empty() ? "Thread settings refresh failed" + : message); + } + } else { + runtime.settingsHydration = SettingsHydration::Hydrated; + runtime.hydration = Hydration::Hydrated; + runtime.operationReady = true; + } + schedulePromptDispatch(threadId); + }); + } + + void ensureThreadHydrated(const std::string &threadId) { + if (threadId.empty() || !model.connection().connected) + return; + const auto found = runtimeByThread.find(threadId); + if (found != runtimeByThread.end() && + (found->second.hydration == Hydration::Hydrated || + found->second.hydration == Hydration::InFlight)) + return; + readThread(threadId); + } + + void hydrateThreadForSelection(const std::string &threadId) { + const auto runtime = runtimeByThread.find(threadId); + if (runtime != runtimeByThread.end() && + runtime->second.hydration == Hydration::Failed) + readThread(threadId, true); + else + ensureThreadHydrated(threadId); + ensureThreadSettingsHydrated(threadId); + hydrateHistoricalChildren(threadId, true); + } + + bool submitPrompt(UiPromptDraft draft) { + draft.text = trimAscii(std::move(draft.text)); + if (draft.text.empty()) + return false; + if (!canControlProvider()) { + showNotice("Codex is not ready for a controlled turn. Your message was " + "not sent."); + return false; + } + draft.text = middle::promptWithFileLinks(std::move(draft.text), + draft.attachments); + const bool selectedNewThreadDraft = + draft.visiblySelectedThreadId == DraftThreadId && newThreadIntent; + if (!draft.visiblySelectedThreadId.empty() && + draft.visiblySelectedThreadId != selectedThreadId && + !selectedNewThreadDraft) { + if (!model.thread(draft.visiblySelectedThreadId)) { + showNotice("The visibly selected thread is no longer available. Your " + "message was not sent."); + return false; + } + selectThread(draft.visiblySelectedThreadId); + } + + std::string destination = selectedThreadId; + const ThreadPresentation *thread = model.thread(destination); + if (destination.empty()) { + if (!newThreadIntent) { + showNotice("No destination thread is selected. Your message was not " + "sent; select a thread or use New thread."); + effects.push_back(UiEffect::FocusComposer); + return false; + } + destination = DraftThreadId; + thread = nullptr; + } else if (!thread) { + showNotice("The selected thread is no longer available. Your message " + "was not sent."); + return false; + } + + if (destination != DraftThreadId) { + const auto runtime = runtimeByThread.find(destination); + if (runtime != runtimeByThread.end() && + runtime->second.hydration == Hydration::Failed) { + showNotice("Thread loading failed. Reload the thread before sending; " + "your message was not sent."); + effects.push_back(UiEffect::FocusComposer); + return false; + } + } + + const auto activeTurn = + destination == DraftThreadId + ? std::optional{} + : model.activeTurnId(destination); + static_cast(prompts.admit( + destination, std::move(draft.text), std::move(draft.attachments), + std::move(draft.turnStartOptions), thread, activeTurn, now())); + effects.push_back(UiEffect::PrepareLocalPromptAdmission); + if (destination == DraftThreadId) { + pendingThreadStartOptions = std::move(draft.threadStartOptions); + pendingThreadWorkspace = draft.workspace.empty() + ? newThreadWorkspace + : std::move(draft.workspace); + changed(); + startThreadForDraft(); + } else { + changed(); + schedulePromptDispatch(destination); + } + return true; + } + + void startThreadForDraft() { + if (!canControlProvider() || newThreadCreationInFlight || + prompts.submissions(std::string(DraftThreadId)).empty()) + return; + newThreadCreationInFlight = true; + nlohmann::json options = pendingThreadStartOptions; + options.update(newThreadOptions); + options["cwd"] = pendingThreadWorkspace.empty() + ? (newThreadWorkspace.empty() ? defaultWorkspace + : newThreadWorkspace) + : pendingThreadWorkspace; + const std::string requestedName = newThreadName; + const auto token = alive; + client.execute( + "thread.create", std::move(options), + [this, token, requestedName](const nlohmann::json &result) { + if (!*token) + return; + newThreadCreationInFlight = false; + if (!result.value("ok", false)) { + if (isTransientCancellation(result)) { + changed(); + return; + } + const std::string message = + safeMessage(result.value("error", nlohmann::json::object())); + const std::string error = + message.empty() ? "Thread creation failed" : message; + std::vector ids; + for (const auto &submission : + prompts.submissions(std::string(DraftThreadId))) + ids.push_back(submission.id); + for (const std::uint64_t id : ids) + static_cast( + prompts.fail(std::string(DraftThreadId), id, error)); + if (optimisticThread) + optimisticThread->phase = UiOptimisticThreadPhase::Failed; + showNotice(error); + return; + } + const std::string threadId = stringValue( + result.value("data", nlohmann::json::object()) + .value("thread", nlohmann::json::object()), + "id"); + if (threadId.empty()) { + const std::string error = + "Thread creation returned no thread identifier"; + std::vector ids; + for (const auto &submission : + prompts.submissions(std::string(DraftThreadId))) + ids.push_back(submission.id); + for (const std::uint64_t id : ids) + static_cast( + prompts.fail(std::string(DraftThreadId), id, error)); + if (optimisticThread) + optimisticThread->phase = UiOptimisticThreadPhase::Failed; + showNotice(error); + return; + } + if (!prompts.reassignThread(std::string(DraftThreadId), threadId)) { + if (optimisticThread) + optimisticThread->phase = UiOptimisticThreadPhase::Failed; + showNotice("Could not attach the draft prompts to the created " + "thread."); + return; + } + ThreadRuntimeState &runtime = runtimeByThread[threadId]; + runtime.hydration = Hydration::Hydrated; + runtime.settingsHydration = SettingsHydration::Hydrated; + runtime.operationReady = true; + if (optimisticThread) { + optimisticThread->threadId = threadId; + } + const bool viewingDraft = selectedThreadId.empty() && newThreadIntent; + if (viewingDraft) { + selectedThreadId = threadId; + newThreadIntent = false; + } + newThreadOptions = nlohmann::json::object(); + newThreadName.clear(); + newThreadWorkspace.clear(); + pendingThreadStartOptions = nlohmann::json::object(); + pendingThreadWorkspace.clear(); + if (!requestedName.empty()) + client.execute("thread.rename", + {{"threadId", threadId}, + {"name", requestedName}}); + changed(); + schedulePromptDispatch(threadId); + }); + } + + void schedulePromptDispatch(const std::string &threadId) { + if (threadId.empty()) + return; + deferredPromptDispatch.insert(threadId); + scheduleWakeup(now()); + } + + void dispatchNextPrompt(const std::string &threadId) { + if (threadId.empty() || !canControlProvider()) + return; + auto runtime = runtimeByThread.find(threadId); + if (runtime != runtimeByThread.end() && + runtime->second.settingsHydration == SettingsHydration::InFlight) + 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 (runtime != runtimeByThread.end() && runtime->second.resumeInFlight) + return; + if (runtime == runtimeByThread.end() || + runtime->second.hydration != Hydration::Hydrated) { + ensureThreadHydrated(threadId); + return; + } + if (prompts.hasInFlight(threadId)) + return; + const ThreadPresentation *thread = model.thread(threadId); + if (!runtime->second.operationReady && thread && + thread->status == "notLoaded") { + resumePromptQueue(threadId); + return; + } + const auto dispatch = + prompts.beginNext(threadId, model.activeTurnId(threadId)); + if (dispatch) + dispatchPrompt(*dispatch); + } + + void dispatchPrompt(middle::PromptDispatch dispatch) { + nlohmann::json input = nlohmann::json::array( + {{{"type", "text"}, + {"text", dispatch.prompt}, + {"text_elements", nlohmann::json::array()}}}); + for (const AttachmentDraft &attachment : dispatch.attachments) { + if (attachment.mimeType.starts_with("image/")) + input.push_back({{"type", "localImage"}, + {"path", attachment.path}}); + else if (attachment.mimeType.starts_with("audio/")) + input.push_back({{"type", "localAudio"}, + {"path", attachment.path}}); + } + 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) { + client.execute("turn.steer", + {{"threadId", dispatch.threadId}, + {"expectedTurnId", *dispatch.expectedTurnId}, + {"clientUserMessageId", dispatch.clientUserMessageId}, + {"input", std::move(input)}}, + std::move(completed)); + } else { + dispatch.turnOptions["clientUserMessageId"] = + dispatch.clientUserMessageId; + dispatch.turnOptions["threadId"] = dispatch.threadId; + dispatch.turnOptions["input"] = std::move(input); + client.execute("turn.start", std::move(dispatch.turnOptions), + std::move(completed)); + } + } + + void resumePromptQueue(const std::string &threadId) { + ThreadRuntimeState &runtime = runtimeByThread[threadId]; + if (runtime.resumeInFlight || !canControlProvider()) + return; + runtime.resumeInFlight = true; + const auto token = alive; + client.execute( + "thread.resume", {{"threadId", threadId}}, + [this, token, threadId](const nlohmann::json &result) { + if (!*token) + return; + const auto found = runtimeByThread.find(threadId); + if (found == runtimeByThread.end()) + return; + ThreadRuntimeState &runtime = found->second; + runtime.resumeInFlight = false; + if (!result.value("ok", false)) { + if (isTransientCancellation(result)) { + runtime.hydration = Hydration::NotHydrated; + runtime.settingsHydration = SettingsHydration::Unknown; + runtime.operationReady = false; + return; + } + runtime.settingsHydration = SettingsHydration::Failed; + const std::string message = + safeMessage(result.value("error", nlohmann::json::object())); + const std::string displayed = + message.empty() ? "Thread resume failed" : message; + static_cast(prompts.failQueued(threadId, displayed)); + showNotice(displayed); + return; + } + runtime.hydration = Hydration::Hydrated; + runtime.settingsHydration = SettingsHydration::Hydrated; + runtime.operationReady = true; + schedulePromptDispatch(threadId); + }); + } + + void completePrompt(const std::string &threadId, + std::uint64_t submissionId, + const nlohmann::json &result) { + if (isTransientCancellation(result)) { + if (prompts.requeue(threadId, submissionId)) { + if (auto runtime = runtimeByThread.find(threadId); + runtime != runtimeByThread.end()) { + runtime->second.hydration = Hydration::NotHydrated; + runtime->second.operationReady = false; + } + changed(); + } + return; + } + if (attemptThreadRecovery(threadId, submissionId, result)) + return; + const auto runtime = runtimeByThread.find(threadId); + if (runtime != runtimeByThread.end()) + runtime->second.recoveryAttemptedSubmissions.erase(submissionId); + if (result.value("ok", false)) { + if (runtime != runtimeByThread.end()) + runtime->second.operationReady = true; + static_cast(prompts.acknowledge( + threadId, submissionId, resultTurnId(result), now())); + scheduleAcceptedTransition(threadId, submissionId); + if (optimisticThread && optimisticThread->threadId == threadId) + optimisticThread->phase = UiOptimisticThreadPhase::Confirmed; + } else { + const std::string message = + safeMessage(result.value("error", nlohmann::json::object())); + const std::string displayed = + message.empty() ? "Submission failed" : message; + static_cast(prompts.fail(threadId, submissionId, displayed)); + if (optimisticThread && optimisticThread->threadId == threadId) + optimisticThread->phase = UiOptimisticThreadPhase::Failed; + showNotice(message.empty() ? "Turn submission failed" : message); + } + changed(); + schedulePromptDispatch(threadId); + } + + bool attemptThreadRecovery(const std::string &threadId, + std::uint64_t submissionId, + const nlohmann::json &result) { + if (!isThreadNotFoundResult(result)) + return false; + const auto found = runtimeByThread.find(threadId); + if (found == runtimeByThread.end()) + return false; + ThreadRuntimeState &runtime = found->second; + if (!runtime.recoveryAttemptedSubmissions.insert(submissionId).second) + return false; + if (!prompts.requeue(threadId, submissionId)) + return false; + runtime.hydration = Hydration::NotHydrated; + runtime.operationReady = false; + changed(); + runtime.resumeInFlight = true; + const auto token = alive; + client.execute( + "thread.resume", {{"threadId", threadId}}, + [this, token, threadId](const nlohmann::json &resumeResult) { + if (!*token) + return; + const auto found = runtimeByThread.find(threadId); + if (found == runtimeByThread.end()) + return; + ThreadRuntimeState &runtime = found->second; + runtime.resumeInFlight = false; + if (!resumeResult.value("ok", false)) { + if (isTransientCancellation(resumeResult)) { + runtime.hydration = Hydration::NotHydrated; + runtime.settingsHydration = SettingsHydration::Unknown; + runtime.operationReady = false; + return; + } + runtime.settingsHydration = SettingsHydration::Failed; + const std::string message = safeMessage( + resumeResult.value("error", nlohmann::json::object())); + const std::string displayed = + message.empty() ? "Thread recovery failed" : message; + static_cast(prompts.failQueued(threadId, displayed)); + showNotice(displayed); + return; + } + runtime.hydration = Hydration::Hydrated; + runtime.settingsHydration = SettingsHydration::Hydrated; + runtime.operationReady = true; + schedulePromptDispatch(threadId); + }); + return true; + } + + void 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 std::int64_t deadline = + submission->acceptedAtMilliseconds + + middle::AcknowledgementTransitionMilliseconds; + acceptedTransitionDeadlines[{threadId, submissionId}] = deadline; + scheduleWakeup(deadline); + } + + void tick() { + nextWakeupAt.reset(); + const auto deferred = std::exchange(deferredPromptDispatch, + std::set{}); + for (const std::string &threadId : deferred) + dispatchNextPrompt(threadId); + + const std::int64_t current = now(); + bool projectionChanged = false; + for (auto iterator = acceptedTransitionDeadlines.begin(); + iterator != acceptedTransitionDeadlines.end();) { + if (iterator->second > current) { + scheduleWakeup(iterator->second); + ++iterator; + continue; + } + const auto &[threadId, submissionId] = iterator->first; + const middle::PromptSubmission *submission = + prompts.submission(threadId, submissionId); + if (submission && submission->state == middle::PromptState::Accepted && + submission->acceptedTransitionActive(current)) { + iterator->second = submission->acceptedAtMilliseconds + + middle::AcknowledgementTransitionMilliseconds; + scheduleWakeup(iterator->second); + ++iterator; + continue; + } + if (const ThreadPresentation *thread = model.thread(threadId)) + prompts.reconcile(threadId, *thread, current); + iterator = acceptedTransitionDeadlines.erase(iterator); + projectionChanged = true; + } + if (projectionChanged) + changed(); + } + + [[nodiscard]] bool isPendingActionable( + const std::string &requestKey) const { + const auto request = model.pendingRequestPresentations().find(requestKey); + return canControlProvider() && + request != model.pendingRequestPresentations().end() && + request->second.generation == model.connection().generation && + !resolvingRequests.contains(requestKey); + } + + [[nodiscard]] UiPendingRequestView pendingView( + const PendingRequestPresentation &request) const { + return {request.id, + request.kind, + request.threadId, + request.generation, + request.raw, + PendingRequestPolicy::title(request.kind), + PendingRequestPolicy::detail(request.id, request.threadId, + request.raw), + PendingRequestPolicy::directAcceptLabel(request.kind), + PendingRequestPolicy::supportsDirectAccept(request.kind), + isPendingActionable(request.id)}; + } + + bool resolvePending(UiPendingRequestView request, + PendingRequestResponse response) { + const auto current = model.pendingRequestPresentations().find(request.id); + if (!canControlProvider() || + current == model.pendingRequestPresentations().end() || + current->second.generation != model.connection().generation || + current->second.generation != request.generation || + current->second.kind != request.kind || + current->second.threadId != request.threadId || + current->second.raw != request.raw || + resolvingRequests.contains(request.id)) { + showNotice("The pending request is no longer actionable.", false); + return false; + } + const nlohmann::json nativeId = + nlohmann::json::parse(request.id, nullptr, false); + if (nativeId.is_discarded()) { + showNotice("The pending request has an invalid identity."); + return false; + } + resolvingRequests.insert(request.id); + if (!client.respond(nativeId, std::move(response.result), + std::move(response.error))) { + resolvingRequests.erase(request.id); + showNotice("The pending response could not be sent."); + return false; + } + if (!current->second.threadId.empty()) + model.noteThreadActivity(current->second.threadId, nowSeconds()); + changed(); + return true; + } + + UiSettingsView projectSettings() const { + UiSettingsView result; + result.identity = "no-thread"; + if (const ThreadPresentation *thread = model.thread(selectedThreadId)) { + result.identity = thread->id; + result.settingsUpdate = thread->latestSettingsUpdate; + result.settingsRevision = thread->settingsRevision; + result.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"]; + if (update.contains("effort")) + result.canonical.erase("reasoningEffort"); + if (update.contains("sandboxPolicy")) + result.canonical.erase("sandbox"); + result.canonical.merge_patch(update); + } + } else if (newThreadIntent) { + result.identity = DraftThreadId; + result.canonical["cwd"] = newThreadWorkspace.empty() + ? defaultWorkspace + : newThreadWorkspace; + } else { + result.canonical["cwd"] = defaultWorkspace; + } + const auto profiles = + model.globalDomains().find("operation.permission-profiles.list"); + if (profiles != model.globalDomains().end()) + result.permissionProfiles = profiles->second; + result.modelCatalog = model.modelCatalog(); + return result; + } + + UiSessionView &refreshView(bool conversationFollowing, + std::string draftWorkspace) { + const std::int64_t current = now(); + const std::string visibleThreadId = + selectedThreadId.empty() && newThreadIntent + ? std::string(DraftThreadId) + : selectedThreadId; + viewState = UiSessionView{}; + viewState.selectedThreadId = selectedThreadId; + viewState.newThreadIntent = newThreadIntent; + viewState.threads = + ui::projectThreadListSnapshot(model, visibleThreadId); + viewState.inspector = ui::projectInspectorSnapshot( + model, selectedThreadId, + [this](std::string_view requestId) { + return isPendingActionable(std::string(requestId)); + }); + viewState.settings = projectSettings(); + viewState.optimisticThread = optimisticThread; + + const ThreadPresentation *thread = model.thread(selectedThreadId); + middle::AuthoritativeItemIndex authoritativeItems = + middle::indexAuthoritativeItems(visibleThreadId, thread); + if (thread) + prompts.reconcile(selectedThreadId, authoritativeItems, current); + const std::size_t authoritativeCount = authoritativeItems.ordered.size(); + HistoryWindow &history = historyWindows[visibleThreadId]; + if (!conversationFollowing && + authoritativeCount > history.lastAuthoritativeCount) + history.effective += + authoritativeCount - history.lastAuthoritativeCount; + else if (conversationFollowing) + history.effective = history.requested; + history.lastAuthoritativeCount = authoritativeCount; + UiConversationView &conversation = viewState.conversation; + conversation.key = visibleThreadId; + conversation.snapshot = middle::ConversationProjection::project( + authoritativeItems, thread, prompts.submissions(visibleThreadId), + history.effective, current); + conversation.snapshot.activeTurnId = + model.activeTurnId(selectedThreadId); + if (thread) { + conversation.mode = UiConversationMode::Thread; + conversation.title = thread->title; + conversation.workspace = thread->cwd; + conversation.status = std::string(classifyStatus(thread->status).text); + conversation.lastActivityAt = thread->lastActivityAt; + conversation.emptyMessage = "No materialized activity."; + } else if (newThreadIntent) { + conversation.mode = UiConversationMode::NewThread; + conversation.title = newThreadName.empty() ? "New thread" : newThreadName; + conversation.workspace = + draftWorkspace.empty() + ? (newThreadWorkspace.empty() ? defaultWorkspace + : newThreadWorkspace) + : std::move(draftWorkspace); + conversation.emptyMessage = "Send a message to create this thread."; + } else { + conversation.mode = UiConversationMode::NoSelection; + conversation.title = "Select a thread"; + conversation.workspace = "No workspace"; + conversation.emptyMessage = "Conversation activity appears here."; + } + + const ConnectionPresentation &connection = model.connection(); + UiStatusView &status = viewState.status; + status.connected = connection.connected; + status.retrying = connection.retrying; + status.role = connection.role; + status.providerState = connection.providerState; + status.connectionSettings = connection.settings; + status.workspace = conversation.workspace; + status.activeTurn = + model.activeTurnId(selectedThreadId).has_value(); + status.totalPending = model.pendingRequestCount(); + 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) { + status.selectedTransport = stringValue(entry, "label"); + break; + } + } + } + for (const auto &[id, request] : model.pendingRequestPresentations()) { + UiPendingRequestView projected = pendingView(request); + if (request.threadId == selectedThreadId) { + ++status.selectedPending; + if (!viewState.selectedPendingRequest) + viewState.selectedPendingRequest = projected; + } + viewState.pendingRequests.push_back(std::move(projected)); + } + status.canSubmit = canControlProvider(); + status.canEditSettings = status.canSubmit && !status.activeTurn; + return viewState; + } + + PresentationClient client; + std::string defaultWorkspace; + UiSession::Clock clock; + std::shared_ptr alive; + UiSession::ChangedHandler changedHandler; + UiSession::WakeupHandler wakeupHandler; + UiSession::ProtocolFrameObserver protocolFrameObserver; + std::optional nextWakeupAt; + + PresentationModel model; + middle::PromptCoordinator prompts; + std::string selectedThreadId; + bool newThreadIntent = false; + bool newThreadCreationInFlight = false; + nlohmann::json newThreadOptions = nlohmann::json::object(); + std::string newThreadName; + std::string newThreadWorkspace; + nlohmann::json pendingThreadStartOptions = nlohmann::json::object(); + std::string pendingThreadWorkspace; + std::optional optimisticThread; + + std::unordered_map runtimeByThread; + std::unordered_set resolvingRequests; + std::unordered_set staleReadResultCorrelations; + std::uint64_t nextReadRevision = 1; + std::unordered_map historyWindows; + std::uint64_t observedConnectionGeneration = 0; + std::uint64_t observedProviderGeneration = 0; + std::set deferredPromptDispatch; + std::map, std::int64_t> + acceptedTransitionDeadlines; + + std::vector notices; + std::vector effects; + std::uint64_t nextNoticeId = 1; + UiSessionView viewState; +}; + +UiSession::UiSession(PresentationClient client, std::string defaultWorkspace, + Clock clock) + : impl(std::make_unique(std::move(client), + std::move(defaultWorkspace), + std::move(clock))) {} + +UiSession::~UiSession() = default; + +void UiSession::setChangedHandler(ChangedHandler handler) { + impl->changedHandler = std::move(handler); +} + +void UiSession::setWakeupHandler(WakeupHandler handler) { + impl->wakeupHandler = std::move(handler); + if (impl->wakeupHandler && impl->nextWakeupAt) + impl->wakeupHandler(*impl->nextWakeupAt); +} + +void UiSession::setProtocolFrameObserver(ProtocolFrameObserver observer) { + impl->protocolFrameObserver = std::move(observer); +} + +void UiSession::onPresentationFrame(const nlohmann::json &frame) { + impl->onPresentationFrame(frame); +} + +void UiSession::noteThreadActivity(const std::string &threadId) { + impl->noteThreadActivity(threadId); +} + +void UiSession::tick() { impl->tick(); } + +std::string UiSession::conversationKey() const { + return impl->selectedThreadId.empty() && impl->newThreadIntent + ? std::string(DraftThreadId) + : impl->selectedThreadId; +} + +const UiSessionView & +UiSession::refreshView(bool conversationFollowing, + std::string draftWorkspace) { + return impl->refreshView(conversationFollowing, std::move(draftWorkspace)); +} + +std::vector UiSession::takeNotices() { + return std::exchange(impl->notices, {}); +} + +std::vector UiSession::takeEffects() { + return std::exchange(impl->effects, {}); +} + +void UiSession::refreshThreads() { + if (impl->providerReady()) + impl->client.execute("threads.list", nlohmann::json::object()); +} + +void UiSession::connectTransport() { + impl->client.send("connection.connect"); +} + +void UiSession::disconnectTransport() { + impl->client.send("connection.disconnect"); +} + +void UiSession::reconnectTransport() { + impl->client.send("connection.reconnect"); +} + +void UiSession::configureConnection(nlohmann::json settings) { + const auto token = impl->alive; + impl->client.execute( + "connection.configure", std::move(settings), + [implementation = impl.get(), token](const nlohmann::json &result) { + if (!*token || result.value("ok", false)) + return; + const std::string message = + safeMessage(result.value("error", nlohmann::json::object())); + implementation->showNotice( + message.empty() ? "Connection configuration failed" : message); + }); +} + +void UiSession::toggleController() { + impl->client.send(impl->model.connection().role == "controller" + ? "controller.release" + : "controller.claim"); +} + +void UiSession::selectThread(std::string threadId) { + impl->selectThread(std::move(threadId)); +} + +void UiSession::reloadThread(const std::string &threadId) { + impl->runtimeByThread[threadId].settingsHydration = + Impl::SettingsHydration::Unknown; + impl->readThread(threadId, true); + impl->ensureThreadSettingsHydrated(threadId); +} + +void UiSession::beginNewThread(UiNewThreadDraft draft) { + impl->beginNewThread(std::move(draft)); +} + +void UiSession::renameThread(const std::string &threadId, std::string name) { + name = trimAscii(std::move(name)); + if (!impl->canControlProvider() || !impl->model.thread(threadId) || + name.empty()) + return; + impl->client.execute("thread.rename", + {{"threadId", threadId}, {"name", std::move(name)}}); +} + +void UiSession::forkThread(const std::string &threadId) { + if (threadId.empty() || !impl->canControlProvider()) + return; + const auto token = impl->alive; + impl->client.execute( + "thread.fork", {{"threadId", threadId}}, + [implementation = impl.get(), 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()) + implementation->selectThread(id); + }); +} + +void UiSession::toggleThreadArchive(const std::string &threadId) { + if (!impl->canControlProvider()) + return; + const ThreadPresentation *thread = impl->model.thread(threadId); + if (!thread) + return; + impl->client.execute(thread->archived ? "thread.unarchive" + : "thread.archive", + {{"threadId", threadId}}); +} + +void UiSession::deleteThread(const std::string &threadId) { + if (!threadId.empty() && impl->canControlProvider()) + impl->client.execute("thread.delete", {{"threadId", threadId}}); +} + +bool UiSession::submitPrompt(UiPromptDraft draft) { + return impl->submitPrompt(std::move(draft)); +} + +void UiSession::interruptTurn() { + const auto turn = impl->model.activeTurnId(impl->selectedThreadId); + if (turn) + impl->client.execute("turn.interrupt", + {{"threadId", impl->selectedThreadId}, + {"turnId", *turn}}); +} + +void UiSession::loadEarlierConversation() { + const std::string key = impl->selectedThreadId.empty() && + impl->newThreadIntent + ? std::string(DraftThreadId) + : impl->selectedThreadId; + Impl::HistoryWindow &history = impl->historyWindows[key]; + history.requested += + middle::ConversationProjection::DefaultAuthoritativeItemLimit; + history.effective += + middle::ConversationProjection::DefaultAuthoritativeItemLimit; + impl->changed(); +} + +bool UiSession::resolvePending(UiPendingRequestView request, + PendingRequestResponse response) { + return impl->resolvePending(std::move(request), std::move(response)); +} + +} // namespace codexui::codex diff --git a/src/codex/UiSession.h b/src/codex/UiSession.h new file mode 100644 index 0000000..2ab948f --- /dev/null +++ b/src/codex/UiSession.h @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_UISESSION_H +#define CODEXUI_CODEX_UISESSION_H + +#include "codex/AttachmentDraft.h" +#include "codex/PendingRequestPolicy.h" +#include "codex/PresentationClient.h" +#include "codex/middle/MiddleTypes.h" +#include "codex/ui/UiViewState.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace codexui::codex { + +struct UiNotice { + std::uint64_t id = 0; + std::string message; + bool error = true; + + bool operator==(const UiNotice &) const = default; +}; + +struct UiNewThreadDraft { + std::string workspace; + std::string name; + std::string baseInstructions; + std::string developerInstructions; + bool ephemeral = false; +}; + +struct UiPromptDraft { + std::string text; + std::vector attachments; + nlohmann::json turnStartOptions = nlohmann::json::object(); + nlohmann::json threadStartOptions = nlohmann::json::object(); + std::string workspace; + std::string visiblySelectedThreadId; +}; + +struct UiSettingsView { + std::string identity; + nlohmann::json canonical = nlohmann::json::object(); + nlohmann::json modelCatalog = nlohmann::json::array(); + nlohmann::json permissionProfiles = nlohmann::json::array(); + std::uint64_t settingsRevision = 0; + nlohmann::json settingsUpdate = nlohmann::json::object(); + + bool operator==(const UiSettingsView &) const = default; +}; + +struct UiPendingRequestView { + std::string id; + std::string kind; + std::string threadId; + std::uint64_t generation = 0; + nlohmann::json raw = nlohmann::json::object(); + std::string title; + std::string detail; + std::string directAcceptLabel; + bool supportsDirectAccept = false; + bool actionable = false; + + bool operator==(const UiPendingRequestView &) const = default; +}; + +struct UiStatusView { + bool connected = false; + bool retrying = false; + std::string role; + std::string providerState; + std::string selectedTransport; + std::string workspace; + bool activeTurn = false; + std::size_t selectedPending = 0; + std::size_t totalPending = 0; + bool canSubmit = false; + bool canEditSettings = false; + nlohmann::json connectionSettings = nlohmann::json::object(); + + bool operator==(const UiStatusView &) const = default; +}; + +enum class UiConversationMode { NoSelection, NewThread, Thread }; + +struct UiConversationView { + UiConversationMode mode = UiConversationMode::NoSelection; + std::string key; + std::string title; + std::string workspace; + std::string status; + std::optional lastActivityAt; + std::string emptyMessage; + middle::ConversationSnapshot snapshot; + + bool operator==(const UiConversationView &) const = default; +}; + +enum class UiOptimisticThreadPhase { Awaiting, Confirmed, Failed }; + +struct UiOptimisticThreadView { + std::string key; + std::string threadId; + std::string title; + std::string workspace; + UiOptimisticThreadPhase phase = UiOptimisticThreadPhase::Awaiting; + + bool operator==(const UiOptimisticThreadView &) const = default; +}; + +struct UiSessionView { + std::string selectedThreadId; + bool newThreadIntent = false; + ui::ThreadListSnapshot threads; + UiConversationView conversation; + ui::InspectorSnapshot inspector; + UiSettingsView settings; + UiStatusView status; + std::optional optimisticThread; + std::vector pendingRequests; + std::optional selectedPendingRequest; + + bool operator==(const UiSessionView &) const = default; +}; + +enum class UiEffect { + ClearComposerDraft, + FocusComposer, + PrepareLocalPromptAdmission, +}; + +// Authoritative UI/UX state owner. It is called on the existing GUI thread in +// this refactor. The class itself is toolkit-neutral and talks downward only +// through PresentationClient's generic presentation-protocol API. +class UiSession final { +public: + using Clock = std::function; + using ChangedHandler = std::function; + using WakeupHandler = std::function; + using ProtocolFrameObserver = + std::function; + + explicit UiSession(PresentationClient client, std::string defaultWorkspace, + Clock clock = {}); + ~UiSession(); + + UiSession(const UiSession &) = delete; + UiSession &operator=(const UiSession &) = delete; + + void setChangedHandler(ChangedHandler handler); + void setWakeupHandler(WakeupHandler handler); + void setProtocolFrameObserver(ProtocolFrameObserver observer); + + void onPresentationFrame(const nlohmann::json &frame); + void noteThreadActivity(const std::string &threadId); + void tick(); + + [[nodiscard]] std::string conversationKey() const; + [[nodiscard]] const UiSessionView & + refreshView(bool conversationFollowing, std::string draftWorkspace = {}); + [[nodiscard]] std::vector takeNotices(); + [[nodiscard]] std::vector takeEffects(); + + void refreshThreads(); + void connectTransport(); + void disconnectTransport(); + void reconnectTransport(); + void configureConnection(nlohmann::json settings); + void toggleController(); + + void selectThread(std::string threadId); + void reloadThread(const std::string &threadId); + void beginNewThread(UiNewThreadDraft draft); + void renameThread(const std::string &threadId, std::string name); + void forkThread(const std::string &threadId); + void toggleThreadArchive(const std::string &threadId); + void deleteThread(const std::string &threadId); + + [[nodiscard]] bool submitPrompt(UiPromptDraft draft); + void interruptTurn(); + void loadEarlierConversation(); + + [[nodiscard]] bool + resolvePending(UiPendingRequestView request, + PendingRequestResponse response); + +private: + class Impl; + std::unique_ptr impl; +}; + +} // namespace codexui::codex + +#endif // CODEXUI_CODEX_UISESSION_H diff --git a/src/codex/middle/ComposerPane.cpp b/src/codex/middle/ComposerPane.cpp index 59372ac..a7e1f23 100644 --- a/src/codex/middle/ComposerPane.cpp +++ b/src/codex/middle/ComposerPane.cpp @@ -20,6 +20,7 @@ #include #include +#include #include namespace codexui::codex::middle { @@ -32,6 +33,10 @@ constexpr int BottomInset = 12; constexpr int AttachmentRowHeight = 28; constexpr int MaximumVisibleAttachments = 4; +QString text(std::string_view value) { + return QString::fromUtf8(value.data(), static_cast(value.size())); +} + QLabel *makeLabel(QString value, const char *kind) { auto *label = new QLabel(std::move(value)); label->setProperty("kind", kind); @@ -95,15 +100,18 @@ ComposerPane::ComposerPane(QWidget *anchor) attentionTextLayout->setSpacing(2); attentionTitle_ = makeLabel(QStringLiteral("A Codex request needs attention"), "attentionSection"); - attentionDetail_ = makeLabel(QStringLiteral("Review the pending request."), - "meta"); + attentionDetail_ = + makeLabel(QStringLiteral("Review the pending request."), "meta"); attentionTextLayout->addWidget(attentionTitle_); attentionTextLayout->addWidget(attentionDetail_); attentionLayout->addWidget(attentionText, 1); attentionLayout->addStretch(); - attentionRejectButton_ = new QPushButton(QStringLiteral("Reject"), attention_); - attentionAcceptButton_ = new QPushButton(QStringLiteral("Accept"), attention_); - attentionReviewButton_ = new QPushButton(QStringLiteral("Review"), attention_); + attentionRejectButton_ = + new QPushButton(QStringLiteral("Reject"), attention_); + attentionAcceptButton_ = + new QPushButton(QStringLiteral("Accept"), attention_); + attentionReviewButton_ = + new QPushButton(QStringLiteral("Review"), attention_); attentionRejectButton_->setObjectName( QStringLiteral("pendingRequestRejectButton")); attentionAcceptButton_->setObjectName( @@ -254,16 +262,15 @@ void ComposerPane::setAttentionVisible(bool visible) { } void ComposerPane::setAttentionRequest(QString title, QString detail, - bool directAccept, - QString acceptLabel) { + bool directAccept, QString acceptLabel) { if (title.isEmpty()) title = QStringLiteral("A Codex request needs attention"); if (detail.isEmpty()) detail = QStringLiteral("Review the pending request."); if (acceptLabel.isEmpty()) acceptLabel = QStringLiteral("Accept"); - const bool unchanged = - attentionTitle_->text() == title && attentionDetail_->text() == detail && + const bool unchanged = attentionTitle_->text() == title && + attentionDetail_->text() == detail && attentionAcceptButton_->isVisible() == directAccept && attentionReviewButton_->isVisible() != directAccept && attentionAcceptButton_->text() == acceptLabel; @@ -414,7 +421,8 @@ void ComposerPane::refreshAttachments() { rowLayout->setSpacing(5); auto *remove = new QPushButton(QStringLiteral("X"), row); - remove->setAccessibleName(QStringLiteral("Remove %1").arg(attachment.name)); + remove->setAccessibleName( + QStringLiteral("Remove %1").arg(text(attachment.name))); remove->setToolTip(QStringLiteral("Remove attachment")); remove->setFixedSize(18, 18); remove->setProperty("kind", "destructiveCompact"); @@ -434,8 +442,8 @@ void ComposerPane::refreshAttachments() { "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)); + auto *name = makeLabel(text(attachment.name), "meta"); + name->setToolTip(QDir::toNativeSeparators(text(attachment.path))); fileLayout->addWidget(name); rowLayout->addWidget(fileBox, 1); rowLayout->addWidget(remove, 0, Qt::AlignVCenter); diff --git a/src/codex/middle/ComposerPane.h b/src/codex/middle/ComposerPane.h index 2bfa415..39dc21d 100644 --- a/src/codex/middle/ComposerPane.h +++ b/src/codex/middle/ComposerPane.h @@ -3,7 +3,7 @@ #ifndef CODEXUI_CODEX_MIDDLE_COMPOSERPANE_H #define CODEXUI_CODEX_MIDDLE_COMPOSERPANE_H -#include "codex/FileSelectionDialog.h" +#include "codex/AttachmentDraft.h" #include diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index de59b97..426dc8b 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -38,6 +38,7 @@ #include #include +#include #include #include #include @@ -56,6 +57,24 @@ constexpr int ViewerMaximumImageExtent = 4096; constexpr qsizetype MaximumGenericActivityCharacters = 4096; constexpr int CardHeaderActionSpacing = 4; +QString text(std::string_view value) { + return QString::fromUtf8(value.data(), static_cast(value.size())); +} + +QStringList textList(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; +} + +std::string utf8(const QString &value) { return value.toUtf8().toStdString(); } + +QString trimmedTrailingLines(const QString &value) { + return text(trimTrailingEmptyLines(utf8(value))); +} + bool initiallyCollapsed(CardKind kind, bool commandInitiallyCollapsed, bool imageInitiallyCollapsed) { if (kind == CardKind::CommandExecution) @@ -225,8 +244,8 @@ class ImageRibbon final : public QScrollArea { setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); setSizeAdjustPolicy(QAbstractScrollArea::AdjustIgnored); setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed); - setStyleSheet(QStringLiteral( - "QScrollArea#messageImages{background:#fbfcfe;" + setStyleSheet( + QStringLiteral("QScrollArea#messageImages{background:#fbfcfe;" "border:1px solid #d7dee8;border-radius:6px;}" "QWidget#messageImageStrip{background:transparent;}")); @@ -279,8 +298,8 @@ class ImageRibbon final : public QScrollArea { const bool overflows = naturalSize_.width() > availableWidth; const int scrollBarHeight = overflows ? style()->pixelMetric(QStyle::PM_ScrollBarExtent) : 0; - const int target = std::max( - 0, naturalSize_.height() + scrollBarHeight + 2 * frameWidth()); + const int target = + std::max(0, naturalSize_.height() + scrollBarHeight + 2 * frameWidth()); if (height() != target) setFixedHeight(target); } @@ -447,11 +466,11 @@ void setStatusTone(QLabel *label, const QString &status) { } QString commandMetadata(const CommandExecutionData &command) { - QStringList metadata{displayStatus(command.status)}; + QStringList metadata{displayStatus(text(command.status))}; if (command.exitCode) metadata << QStringLiteral("exit %1").arg(*command.exitCode); - if (!command.cwd.isEmpty()) - metadata << command.cwd; + if (!command.cwd.empty()) + metadata << text(command.cwd); if (command.durationMilliseconds) { const qreal seconds = qreal(*command.durationMilliseconds) / 1000.0; metadata << QStringLiteral("%1 s").arg(seconds, 0, 'f', @@ -462,29 +481,29 @@ QString commandMetadata(const CommandExecutionData &command) { QString agentMetadata(const AgentActivityData &activity) { QStringList metadata; - if (!activity.tool.isEmpty()) - metadata << activity.tool; - metadata << displayStatus(activity.status.isEmpty() ? activity.kind - : activity.status); - if (!activity.receivers.isEmpty()) - metadata << activity.receivers.join(QStringLiteral(", ")); - if (!activity.model.isEmpty()) - metadata << activity.model; - if (!activity.reasoningEffort.isEmpty()) - metadata << activity.reasoningEffort; - if (!activity.childThreadId.isEmpty()) - metadata << QStringLiteral("thread %1").arg(activity.childThreadId); - if (!activity.agentPath.isEmpty()) - metadata << activity.agentPath; - if (!activity.senderThreadId.isEmpty()) - metadata << QStringLiteral("sender %1").arg(activity.senderThreadId); + if (!activity.tool.empty()) + metadata << text(activity.tool); + metadata << displayStatus( + text(activity.status.empty() ? activity.kind : activity.status)); + if (!activity.receivers.empty()) + metadata << textList(activity.receivers).join(QStringLiteral(", ")); + if (!activity.model.empty()) + metadata << text(activity.model); + if (!activity.reasoningEffort.empty()) + metadata << text(activity.reasoningEffort); + if (!activity.childThreadId.empty()) + metadata << QStringLiteral("thread %1").arg(text(activity.childThreadId)); + if (!activity.agentPath.empty()) + metadata << text(activity.agentPath); + if (!activity.senderThreadId.empty()) + metadata << QStringLiteral("sender %1").arg(text(activity.senderThreadId)); return metadata.join(QStringLiteral(" | ")); } -QString displayChangeKind(const QString &kind) { - if (kind.isEmpty()) +QString displayChangeKind(std::string_view kind) { + if (kind.empty()) return QStringLiteral("Changed"); - return UiStyle::humanizeLabel(kind); + return UiStyle::humanizeLabel(text(kind)); } struct DiffCounts { @@ -505,10 +524,10 @@ QString joinedCopyText(QStringList parts) { QString fileChangesText(const FileChangesData &data) { QStringList rows; for (const FileChangeData &change : data.changes) { - if (change.path.isEmpty()) + if (change.path.empty()) continue; QString row = QStringLiteral("%1 · %2") - .arg(change.path, displayChangeKind(change.kind)); + .arg(text(change.path), displayChangeKind(change.kind)); if (change.additions && change.deletions) row += QStringLiteral(" +%1 −%2") .arg(*change.additions) @@ -532,19 +551,18 @@ std::optional totalDiffCounts(const FileChangesData &data) { } QString planMarkdown(const PlanData &plan) { - if (!plan.legacyText.isEmpty()) - return plan.legacyText; + if (!plan.legacyText.empty()) + return text(plan.legacyText); QStringList rows; - if (!plan.explanation.isEmpty()) - rows << plan.explanation; + if (!plan.explanation.empty()) + rows << text(plan.explanation); if (!plan.steps.empty() && !rows.empty()) rows << QString{}; for (const PlanStepData &step : plan.steps) { - const QString marker = - step.status == QStringLiteral("completed") ? QStringLiteral("✓") - : step.status == QStringLiteral("inProgress") ? QStringLiteral("◉") + const QString marker = step.status == "completed" ? QStringLiteral("✓") + : step.status == "inProgress" ? QStringLiteral("◉") : QStringLiteral("○"); - rows << QStringLiteral("%1 %2 ").arg(marker, step.text); + rows << QStringLiteral("%1 %2 ").arg(marker, text(step.text)); } return rows.join(QLatin1Char('\n')); } @@ -562,33 +580,40 @@ CardCopyContent cardCopyContent(const VisibleCardData &card) { [](const auto &payload) -> CardCopyContent { using Payload = std::decay_t; if constexpr (std::is_same_v) { - return payload.text.isEmpty() - ? CardCopyContent{ - payload.imagePaths.join(QLatin1Char('\n')), false} - : CardCopyContent{payload.text, true}; + return payload.text.empty() + ? CardCopyContent{textList(payload.imagePaths) + .join(QLatin1Char('\n')), + false} + : CardCopyContent{text(payload.text), true}; } else if constexpr (std::is_same_v) { - return {payload.text, true}; + return {text(payload.text), true}; } else if constexpr (std::is_same_v) { - return {joinedCopyText({trimTrailingEmptyLines(payload.command), - trimTrailingEmptyLines(payload.output)}), + return { + joinedCopyText({text(trimTrailingEmptyLines(payload.command)), + text(trimTrailingEmptyLines(payload.output))}), false}; } else if constexpr (std::is_same_v) { - return {joinedCopyText({payload.prompt, payload.resultText}), true}; + return { + joinedCopyText({text(payload.prompt), text(payload.resultText)}), + true}; } else if constexpr (std::is_same_v) { - return {payload.summary, true}; + return {text(payload.summary), true}; } else if constexpr (std::is_same_v) { return {fileChangesText(payload), false}; } else if constexpr (std::is_same_v) { return {planMarkdown(payload), true}; } else if constexpr (std::is_same_v) { - return {joinedCopyText({payload.revisedPrompt, payload.path}), false}; + return { + joinedCopyText({text(payload.revisedPrompt), text(payload.path)}), + false}; } else if constexpr (std::is_same_v) { return {boundedGenericActivity(payload.raw), false}; } else { - return payload.prompt.isEmpty() - ? CardCopyContent{ - payload.imagePaths.join(QLatin1Char('\n')), false} - : CardCopyContent{payload.prompt, true}; + return payload.prompt.empty() + ? CardCopyContent{textList(payload.imagePaths) + .join(QLatin1Char('\n')), + false} + : CardCopyContent{text(payload.prompt), true}; } }, card.payload); @@ -764,7 +789,7 @@ bool CommandOutputView::followsLatest() const noexcept { } bool CommandOutputView::setOutput(const QString &output) { - const QString displayOutput = trimTrailingEmptyLines(output); + const QString displayOutput = trimmedTrailingLines(output); if (currentOutput_ == displayOutput) return false; @@ -1046,8 +1071,8 @@ class ConversationCard::Impl final { } void updateComposition(const UserMessageData &message) { - setVisibleMarkdown(body, message.text); - setImages(message.imagePaths); + setVisibleMarkdown(body, text(message.text)); + setImages(textList(message.imagePaths)); } void createComposition(const AgentMessageData &message) { @@ -1072,8 +1097,8 @@ class ConversationCard::Impl final { } void updateComposition(const AgentMessageData &message) { - const QString messagePhase = - message.finalAnswer ? QStringLiteral("final") : QStringLiteral("update"); + const QString messagePhase = message.finalAnswer ? QStringLiteral("final") + : QStringLiteral("update"); if (owner->property("messagePhase").toString() != messagePhase) { owner->setProperty("messagePhase", messagePhase); owner->style()->unpolish(owner); @@ -1088,7 +1113,7 @@ class ConversationCard::Impl final { setStatusTone(phase, phaseStatus); layout->setContentsMargins(12, message.finalAnswer ? 10 : 8, 12, message.finalAnswer ? 10 : 8); - setVisibleMarkdown(body, message.text); + setVisibleMarkdown(body, text(message.text)); } void createComposition(const CommandExecutionData &execution) { @@ -1110,14 +1135,15 @@ class ConversationCard::Impl final { } void updateComposition(const CommandExecutionData &execution) { - const QByteArray status = execution.status.toUtf8(); - setActiveWork(isActiveStatus(std::string_view( - status.constData(), static_cast(status.size())))); - const QString displayCommand = trimTrailingEmptyLines(execution.command); + setActiveWork(isActiveStatus(execution.status)); + const std::string trimmedCommand = + trimTrailingEmptyLines(execution.command); + const QString displayCommand = text(trimmedCommand); command->setContent(displayCommand); command->setVisible(!displayCommand.isEmpty()); - const QString displayOutput = trimTrailingEmptyLines(execution.output); - const bool visibleOutput = terminalOutputHasVisibleText(displayOutput); + const std::string trimmedOutput = trimTrailingEmptyLines(execution.output); + const QString displayOutput = text(trimmedOutput); + const bool visibleOutput = terminalOutputHasVisibleText(trimmedOutput); if (visibleOutput) { output->setOutput(displayOutput); output->show(); @@ -1130,7 +1156,7 @@ class ConversationCard::Impl final { output->restoreScrollState({true, 0}); } metadata->setText(commandMetadata(execution)); - setStatusTone(metadata, execution.status); + setStatusTone(metadata, text(execution.status)); metadata->show(); } @@ -1147,11 +1173,11 @@ class ConversationCard::Impl final { void updateComposition(const AgentActivityData &activity) { metadata->setText(agentMetadata(activity)); - setStatusTone(metadata, - activity.status.isEmpty() ? activity.kind : activity.status); + setStatusTone(metadata, text(activity.status.empty() ? activity.kind + : activity.status)); metadata->show(); - setVisibleText(body, activity.prompt); - setVisibleMarkdown(detail, activity.resultText); + setVisibleText(body, text(activity.prompt)); + setVisibleMarkdown(detail, text(activity.resultText)); } void createComposition(const ReasoningData &reasoning) { @@ -1162,7 +1188,7 @@ class ConversationCard::Impl final { } void updateComposition(const ReasoningData &reasoning) { - setVisibleMarkdown(body, reasoning.summary); + setVisibleMarkdown(body, text(reasoning.summary)); } void createComposition(const FileChangesData &changes) { @@ -1176,14 +1202,14 @@ class ConversationCard::Impl final { void updateComposition(const FileChangesData &changes) { setVisibleText(body, fileChangesText(changes)); - QStringList values{displayStatus(changes.status)}; + QStringList values{displayStatus(text(changes.status))}; values << QStringLiteral("%1 paths").arg(changes.changes.size()); if (const auto counts = totalDiffCounts(changes)) values << QStringLiteral("+%1 −%2") .arg(counts->additions) .arg(counts->deletions); metadata->setText(values.join(QStringLiteral(" | "))); - setStatusTone(metadata, changes.status); + setStatusTone(metadata, text(changes.status)); metadata->show(); } @@ -1209,19 +1235,18 @@ class ConversationCard::Impl final { } void updateComposition(const ImageGenerationData &image) { - const QByteArray status = image.status.toUtf8(); - setActiveWork(isActiveStatus(std::string_view( - status.constData(), static_cast(status.size())))); + setActiveWork(isActiveStatus(image.status)); const bool generated = - !image.status.isEmpty() || !image.revisedPrompt.isEmpty(); + !image.status.empty() || !image.revisedPrompt.empty(); title->setText(generated ? QStringLiteral("Generated image") : QStringLiteral("Image")); - setVisibleText(metadata, displayStatus(image.status)); - setStatusTone(metadata, image.status); - setVisibleText(body, image.revisedPrompt); + setVisibleText(metadata, displayStatus(text(image.status))); + setStatusTone(metadata, text(image.status)); + setVisibleText(body, text(image.revisedPrompt)); // A generated image can become readable at the same path as its status // advances, so its update remains the authoritative reload boundary. - setImages(image.path.isEmpty() ? QStringList{} : QStringList{image.path}, + setImages(image.path.empty() ? QStringList{} + : QStringList{text(image.path)}, true); } @@ -1232,9 +1257,9 @@ class ConversationCard::Impl final { } void updateComposition(const GenericActivityData &activity) { - title->setText(activity.type.isEmpty() + title->setText(activity.type.empty() ? QStringLiteral("Activity") - : UiStyle::humanizeLabel(activity.type)); + : UiStyle::humanizeLabel(text(activity.type))); metadata->setText(boundedGenericActivity(activity.raw)); metadata->setObjectName(QStringLiteral("genericActivityMetadata")); metadata->show(); @@ -1262,8 +1287,8 @@ class ConversationCard::Impl final { } void updateComposition(const LocalPromptData &prompt) { - setVisibleMarkdown(body, prompt.prompt); - setImages(prompt.imagePaths); + setVisibleMarkdown(body, text(prompt.prompt)); + setImages(textList(prompt.imagePaths)); refreshPendingPresentation(); } @@ -1292,9 +1317,9 @@ class ConversationCard::Impl final { QString status; if (failed) - status = prompt->error.isEmpty() + status = prompt->error.empty() ? QStringLiteral("Not sent") - : QStringLiteral("Not sent: %1").arg(prompt->error); + : QStringLiteral("Not sent: %1").arg(text(prompt->error)); changed = setVisibleText(metadata, status) || changed; @@ -1393,8 +1418,8 @@ void ConversationCard::paintEvent(QPaintEvent *event) { painter.setRenderHint(QPainter::Antialiasing); painter.setBrush(Qt::NoBrush); painter.setPen(QPen(QColor(QStringLiteral("#98a2b3")), 1.5)); - painter.drawRoundedRect(QRectF(rect()).adjusted(1.0, 1.0, -1.0, -1.0), - 9.0, 9.0); + painter.drawRoundedRect(QRectF(rect()).adjusted(1.0, 1.0, -1.0, -1.0), 9.0, + 9.0); return; } if (impl_->current.kind == CardKind::UserMessage && @@ -1403,8 +1428,8 @@ void ConversationCard::paintEvent(QPaintEvent *event) { painter.setRenderHint(QPainter::Antialiasing); painter.setBrush(Qt::NoBrush); painter.setPen(QPen(QColor(QStringLiteral("#6f98e8")), 1.5)); - painter.drawRoundedRect(QRectF(rect()).adjusted(1.0, 1.0, -1.0, -1.0), - 8.0, 8.0); + painter.drawRoundedRect(QRectF(rect()).adjusted(1.0, 1.0, -1.0, -1.0), 8.0, + 8.0); return; } if (impl_->current.kind != CardKind::LocalPrompt) diff --git a/src/codex/middle/ConversationProjection.cpp b/src/codex/middle/ConversationProjection.cpp index ab9a47e..c8daec2 100644 --- a/src/codex/middle/ConversationProjection.cpp +++ b/src/codex/middle/ConversationProjection.cpp @@ -19,10 +19,6 @@ namespace { // complete Conversation projection available for a one-line policy reversal. constexpr bool projectStructuredPlansInConversation = false; -QString text(const std::string &value) { - return QString::fromUtf8(value.data(), static_cast(value.size())); -} - std::string stringValue(const nlohmann::json &object, const char *key) { if (!object.is_object()) return {}; @@ -31,88 +27,101 @@ std::string stringValue(const nlohmann::json &object, const char *key) { : std::string{}; } -std::optional integerValue(const nlohmann::json &object, +std::optional integerValue(const nlohmann::json &object, const char *key) { if (!object.is_object()) return std::nullopt; const auto value = object.find(key); if (value == object.end() || !value->is_number_integer()) return std::nullopt; - return value->get(); + return value->get(); } -std::optional optionalText(const nlohmann::json &object, +std::optional optionalText(const nlohmann::json &object, const char *key) { if (!object.is_object()) return std::nullopt; const auto value = object.find(key); if (value == object.end() || !value->is_string()) return std::nullopt; - return text(value->get()); + return value->get(); } std::uint64_t omittedTextBytes(const ItemPresentation &item, const char *field) { - const auto value = std::find_if( - item.textRetention.begin(), item.textRetention.end(), + const auto value = + std::find_if(item.textRetention.begin(), item.textRetention.end(), [field](const TextRetentionPresentation &entry) { return entry.field == field; }); return value == item.textRetention.end() ? 0 : value->discardedBytes; } -QString withTruncationNotice(QString value, std::uint64_t omitted, - const QString &subject, bool markdown) { +std::string withTruncationNotice(std::string value, std::uint64_t omitted, + std::string_view subject, bool markdown) { if (omitted == 0) return value; - const QString notice = - QStringLiteral("Earlier %1 was truncated (%2 bytes omitted).") - .arg(subject, QString::number(omitted)); - return markdown ? QStringLiteral("> %1\n\n%2").arg(notice, value) - : QStringLiteral("[%1]\n%2").arg(notice, value); + const std::string notice = "Earlier " + std::string(subject) + + " was truncated (" + std::to_string(omitted) + + " bytes omitted)."; + return markdown ? "> " + notice + "\n\n" + value + : '[' + notice + "]\n" + value; } -std::pair unifiedDiffCounts(QStringView diff) { +std::pair unifiedDiffCounts(std::string_view diff) { int additions = 0; int deletions = 0; - for (const QStringView line : diff.split(QLatin1Char('\n'))) { - if (line.startsWith(QStringLiteral("+++ ")) || - line.startsWith(QStringLiteral("--- "))) + for (std::size_t offset = 0; offset <= diff.size();) { + const std::size_t end = diff.find('\n', offset); + const std::string_view line = + diff.substr(offset, end == std::string_view::npos ? diff.size() - offset + : end - offset); + if (line.starts_with("+++ ") || line.starts_with("--- ")) { + if (end == std::string_view::npos) + break; + offset = end + 1; continue; - if (line.startsWith(QLatin1Char('+'))) + } + if (line.starts_with('+')) ++additions; - else if (line.startsWith(QLatin1Char('-'))) + else if (line.starts_with('-')) ++deletions; + if (end == std::string_view::npos) + break; + offset = end + 1; } return {additions, deletions}; } -QString messageText(const nlohmann::json &item) { +std::string messageText(const nlohmann::json &item) { const std::string type = stringValue(item, "type"); if (type == "agentMessage" || type == "plan") - return text(stringValue(item, "text")); + return stringValue(item, "text"); if (type != "userMessage") return {}; - QStringList parts; + std::string result; const auto content = item.find("content"); if (content != item.end() && content->is_array()) { for (const nlohmann::json &entry : *content) { const std::string value = stringValue(entry, "text"); - if (!value.empty()) - parts.push_back(text(value)); + if (value.empty()) + continue; + if (!result.empty()) + result.push_back('\n'); + result += value; } } - if (parts.empty()) { + if (result.empty()) { const std::string fallback = stringValue(item, "text"); if (!fallback.empty()) - parts.push_back(text(fallback)); + result = fallback; } - return parts.join(QStringLiteral("\n")); + return result; } -QStringList messageImagePaths(const nlohmann::json &item) { - QStringList result; +std::vector messageImagePaths(const nlohmann::json &item) { + std::vector result; const auto content = item.find("content"); if (content == item.end() || !content->is_array()) return result; @@ -121,36 +130,40 @@ QStringList messageImagePaths(const nlohmann::json &item) { continue; const std::string path = stringValue(entry, "path"); if (!path.empty()) - result.push_back(text(path)); + result.push_back(path); } return result; } -QStringList localImagePaths(const PromptSubmission &submission) { - QStringList result; +std::vector localImagePaths(const PromptSubmission &submission) { + std::vector result; for (const AttachmentDraft &attachment : submission.attachments) - if (attachment.mimeType.startsWith(QStringLiteral("image/"))) + if (attachment.mimeType.starts_with("image/")) result.push_back(attachment.path); return result; } -QString joinedStrings(const nlohmann::json &value) { +std::string joinedStrings(const nlohmann::json &value) { if (!value.is_array()) return {}; - QStringList result; - for (const nlohmann::json &entry : value) - if (entry.is_string()) - result.push_back(text(entry.get())); - return result.join(QStringLiteral(", ")); + std::string result; + for (const nlohmann::json &entry : value) { + if (!entry.is_string()) + continue; + if (!result.empty()) + result += ", "; + result += entry.get(); + } + return result; } -QStringList stringList(const nlohmann::json &value) { - QStringList result; +std::vector stringList(const nlohmann::json &value) { + std::vector result; if (!value.is_array()) return result; for (const nlohmann::json &entry : value) if (entry.is_string()) - result.push_back(text(entry.get())); + result.push_back(entry.get()); return result; } @@ -164,15 +177,15 @@ bool hasStructuredPlan(const TurnPresentation &turn) { PlanData structuredPlan(const TurnPresentation &turn) { PlanData result; - result.explanation = text(stringValue(turn.plan, "explanation")); + result.explanation = stringValue(turn.plan, "explanation"); const auto steps = turn.plan.find("steps"); if (steps == turn.plan.end() || !steps->is_array()) return result; result.steps.reserve(steps->size()); for (const nlohmann::json &step : *steps) { - const QString value = text(stringValue(step, "step")); - if (!value.isEmpty()) - result.steps.push_back({value, text(stringValue(step, "status"))}); + const std::string value = stringValue(step, "step"); + if (!value.empty()) + result.steps.push_back({value, stringValue(step, "status")}); } return result; } @@ -194,10 +207,9 @@ VisibleCardData authoritativeCard(const AuthoritativeItemKey &identity, CardKey visualKey) { const nlohmann::json &item = presentation.raw; const std::string type = stringValue(item, "type"); - VisibleCardData result{ - std::move(visualKey), CardKind::GenericActivity, + VisibleCardData result{std::move(visualKey), CardKind::GenericActivity, identity.threadId, identity.turnId, - identity.itemId, GenericActivityData{text(type), item}}; + identity.itemId, GenericActivityData{type, item}}; if (type == "userMessage") { result.kind = CardKind::UserMessage; @@ -208,65 +220,60 @@ VisibleCardData authoritativeCard(const AuthoritativeItemKey &identity, result.payload = AgentMessageData{ withTruncationNotice(messageText(item), omittedTextBytes(presentation, "text"), - QStringLiteral("Codex response"), true), + "Codex response", true), stringValue(item, "phase") == "final_answer"}; } else if (type == "commandExecution") { result.kind = CardKind::CommandExecution; const char *outputField = "aggregatedOutput"; - QString output = text(stringValue(item, "aggregatedOutput")); - if (output.isEmpty()) { + std::string output = stringValue(item, "aggregatedOutput"); + if (output.empty()) { outputField = "output"; - output = text(stringValue(item, "output")); + output = stringValue(item, "output"); } - output = withTruncationNotice( - output, omittedTextBytes(presentation, outputField), - QStringLiteral("command output"), false); + output = withTruncationNotice(output, + omittedTextBytes(presentation, outputField), + "command output", false); if (!terminalOutputHasVisibleText(output)) output.clear(); std::optional exitCode; const auto rawExitCode = item.find("exitCode"); if (rawExitCode != item.end() && rawExitCode->is_number_integer()) exitCode = rawExitCode->get(); - std::optional duration = integerValue(item, "durationMs"); + std::optional duration = integerValue(item, "durationMs"); if (!duration) duration = integerValue(item, "duration_ms"); - result.payload = CommandExecutionData{text(stringValue(item, "command")), - output, - text(stringValue(item, "status")), - text(stringValue(item, "cwd")), - exitCode, - duration}; + result.payload = CommandExecutionData{ + stringValue(item, "command"), output, stringValue(item, "status"), + stringValue(item, "cwd"), exitCode, duration}; } else if (type == "collabAgentToolCall" || type == "subAgentActivity") { result.kind = CardKind::AgentActivity; result.payload = AgentActivityData{ - text(stringValue(item, "tool")), - text(stringValue(item, "status")), - text(stringValue(item, "kind")), - text(stringValue(item, "prompt")), - text(stringValue(item, "resultText")), + stringValue(item, "tool"), + stringValue(item, "status"), + stringValue(item, "kind"), + stringValue(item, "prompt"), + stringValue(item, "resultText"), stringList(item.value("receiverThreadIds", nlohmann::json::array())), - text(stringValue(item, "model")), - text(stringValue(item, "reasoningEffort")), - text(stringValue(item, "agentThreadId")), - text(stringValue(item, "agentPath")), - text(stringValue(item, "senderThreadId"))}; + stringValue(item, "model"), + stringValue(item, "reasoningEffort"), + stringValue(item, "agentThreadId"), + stringValue(item, "agentPath"), + stringValue(item, "senderThreadId")}; } else if (type == "reasoning") { result.kind = CardKind::Reasoning; - result.payload = ReasoningData{ - withTruncationNotice( + result.payload = ReasoningData{withTruncationNotice( joinedStrings(item.value("summary", nlohmann::json::array())), - omittedTextBytes(presentation, "summary"), - QStringLiteral("reasoning"), true)}; + omittedTextBytes(presentation, "summary"), "reasoning", true)}; } else if (type == "fileChange") { result.kind = CardKind::FileChanges; const nlohmann::json changes = item.value("changes", nlohmann::json::array()); - FileChangesData projected{text(stringValue(item, "status")), {}}; + FileChangesData projected{stringValue(item, "status"), {}}; if (changes.is_array()) { projected.changes.reserve(changes.size()); for (const nlohmann::json &change : changes) { - FileChangeData entry{text(stringValue(change, "path")), - text(stringValue(change, "kind")), std::nullopt, + FileChangeData entry{stringValue(change, "path"), + stringValue(change, "kind"), std::nullopt, std::nullopt}; if (const auto diff = optionalText(change, "diff")) { const auto [additions, deletions] = unifiedDiffCounts(*diff); @@ -287,13 +294,13 @@ VisibleCardData authoritativeCard(const AuthoritativeItemKey &identity, if (revisedPrompt.empty()) revisedPrompt = stringValue(item, "revised_prompt"); result.kind = CardKind::ImageGeneration; - result.payload = ImageGenerationData{ - text(path), text(stringValue(item, "status")), text(revisedPrompt)}; + result.payload = + ImageGenerationData{path, stringValue(item, "status"), revisedPrompt}; } else if (type == "plan") { - const QString plan = withTruncationNotice( - messageText(item), omittedTextBytes(presentation, "text"), - QStringLiteral("plan text"), true); - if (!plan.isEmpty()) { + const std::string plan = withTruncationNotice( + messageText(item), omittedTextBytes(presentation, "text"), "plan text", + true); + if (!plan.empty()) { result.kind = CardKind::Plan; result.payload = PlanData{{}, {}, plan}; } @@ -345,7 +352,7 @@ ConversationSnapshot ConversationProjection::project( const AuthoritativeItemIndex &authoritativeItems, const ThreadPresentation *authoritativeThread, std::span localSubmissions, - std::size_t authoritativeItemLimit, qint64 nowMilliseconds) { + std::size_t authoritativeItemLimit, std::int64_t nowMilliseconds) { ConversationSnapshot result; result.threadId = authoritativeItems.threadId; @@ -370,8 +377,7 @@ ConversationSnapshot ConversationProjection::project( root->second < firstVisible) pinnedRootIndexes.insert(root->second); } - result.hiddenAuthoritativeItemCount = - firstVisible - pinnedRootIndexes.size(); + result.hiddenAuthoritativeItemCount = firstVisible - pinnedRootIndexes.size(); result.hasMore = result.hiddenAuthoritativeItemCount > 0; std::map bindings; @@ -381,8 +387,7 @@ ConversationSnapshot ConversationProjection::project( std::vector nodes; nodes.reserve(authoritativeItems.ordered.size() - firstVisible + - pinnedRootIndexes.size() + - localSubmissions.size()); + pinnedRootIndexes.size() + localSubmissions.size()); for (std::size_t index = 0; index < authoritativeItems.ordered.size(); ++index) { if (index < firstVisible && !pinnedRootIndexes.contains(index)) @@ -411,8 +416,8 @@ ConversationSnapshot ConversationProjection::project( } VisibleCardData card = authoritativeCard(item.key, *item.presentation, std::move(visualKey)); - const auto root = authoritativeItems.turnRootUserMessagePositions.find( - item.key.turnId); + const auto root = + authoritativeItems.turnRootUserMessagePositions.find(item.key.turnId); const bool turnRoot = root != authoritativeItems.turnRootUserMessagePositions.end() && root->second == index; @@ -511,16 +516,17 @@ ConversationSnapshot ConversationProjection::project( submission.error, localImagePaths(submission)}}; const bool authoritativeRootExists = - !turnId.empty() && authoritativeItems.turnRootUserMessagePositions - .contains(turnId); - bool turnRoot = (!knownTurn || submission.startsTurn) && - !authoritativeRootExists; + !turnId.empty() && + authoritativeItems.turnRootUserMessagePositions.contains(turnId); + bool turnRoot = + (!knownTurn || submission.startsTurn) && !authoritativeRootExists; if (submission.materializedItem) { const auto root = authoritativeItems.turnRootUserMessagePositions.find( submission.materializedItem->turnId); const auto materialized = authoritativeItems.position(*submission.materializedItem); - turnRoot = root != authoritativeItems.turnRootUserMessagePositions.end() && + turnRoot = + root != authoritativeItems.turnRootUserMessagePositions.end() && materialized && root->second == *materialized; } nodes.push_back({position, submission.admissionOrdinal, sectionKey, turnId, @@ -542,7 +548,8 @@ ConversationSnapshot ConversationProjection::project( if (section == sectionIndexes.end()) { const std::size_t index = result.sections.size(); sectionIndexes.emplace(node.sectionKey, index); - result.sections.push_back({node.sectionKey, node.turnId, {}, std::nullopt}); + result.sections.push_back( + {node.sectionKey, node.turnId, {}, std::nullopt}); section = sectionIndexes.find(node.sectionKey); } TurnSection &projectedSection = result.sections[section->second]; diff --git a/src/codex/middle/ConversationProjection.h b/src/codex/middle/ConversationProjection.h index a58c6a7..aaed353 100644 --- a/src/codex/middle/ConversationProjection.h +++ b/src/codex/middle/ConversationProjection.h @@ -7,9 +7,8 @@ #include "codex/middle/MiddleTypes.h" #include "codex/middle/PromptCoordinator.h" -#include - #include +#include #include #include @@ -26,12 +25,12 @@ class ConversationProjection final { project(const AuthoritativeItemIndex &authoritativeItems, const ThreadPresentation *authoritativeThread, std::span localSubmissions, - std::size_t authoritativeItemLimit, qint64 nowMilliseconds); + std::size_t authoritativeItemLimit, std::int64_t nowMilliseconds); [[nodiscard]] static ConversationSnapshot project(const ThreadPresentation &authoritativeThread, std::span localSubmissions, - std::size_t authoritativeItemLimit, qint64 nowMilliseconds) { + std::size_t authoritativeItemLimit, std::int64_t nowMilliseconds) { const auto items = indexAuthoritativeItems(authoritativeThread.id, &authoritativeThread); return project(items, &authoritativeThread, localSubmissions, diff --git a/src/codex/middle/InspectorPane.cpp b/src/codex/middle/InspectorPane.cpp index 65ce3fa..f490076 100644 --- a/src/codex/middle/InspectorPane.cpp +++ b/src/codex/middle/InspectorPane.cpp @@ -3,7 +3,6 @@ #include "codex/middle/InspectorPane.h" #include "codex/DiffViewer.h" -#include "codex/PresentationModel.h" #include "codex/PresentationStatus.h" #include "codex/ui/UiStyle.h" @@ -84,24 +83,6 @@ QLabel *statusLabel(const std::string &status) { return label; } -std::string effectivePlanStepStatus(const std::string &stepStatus, - const std::string &turnStatus, - const std::string &threadStatus) { - if (!isActiveStatus(stepStatus)) - return stepStatus; - StatusKind outcome = classifyStatus(turnStatus).kind; - if (outcome != StatusKind::Completed && outcome != StatusKind::Failed && - outcome != StatusKind::Interrupted) - outcome = classifyStatus(threadStatus).kind; - if (outcome == StatusKind::Completed) - return "completed"; - if (outcome == StatusKind::Failed) - return "failed"; - if (outcome == StatusKind::Interrupted) - return "interrupted"; - return stepStatus; -} - QLabel *makeMarkdownLabel(const QString &value) { QTextDocument document; document.setMarkdown( @@ -209,7 +190,7 @@ void restoreScrollPosition(QPlainTextEdit *view, } // namespace -QFrame *InspectorPane::agentFrame(const AgentSnapshot &agent) { +QFrame *InspectorPane::agentFrame(const ui::InspectorAgentRow &agent) { auto *frame = new QFrame; frame->setProperty("kind", "raised"); auto *layout = new QVBoxLayout(frame); @@ -426,23 +407,19 @@ void InspectorPane::setHideAction(std::function hide) { void InspectorPane::setRequestActions(RequestAction review, RequestAction accept, - RequestAction reject, - RequestEligibility eligible) { + RequestAction reject) { reviewRequest = std::move(review); acceptRequest = std::move(accept); rejectRequest = std::move(reject); - requestEligible = std::move(eligible); } -void InspectorPane::refresh(const PresentationModel &model, - const std::string &selectedThreadId) { - currentModel = &model; - currentThreadId = selectedThreadId; +void InspectorPane::refresh(const ui::InspectorSnapshot &snapshot) { + currentSnapshot = snapshot; refreshCurrentTab(); } void InspectorPane::refreshCurrentTab() { - if (!currentModel) + if (!currentSnapshot) return; switch (inspectorTabs->currentIndex()) { case 0: @@ -471,48 +448,11 @@ void InspectorPane::refreshCurrentTab() { } void InspectorPane::refreshPlan() { - const ThreadPresentation *thread = currentModel->thread(currentThreadId); - PlanSnapshot next; - next.threadId = currentThreadId; - next.threadPresent = thread != nullptr; - if (thread) { - for (auto id = thread->turnOrder.rbegin(); id != thread->turnOrder.rend(); - ++id) { - const auto turn = thread->turns.find(*id); - if (turn == thread->turns.end()) - continue; - if (turn->second.plan.is_object() && - turn->second.plan.contains("steps")) { - PlanContentSnapshot plan; - plan.explanation = stringValue(turn->second.plan, "explanation"); - for (const auto &step : - turn->second.plan.value("steps", nlohmann::json::array())) { - const std::string status = stringValue(step, "status"); - plan.steps.push_back({ - stringValue(step, "step"), - effectivePlanStepStatus(status, turn->second.status, - thread->status)}); - } - next.plan = std::move(plan); - 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") { - next.planItem = stringValue(item->second.raw, "text"); - break; - } - } - if (next.planItem) - break; - } - } + const ui::InspectorPlanSnapshot &next = currentSnapshot->plan; if (planSnapshot && *planSnapshot == next) return; - planSnapshot = std::move(next); - const PlanSnapshot &snapshot = *planSnapshot; + planSnapshot = next; + const ui::InspectorPlanSnapshot &snapshot = *planSnapshot; setUpdatesEnabled(false); clearLayout(planLayout); if (!snapshot.threadPresent) { @@ -522,7 +462,7 @@ void InspectorPane::refreshPlan() { const QString explanation = text(snapshot.plan->explanation); if (!explanation.isEmpty()) planLayout->addWidget(makeMarkdownLabel(explanation)); - for (const PlanStepSnapshot &step : snapshot.plan->steps) { + for (const ui::InspectorPlanStep &step : snapshot.plan->steps) { auto *row = new QFrame; row->setProperty("kind", "raised"); auto *layout = new QVBoxLayout(row); @@ -547,44 +487,11 @@ void InspectorPane::refreshPlan() { } void InspectorPane::refreshAgents() { - const ThreadPresentation *thread = currentModel->thread(currentThreadId); - AgentsSnapshot next; - next.threadId = currentThreadId; - next.threadPresent = thread != nullptr; - if (thread) { - next.agents.reserve(thread->agentOrder.size()); - for (const std::string &id : thread->agentOrder) { - const auto agent = thread->agents.find(id); - if (agent == thread->agents.end()) - continue; - AgentSnapshot snapshot; - snapshot.id = id; - snapshot.status = agent->second.status; - snapshot.childThreadId = agent->second.childThreadId; - snapshot.agentPath = stringValue(agent->second.raw, "agentPath"); - snapshot.tool = stringValue(agent->second.raw, "tool"); - snapshot.model = stringValue(agent->second.raw, "model"); - snapshot.reasoningEffort = - stringValue(agent->second.raw, "reasoningEffort"); - snapshot.prompt = stringValue(agent->second.raw, "prompt"); - snapshot.resultText = stringValue(agent->second.raw, "resultText"); - snapshot.senderThreadId = - stringValue(agent->second.raw, "senderThreadId"); - const auto receivers = agent->second.raw.find("receiverThreadIds"); - if (receivers != agent->second.raw.end() && receivers->is_array()) { - for (const auto &receiver : *receivers) { - if (receiver.is_string()) - snapshot.receiverThreadIds.push_back( - receiver.get()); - } - } - next.agents.push_back(std::move(snapshot)); - } - } + const ui::InspectorAgentsSnapshot &next = currentSnapshot->agents; if (agentsSnapshot && *agentsSnapshot == next) return; - agentsSnapshot = std::move(next); - const AgentsSnapshot &snapshot = *agentsSnapshot; + agentsSnapshot = next; + const ui::InspectorAgentsSnapshot &snapshot = *agentsSnapshot; setUpdatesEnabled(false); clearLayout(agentsLayout); if (!snapshot.threadPresent) @@ -594,51 +501,30 @@ void InspectorPane::refreshAgents() { agentsLayout->addWidget(makeLabel( QStringLiteral("No agent activity for this thread."), "muted")); else - for (const AgentSnapshot &agent : snapshot.agents) + for (const ui::InspectorAgentRow &agent : snapshot.agents) agentsLayout->addWidget(agentFrame(agent)); agentsLayout->addStretch(); setUpdatesEnabled(true); } void InspectorPane::refreshChanges() { - const ThreadPresentation *thread = currentModel->thread(currentThreadId); + const ui::InspectorChangesSnapshot &snapshot = currentSnapshot->changes; diffViewer->setRepositoryContext( - text(currentThreadId), thread ? text(thread->cwd) : QString{}, - thread ? texts(thread->commandCwds) : QStringList{}, - thread ? texts(thread->changedPaths) : QStringList{}); + text(snapshot.threadId), text(snapshot.cwd), texts(snapshot.commandCwds), + texts(snapshot.changedPaths)); diffViewer->refreshRepository(); } void InspectorPane::refreshRequests() { - std::vector next; - next.reserve(currentModel->pendingRequestCount()); - for (const auto &[id, request] : - currentModel->pendingRequestPresentations()) { - RequestSnapshot snapshot; - snapshot.id = id; - snapshot.kind = request.kind; - snapshot.threadContext = request.threadId; - if (const ThreadPresentation *thread = - currentModel->thread(request.threadId); - thread && !thread->title.empty()) - snapshot.threadContext = thread->title; - snapshot.generation = request.generation; - snapshot.command = stringValue(request.raw, "command"); - snapshot.reason = stringValue(request.raw, "reason"); - snapshot.message = stringValue(request.raw, "message"); - const auto questions = request.raw.find("questions"); - if (questions != request.raw.end() && questions->is_array()) - snapshot.questionCount = questions->size(); - snapshot.actionable = requestEligible && requestEligible(id); - next.push_back(std::move(snapshot)); - } + const ui::InspectorRequestsSnapshot &next = currentSnapshot->requests; if (requestsSnapshot && *requestsSnapshot == next) return; - requestsSnapshot = std::move(next); - const std::vector &snapshot = *requestsSnapshot; + requestsSnapshot = next; + const std::vector &snapshot = + requestsSnapshot->requests; setUpdatesEnabled(false); clearLayout(requestsLayout); - for (const RequestSnapshot &request : snapshot) { + for (const ui::InspectorRequestRow &request : snapshot) { auto *frame = new QFrame; frame->setProperty("kind", "raised"); frame->setProperty("tone", "warning"); @@ -718,18 +604,7 @@ void InspectorPane::refreshRequests() { } void InspectorPane::refreshState() { - nlohmann::json domains = nlohmann::json::object(); - for (const auto &[name, value] : currentModel->globalDomains()) - domains[name] = value; - nlohmann::json pending = nlohmann::json::object(); - for (const auto &[id, request] : currentModel->pendingRequestPresentations()) - pending[id] = {{"category", request.kind}, - {"threadId", request.threadId}, - {"generation", request.generation}}; - nlohmann::json state{{"models", currentModel->modelCatalog()}, - {"pendingRequests", std::move(pending)}, - {"domains", std::move(domains)}}; - std::string rendered = state.dump(2); + std::string rendered = currentSnapshot->state.state.dump(2); constexpr std::size_t MaximumBytes = 32U * 1024U; if (rendered.size() > MaximumBytes) { const std::size_t total = rendered.size(); @@ -746,26 +621,17 @@ void InspectorPane::refreshState() { } void InspectorPane::refreshProtocolStats() { - std::size_t turns = 0; - std::size_t items = 0; - if (const ThreadPresentation *thread = - currentModel->thread(currentThreadId)) { - turns = thread->turnOrder.size(); - for (const auto &[id, turn] : thread->turns) { - static_cast(id); - items += turn.itemOrder.size(); - } - } + const ui::InspectorStateSnapshot &snapshot = currentSnapshot->state; const QString value = QStringLiteral("seq %1 | threads %2 | models %3 | turns %4 | " "items %5 | pending %6 | telemetry %7") .arg(static_cast(observedSequence)) - .arg(static_cast(currentModel->threadOrder().size())) - .arg(static_cast(currentModel->modelCatalog().size())) - .arg(static_cast(turns)) - .arg(static_cast(items)) - .arg(static_cast(currentModel->pendingRequestCount())) - .arg(static_cast(currentModel->telemetry().size())); + .arg(static_cast(snapshot.threadCount)) + .arg(static_cast(snapshot.modelCount)) + .arg(static_cast(snapshot.selectedThreadTurnCount)) + .arg(static_cast(snapshot.selectedThreadItemCount)) + .arg(static_cast(snapshot.pendingRequestCount)) + .arg(static_cast(snapshot.telemetryCount)); if (value.toUtf8() == protocolStatsSnapshot) return; protocolStatsSnapshot = value.toUtf8(); diff --git a/src/codex/middle/InspectorPane.h b/src/codex/middle/InspectorPane.h index 94abf69..582769f 100644 --- a/src/codex/middle/InspectorPane.h +++ b/src/codex/middle/InspectorPane.h @@ -3,6 +3,8 @@ #ifndef CODEXUI_CODEX_MIDDLE_INSPECTORPANE_H #define CODEXUI_CODEX_MIDDLE_INSPECTORPANE_H +#include "codex/ui/UiViewState.h" + #include #include #include @@ -26,7 +28,6 @@ class QVBoxLayout; namespace codexui::codex { class DiffViewer; -class PresentationModel; namespace middle { @@ -36,77 +37,19 @@ namespace middle { class InspectorPane final : public QFrame { public: using RequestAction = std::function; - using RequestEligibility = std::function; explicit InspectorPane(QWidget *parent = nullptr); void setHideAction(std::function hide); void setRequestActions(RequestAction review, RequestAction accept, - RequestAction reject, RequestEligibility eligible); - void refresh(const PresentationModel &model, - const std::string &selectedThreadId); + RequestAction reject); + void refresh(const ui::InspectorSnapshot &snapshot); void appendProtocolFrame(const nlohmann::json &frame); [[nodiscard]] QTabWidget *tabs() const noexcept { return inspectorTabs; } private: - struct PlanStepSnapshot { - std::string step; - std::string status; - - bool operator==(const PlanStepSnapshot &) const = default; - }; - struct PlanContentSnapshot { - std::string explanation; - std::vector steps; - - bool operator==(const PlanContentSnapshot &) const = default; - }; - struct PlanSnapshot { - std::string threadId; - bool threadPresent = false; - std::optional plan; - std::optional planItem; - - bool operator==(const PlanSnapshot &) const = default; - }; - struct AgentSnapshot { - std::string id; - std::string status; - std::string childThreadId; - std::string agentPath; - std::string tool; - std::string model; - std::string reasoningEffort; - std::string prompt; - std::string resultText; - std::string senderThreadId; - std::vector receiverThreadIds; - - bool operator==(const AgentSnapshot &) const = default; - }; - struct AgentsSnapshot { - std::string threadId; - bool threadPresent = false; - std::vector agents; - - bool operator==(const AgentsSnapshot &) const = default; - }; - struct RequestSnapshot { - std::string id; - std::string kind; - std::string threadContext; - std::uint64_t generation = 0; - std::string command; - std::string reason; - std::string message; - std::optional questionCount; - bool actionable = false; - - bool operator==(const RequestSnapshot &) const = default; - }; - - static QFrame *agentFrame(const AgentSnapshot &agent); + static QFrame *agentFrame(const ui::InspectorAgentRow &agent); void refreshCurrentTab(); void refreshPlan(); void refreshAgents(); @@ -117,12 +60,10 @@ class InspectorPane final : public QFrame { void showProtocolTail(); void restoreProtocolScroll(bool followsTail, int pausedValue); - const PresentationModel *currentModel = nullptr; - std::string currentThreadId; + std::optional currentSnapshot; RequestAction reviewRequest; RequestAction acceptRequest; RequestAction rejectRequest; - RequestEligibility requestEligible; std::function hideAction; QTabWidget *inspectorTabs = nullptr; @@ -138,9 +79,9 @@ class InspectorPane final : public QFrame { QPlainTextEdit *protocolLog = nullptr; QLabel *protocolStats = nullptr; - std::optional planSnapshot; - std::optional agentsSnapshot; - std::optional> requestsSnapshot; + std::optional planSnapshot; + std::optional agentsSnapshot; + std::optional requestsSnapshot; QByteArray stateSnapshot; QByteArray protocolStatsSnapshot; std::deque protocolLines; diff --git a/src/codex/middle/MiddleRegionWidget.cpp b/src/codex/middle/MiddleRegionWidget.cpp index 691ef69..c6f124e 100644 --- a/src/codex/middle/MiddleRegionWidget.cpp +++ b/src/codex/middle/MiddleRegionWidget.cpp @@ -26,6 +26,7 @@ #include #include +#include #include #include @@ -238,8 +239,19 @@ MiddleRegionWidget::MiddleRegionWidget(QWidget *parent) : QWidget(parent) { conversationMetadata->setObjectName( QStringLiteral("conversationMetadata")); conversationMetadata->setWordWrap(false); - threadHeading->addWidget(conversationTitle, 0, Qt::AlignBaseline); - threadHeading->addWidget(conversationMetadata, 1, Qt::AlignBaseline); + conversationTrailingMetadata = makeLabel({}, "meta"); + conversationTrailingMetadata->setObjectName( + QStringLiteral("conversationTrailingMetadata")); + conversationTrailingMetadata->setWordWrap(false); + conversationTrailingMetadata->setSizePolicy(QSizePolicy::Minimum, + QSizePolicy::Preferred); + conversationTitle->setAlignment(Qt::AlignLeft | Qt::AlignTop); + conversationMetadata->setAlignment(Qt::AlignLeft | Qt::AlignTop); + conversationTrailingMetadata->setAlignment(Qt::AlignRight | Qt::AlignTop); + alignThreadHeadingBaselines(); + threadHeading->addWidget(conversationTitle, 0, Qt::AlignTop); + threadHeading->addWidget(conversationMetadata, 1, Qt::AlignTop); + threadHeading->addWidget(conversationTrailingMetadata, 0, Qt::AlignTop); contentLayout->addLayout(threadHeading); contentLayout->addSpacing(7); contentLayout->addWidget(divider()); @@ -334,11 +346,29 @@ QSplitter *MiddleRegionWidget::splitterWidget() const noexcept { return splitter; } -void MiddleRegionWidget::setThreadHeading(QString title, QString metadata) { +void MiddleRegionWidget::setThreadHeading(QString title, QString metadata, + QString trailingMetadata) { if (conversationTitle->text() != title) conversationTitle->setText(std::move(title)); if (conversationMetadata->text() != metadata) conversationMetadata->setText(std::move(metadata)); + if (conversationTrailingMetadata->text() != trailingMetadata) + conversationTrailingMetadata->setText(std::move(trailingMetadata)); + alignThreadHeadingBaselines(); +} + +void MiddleRegionWidget::alignThreadHeadingBaselines() { + conversationTitle->ensurePolished(); + conversationMetadata->ensurePolished(); + conversationTrailingMetadata->ensurePolished(); + const int offset = std::max( + 0, conversationTitle->fontMetrics().ascent() - + conversationMetadata->fontMetrics().ascent()); + conversationMetadata->setContentsMargins(0, offset, 0, 0); + const int trailingOffset = std::max( + 0, conversationTitle->fontMetrics().ascent() - + conversationTrailingMetadata->fontMetrics().ascent()); + conversationTrailingMetadata->setContentsMargins(0, trailingOffset, 0, 0); } void MiddleRegionWidget::showNotice(QString message, bool error) { diff --git a/src/codex/middle/MiddleRegionWidget.h b/src/codex/middle/MiddleRegionWidget.h index 0545189..a8911b7 100644 --- a/src/codex/middle/MiddleRegionWidget.h +++ b/src/codex/middle/MiddleRegionWidget.h @@ -21,8 +21,8 @@ class ConversationView; class InspectorPane; class ThreadPane; -// The sole geometry owner for the three-pane workspace. Protocol and domain -// decisions remain in ShellWidget; this class owns only visible layout and +// The sole geometry owner for the three-pane workspace. Protocol and domain +// decisions remain behind UiSession; this class owns only visible layout and // wheel routing across the complete center strip. class MiddleRegionWidget final : public QWidget { public: @@ -34,7 +34,8 @@ class MiddleRegionWidget final : public QWidget { [[nodiscard]] InspectorPane &inspector() const noexcept; [[nodiscard]] QSplitter *splitterWidget() const noexcept; - void setThreadHeading(QString title, QString metadata); + void setThreadHeading(QString title, QString metadata, + QString trailingMetadata = {}); void showNotice(QString message, bool error = true); void showSidebar(bool visible); void showInspector(bool visible); @@ -49,12 +50,14 @@ class MiddleRegionWidget final : public QWidget { private: void applyConversationPresentationOptions(); + void alignThreadHeadingBaselines(); QSplitter *splitter = nullptr; ThreadPane *threadPane = nullptr; QFrame *conversationRegion = nullptr; QLabel *conversationTitle = nullptr; QLabel *conversationMetadata = nullptr; + QLabel *conversationTrailingMetadata = nullptr; QToolButton *reasoningVisibility = nullptr; QToolButton *updateVisibility = nullptr; QToolButton *commandInitialFolding = nullptr; diff --git a/src/codex/middle/MiddleTypes.cpp b/src/codex/middle/MiddleTypes.cpp index bf6dc20..08fa9c4 100644 --- a/src/codex/middle/MiddleTypes.cpp +++ b/src/codex/middle/MiddleTypes.cpp @@ -2,6 +2,7 @@ #include "codex/middle/MiddleTypes.h" +#include #include namespace codexui::codex::middle { @@ -13,6 +14,81 @@ void appendComponent(std::string &result, std::string_view value) { result.append(value); } +struct Utf8CodePoint { + char32_t value = 0; + std::size_t next = 0; +}; + +Utf8CodePoint decodeUtf8(std::string_view value, std::size_t offset) noexcept { + const auto byte = [&value](std::size_t index) { + return static_cast(value[index]); + }; + const unsigned char first = byte(offset); + if (first < 0x80) + return {first, offset + 1}; + + std::size_t length = 0; + char32_t codePoint = 0; + char32_t minimum = 0; + if ((first & 0xe0) == 0xc0) { + length = 2; + codePoint = first & 0x1f; + minimum = 0x80; + } else if ((first & 0xf0) == 0xe0) { + length = 3; + codePoint = first & 0x0f; + minimum = 0x800; + } else if ((first & 0xf8) == 0xf0) { + length = 4; + codePoint = first & 0x07; + minimum = 0x10000; + } else { + return {0xfffd, offset + 1}; + } + if (offset + length > value.size()) + return {0xfffd, offset + 1}; + for (std::size_t index = 1; index < length; ++index) { + const unsigned char continuation = byte(offset + index); + if ((continuation & 0xc0) != 0x80) + return {0xfffd, offset + 1}; + codePoint = (codePoint << 6) | (continuation & 0x3f); + } + if (codePoint < minimum || codePoint > 0x10ffff || + (codePoint >= 0xd800 && codePoint <= 0xdfff)) + return {0xfffd, offset + 1}; + return {codePoint, offset + length}; +} + +bool unicodeSpace(char32_t value) noexcept { + return (value >= 0x09 && value <= 0x0d) || (value >= 0x1c && value <= 0x20) || + value == 0x85 || value == 0xa0 || value == 0x1680 || + (value >= 0x2000 && value <= 0x200a) || value == 0x2028 || + value == 0x2029 || value == 0x202f || value == 0x205f || + value == 0x3000; +} + +bool printableNonSpace(char32_t value) noexcept { + if (unicodeSpace(value) || value < 0x20 || (value >= 0x7f && value <= 0x9f)) + return false; + // Unicode format controls are not visibly printable even though they may + // influence adjacent text. + if ((value >= 0x200b && value <= 0x200f) || + (value >= 0x202a && value <= 0x202e) || + (value >= 0x2060 && value <= 0x206f) || value == 0xfeff) + return false; + return true; +} + +bool whitespaceOnly(std::string_view value) noexcept { + for (std::size_t offset = 0; offset < value.size();) { + const Utf8CodePoint decoded = decodeUtf8(value, offset); + if (!unicodeSpace(decoded.value)) + return false; + offset = decoded.next; + } + return true; +} + } // namespace std::string stableKey(const CardKey &key) { @@ -32,97 +108,121 @@ std::string stableKey(const CardKey &key) { return "prompt:" + std::to_string(std::get(key).submissionId); } -bool terminalOutputHasVisibleText(QStringView output) { - for (qsizetype index = 0; index < output.size(); ++index) { - const ushort code = output[index].unicode(); +bool terminalOutputHasVisibleText(std::string_view output) { + for (std::size_t index = 0; index < output.size();) { + const Utf8CodePoint current = decodeUtf8(output, index); + const char32_t code = current.value; + index = current.next; if (code == 0x9b) { - while (++index < output.size()) { - const ushort candidate = output[index].unicode(); - if (candidate >= 0x40 && candidate <= 0x7e) + while (index < output.size()) { + const Utf8CodePoint candidate = decodeUtf8(output, index); + index = candidate.next; + if (candidate.value >= 0x40 && candidate.value <= 0x7e) break; } continue; } if (code == 0x90 || code == 0x98 || code == 0x9d || code == 0x9e || code == 0x9f) { - while (++index < output.size()) { - const ushort candidate = output[index].unicode(); - if (candidate == 0x07 || candidate == 0x9c) + while (index < output.size()) { + const Utf8CodePoint candidate = decodeUtf8(output, index); + index = candidate.next; + if (candidate.value == 0x07 || candidate.value == 0x9c) break; - if (candidate == 0x1b && index + 1 < output.size() && - output[index + 1].unicode() == '\\') { - ++index; + if (candidate.value == 0x1b && index < output.size()) { + const Utf8CodePoint terminator = decodeUtf8(output, index); + if (terminator.value != '\\') + continue; + index = terminator.next; break; } } continue; } if (code == 0x1b) { - if (++index >= output.size()) + if (index >= output.size()) break; - const ushort introducer = output[index].unicode(); + const Utf8CodePoint introduced = decodeUtf8(output, index); + const char32_t introducer = introduced.value; + index = introduced.next; if (introducer == '[') { - while (++index < output.size()) { - const ushort candidate = output[index].unicode(); - if (candidate >= 0x40 && candidate <= 0x7e) + while (index < output.size()) { + const Utf8CodePoint candidate = decodeUtf8(output, index); + index = candidate.next; + if (candidate.value >= 0x40 && candidate.value <= 0x7e) break; } continue; } if (introducer == ']' || introducer == 'P' || introducer == '^' || introducer == '_' || introducer == 'X') { - while (++index < output.size()) { - if (output[index].unicode() == 0x07 || - output[index].unicode() == 0x9c) + while (index < output.size()) { + const Utf8CodePoint candidate = decodeUtf8(output, index); + index = candidate.next; + if (candidate.value == 0x07 || candidate.value == 0x9c) break; - if (output[index].unicode() == 0x1b && index + 1 < output.size() && - output[index + 1].unicode() == '\\') { - ++index; + if (candidate.value == 0x1b && index < output.size()) { + const Utf8CodePoint terminator = decodeUtf8(output, index); + if (terminator.value != '\\') + continue; + index = terminator.next; break; } } continue; } if (introducer >= 0x20 && introducer <= 0x2f) { - while (++index < output.size()) { - const ushort candidate = output[index].unicode(); - if (candidate >= 0x30 && candidate <= 0x7e) + while (index < output.size()) { + const Utf8CodePoint candidate = decodeUtf8(output, index); + index = candidate.next; + if (candidate.value >= 0x30 && candidate.value <= 0x7e) break; } } continue; } - if (output[index].isPrint() && !output[index].isSpace()) + if (printableNonSpace(code)) return true; } return false; } -QString trimTrailingEmptyLines(QStringView text) { - qsizetype end = text.size(); +std::string trimUnicodeWhitespace(std::string_view text) { + std::size_t first = 0; + std::size_t last = 0; + bool found = false; + for (std::size_t offset = 0; offset < text.size();) { + const std::size_t start = offset; + const Utf8CodePoint decoded = decodeUtf8(text, offset); + offset = decoded.next; + if (unicodeSpace(decoded.value)) + continue; + if (!found) { + first = start; + found = true; + } + last = offset; + } + return found ? std::string(text.substr(first, last - first)) : std::string{}; +} + +std::string trimTrailingEmptyLines(std::string_view text) { + std::size_t end = text.size(); while (end > 0) { - while (end > 0 && (text[end - 1] == QLatin1Char('\n') || - text[end - 1] == QLatin1Char('\r'))) + while (end > 0 && (text[end - 1] == '\n' || text[end - 1] == '\r')) --end; if (end == 0) break; - qsizetype lineStart = end; - while (lineStart > 0 && text[lineStart - 1] != QLatin1Char('\n') && - text[lineStart - 1] != QLatin1Char('\r')) + std::size_t lineStart = end; + while (lineStart > 0 && text[lineStart - 1] != '\n' && + text[lineStart - 1] != '\r') --lineStart; - bool emptyLine = true; - for (qsizetype index = lineStart; index < end; ++index) { - if (!text[index].isSpace()) { - emptyLine = false; - break; - } - } - if (!emptyLine) + if (!whitespaceOnly(text.substr(lineStart, end - lineStart))) break; end = lineStart; } - return text.first(end).toString(); + return std::string(text.substr(0, end)); } std::vector ConversationSnapshot::cardKeys() const { diff --git a/src/codex/middle/MiddleTypes.h b/src/codex/middle/MiddleTypes.h index 44f0ef2..0f62c39 100644 --- a/src/codex/middle/MiddleTypes.h +++ b/src/codex/middle/MiddleTypes.h @@ -5,21 +5,17 @@ #include -#include -#include -#include -#include - #include #include #include #include +#include #include #include namespace codexui::codex::middle { -inline constexpr qint64 AcknowledgementTransitionMilliseconds = 500; +inline constexpr std::int64_t AcknowledgementTransitionMilliseconds = 500; inline constexpr std::size_t AuthoritativeHistoryPageSize = 80; struct AuthoritativeItemKey { @@ -49,8 +45,9 @@ struct TurnPlanKey { using CardKey = std::variant; [[nodiscard]] std::string stableKey(const CardKey &key); -[[nodiscard]] bool terminalOutputHasVisibleText(QStringView output); -[[nodiscard]] QString trimTrailingEmptyLines(QStringView text); +[[nodiscard]] bool terminalOutputHasVisibleText(std::string_view output); +[[nodiscard]] std::string trimUnicodeWhitespace(std::string_view text); +[[nodiscard]] std::string trimTrailingEmptyLines(std::string_view text); enum class PromptState { Queued, InFlight, Accepted, Failed }; @@ -68,55 +65,55 @@ enum class CardKind { }; struct UserMessageData { - QString text; - QStringList imagePaths; + std::string text; + std::vector imagePaths; bool operator==(const UserMessageData &) const = default; }; struct AgentMessageData { - QString text; + std::string text; bool finalAnswer = false; bool operator==(const AgentMessageData &) const = default; }; struct CommandExecutionData { - QString command; - QString output; - QString status; - QString cwd; + std::string command; + std::string output; + std::string status; + std::string cwd; std::optional exitCode; - std::optional durationMilliseconds; + std::optional durationMilliseconds; bool operator==(const CommandExecutionData &) const = default; }; struct AgentActivityData { - QString tool; - QString status; - QString kind; - QString prompt; - QString resultText; - QStringList receivers; - QString model; - QString reasoningEffort; - QString childThreadId; - QString agentPath; - QString senderThreadId; + std::string tool; + std::string status; + std::string kind; + std::string prompt; + std::string resultText; + std::vector receivers; + std::string model; + std::string reasoningEffort; + std::string childThreadId; + std::string agentPath; + std::string senderThreadId; bool operator==(const AgentActivityData &) const = default; }; struct ReasoningData { - QString summary; + std::string summary; bool operator==(const ReasoningData &) const = default; }; struct FileChangeData { - QString path; - QString kind; + std::string path; + std::string kind; std::optional additions; std::optional deletions; @@ -124,37 +121,37 @@ struct FileChangeData { }; struct FileChangesData { - QString status; + std::string status; std::vector changes; bool operator==(const FileChangesData &) const = default; }; struct ImageGenerationData { - QString path; - QString status; - QString revisedPrompt; + std::string path; + std::string status; + std::string revisedPrompt; bool operator==(const ImageGenerationData &) const = default; }; struct PlanStepData { - QString text; - QString status; + std::string text; + std::string status; bool operator==(const PlanStepData &) const = default; }; struct PlanData { - QString explanation; + std::string explanation; std::vector steps; - QString legacyText; + std::string legacyText; bool operator==(const PlanData &) const = default; }; struct GenericActivityData { - QString type; + std::string type; nlohmann::json raw = nlohmann::json::object(); bool operator==(const GenericActivityData &) const = default; @@ -162,14 +159,14 @@ struct GenericActivityData { struct LocalPromptData { std::uint64_t submissionId = 0; - QString prompt; + std::string prompt; PromptState state = PromptState::Queued; - qint64 acceptedAtMilliseconds = 0; - QString error; - QStringList imagePaths; + std::int64_t acceptedAtMilliseconds = 0; + std::string error; + std::vector imagePaths; [[nodiscard]] bool - acceptedTransitionActive(qint64 nowMilliseconds) const noexcept { + acceptedTransitionActive(std::int64_t nowMilliseconds) const noexcept { return state == PromptState::Accepted && acceptedAtMilliseconds > 0 && nowMilliseconds >= acceptedAtMilliseconds && nowMilliseconds - acceptedAtMilliseconds < diff --git a/src/codex/middle/PromptCoordinator.cpp b/src/codex/middle/PromptCoordinator.cpp index d807435..8fb1911 100644 --- a/src/codex/middle/PromptCoordinator.cpp +++ b/src/codex/middle/PromptCoordinator.cpp @@ -2,18 +2,14 @@ #include "codex/middle/PromptCoordinator.h" -#include #include #include +#include #include namespace codexui::codex::middle { namespace { -QString text(const std::string &value) { - return QString::fromUtf8(value.data(), static_cast(value.size())); -} - std::string stringValue(const nlohmann::json &object, const char *key) { if (!object.is_object()) return {}; @@ -22,31 +18,63 @@ std::string stringValue(const nlohmann::json &object, const char *key) { : std::string{}; } -QString userMessageText(const nlohmann::json &item) { - QStringList parts; +std::string userMessageText(const nlohmann::json &item) { + std::string result; const auto content = item.find("content"); if (content != item.end() && content->is_array()) { for (const nlohmann::json &entry : *content) { const std::string value = stringValue(entry, "text"); - if (!value.empty()) - parts.push_back(text(value)); + if (value.empty()) + continue; + if (!result.empty()) + result.push_back('\n'); + result += value; } } - if (parts.empty()) { + if (result.empty()) { const std::string value = stringValue(item, "text"); if (!value.empty()) - parts.push_back(text(value)); + result = value; + } + return result; +} + +std::string markdownLinkLabel(std::string_view label) { + std::string result; + result.reserve(label.size()); + for (const char character : label) { + if (character == '\\' || character == '[' || character == ']') + result.push_back('\\'); + result.push_back(character == '\r' || character == '\n' ? ' ' : character); } - return parts.join(QStringLiteral("\n")); + return result; } -QString markdownLinkLabel(QString label) { - label.replace(QLatin1Char('\\'), QStringLiteral("\\\\")); - label.replace(QLatin1Char('['), QStringLiteral("\\[")); - label.replace(QLatin1Char(']'), QStringLiteral("\\]")); - label.replace(QLatin1Char('\r'), QLatin1Char(' ')); - label.replace(QLatin1Char('\n'), QLatin1Char(' ')); - return label; +bool urlPathByteAllowed(unsigned char byte) noexcept { + const bool alphanumeric = (byte >= 'a' && byte <= 'z') || + (byte >= 'A' && byte <= 'Z') || + (byte >= '0' && byte <= '9'); + return alphanumeric || byte == '-' || byte == '.' || byte == '_' || + byte == '~' || byte == '/' || byte == ':' || byte == '@' || + byte == '!' || byte == '$' || byte == '&' || byte == '\'' || + byte == '*' || byte == '+' || byte == ',' || byte == ';' || + byte == '='; +} + +std::string localFileUrl(std::string_view path) { + static constexpr char Hex[] = "0123456789ABCDEF"; + std::string result = path.starts_with('/') ? "file://" : "file:"; + result.reserve(result.size() + path.size()); + for (const unsigned char byte : path) { + if (urlPathByteAllowed(byte)) { + result.push_back(static_cast(byte)); + continue; + } + result.push_back('%'); + result.push_back(Hex[byte >> 4]); + result.push_back(Hex[byte & 0x0f]); + } + return result; } } // namespace @@ -82,7 +110,8 @@ indexAuthoritativeItems(const std::string &threadId, const std::string clientId = stringValue(item->second.raw, "clientId"); if (!clientId.empty()) result.userMessagesByClientId.try_emplace(clientId, position); - const QString content = userMessageText(item->second.raw).trimmed(); + const std::string content = + trimUnicodeWhitespace(userMessageText(item->second.raw)); result.userMessagesByText.emplace(std::string{}, content, position); result.userMessagesByText.emplace(turnId, content, position); } @@ -91,47 +120,47 @@ indexAuthoritativeItems(const std::string &threadId, return result; } -QString promptWithFileLinks(QString prompt, +std::string promptWithFileLinks(std::string prompt, std::span attachments) { - QStringList links; + std::vector links; for (const AttachmentDraft &attachment : attachments) { - if (attachment.mimeType.startsWith(QStringLiteral("image/")) || - attachment.mimeType.startsWith(QStringLiteral("audio/"))) + if (attachment.mimeType.starts_with("image/") || + attachment.mimeType.starts_with("audio/")) continue; - QString target = - QUrl::fromLocalFile(attachment.path).toString(QUrl::FullyEncoded); - target.replace(QLatin1Char('['), QStringLiteral("%5B")); - target.replace(QLatin1Char(']'), QStringLiteral("%5D")); - target.replace(QLatin1Char('('), QStringLiteral("%28")); - target.replace(QLatin1Char(')'), QStringLiteral("%29")); - links.push_back(QStringLiteral("- [%1](%2)") - .arg(markdownLinkLabel(attachment.name), target)); + links.push_back("- [" + markdownLinkLabel(attachment.name) + "](" + + localFileUrl(attachment.path) + ')'); } if (links.empty()) return prompt; - return prompt + QStringLiteral("\n\nAttached files:\n") + - links.join(QLatin1Char('\n')); + prompt += "\n\nAttached files:\n"; + for (std::size_t index = 0; index < links.size(); ++index) { + if (index != 0) + prompt.push_back('\n'); + prompt += links[index]; + } + return prompt; } bool PromptSubmission::acceptedTransitionActive( - qint64 nowMilliseconds) const noexcept { + std::int64_t nowMilliseconds) const noexcept { return state == PromptState::Accepted && acceptedAtMilliseconds > 0 && nowMilliseconds >= acceptedAtMilliseconds && nowMilliseconds - acceptedAtMilliseconds < AcknowledgementTransitionMilliseconds; } -bool PromptSubmission::localCardVisible(qint64 nowMilliseconds) const noexcept { +bool PromptSubmission::localCardVisible( + std::int64_t nowMilliseconds) const noexcept { return state == PromptState::Queued || state == PromptState::InFlight || state == PromptState::Failed || !materializedItem || acceptedTransitionActive(nowMilliseconds); } std::uint64_t PromptCoordinator::admit( - std::string threadId, QString prompt, + std::string threadId, std::string prompt, std::vector attachments, nlohmann::json turnOptions, const ThreadPresentation *authoritativeThread, - std::optional activeTurnId, qint64 nowMilliseconds) { + std::optional activeTurnId, std::int64_t nowMilliseconds) { PromptSubmission submission; submission.id = nextSubmissionId++; submission.admissionOrdinal = nextAdmissionOrdinal++; @@ -190,7 +219,8 @@ PromptCoordinator::beginNext(const std::string &threadId, bool PromptCoordinator::acknowledge( const std::string &threadId, std::uint64_t submissionId, - std::optional authoritativeTurnId, qint64 nowMilliseconds) { + std::optional authoritativeTurnId, + std::int64_t nowMilliseconds) { PromptSubmission *pending = find(threadId, submissionId); if (!pending || pending->state != PromptState::InFlight) return false; @@ -203,7 +233,7 @@ bool PromptCoordinator::acknowledge( } bool PromptCoordinator::fail(const std::string &threadId, - std::uint64_t submissionId, QString error) { + std::uint64_t submissionId, std::string error) { PromptSubmission *pending = find(threadId, submissionId); if (!pending || (pending->state != PromptState::InFlight && pending->state != PromptState::Queued)) @@ -224,7 +254,7 @@ bool PromptCoordinator::requeue(const std::string &threadId, } std::size_t PromptCoordinator::failQueued(const std::string &threadId, - const QString &error) { + const std::string &error) { auto found = byThread.find(threadId); if (found == byThread.end()) return 0; @@ -297,7 +327,7 @@ bool PromptCoordinator::reassignThread(const std::string &fromThreadId, void PromptCoordinator::reconcile(const std::string &threadId, const ThreadPresentation &authoritativeThread, - qint64 nowMilliseconds) { + std::int64_t nowMilliseconds) { auto authoritativeItems = indexAuthoritativeItems(threadId, &authoritativeThread); reconcile(threadId, authoritativeItems, nowMilliseconds); @@ -305,7 +335,7 @@ void PromptCoordinator::reconcile(const std::string &threadId, void PromptCoordinator::reconcile(const std::string &threadId, AuthoritativeItemIndex &authoritativeItems, - qint64 nowMilliseconds) { + std::int64_t nowMilliseconds) { applyVisualAliases(threadId, authoritativeItems); auto found = byThread.find(threadId); if (found == byThread.end()) @@ -364,7 +394,7 @@ void PromptCoordinator::reconcile(const std::string &threadId, const std::string turnId = submission.expectedTurnId.value_or(std::string{}); - const QString prompt = submission.prompt.trimmed(); + const std::string prompt = trimUnicodeWhitespace(submission.prompt); auto candidate = authoritativeItems.userMessagesByText.lower_bound( {turnId, prompt, firstCandidate}); while (candidate != authoritativeItems.userMessagesByText.end() && diff --git a/src/codex/middle/PromptCoordinator.h b/src/codex/middle/PromptCoordinator.h index 6ac1520..df85370 100644 --- a/src/codex/middle/PromptCoordinator.h +++ b/src/codex/middle/PromptCoordinator.h @@ -3,15 +3,12 @@ #ifndef CODEXUI_CODEX_MIDDLE_PROMPTCOORDINATOR_H #define CODEXUI_CODEX_MIDDLE_PROMPTCOORDINATOR_H -#include "codex/FileSelectionDialog.h" +#include "codex/AttachmentDraft.h" #include "codex/PresentationModel.h" #include "codex/middle/MiddleTypes.h" #include -#include -#include - #include #include #include @@ -24,8 +21,8 @@ namespace codexui::codex::middle { -[[nodiscard]] QString -promptWithFileLinks(QString prompt, +[[nodiscard]] std::string +promptWithFileLinks(std::string prompt, std::span attachments); struct PromptSubmission { @@ -33,12 +30,12 @@ struct PromptSubmission { std::uint64_t admissionOrdinal = 0; std::string threadId; std::string clientUserMessageId; - QString prompt; + std::string prompt; std::vector attachments; nlohmann::json turnOptions = nlohmann::json::object(); PromptState state = PromptState::Queued; - qint64 acceptedAtMilliseconds = 0; - QString error; + std::int64_t acceptedAtMilliseconds = 0; + std::string error; std::optional admissionAnchor; bool admissionAtStart = false; bool startsTurn = false; @@ -46,15 +43,16 @@ struct PromptSubmission { std::optional materializedItem; [[nodiscard]] bool - acceptedTransitionActive(qint64 nowMilliseconds) const noexcept; - [[nodiscard]] bool localCardVisible(qint64 nowMilliseconds) const noexcept; + acceptedTransitionActive(std::int64_t nowMilliseconds) const noexcept; + [[nodiscard]] bool + localCardVisible(std::int64_t nowMilliseconds) const noexcept; }; struct PromptDispatch { std::uint64_t id = 0; std::string threadId; std::string clientUserMessageId; - QString prompt; + std::string prompt; std::vector attachments; nlohmann::json turnOptions = nlohmann::json::object(); std::optional expectedTurnId; @@ -77,7 +75,8 @@ struct AuthoritativeItemIndex { std::vector ordered; std::map positions; std::unordered_map userMessagesByClientId; - std::set> userMessagesByText; + std::set> + userMessagesByText; std::unordered_map turnRootUserMessagePositions; [[nodiscard]] std::optional @@ -94,10 +93,10 @@ indexAuthoritativeItems(const std::string &threadId, class PromptCoordinator final { public: [[nodiscard]] std::uint64_t - admit(std::string threadId, QString prompt, + admit(std::string threadId, std::string prompt, std::vector attachments, nlohmann::json turnOptions, const ThreadPresentation *authoritativeThread, - std::optional activeTurnId, qint64 nowMilliseconds); + std::optional activeTurnId, std::int64_t nowMilliseconds); // Starts at most one queued submission for a thread. The active turn is // sampled at dispatch time because earlier queued submissions may have @@ -109,12 +108,12 @@ class PromptCoordinator final { [[nodiscard]] bool acknowledge(const std::string &threadId, std::uint64_t submissionId, std::optional authoritativeTurnId, - qint64 nowMilliseconds); + std::int64_t nowMilliseconds); [[nodiscard]] bool fail(const std::string &threadId, - std::uint64_t submissionId, QString error); + std::uint64_t submissionId, std::string error); [[nodiscard]] bool requeue(const std::string &threadId, std::uint64_t submissionId); - std::size_t failQueued(const std::string &threadId, const QString &error); + std::size_t failQueued(const std::string &threadId, const std::string &error); // Used when the app-server assigns an id to an explicit New Thread draft. // LocalPromptKey is unaffected by this move. @@ -128,10 +127,10 @@ class PromptCoordinator final { // compact visual alias retains the admitted card identity and boundary. void reconcile(const std::string &threadId, const ThreadPresentation &authoritativeThread, - qint64 nowMilliseconds); + std::int64_t nowMilliseconds); void reconcile(const std::string &threadId, AuthoritativeItemIndex &authoritativeItems, - qint64 nowMilliseconds); + std::int64_t nowMilliseconds); [[nodiscard]] std::span submissions(const std::string &threadId) const noexcept; diff --git a/src/codex/middle/ThreadPane.cpp b/src/codex/middle/ThreadPane.cpp index 95e6b0c..b9916c7 100644 --- a/src/codex/middle/ThreadPane.cpp +++ b/src/codex/middle/ThreadPane.cpp @@ -2,7 +2,6 @@ #include "codex/middle/ThreadPane.h" -#include "codex/PresentationModel.h" #include "codex/PresentationStatus.h" #include "codex/ui/UiStyle.h" @@ -283,7 +282,7 @@ QWidget *createRow() { return row; } -std::optional timestampFor(const ThreadPresentation &thread, +std::optional timestampFor(const ui::ThreadListRow &thread, ThreadPane::SortCriterion criterion) { if (criterion == ThreadPane::SortCriterion::Created) return thread.createdAt; @@ -292,6 +291,48 @@ std::optional timestampFor(const ThreadPresentation &thread, return thread.recencyAt; } +const ui::ThreadListRow *findThread(const ui::ThreadListRow &row, + std::string_view id) { + if (row.id == id) + return &row; + for (const ui::ThreadListRow &child : row.children) { + if (const ui::ThreadListRow *found = findThread(child, id)) + return found; + } + return nullptr; +} + +const ui::ThreadListRow *findThread( + const std::vector &roots, std::string_view id) { + for (const ui::ThreadListRow &root : roots) { + if (const ui::ThreadListRow *found = findThread(root, id)) + return found; + } + return nullptr; +} + +const ui::ThreadListRow *rootForThread( + const std::vector &roots, std::string_view id) { + for (const ui::ThreadListRow &root : roots) { + if (findThread(root, id)) + return &root; + } + return nullptr; +} + +bool expandAncestors(const ui::ThreadListRow &row, std::string_view id, + std::unordered_set &expanded) { + if (row.id == id) + return true; + for (const ui::ThreadListRow &child : row.children) { + if (expandAncestors(child, id, expanded)) { + expanded.insert(row.id); + return true; + } + } + return false; +} + } // namespace ThreadPane::ThreadPane(QWidget *parent) : QFrame(parent) { @@ -490,22 +531,16 @@ bool ThreadPane::isOptimisticThread(const std::string &threadId) const { } void ThreadPane::promotePromptedThread(const std::string &threadId) { - if (!currentModel || threadId.empty()) + if (!currentSnapshot || threadId.empty()) return; - std::string rootId = threadId; - std::unordered_set visited; - while (visited.insert(rootId).second) { - const ChildThreadOwnership *ownership = currentModel->childOwnership(rootId); - if (!ownership) - break; - rootId = ownership->parentThreadId; - } - const ThreadPresentation *root = currentModel->thread(rootId); + const ui::ThreadListRow *root = + rootForThread(currentSnapshot->roots, threadId); if (!root) return; - promptPromotion = PromptPromotion{rootId, root->updatedAt, root->recencyAt}; + promptPromotion = + PromptPromotion{root->id, root->updatedAt, root->recencyAt}; visibleSnapshot.reset(); - refresh(*currentModel, projectedSelectedThreadId); + refresh(*currentSnapshot); } void ThreadPane::setSortCriterion(SortCriterion criterion) { @@ -514,8 +549,8 @@ void ThreadPane::setSortCriterion(SortCriterion criterion) { sortCriterion = criterion; updateSortButton(); visibleSnapshot.reset(); - if (currentModel) - refresh(*currentModel, projectedSelectedThreadId); + if (currentSnapshot) + refresh(*currentSnapshot); } ThreadPane::SortCriterion ThreadPane::currentSortCriterion() const noexcept { @@ -552,8 +587,7 @@ void ThreadPane::updateSortButton() { : QStringLiteral("Recent"))); } -void ThreadPane::sortRootThreads(std::vector &ids, - const PresentationModel &model) const { +void ThreadPane::sortRootThreads(std::vector &rows) const { QCollator collator(QLocale::system().language() == QLocale::C ? QLocale(QLocale::English) : QLocale::system()); @@ -563,27 +597,25 @@ void ThreadPane::sortRootThreads(std::vector &ids, std::string promotedRoot; if (promptPromotion && (sortCriterion == SortCriterion::LastChanged || sortCriterion == SortCriterion::Recency)) { - if (const ThreadPresentation *thread = - model.thread(promptPromotion->rootThreadId)) { + if (currentSnapshot) { + const ui::ThreadListRow *thread = + findThread(currentSnapshot->roots, promptPromotion->rootThreadId); const auto observed = sortCriterion == SortCriterion::LastChanged ? promptPromotion->updatedAt : promptPromotion->recencyAt; - if (timestampFor(*thread, sortCriterion) == observed) + if (thread && timestampFor(*thread, sortCriterion) == observed) promotedRoot = promptPromotion->rootThreadId; } } - std::sort(ids.begin(), ids.end(), - [&](const std::string &leftId, const std::string &rightId) { - if (leftId != rightId && - (leftId == promotedRoot || rightId == promotedRoot)) - return leftId == promotedRoot; - const ThreadPresentation *left = model.thread(leftId); - const ThreadPresentation *right = model.thread(rightId); - if (!left || !right) - return leftId < rightId; + std::sort(rows.begin(), rows.end(), + [&](const ui::ThreadListRow &left, + const ui::ThreadListRow &right) { + if (left.id != right.id && + (left.id == promotedRoot || right.id == promotedRoot)) + return left.id == promotedRoot; if (sortCriterion == SortCriterion::Alphanumeric) { - const QString leftTitle = text(left->title).trimmed(); - const QString rightTitle = text(right->title).trimmed(); + 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 = @@ -594,8 +626,8 @@ void ThreadPane::sortRootThreads(std::vector &ids, if (comparison != 0) return comparison < 0; } else { - const auto leftTimestamp = timestampFor(*left, sortCriterion); - const auto rightTimestamp = timestampFor(*right, sortCriterion); + const auto leftTimestamp = timestampFor(left, sortCriterion); + const auto rightTimestamp = timestampFor(right, sortCriterion); if (leftTimestamp != rightTimestamp) { if (!leftTimestamp) return false; @@ -604,34 +636,25 @@ void ThreadPane::sortRootThreads(std::vector &ids, return *leftTimestamp > *rightTimestamp; } } - return leftId < rightId; + return left.id < right.id; }); } void ThreadPane::appendVisibleThread( - ThreadPaneSnapshot &snapshot, const PresentationModel &model, - const std::unordered_map &pendingByThread, - const std::string &threadId, const std::string &parentId, std::size_t depth, + RenderedThreadList &snapshot, const ui::ThreadListRow &thread, + const std::string &parentId, std::size_t depth, std::unordered_set &visited) const { - if (!visited.insert(threadId).second) + if (!visited.insert(thread.id).second) return; - const ThreadPresentation *thread = model.thread(threadId); - if (!thread) - return; - const bool hasChildren = std::ranges::any_of( - thread->childThreadOrder, - [&model](const std::string &id) { return model.thread(id) != nullptr; }); - const bool expanded = hasChildren && expandedThreads.contains(threadId); - const auto pending = pendingByThread.find(threadId); + const bool hasChildren = !thread.children.empty(); + const bool expanded = hasChildren && expandedThreads.contains(thread.id); snapshot.rows.push_back( - {threadId, thread->title, thread->cwd, thread->status, parentId, - pending == pendingByThread.end() ? std::size_t{} : pending->second, - depth, hasChildren, expanded}); + {thread.id, thread.title, thread.cwd, thread.status, parentId, + thread.pending, depth, hasChildren, expanded}); if (!expanded) return; - for (const std::string &childThreadId : thread->childThreadOrder) - appendVisibleThread(snapshot, model, pendingByThread, childThreadId, - threadId, depth + 1, visited); + for (const ui::ThreadListRow &child : thread.children) + appendVisibleThread(snapshot, child, thread.id, depth + 1, visited); } void ThreadPane::toggleExpanded(const std::string &threadId) { @@ -640,8 +663,8 @@ void ThreadPane::toggleExpanded(const std::string &threadId) { else expandedThreads.insert(threadId); visibleSnapshot.reset(); - if (currentModel) - refresh(*currentModel, projectedSelectedThreadId); + if (currentSnapshot) + refresh(*currentSnapshot); } void ThreadPane::navigateHierarchy(int key) { @@ -684,41 +707,30 @@ void ThreadPane::setContextHighlight(const std::string &threadId, found->second->setData(ContextMenuRole, highlighted); } -void ThreadPane::refresh(const PresentationModel &model, - const std::string &selectedThreadId) { +void ThreadPane::refresh(const ui::ThreadListSnapshot &input) { + currentSnapshot = input; + const ui::ThreadListSnapshot &view = *currentSnapshot; + const std::string &selectedThreadId = view.selectedThreadId; const bool selectionChanged = selectedThreadId != projectedSelectedThreadId; - currentModel = &model; projectedSelectedThreadId = selectedThreadId; if (selectionChanged) { - std::unordered_set visited; - std::string descendantId = selectedThreadId; - while (!descendantId.empty() && visited.insert(descendantId).second) { - const ChildThreadOwnership *ownership = - model.childOwnership(descendantId); - if (!ownership) + for (const ui::ThreadListRow &root : view.roots) + if (expandAncestors(root, selectedThreadId, expandedThreads)) break; - expandedThreads.insert(ownership->parentThreadId); - descendantId = ownership->parentThreadId; - } } - std::erase_if(expandedThreads, [&model](const std::string &id) { - const ThreadPresentation *thread = model.thread(id); - return !thread || thread->childThreadOrder.empty(); + std::erase_if(expandedThreads, [&view](const std::string &id) { + const ui::ThreadListRow *thread = findThread(view.roots, id); + return !thread || thread->children.empty(); }); - std::vector rootOrder = model.threadOrder(); - sortRootThreads(rootOrder, model); - - std::unordered_map pendingByThread; - pendingByThread.reserve(model.pendingRequestCount()); - for (const auto &[requestId, request] : model.pendingRequestPresentations()) { - static_cast(requestId); - ++pendingByThread[request.threadId]; - } - ThreadPaneSnapshot next{selectedThreadId, sortCriterion, {}}; + std::vector rootRows = view.roots; + sortRootThreads(rootRows); + + RenderedThreadList next{selectedThreadId, sortCriterion, {}}; std::unordered_set visited; - visited.reserve(rootOrder.size()); + visited.reserve(rootRows.size()); for (const OptimisticThread &optimisticThread : optimisticThreads) { - if (const ThreadPresentation *thread = model.thread(optimisticThread.id)) { + if (const ui::ThreadListRow *thread = + findThread(view.roots, optimisticThread.id)) { next.rows.push_back({thread->id, thread->title, thread->cwd, @@ -745,12 +757,12 @@ void ThreadPane::refresh(const PresentationModel &model, } visited.insert(optimisticThread.id); } - for (const std::string &id : rootOrder) - appendVisibleThread(next, model, pendingByThread, id, {}, 0, visited); + for (const ui::ThreadListRow &row : rootRows) + appendVisibleThread(next, row, {}, 0, visited); if (visibleSnapshot && *visibleSnapshot == next) return; visibleSnapshot = std::move(next); - const ThreadPaneSnapshot &snapshot = *visibleSnapshot; + const RenderedThreadList &snapshot = *visibleSnapshot; list->blockSignals(true); list->setUpdatesEnabled(false); // Selection is a projection of selectedThreadId, never retained widget @@ -761,7 +773,7 @@ void ThreadPane::refresh(const PresentationModel &model, std::unordered_set wanted; wanted.reserve(snapshot.rows.size()); - for (const ThreadRowSnapshot &row : snapshot.rows) + for (const RenderedThreadRow &row : snapshot.rows) wanted.insert(row.id); for (int index = list->count() - 1; index >= 0; --index) { QListWidgetItem *item = list->item(index); @@ -775,7 +787,7 @@ void ThreadPane::refresh(const PresentationModel &model, std::unordered_map existingPositions; existingPositions.reserve(rows.size()); int existingIndex = 0; - for (const ThreadRowSnapshot &row : snapshot.rows) { + for (const RenderedThreadRow &row : snapshot.rows) { if (rows.contains(row.id)) existingPositions.emplace(row.id, existingIndex++); } @@ -794,7 +806,7 @@ void ThreadPane::refresh(const PresentationModel &model, moved.insert(id); } int wantedIndex = 0; - for (const ThreadRowSnapshot &row : snapshot.rows) { + for (const RenderedThreadRow &row : snapshot.rows) { auto found = rows.find(row.id); if (found == rows.end()) { auto *item = new QListWidgetItem; @@ -846,10 +858,10 @@ std::string ThreadPane::visiblySelectedThreadId() const { void ThreadPane::showContextMenu(const QPoint &position) { QListWidgetItem *item = list->itemAt(position); - if (!item || !currentModel) + if (!item || !currentSnapshot) return; const std::string id = item->data(Qt::UserRole).toString().toStdString(); - const ThreadPresentation *thread = currentModel->thread(id); + const ui::ThreadListRow *thread = findThread(currentSnapshot->roots, id); if (!thread) return; if (contextMenu) @@ -870,10 +882,8 @@ void ThreadPane::showContextMenu(const QPoint &position) { if (actions.reload) actions.reload(id); }); - const bool providerReady = currentModel->connection().connected && - currentModel->connection().providerState == "ready"; - const bool canControl = providerReady && - currentModel->connection().role == "controller"; + const bool providerReady = currentSnapshot->providerReady; + const bool canControl = currentSnapshot->canControl; QAction *rename = menu->addAction(QStringLiteral("Rename"), this, [this, id] { if (actions.rename) actions.rename(id); diff --git a/src/codex/middle/ThreadPane.h b/src/codex/middle/ThreadPane.h index c433def..9943d27 100644 --- a/src/codex/middle/ThreadPane.h +++ b/src/codex/middle/ThreadPane.h @@ -3,6 +3,8 @@ #ifndef CODEXUI_CODEX_MIDDLE_THREADPANE_H #define CODEXUI_CODEX_MIDDLE_THREADPANE_H +#include "codex/ui/UiViewState.h" + #include #include @@ -20,8 +22,6 @@ class QToolButton; class QTimer; namespace codexui::codex { -class PresentationModel; - namespace middle { class ThreadPane final : public QFrame { @@ -43,8 +43,7 @@ class ThreadPane final : public QFrame { explicit ThreadPane(QWidget *parent = nullptr); void setActions(Actions actions); - void refresh(const PresentationModel &model, - const std::string &selectedThreadId); + void refresh(const ui::ThreadListSnapshot &snapshot); void beginOptimisticThread(std::string id, std::string title, std::string cwd); void promoteOptimisticThread(const std::string &draftId, @@ -58,7 +57,7 @@ class ThreadPane final : public QFrame { [[nodiscard]] std::string visiblySelectedThreadId() const; private: - struct ThreadRowSnapshot { + struct RenderedThreadRow { std::string id; std::string title; std::string cwd; @@ -71,14 +70,14 @@ class ThreadPane final : public QFrame { bool optimistic = false; bool optimisticFailed = false; - bool operator==(const ThreadRowSnapshot &) const = default; + bool operator==(const RenderedThreadRow &) const = default; }; - struct ThreadPaneSnapshot { + struct RenderedThreadList { std::string selectedThreadId; SortCriterion sortCriterion = SortCriterion::Recency; - std::vector rows; + std::vector rows; - bool operator==(const ThreadPaneSnapshot &) const = default; + bool operator==(const RenderedThreadList &) const = default; }; struct OptimisticThread { std::string id; @@ -93,19 +92,17 @@ class ThreadPane final : public QFrame { }; void updateSortButton(); - void sortRootThreads(std::vector &ids, - const PresentationModel &model) const; - void appendVisibleThread( - ThreadPaneSnapshot &snapshot, const PresentationModel &model, - const std::unordered_map &pendingByThread, - const std::string &threadId, const std::string &parentId, - std::size_t depth, std::unordered_set &visited) const; + void sortRootThreads(std::vector &rows) const; + void appendVisibleThread(RenderedThreadList &snapshot, + const ui::ThreadListRow &thread, + const std::string &parentId, std::size_t depth, + std::unordered_set &visited) const; void toggleExpanded(const std::string &threadId); void navigateHierarchy(int key); void setContextHighlight(const std::string &threadId, bool highlighted); void showContextMenu(const QPoint &position); - const PresentationModel *currentModel = nullptr; + std::optional currentSnapshot; Actions actions; SortCriterion sortCriterion = SortCriterion::Recency; QToolButton *sortButton = nullptr; @@ -118,7 +115,7 @@ class ThreadPane final : public QFrame { QTimer *optimisticAnimation = nullptr; std::vector optimisticThreads; std::optional promptPromotion; - std::optional visibleSnapshot; + std::optional visibleSnapshot; }; } // namespace middle diff --git a/src/codex/ui/UiViewProjection.cpp b/src/codex/ui/UiViewProjection.cpp new file mode 100644 index 0000000..f48e812 --- /dev/null +++ b/src/codex/ui/UiViewProjection.cpp @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/ui/UiViewProjection.h" + +#include "codex/PresentationModel.h" +#include "codex/PresentationStatus.h" + +#include +#include +#include + +namespace codexui::codex::ui { +namespace { + +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{}; +} + +std::string effectivePlanStepStatus(const std::string &stepStatus, + const std::string &turnStatus, + const std::string &threadStatus) { + if (!isActiveStatus(stepStatus)) + return stepStatus; + StatusKind outcome = classifyStatus(turnStatus).kind; + if (outcome != StatusKind::Completed && outcome != StatusKind::Failed && + outcome != StatusKind::Interrupted) + outcome = classifyStatus(threadStatus).kind; + if (outcome == StatusKind::Completed) + return "completed"; + if (outcome == StatusKind::Failed) + return "failed"; + if (outcome == StatusKind::Interrupted) + return "interrupted"; + return stepStatus; +} + +std::optional projectThread( + const PresentationModel &model, const std::string &threadId, + const std::unordered_map &pendingByThread, + std::unordered_set &visited) { + if (!visited.insert(threadId).second) + return std::nullopt; + const ThreadPresentation *thread = model.thread(threadId); + if (!thread) + return std::nullopt; + + ThreadListRow row; + row.id = thread->id; + row.title = thread->title; + row.cwd = thread->cwd; + row.status = thread->status; + row.createdAt = thread->createdAt; + row.updatedAt = thread->updatedAt; + row.recencyAt = thread->recencyAt; + if (const auto pending = pendingByThread.find(threadId); + pending != pendingByThread.end()) + row.pending = pending->second; + row.archived = thread->archived; + row.children.reserve(thread->childThreadOrder.size()); + for (const std::string &childId : thread->childThreadOrder) { + if (auto child = projectThread(model, childId, pendingByThread, visited)) + row.children.push_back(std::move(*child)); + } + return row; +} + +InspectorPlanSnapshot projectPlan(const PresentationModel &model, + const std::string &threadId) { + InspectorPlanSnapshot result; + result.threadId = threadId; + const ThreadPresentation *thread = model.thread(threadId); + result.threadPresent = thread != nullptr; + if (!thread) + return result; + + for (auto id = thread->turnOrder.rbegin(); id != thread->turnOrder.rend(); + ++id) { + const auto turn = thread->turns.find(*id); + if (turn == thread->turns.end()) + continue; + if (turn->second.plan.is_object() && turn->second.plan.contains("steps")) { + InspectorPlan plan; + plan.explanation = stringValue(turn->second.plan, "explanation"); + for (const auto &step : + turn->second.plan.value("steps", nlohmann::json::array())) { + const std::string status = stringValue(step, "status"); + plan.steps.push_back( + {stringValue(step, "step"), + effectivePlanStepStatus(status, turn->second.status, + thread->status)}); + } + result.plan = std::move(plan); + 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") { + result.planItem = stringValue(item->second.raw, "text"); + break; + } + } + if (result.planItem) + break; + } + return result; +} + +InspectorAgentsSnapshot projectAgents(const PresentationModel &model, + const std::string &threadId) { + InspectorAgentsSnapshot result; + result.threadId = threadId; + const ThreadPresentation *thread = model.thread(threadId); + result.threadPresent = thread != nullptr; + if (!thread) + return result; + + result.agents.reserve(thread->agentOrder.size()); + for (const std::string &id : thread->agentOrder) { + const auto agent = thread->agents.find(id); + if (agent == thread->agents.end()) + continue; + InspectorAgentRow row; + row.id = id; + row.status = agent->second.status; + row.childThreadId = agent->second.childThreadId; + row.agentPath = stringValue(agent->second.raw, "agentPath"); + row.tool = stringValue(agent->second.raw, "tool"); + row.model = stringValue(agent->second.raw, "model"); + row.reasoningEffort = stringValue(agent->second.raw, "reasoningEffort"); + row.prompt = stringValue(agent->second.raw, "prompt"); + row.resultText = stringValue(agent->second.raw, "resultText"); + row.senderThreadId = stringValue(agent->second.raw, "senderThreadId"); + const auto receivers = agent->second.raw.find("receiverThreadIds"); + if (receivers != agent->second.raw.end() && receivers->is_array()) { + for (const auto &receiver : *receivers) { + if (receiver.is_string()) + row.receiverThreadIds.push_back(receiver.get()); + } + } + result.agents.push_back(std::move(row)); + } + return result; +} + +InspectorRequestsSnapshot +projectRequests(const PresentationModel &model, + const std::function &requestEligible) { + InspectorRequestsSnapshot result; + result.requests.reserve(model.pendingRequestCount()); + for (const auto &[id, request] : model.pendingRequestPresentations()) { + InspectorRequestRow row; + row.id = id; + row.kind = request.kind; + row.threadContext = request.threadId; + if (const ThreadPresentation *thread = model.thread(request.threadId); + thread && !thread->title.empty()) + row.threadContext = thread->title; + row.generation = request.generation; + row.command = stringValue(request.raw, "command"); + row.reason = stringValue(request.raw, "reason"); + row.message = stringValue(request.raw, "message"); + const auto questions = request.raw.find("questions"); + if (questions != request.raw.end() && questions->is_array()) + row.questionCount = questions->size(); + row.actionable = requestEligible && requestEligible(id); + result.requests.push_back(std::move(row)); + } + return result; +} + +InspectorChangesSnapshot projectChanges(const PresentationModel &model, + const std::string &threadId) { + InspectorChangesSnapshot result; + result.threadId = threadId; + if (const ThreadPresentation *thread = model.thread(threadId)) { + result.cwd = thread->cwd; + result.commandCwds = thread->commandCwds; + result.changedPaths = thread->changedPaths; + } + return result; +} + +InspectorStateSnapshot projectState(const PresentationModel &model, + const std::string &threadId) { + InspectorStateSnapshot result; + 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}}; + result.state = {{"models", model.modelCatalog()}, + {"pendingRequests", std::move(pending)}, + {"domains", std::move(domains)}}; + result.threadCount = model.threadOrder().size(); + result.modelCount = model.modelCatalog().size(); + result.pendingRequestCount = model.pendingRequestCount(); + result.telemetryCount = model.telemetry().size(); + if (const ThreadPresentation *thread = model.thread(threadId)) { + result.selectedThreadTurnCount = thread->turnOrder.size(); + for (const auto &[id, turn] : thread->turns) { + static_cast(id); + result.selectedThreadItemCount += turn.itemOrder.size(); + } + } + return result; +} + +} // namespace + +ThreadListSnapshot projectThreadListSnapshot(const PresentationModel &model, + std::string selectedThreadId) { + ThreadListSnapshot result; + result.selectedThreadId = std::move(selectedThreadId); + const ConnectionPresentation &connection = model.connection(); + result.providerReady = + connection.connected && connection.providerState == "ready"; + result.canControl = result.providerReady && connection.role == "controller"; + + std::unordered_map pendingByThread; + pendingByThread.reserve(model.pendingRequestCount()); + for (const auto &[id, request] : model.pendingRequestPresentations()) { + static_cast(id); + ++pendingByThread[request.threadId]; + } + + std::unordered_set visited; + visited.reserve(model.threadOrder().size()); + result.roots.reserve(model.threadOrder().size()); + for (const std::string &id : model.threadOrder()) { + if (auto row = projectThread(model, id, pendingByThread, visited)) + result.roots.push_back(std::move(*row)); + } + return result; +} + +InspectorSnapshot projectInspectorSnapshot( + const PresentationModel &model, std::string selectedThreadId, + const std::function &requestEligible) { + InspectorSnapshot result; + result.plan = projectPlan(model, selectedThreadId); + result.agents = projectAgents(model, selectedThreadId); + result.changes = projectChanges(model, selectedThreadId); + result.requests = projectRequests(model, requestEligible); + result.state = projectState(model, selectedThreadId); + return result; +} + +} // namespace codexui::codex::ui diff --git a/src/codex/ui/UiViewProjection.h b/src/codex/ui/UiViewProjection.h new file mode 100644 index 0000000..5b4a584 --- /dev/null +++ b/src/codex/ui/UiViewProjection.h @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_UI_UIVIEWPROJECTION_H +#define CODEXUI_CODEX_UI_UIVIEWPROJECTION_H + +#include "codex/ui/UiViewState.h" + +#include +#include +#include + +namespace codexui::codex { + +class PresentationModel; + +namespace ui { + +[[nodiscard]] ThreadListSnapshot +projectThreadListSnapshot(const PresentationModel &model, + std::string selectedThreadId); + +[[nodiscard]] InspectorSnapshot projectInspectorSnapshot( + const PresentationModel &model, std::string selectedThreadId, + const std::function &requestEligible = {}); + +} // namespace ui +} // namespace codexui::codex + +#endif // CODEXUI_CODEX_UI_UIVIEWPROJECTION_H diff --git a/src/codex/ui/UiViewState.h b/src/codex/ui/UiViewState.h new file mode 100644 index 0000000..cae0380 --- /dev/null +++ b/src/codex/ui/UiViewState.h @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_UI_UIVIEWSTATE_H +#define CODEXUI_CODEX_UI_UIVIEWSTATE_H + +#include + +#include +#include +#include +#include +#include + +namespace codexui::codex::ui { + +// Toolkit-neutral inputs for the concrete thread-list renderer. Expansion, +// sorting, and optimistic rows deliberately remain local to that renderer. +struct ThreadListRow { + std::string id; + std::string title; + std::string cwd; + std::string status; + std::optional createdAt; + std::optional updatedAt; + std::optional recencyAt; + std::size_t pending = 0; + bool archived = false; + std::vector children; + + bool operator==(const ThreadListRow &) const = default; +}; + +struct ThreadListSnapshot { + std::string selectedThreadId; + bool providerReady = false; + bool canControl = false; + std::vector roots; + + bool operator==(const ThreadListSnapshot &) const = default; +}; + +struct InspectorPlanStep { + std::string step; + std::string status; + + bool operator==(const InspectorPlanStep &) const = default; +}; + +struct InspectorPlan { + std::string explanation; + std::vector steps; + + bool operator==(const InspectorPlan &) const = default; +}; + +struct InspectorPlanSnapshot { + std::string threadId; + bool threadPresent = false; + std::optional plan; + std::optional planItem; + + bool operator==(const InspectorPlanSnapshot &) const = default; +}; + +struct InspectorAgentRow { + std::string id; + std::string status; + std::string childThreadId; + std::string agentPath; + std::string tool; + std::string model; + std::string reasoningEffort; + std::string prompt; + std::string resultText; + std::string senderThreadId; + std::vector receiverThreadIds; + + bool operator==(const InspectorAgentRow &) const = default; +}; + +struct InspectorAgentsSnapshot { + std::string threadId; + bool threadPresent = false; + std::vector agents; + + bool operator==(const InspectorAgentsSnapshot &) const = default; +}; + +struct InspectorRequestRow { + std::string id; + std::string kind; + std::string threadContext; + std::uint64_t generation = 0; + std::string command; + std::string reason; + std::string message; + std::optional questionCount; + bool actionable = false; + + bool operator==(const InspectorRequestRow &) const = default; +}; + +struct InspectorRequestsSnapshot { + std::vector requests; + + bool operator==(const InspectorRequestsSnapshot &) const = default; +}; + +struct InspectorChangesSnapshot { + std::string threadId; + std::string cwd; + std::vector commandCwds; + std::vector changedPaths; + + bool operator==(const InspectorChangesSnapshot &) const = default; +}; + +struct InspectorStateSnapshot { + nlohmann::json state = nlohmann::json::object(); + std::size_t threadCount = 0; + std::size_t modelCount = 0; + std::size_t selectedThreadTurnCount = 0; + std::size_t selectedThreadItemCount = 0; + std::size_t pendingRequestCount = 0; + std::size_t telemetryCount = 0; + + bool operator==(const InspectorStateSnapshot &) const = default; +}; + +struct InspectorSnapshot { + InspectorPlanSnapshot plan; + InspectorAgentsSnapshot agents; + InspectorChangesSnapshot changes; + InspectorRequestsSnapshot requests; + InspectorStateSnapshot state; + + bool operator==(const InspectorSnapshot &) const = default; +}; + +} // namespace codexui::codex::ui + +#endif // CODEXUI_CODEX_UI_UIVIEWSTATE_H diff --git a/tests/codex/ApplicationLayoutTest.cpp b/tests/codex/ApplicationLayoutTest.cpp index 2e97bbe..214de5b 100644 --- a/tests/codex/ApplicationLayoutTest.cpp +++ b/tests/codex/ApplicationLayoutTest.cpp @@ -12,11 +12,12 @@ #include "codex/middle/ThreadPane.h" #include "codex/ui/ExpandingPromptEditor.h" #include "codex/ui/UiStyle.h" +#include "codex/ui/UiViewProjection.h" #include -#include -#include #include +#include +#include #include #include #include @@ -39,9 +40,8 @@ #include #include #include -#include -#include #include +#include #include #include @@ -75,6 +75,20 @@ bool expect(bool condition, const char *message) { return false; } +std::string utf8(const QString &value) { return value.toUtf8().toStdString(); } + +void refresh(ThreadPane &pane, const PresentationModel &model, + std::string selectedThreadId) { + pane.refresh( + ui::projectThreadListSnapshot(model, std::move(selectedThreadId))); +} + +void refresh(InspectorPane &pane, const PresentationModel &model, + std::string selectedThreadId) { + pane.refresh( + ui::projectInspectorSnapshot(model, std::move(selectedThreadId))); +} + void sendPromptKey(codexui::ExpandingPromptEditor &editor, int key, Qt::KeyboardModifiers modifiers = Qt::NoModifier, bool autoRepeat = false) { @@ -90,8 +104,7 @@ bool testPromptKeyboardSubmission() { QCoreApplication::processEvents(); int submissions = 0; - QObject::connect(&editor, - &codexui::ExpandingPromptEditor::submitRequested, + QObject::connect(&editor, &codexui::ExpandingPromptEditor::submitRequested, [&submissions] { ++submissions; }); const auto resetDraft = [&editor] { editor.setPlainText(QStringLiteral("draft")); @@ -106,8 +119,8 @@ bool testPromptKeyboardSubmission() { resetDraft(); sendPromptKey(editor, Qt::Key_Return); - result &= expect(submissions == 1, - "Return submits the focused prompt editor"); + result &= + expect(submissions == 1, "Return submits the focused prompt editor"); resetDraft(); sendPromptKey(editor, Qt::Key_Enter, Qt::KeypadModifier); result &= expect(submissions == 2, @@ -118,8 +131,7 @@ bool testPromptKeyboardSubmission() { "Control+Enter remains a prompt submission alias"); resetDraft(); sendPromptKey(editor, Qt::Key_Return, Qt::MetaModifier); - result &= expect(submissions == 4, - "Meta+Enter is a prompt submission alias"); + result &= expect(submissions == 4, "Meta+Enter is a prompt submission alias"); resetDraft(); sendPromptKey(editor, Qt::Key_Return, Qt::ShiftModifier); @@ -161,8 +173,8 @@ 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; + 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); @@ -229,9 +241,9 @@ VisibleCardData textCard(const std::string &thread, int index) { turn, item, AgentMessageData{ - QStringLiteral("A materialized response line %1 with enough " + utf8(QStringLiteral("A materialized response line %1 with enough " "content to occupy normal card height.") - .arg(index), + .arg(index)), false}}; } @@ -249,8 +261,8 @@ QWheelEvent wheelFor(QWidget *target, int pixelDelta, Qt::ScrollPhase phase = Qt::ScrollUpdate) { const QPointF local(target->rect().center()); return QWheelEvent(local, target->mapToGlobal(local.toPoint()), QPoint(), - QPoint(0, pixelDelta), Qt::NoButton, Qt::NoModifier, - phase, false); + QPoint(0, pixelDelta), Qt::NoButton, Qt::NoModifier, phase, + false); } std::vector threadOrder(const ThreadPane &pane) { @@ -285,6 +297,9 @@ bool testOverlayGeometryAndRegionRouting() { "composer construction reports no pre-canonical trailing space"); region.resize(1500, 820); region.show(); + region.setThreadHeading(QStringLiteral("Thread title"), + QStringLiteral("/workspace | Completed"), + QStringLiteral("Last activity: 14:15:51")); spin(20); QSplitter *splitter = region.splitterWidget(); @@ -297,36 +312,30 @@ bool testOverlayGeometryAndRegionRouting() { splitter->widget(2)->maximumWidth() == 520, "pane width constraints match the visual contract"); - auto *threadHeaderDivider = - splitter->widget(0)->findChild( + auto *threadHeaderDivider = splitter->widget(0)->findChild( QStringLiteral("threadHeaderDivider")); - auto *conversationHeaderDivider = - splitter->widget(1)->findChild( + auto *conversationHeaderDivider = splitter->widget(1)->findChild( QStringLiteral("conversationHeaderDivider")); - auto *conversationTitle = - splitter->widget(1)->findChild( + auto *conversationTitle = splitter->widget(1)->findChild( QStringLiteral("conversationTitle")); - auto *conversationMetadata = - splitter->widget(1)->findChild( + auto *conversationMetadata = splitter->widget(1)->findChild( QStringLiteral("conversationMetadata")); - auto *reasoningToggle = - splitter->widget(1)->findChild( + auto *conversationTrailingMetadata = + splitter->widget(1)->findChild( + QStringLiteral("conversationTrailingMetadata")); + auto *reasoningToggle = splitter->widget(1)->findChild( QStringLiteral("conversationReasoningToggle")); - auto *updatesToggle = - splitter->widget(1)->findChild( + auto *updatesToggle = splitter->widget(1)->findChild( QStringLiteral("conversationUpdatesToggle")); - auto *commandFoldingToggle = - splitter->widget(1)->findChild( + auto *commandFoldingToggle = splitter->widget(1)->findChild( QStringLiteral("conversationCommandFoldingToggle")); - auto *imageFoldingToggle = - splitter->widget(1)->findChild( + auto *imageFoldingToggle = splitter->widget(1)->findChild( QStringLiteral("conversationImageFoldingToggle")); const auto paneRect = [](QWidget *widget, QWidget *pane) { return QRect(widget->mapTo(pane, QPoint()), widget->size()); }; const QRect threadDividerRect = - threadHeaderDivider - ? paneRect(threadHeaderDivider, splitter->widget(0)) + threadHeaderDivider ? paneRect(threadHeaderDivider, splitter->widget(0)) : QRect{}; const QRect conversationDividerRect = conversationHeaderDivider @@ -337,21 +346,33 @@ bool testOverlayGeometryAndRegionRouting() { threadDividerRect.left() == 10 && threadDividerRect.right() == splitter->widget(0)->width() - 11 && conversationDividerRect.left() == 10 && - conversationDividerRect.right() == - splitter->widget(1)->width() - 11, + conversationDividerRect.right() == splitter->widget(1)->width() - 11, "Threads and Conversation header dividers share the 10 px inset"); - result &= expect( - conversationTitle && conversationMetadata && - conversationMetadata->geometry().left() > - conversationTitle->geometry().right() && - std::abs(conversationMetadata->geometry().bottom() - - conversationTitle->geometry().bottom()) <= 1, - "thread title and metadata form one baseline-aligned lockup"); + result &= + expect(conversationTitle && conversationMetadata && + conversationTrailingMetadata && + conversationMetadata->geometry().left() > + conversationTitle->geometry().right() && + conversationTrailingMetadata->geometry().right() >= + conversationTrailingMetadata->parentWidget()->width() - + 16 && + conversationTrailingMetadata->text() == + QStringLiteral("Last activity: 14:15:51") && + conversationTrailingMetadata->width() >= + conversationTrailingMetadata->fontMetrics() + .horizontalAdvance( + conversationTrailingMetadata->text()) && + std::abs((conversationMetadata->geometry().top() + + conversationMetadata->contentsMargins().top() + + conversationMetadata->fontMetrics().ascent()) - + (conversationTitle->geometry().top() + + conversationTitle->contentsMargins().top() + + conversationTitle->fontMetrics().ascent())) <= 1, + "thread title metadata align by baseline and activity aligns right"); result &= expect( reasoningToggle && updatesToggle && commandFoldingToggle && - imageFoldingToggle && - !reasoningToggle->isChecked() && updatesToggle->isChecked() && - commandFoldingToggle->isChecked() && + imageFoldingToggle && !reasoningToggle->isChecked() && + updatesToggle->isChecked() && commandFoldingToggle->isChecked() && imageFoldingToggle->isChecked() && reasoningToggle->text().isEmpty() && updatesToggle->text().isEmpty() && @@ -377,12 +398,11 @@ bool testOverlayGeometryAndRegionRouting() { QStringLiteral("Hide reasoning cards") && commandFoldingToggle->accessibleName() == QStringLiteral("New command cards start collapsed") && - persisted - .value(QStringLiteral("conversation/showReasoning"), false) + persisted.value(QStringLiteral("conversation/showReasoning"), false) .toBool() && !persisted - .value(QStringLiteral( - "conversation/commandsInitiallyExpanded"), + .value( + QStringLiteral("conversation/commandsInitiallyExpanded"), true) .toBool(), "Conversation presentation controls update the view and persistent " @@ -421,7 +441,9 @@ bool testOverlayGeometryAndRegionRouting() { : -1; }; const auto settingsToEditorGap = [&] { - return region.composer().promptEditor()->mapTo(®ion.composer(), QPoint()) + return region.composer() + .promptEditor() + ->mapTo(®ion.composer(), QPoint()) .y() - overlayRect(settings).bottom() - 1; }; @@ -495,25 +517,23 @@ bool testOverlayGeometryAndRegionRouting() { view.verticalScrollBar()->setValue(view.verticalScrollBar()->maximum()); spin(10); result &= expect( - stableComposerGeometry() && - settingsToEditorGap() == compactEditorGap && + stableComposerGeometry() && settingsToEditorGap() == compactEditorGap && view.viewport()->height() - finalCardBottom() == extra && view.geometry() == viewGeometry && view.viewport()->geometry() == viewportGeometry && region.composer().canonicalReserve()->height() == canonical, "prompt growth keeps gaps fixed without shifting the message viewport"); region.composer().setAttachments( - {{QStringLiteral("/tmp/layout-diagnostic.png"), - QStringLiteral("layout-diagnostic.png"), QStringLiteral("image/png")}}); + {{"/tmp/layout-diagnostic.png", "layout-diagnostic.png", "image/png"}}); spin(30); view.verticalScrollBar()->setValue(view.verticalScrollBar()->maximum()); spin(10); - result &= expect( - stableComposerGeometry() && + result &= expect(stableComposerGeometry() && region.composer().extraOverlayHeight() > extra && view.viewport()->height() - finalCardBottom() == region.composer().extraOverlayHeight() && - view.trailingSpaceHeight() == region.composer().extraOverlayHeight(), + view.trailingSpaceHeight() == + region.composer().extraOverlayHeight(), "attachments retain the canonical settings-to-composer gap"); region.composer().clearDraft(); spin(30); @@ -524,8 +544,7 @@ bool testOverlayGeometryAndRegionRouting() { view.trailingSpaceHeight() == 0 && view.geometry() == viewGeometry && view.viewport()->geometry() == viewportGeometry && finalCardBottom() == view.viewport()->height() && - stableComposerGeometry() && - settingsToEditorGap() == compactEditorGap, + stableComposerGeometry() && settingsToEditorGap() == compactEditorGap, "prompt contraction restores canonical layout, gaps, and trailing space"); region.composer().setActiveTurn(false); spin(20); @@ -593,8 +612,8 @@ bool testStableComposerLayoutRequests() { LayoutRequestCounter composerLayoutRequests; region.composer().installEventFilter(&composerLayoutRequests); spin(80); - result = expect( - composerLayoutRequests.count <= 1, + result = + expect(composerLayoutRequests.count <= 1, "stable composer geometry does not perpetually request layout"); } qApp->setStyleSheet(QString{}); @@ -608,16 +627,15 @@ bool testThreadSelectionProjection() { presentation::Authority::Merge, {{"threadId", "thread-a"}})); model.applyEvent(presentation::event( 2, 1, "thread.upsert", - {{"thread", {{"id", "thread-b"}, - {"name", "B"}, - {"status", {{"type", "active"}}}}}}, + {{"thread", + {{"id", "thread-b"}, {"name", "B"}, {"status", {{"type", "active"}}}}}}, presentation::Authority::Merge, {{"threadId", "thread-b"}})); ThreadPane pane; - pane.refresh(model, "thread-a"); + refresh(pane, model, "thread-a"); bool result = expect(pane.visiblySelectedThreadId() == "thread-a", "thread selection is projected from Shell state"); - pane.refresh(model, "draft:new-thread"); + refresh(pane, model, "draft:new-thread"); result &= expect(pane.visiblySelectedThreadId().empty(), "a New Thread draft cannot retain an old visible row"); @@ -631,11 +649,11 @@ bool testThreadSelectionProjection() { {{"threadId", "thread-a"}, {"turnId", "turn-a"}, {"itemId", "thread-b"}})); - pane.refresh(model, "thread-b"); + refresh(pane, model, "thread-b"); auto *list = pane.findChild(QStringLiteral("threadList")); QListWidgetItem *selected = list ? list->currentItem() : nullptr; - result &= expect( - selected && + result &= + expect(selected && selected->data(Qt::UserRole).toString() == QStringLiteral("thread-b") && pane.visiblySelectedThreadId() == "thread-b", @@ -646,8 +664,7 @@ bool testThreadSelectionProjection() { row ? row->findChild(QStringLiteral("threadTitle")) : nullptr; auto *status = row ? row->findChild(QStringLiteral("threadStatus")) : nullptr; - auto *dot = - row ? row->findChild(QStringLiteral("threadStatusDot")) + auto *dot = row ? row->findChild(QStringLiteral("threadStatusDot")) : nullptr; auto *rowLayout = row ? qobject_cast(row->layout()) : nullptr; auto *sortButton = @@ -655,19 +672,18 @@ bool testThreadSelectionProjection() { QListWidgetItem *parentItem = threadItem(list, "thread-a"); QWidget *parentRow = list && parentItem ? list->itemWidget(parentItem) : nullptr; - QWidget *disclosure = - parentRow ? parentRow->findChild( + QWidget *disclosure = parentRow + ? parentRow->findChild( QStringLiteral("threadExpansionIndicator")) : nullptr; auto *parentDot = - parentRow ? parentRow->findChild( - QStringLiteral("threadStatusDot")) + parentRow + ? parentRow->findChild(QStringLiteral("threadStatusDot")) : nullptr; const QString selectedAccessible = selected ? selected->data(Qt::AccessibleTextRole).toString() : QString{}; - const QString parentAccessible = parentItem - ? parentItem->data(Qt::AccessibleTextRole) - .toString() + const QString parentAccessible = + parentItem ? parentItem->data(Qt::AccessibleTextRole).toString() : QString{}; result &= expect( selected && selected->sizeHint().height() == 54 && rowLayout && @@ -695,7 +711,7 @@ bool testThreadSelectionProjection() { status->textInteractionFlags().testFlag(Qt::TextSelectableByMouse), "thread cards keep their status dot and canonical disclosure styling " "inside the UI contract"); - pane.refresh(model, "thread-a"); + refresh(pane, model, "thread-a"); bool childPresent = false; if (list) { for (int index = 0; index < list->count(); ++index) { @@ -709,7 +725,7 @@ bool testThreadSelectionProjection() { model.applyEvent(presentation::event( 4, 1, "thread.removed", nlohmann::json::object(), presentation::Authority::Remove, {{"threadId", "thread-b"}})); - pane.refresh(model, "thread-a"); + refresh(pane, model, "thread-a"); bool retainedAfterRemoval = false; if (list) { for (int index = 0; index < list->count(); ++index) { @@ -729,8 +745,8 @@ bool testIncrementalThreadSettings() { nlohmann::json::array({{{"model", "gpt-a"}, {"displayName", "A"}}, {{"model", "gpt-b"}, {"displayName", "B"}}}); settings.setContext("thread-a", - {{"model", "gpt-a"}, {"approvalPolicy", "never"}}, - models, nlohmann::json::array()); + {{"model", "gpt-a"}, {"approvalPolicy", "never"}}, models, + nlohmann::json::array()); auto *model = settings.findChild(QStringLiteral("codexModel")); auto *approval = settings.findChild(QStringLiteral("codexApproval")); @@ -740,8 +756,8 @@ bool testIncrementalThreadSettings() { settings.findChild(QStringLiteral("codexSandbox")); auto *network = settings.findChild(QStringLiteral("codexNetwork")); - auto *permissionProfile = settings.findChild( - QStringLiteral("codexPermissionProfile")); + auto *permissionProfile = + settings.findChild(QStringLiteral("codexPermissionProfile")); if (!model || !approval || !personality || !access || !network || !permissionProfile) return expect(false, "thread settings controls are discoverable"); @@ -755,9 +771,8 @@ bool testIncrementalThreadSettings() { model->setCurrentIndex(model->findData(QStringLiteral("gpt-b"))); settings.setContext( - "thread-a", - {{"model", "gpt-a"}, {"approvalPolicy", "on-request"}}, models, - nlohmann::json::array(), 1, {{"approvalPolicy", "on-request"}}); + "thread-a", {{"model", "gpt-a"}, {"approvalPolicy", "on-request"}}, + models, nlohmann::json::array(), 1, {{"approvalPolicy", "on-request"}}); bool result = expect(canonicalSettingsStyle, "thread settings use canonical application styling"); result &= expect( @@ -765,17 +780,15 @@ bool testIncrementalThreadSettings() { approval->currentData().toString() == QStringLiteral("on-request"), "a partial authoritative update preserves unrelated pending settings"); - settings.setContext( - "thread-a", - {{"model", "gpt-b"}, {"approvalPolicy", "on-request"}}, models, - nlohmann::json::array(), 2, + settings.setContext("thread-a", + {{"model", "gpt-b"}, {"approvalPolicy", "on-request"}}, + models, nlohmann::json::array(), 2, {{"model", "gpt-b"}, {"approvalPolicy", "on-request"}}); result &= expect(!settings.turnStartOptions().contains("model") && !settings.turnStartOptions().contains("approvalPolicy"), "authoritative settings clear their pending overrides"); - settings.setContext( - "thread-b", + settings.setContext("thread-b", {{"model", "gpt-a"}, {"reasoningEffort", "medium"}, {"personality", "friendly"}, @@ -791,10 +804,9 @@ bool testIncrementalThreadSettings() { models, nlohmann::json::array()); result &= expect(model->currentData().toString() == QStringLiteral("gpt-a"), "thread selection restores that thread's retained value"); - result &= expect( - settings.turnStartOptions() == - nlohmann::json{ - {"collaborationMode", + result &= + expect(settings.turnStartOptions() == + nlohmann::json{{"collaborationMode", {{"mode", "default"}, {"settings", {{"model", "gpt-a"}, @@ -806,16 +818,15 @@ bool testIncrementalThreadSettings() { "a permission preset does not lock its effective access " "controls"); - settings.setContext( - "full-access-thread", + settings.setContext("full-access-thread", {{"sandboxPolicy", {{"type", "dangerFullAccess"}}}, {"activePermissionProfile", {{"id", ":full-access"}}}}, nlohmann::json::array(), - {{"data", nlohmann::json::array( - {{{"id", ":full-access"}, {"allowed", true}}})}}); - result &= expect(access->isEnabled() && !network->isEnabled() && - network->currentData().toString() == - QStringLiteral("enabled"), + {{"data", nlohmann::json::array({{{"id", ":full-access"}, + {"allowed", true}}})}}); + result &= + expect(access->isEnabled() && !network->isEnabled() && + network->currentData().toString() == QStringLiteral("enabled"), "only logically redundant network selection is disabled"); settings.setContext( @@ -829,12 +840,10 @@ bool testIncrementalThreadSettings() { {{"id", ":read-only"}, {"allowed", true}}, {{"id", ":danger-full-access"}, {"allowed", true}}})}}); result &= expect( - permissionProfile->itemText( - permissionProfile->findData(QStringLiteral(":workspace"))) == - QStringLiteral("Workspace") && - permissionProfile->itemText( - permissionProfile->findData(QStringLiteral(":read-only"))) == - QStringLiteral("Read only") && + permissionProfile->itemText(permissionProfile->findData( + QStringLiteral(":workspace"))) == QStringLiteral("Workspace") && + permissionProfile->itemText(permissionProfile->findData( + QStringLiteral(":read-only"))) == QStringLiteral("Read only") && permissionProfile->itemText(permissionProfile->findData( QStringLiteral(":danger-full-access"))) == QStringLiteral("Full access"), @@ -855,8 +864,7 @@ bool testIncrementalThreadSettings() { nlohmann::json("danger-full-access"), "an explicit access choice replaces the active permission profile"); - settings.setContext( - "individual-overrides-thread", + settings.setContext("individual-overrides-thread", {{"model", "gpt-a"}, {"approvalPolicy", "never"}, {"personality", "friendly"}, @@ -864,11 +872,10 @@ bool testIncrementalThreadSettings() { {{"type", "workspaceWrite"}, {"networkAccess", false}}}, {"activePermissionProfile", {{"id", ":workspace"}}}}, models, - {{"data", nlohmann::json::array( - {{{"id", ":workspace"}, {"allowed", true}}})}}); + {{"data", nlohmann::json::array({{{"id", ":workspace"}, + {"allowed", true}}})}}); model->setCurrentIndex(model->findData(QStringLiteral("gpt-b"))); - approval->setCurrentIndex( - approval->findData(QStringLiteral("on-request"))); + approval->setCurrentIndex(approval->findData(QStringLiteral("on-request"))); personality->setCurrentIndex( personality->findData(QStringLiteral("pragmatic"))); const nlohmann::json individualOverrides = settings.turnStartOptions(); @@ -894,16 +901,15 @@ bool testThreadHierarchyExpansionAndNavigation() { nlohmann::json::array({{{"id", "root-z"}, {"name", "Z root"}}, {{"id", "root-a"}, {"name", "A root"}}})}}, presentation::Authority::Merge)); - const auto addChild = [&model](std::uint64_t sequence, - const std::string &parent, - const std::string &child, - const std::string &title) { + const auto addChild = + [&model](std::uint64_t sequence, const std::string &parent, + const std::string &child, const std::string &title) { model.applyEvent(presentation::event( sequence, 1, "thread.upsert", {{"thread", {{"id", child}, {"name", title}}}}, presentation::Authority::Merge, {{"threadId", child}})); - model.applyEvent(presentation::event( - sequence + 1, 1, "agents.activity.upsert", + model.applyEvent(presentation::event(sequence + 1, 1, + "agents.activity.upsert", {{"activity", {{"id", "spawn-" + child}, {"type", "subAgentActivity"}, @@ -933,12 +939,12 @@ bool testThreadHierarchyExpansionAndNavigation() { actions.select = [&](const std::string &id) { selectedThread = id; ++selections; - pane.refresh(model, selectedThread); + refresh(pane, model, selectedThread); }; pane.setActions(std::move(actions)); pane.resize(340, 620); pane.show(); - pane.refresh(model, selectedThread); + refresh(pane, model, selectedThread); spin(20); auto *list = pane.findChild(QStringLiteral("threadList")); @@ -949,8 +955,8 @@ bool testThreadHierarchyExpansionAndNavigation() { QStringLiteral("threadExpansionIndicator")) : nullptr; bool result = expect( - list && threadOrder(pane) == - std::vector{"root-a", "root-z"} && + list && + threadOrder(pane) == std::vector{"root-a", "root-z"} && rootA && rootA->data(Qt::UserRole + 2).toInt() == 0 && rootDisclosure && rootDisclosure->size() == QSize(16, 24) && rootDisclosure->property("chevronDirection").toString() == @@ -963,8 +969,7 @@ bool testThreadHierarchyExpansionAndNavigation() { const auto clickExpansion = [list](QListWidgetItem *item) { QWidget *row = item ? list->itemWidget(item) : nullptr; - QWidget *indicator = - row ? row->findChild( + QWidget *indicator = row ? row->findChild( QStringLiteral("threadExpansionIndicator")) : nullptr; const QPoint position = @@ -983,14 +988,12 @@ bool testThreadHierarchyExpansionAndNavigation() { QListWidgetItem *childA = threadItem(list, "child-a"); rootA = threadItem(list, "root-a"); rootRow = rootA ? list->itemWidget(rootA) : nullptr; - rootDisclosure = - rootRow ? rootRow->findChild( + rootDisclosure = rootRow ? rootRow->findChild( QStringLiteral("threadExpansionIndicator")) : nullptr; result &= expect( - threadOrder(pane) == - std::vector{"root-a", "child-z", "child-a", - "root-z"} && + threadOrder(pane) == std::vector{"root-a", "child-z", + "child-a", "root-z"} && childZ && childZ->data(Qt::UserRole + 2).toInt() == 1 && childA && rootDisclosure && rootDisclosure->property("chevronDirection").toString() == @@ -1002,19 +1005,19 @@ bool testThreadHierarchyExpansionAndNavigation() { return false; selectedThread = "grandchild"; - pane.refresh(model, selectedThread); + refresh(pane, model, selectedThread); spin(); QListWidgetItem *grandchild = threadItem(list, "grandchild"); QWidget *grandchildRow = list && grandchild ? list->itemWidget(grandchild) : nullptr; - QLabel *grandchildTitle = grandchildRow - ? grandchildRow->findChild( - QStringLiteral("threadTitle")) + QLabel *grandchildTitle = + grandchildRow + ? grandchildRow->findChild(QStringLiteral("threadTitle")) : nullptr; result &= expect( - threadOrder(pane) == - std::vector{"root-a", "child-z", "grandchild", - "child-a", "root-z"} && + threadOrder(pane) == std::vector{"root-a", "child-z", + "grandchild", "child-a", + "root-z"} && grandchild && grandchild->data(Qt::UserRole + 2).toInt() == 2 && grandchildTitle && grandchildTitle->text().startsWith("! ") && pane.visiblySelectedThreadId() == "grandchild" && selections == 0, @@ -1026,17 +1029,16 @@ bool testThreadHierarchyExpansionAndNavigation() { childZ = threadItem(list, "child-z"); clickExpansion(childZ); result &= expect( - threadOrder(pane) == - std::vector{"root-a", "child-z", "child-a", - "root-z"} && + threadOrder(pane) == std::vector{"root-a", "child-z", + "child-a", "root-z"} && pane.visiblySelectedThreadId().empty() && selections == 0, "collapsing a nested parent hides descendants without selecting it"); childZ = threadItem(list, "child-z"); clickExpansion(childZ); result &= expect( - threadOrder(pane) == - std::vector{"root-a", "child-z", "grandchild", - "child-a", "root-z"} && + threadOrder(pane) == std::vector{"root-a", "child-z", + "grandchild", "child-a", + "root-z"} && pane.visiblySelectedThreadId() == "grandchild" && selections == 0, "expanding restores arbitrary nesting and projected child selection"); @@ -1056,7 +1058,8 @@ bool testThreadHierarchyExpansionAndNavigation() { QKeyEvent rightToChild(QEvent::KeyPress, Qt::Key_Right, Qt::NoModifier); QApplication::sendEvent(list, &rightToChild); spin(); - result &= expect(selectedThread == "grandchild" && + result &= + expect(selectedThread == "grandchild" && pane.visiblySelectedThreadId() == "grandchild" && selections == beforeKeyboard + 1, "Right navigates from an expanded parent to its first child"); @@ -1071,17 +1074,16 @@ bool testThreadHierarchyExpansionAndNavigation() { QApplication::sendEvent(list, &leftCollapse); spin(); result &= expect( - threadOrder(pane) == - std::vector{"root-a", "child-z", "child-a", - "root-z"} && + threadOrder(pane) == std::vector{"root-a", "child-z", + "child-a", "root-z"} && pane.visiblySelectedThreadId() == "child-z" && selections == beforeKeyboard + 2, "Left collapses an expanded parent without changing selection"); QKeyEvent rightExpand(QEvent::KeyPress, Qt::Key_Right, Qt::NoModifier); QApplication::sendEvent(list, &rightExpand); spin(); - result &= expect( - threadOrder(pane) == + result &= + expect(threadOrder(pane) == std::vector{"root-a", "child-z", "grandchild", "child-a", "root-z"} && pane.visiblySelectedThreadId() == "child-z" && @@ -1103,7 +1105,7 @@ bool testThreadAlphanumericSort() { presentation::Authority::Merge)); ThreadPane pane; pane.setSortCriterion(ThreadPane::SortCriterion::Alphanumeric); - pane.refresh(model, "two"); + refresh(pane, model, "two"); const std::vector order = threadOrder(pane); const bool correct = order == std::vector( {"one", "two", "ten", "alpha", "beta"}) && @@ -1130,7 +1132,7 @@ bool testThreadCreatedSort() { presentation::Authority::Merge)); ThreadPane pane; pane.setSortCriterion(ThreadPane::SortCriterion::Created); - pane.refresh(model, {}); + refresh(pane, model, {}); return expect(threadOrder(pane) == std::vector( {"new", "middle", "old", "missing"}), "Created sorting is newest first with missing values last"); @@ -1151,7 +1153,7 @@ bool testThreadLastChangedSort() { presentation::Authority::Merge, {{"threadId", "first"}})); ThreadPane pane; pane.setSortCriterion(ThreadPane::SortCriterion::LastChanged); - pane.refresh(model, {}); + refresh(pane, model, {}); return expect(threadOrder(pane) == std::vector({"third", "first", "second"}), "Last changed sorting uses retained updated timestamps"); @@ -1167,7 +1169,7 @@ bool testThreadRecencySort() { {{"id", "middle"}, {"recencyAt", 20}}})}}, presentation::Authority::Merge)); ThreadPane pane; - pane.refresh(model, "older"); + refresh(pane, model, "older"); return expect( pane.currentSortCriterion() == ThreadPane::SortCriterion::Recency && threadOrder(pane) == @@ -1176,13 +1178,43 @@ bool testThreadRecencySort() { "Recent is the default and preserves selection"); } -bool testPromptAdmissionPromotesThread() { +bool testThreadLastActivityRetention() { PresentationModel model; model.applyEvent(presentation::result( - 1, 1, "threads.list", "prompt-promotion", true, + 1, 1, "threads.list", "activity-threads", true, {{"threads", nlohmann::json::array( - {{{"id", "older"}, + {{{"id", "tracked"}, {"updatedAt", 20}, {"recencyAt", 30}}, + {{"id", "updated-only"}, {"updatedAt", 25}}})}}, + presentation::Authority::Merge)); + const ThreadPresentation *thread = model.thread("tracked"); + bool result = + expect(thread && thread->lastActivityAt == 30, + "provider recency and update timestamps seed activity by maximum"); + const ThreadPresentation *updatedOnly = model.thread("updated-only"); + result &= expect(updatedOnly && updatedOnly->lastActivityAt == 25, + "provider update timestamp seeds activity without recency"); + model.noteThreadActivity("tracked", 25); + thread = model.thread("tracked"); + result &= expect(thread && thread->lastActivityAt == 30, + "older local traffic cannot move activity backwards"); + model.noteThreadActivity("tracked", 40); + model.applyEvent(presentation::event( + 2, 1, "thread.upsert", + {{"thread", {{"id", "tracked"}, {"recencyAt", 35}}}}, + presentation::Authority::Merge, {{"threadId", "tracked"}})); + thread = model.thread("tracked"); + result &= + expect(thread && thread->lastActivityAt == 40, + "stale provider hydration cannot replace newer live activity"); + return result; +} + +bool testPromptAdmissionPromotesThread() { + PresentationModel model; + model.applyEvent(presentation::result( + 1, 1, "threads.list", "prompt-promotion", true, + {{"threads", nlohmann::json::array({{{"id", "older"}, {"name", "Older"}, {"createdAt", 10}, {"updatedAt", 10}, @@ -1194,32 +1226,31 @@ bool testPromptAdmissionPromotesThread() { {"recencyAt", 30}}})}}, presentation::Authority::Merge)); ThreadPane pane; - pane.refresh(model, "older"); - bool result = expect( - threadOrder(pane) == std::vector({"recent", "older"}), + refresh(pane, model, "older"); + bool result = + expect(threadOrder(pane) == std::vector({"recent", "older"}), "provider recency initially determines thread order"); pane.promotePromptedThread("older"); - result &= expect( - threadOrder(pane) == std::vector({"older", "recent"}), + result &= + expect(threadOrder(pane) == std::vector({"older", "recent"}), "prompt admission immediately promotes the thread under Recent"); pane.setSortCriterion(ThreadPane::SortCriterion::LastChanged); - result &= expect( - threadOrder(pane) == std::vector({"older", "recent"}), + result &= + expect(threadOrder(pane) == std::vector({"older", "recent"}), "the same admission promotes the thread under Last changed"); pane.setSortCriterion(ThreadPane::SortCriterion::Created); - result &= expect( - threadOrder(pane) == std::vector({"recent", "older"}), + result &= + expect(threadOrder(pane) == std::vector({"recent", "older"}), "prompt admission does not affect Created ordering"); pane.setSortCriterion(ThreadPane::SortCriterion::Recency); model.applyEvent(presentation::event( - 2, 1, "thread.upsert", - {{"thread", {{"id", "older"}, {"recencyAt", 20}}}}, + 2, 1, "thread.upsert", {{"thread", {{"id", "older"}, {"recencyAt", 20}}}}, presentation::Authority::Merge, {{"threadId", "older"}})); - pane.refresh(model, "older"); - result &= expect( - threadOrder(pane) == std::vector({"recent", "older"}), + refresh(pane, model, "older"); + result &= + expect(threadOrder(pane) == std::vector({"recent", "older"}), "authoritative recency changes retire the local promotion"); return result; } @@ -1231,7 +1262,7 @@ bool testOptimisticThreadRowLifecycle() { pane.show(); pane.beginOptimisticThread("draft:new-thread", "Draft title", "/workspace/draft"); - pane.refresh(model, "draft:new-thread"); + refresh(pane, model, "draft:new-thread"); spin(); auto *list = pane.findChild(QStringLiteral("threadList")); @@ -1247,46 +1278,48 @@ bool testOptimisticThreadRowLifecycle() { if (!draft) return false; - model.applyEvent(presentation::event( - 1, 1, "thread.upsert", - {{"thread", {{"id", "thread-created"}, + model.applyEvent(presentation::event(1, 1, "thread.upsert", + {{"thread", + {{"id", "thread-created"}, {"name", "Created title"}, {"cwd", "/workspace/created"}, {"status", "idle"}}}}, - presentation::Authority::Merge, {{"threadId", "thread-created"}})); + presentation::Authority::Merge, + {{"threadId", "thread-created"}})); pane.promoteOptimisticThread("draft:new-thread", "thread-created"); - pane.refresh(model, "thread-created"); + refresh(pane, model, "thread-created"); spin(); QListWidgetItem *promoted = threadItem(list, "thread-created"); - result &= expect( - promoted == draft && promoted->data(Qt::UserRole + 6).toBool() && + result &= + expect(promoted == draft && promoted->data(Qt::UserRole + 6).toBool() && pane.visiblySelectedThreadId() == "thread-created" && animation->isActive(), - "thread/start rekeys the existing row without replacing its item or animation"); + "thread/start rekeys the existing row without replacing its item " + "or animation"); pane.beginOptimisticThread("draft:second", "Second draft", "/workspace/second"); - pane.refresh(model, "draft:second"); + refresh(pane, model, "draft:second"); spin(); QListWidgetItem *second = threadItem(list, "draft:second"); - result &= expect( - second && threadItem(list, "thread-created") == draft && + result &= expect(second && threadItem(list, "thread-created") == draft && animation->isActive(), - "a second draft can animate while the first created thread still awaits acknowledgment"); + "a second draft can animate while the first created thread " + "still awaits acknowledgment"); pane.confirmOptimisticThread("thread-created"); - pane.refresh(model, "draft:second"); + refresh(pane, model, "draft:second"); spin(); - result &= expect( - threadItem(list, "thread-created") == draft && + result &= expect(threadItem(list, "thread-created") == draft && !draft->data(Qt::UserRole + 6).toBool() && !pane.isOptimisticThread("thread-created") && threadItem(list, "draft:second") == second && - second->data(Qt::UserRole + 6).toBool() && animation->isActive(), + second->data(Qt::UserRole + 6).toBool() && + animation->isActive(), "acknowledging one new thread canonicalizes only that row"); pane.failOptimisticThread("draft:second"); - pane.refresh(model, "draft:second"); + refresh(pane, model, "draft:second"); spin(); result &= expect( threadItem(list, "draft:second") == second && @@ -1312,7 +1345,7 @@ bool testThreadRowReorderOwnership() { pane.setSortCriterion(ThreadPane::SortCriterion::Alphanumeric); pane.resize(320, 500); pane.show(); - pane.refresh(model, "thread-a"); + refresh(pane, model, "thread-a"); spin(20); auto *list = pane.findChild(QStringLiteral("threadList")); QListWidgetItem *threadA = nullptr; @@ -1357,7 +1390,7 @@ bool testThreadRowReorderOwnership() { model.applyEvent(presentation::event( 3, 1, "thread.status.changed", {{"status", "completed"}}, presentation::Authority::Merge, {{"threadId", "thread-b"}})); - pane.refresh(model, "thread-a"); + refresh(pane, model, "thread-a"); result &= expect(stableThreadARow == list->itemWidget(threadA) && originalRow == list->itemWidget(threadB), "content-only refreshes preserve thread row widgets"); @@ -1368,7 +1401,7 @@ bool testThreadRowReorderOwnership() { nlohmann::json::array({{{"id", "thread-a"}, {"name", "Z"}}, {{"id", "thread-b"}, {"name", "B"}}})}}, presentation::Authority::Replace)); - pane.refresh(model, "thread-a"); + refresh(pane, model, "thread-a"); QPointer movedRow = list->itemWidget(threadB); result &= expect(originalRow && movedRow && originalRow != movedRow, "moving an item never reattaches its deferred-delete row"); @@ -1402,11 +1435,8 @@ bool testNestedCommandScrollOwnership() { snapshot.sections.back().cards.push_back( {AuthoritativeItemKey{"command-thread", "turn-2", "command"}, CardKind::CommandExecution, "command-thread", "turn-2", "command", - CommandExecutionData{command, - output, - QStringLiteral("inProgress"), - {}, - std::nullopt}}); + CommandExecutionData{ + utf8(command), utf8(output), "inProgress", {}, std::nullopt}}); region.conversation().reconcile(snapshot); spin(30); @@ -1416,8 +1446,8 @@ bool testNestedCommandScrollOwnership() { if (auto *candidate = dynamic_cast(widget)) { commandOutput = candidate; } else if (auto *candidate = dynamic_cast(widget); - candidate && candidate->objectName() == - QStringLiteral("commandTextView")) { + candidate && + candidate->objectName() == QStringLiteral("commandTextView")) { commandText = candidate; } bool result = expect( @@ -1444,14 +1474,16 @@ bool testNestedCommandScrollOwnership() { inner->setValue(inner->minimum()); const int outerBeforeOverscroll = outer->value(); QWheelEvent sameGesture = wheelFor(view, 120, Qt::ScrollUpdate); - passed &= expect(!region.routeScrollEvent(view, &sameGesture) && + passed &= + expect(!region.routeScrollEvent(view, &sameGesture) && outer->value() == outerBeforeOverscroll, "a gesture reaching the top cannot leak to the conversation"); QWheelEvent end = wheelFor(view, 0, Qt::ScrollEnd); region.routeScrollEvent(view, &end); QWheelEvent freshAtTop = wheelFor(view, 120, Qt::ScrollBegin); - passed &= expect(region.routeScrollEvent(view, &freshAtTop) && + passed &= + expect(region.routeScrollEvent(view, &freshAtTop) && outer->value() < outerBeforeOverscroll, "a fresh outward gesture at the top scrolls the conversation"); QWheelEvent topEnd = wheelFor(view, 0, Qt::ScrollEnd); @@ -1464,14 +1496,16 @@ bool testNestedCommandScrollOwnership() { inner->setValue(inner->maximum()); const int outerBeforeBottomOverscroll = outer->value(); QWheelEvent sameDownGesture = wheelFor(view, -120, Qt::ScrollUpdate); - passed &= expect(!region.routeScrollEvent(view, &sameDownGesture) && + passed &= + expect(!region.routeScrollEvent(view, &sameDownGesture) && outer->value() == outerBeforeBottomOverscroll, "a gesture reaching the bottom cannot leak to the conversation"); QWheelEvent downEnd = wheelFor(view, 0, Qt::ScrollEnd); region.routeScrollEvent(view, &downEnd); QWheelEvent freshAtBottom = wheelFor(view, -120, Qt::ScrollBegin); - passed &= expect(region.routeScrollEvent(view, &freshAtBottom) && + passed &= expect( + region.routeScrollEvent(view, &freshAtBottom) && outer->value() > outerBeforeBottomOverscroll, "a fresh outward gesture at the bottom scrolls the conversation"); QWheelEvent bottomEnd = wheelFor(view, 0, Qt::ScrollEnd); @@ -1486,7 +1520,8 @@ bool testNestedCommandScrollOwnership() { "a mouse-wheel notch scrolls a movable nested view"); inner->setValue(inner->minimum()); QWheelEvent boundaryNotch = wheelFor(view, 120, Qt::NoScrollPhase); - passed &= expect(region.routeScrollEvent(view, &boundaryNotch) && + passed &= + expect(region.routeScrollEvent(view, &boundaryNotch) && outer->value() < outerBeforeMouseWheel, "a mouse-wheel notch at the boundary scrolls the conversation"); return passed; @@ -1504,27 +1539,31 @@ bool testInfoViewerLayout() { inspector.resize(420, 700); inspector.show(); PresentationModel model; - inspector.refresh(model, {}); + refresh(inspector, model, {}); inspector.tabs()->setCurrentIndex(4); auto *infoStack = inspector.findChild(QStringLiteral("infoStack")); - auto *protocolChoice = inspector.findChild( - QStringLiteral("protocolInfoChoice")); + 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(infoStack && protocolChoice && 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; const auto inspectorScrolls = inspector.findChildren(); result &= expect( inspectorScrolls.size() == 3 && - std::ranges::all_of(inspectorScrolls, [](QScrollArea *scroll) { - return scroll && scroll->property("kind") == "inspectorScroll" && + std::ranges::all_of( + inspectorScrolls, + [](QScrollArea *scroll) { + return scroll && + scroll->property("kind") == "inspectorScroll" && scroll->verticalScrollBarPolicy() == Qt::ScrollBarAsNeeded && scroll->verticalScrollBar() @@ -1562,21 +1601,15 @@ bool testInfoViewerLayout() { {"authority", "app-server"}, {"scope", {{"threadId", "thread"}}}}); } - inspector.refresh(model, {}); + refresh(inspector, model, {}); spin(20); result &= expect(protocol->verticalScrollBarPolicy() == Qt::ScrollBarAsNeeded && state->verticalScrollBarPolicy() == Qt::ScrollBarAsNeeded, "both Info viewers use the common as-needed scrollbar policy"); - result &= - expect(protocol->verticalScrollBar() - ->property("kind") - .toString() - .isEmpty() && - state->verticalScrollBar() - ->property("kind") - .toString() - .isEmpty() && + result &= expect( + protocol->verticalScrollBar()->property("kind").toString().isEmpty() && + state->verticalScrollBar()->property("kind").toString().isEmpty() && protocol->verticalScrollBar()->styleSheet().isEmpty() && state->verticalScrollBar()->styleSheet().isEmpty(), "both Info viewer scrollbars inherit the shared visual style"); @@ -1595,7 +1628,7 @@ bool testInfoViewerLayout() { {"sequence", 91}, {"generation", 1}, {"authority", "app-server"}}); - inspector.refresh(model, {}); + refresh(inspector, model, {}); spin(20); result &= expect(protocolScroll->value() == pausedValue, @@ -1698,7 +1731,7 @@ bool testInspectorDetailParity() { InspectorPane inspector; inspector.resize(420, 700); inspector.show(); - inspector.refresh(model, "owner-thread"); + refresh(inspector, model, "owner-thread"); inspector.tabs()->setCurrentIndex(1); spin(20); bool result = expect( @@ -1718,28 +1751,25 @@ bool testInspectorDetailParity() { "running agent status uses the canonical active tone"); auto *agentResult = inspector.findChild(QStringLiteral("agentResult")); - auto *agentFrame = - agentResult ? qobject_cast(agentResult->parentWidget()) : nullptr; - auto *agentTitle = agentFrame - ? agentFrame->findChild( - QStringLiteral("agentTitle")) + auto *agentFrame = agentResult + ? qobject_cast(agentResult->parentWidget()) : nullptr; - auto *agentName = agentFrame - ? agentFrame->findChild( - QStringLiteral("agentName")) + auto *agentTitle = + agentFrame ? agentFrame->findChild(QStringLiteral("agentTitle")) + : nullptr; + auto *agentName = + agentFrame ? agentFrame->findChild(QStringLiteral("agentName")) : nullptr; const int statusBottom = agentStatus && agentFrame ? agentStatus->mapTo(agentFrame, QPoint()).y() + agentStatus->height() : 0; - const int headingBottom = - std::max({agentTitle && agentFrame - ? agentTitle->mapTo(agentFrame, QPoint()).y() + - agentTitle->height() + const int headingBottom = std::max( + {agentTitle && agentFrame + ? agentTitle->mapTo(agentFrame, QPoint()).y() + agentTitle->height() : 0, agentName && agentFrame - ? agentName->mapTo(agentFrame, QPoint()).y() + - agentName->height() + ? agentName->mapTo(agentFrame, QPoint()).y() + agentName->height() : 0, statusBottom}); const int resultTop = agentResult && agentFrame @@ -1791,18 +1821,17 @@ bool testInspectorDetailParity() { if (agentsScroll) agentsScroll->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn); spin(20); - result &= expect( - agentsScrollBar && agentsScrollBar->isVisible() && + result &= expect(agentsScrollBar && agentsScrollBar->isVisible() && agentsScrollBar->width() == 8 && !hasNativeBlackFrame(agentsScrollBar), - "a visible Inspector scrollbar renders with the canonical frameless style"); - auto *compactDiff = inspector.findChild( - QStringLiteral("codexDiffText")); + "a visible Inspector scrollbar renders with the canonical " + "frameless style"); + auto *compactDiff = + inspector.findChild(QStringLiteral("codexDiffText")); QStringList diffLines; for (int line = 0; line < 80; ++line) - diffLines - << QStringLiteral( - "+%1 a deliberately long changed line for scrollbar verification") + diffLines << QStringLiteral("+%1 a deliberately long changed line for " + "scrollbar verification") .arg(line); inspector.tabs()->setCurrentIndex(2); spin(20); @@ -1816,8 +1845,7 @@ bool testInspectorDetailParity() { result &= expect( diffVertical && diffHorizontal && diffVertical->isVisible() && diffHorizontal->isVisible() && diffVertical->width() == 8 && - diffHorizontal->height() == 8 && - !hasNativeBlackFrame(diffVertical) && + diffHorizontal->height() == 8 && !hasNativeBlackFrame(diffVertical) && !hasNativeBlackFrame(diffHorizontal), "Changes preview scrollbars retain overview rendering without native " "frames"); @@ -1832,7 +1860,7 @@ bool testInspectorDetailParity() { model.applyEvent(presentation::event( 5, 1, "thread.name.changed", {{"name", "Renamed title"}}, presentation::Authority::Replace, {{"threadId", "owner-thread"}})); - inspector.refresh(model, "owner-thread"); + refresh(inspector, model, "owner-thread"); spin(20); result &= expect( hasLabelContaining(inspector, QStringLiteral("thread Renamed title")) && @@ -1869,7 +1897,7 @@ bool testInspectorDetailParity() { {"cwd", "/home/voc/projects/drafts"}}}}, presentation::Authority::Merge, {{"threadId", "owner-thread"}, {"requestId", "request-two"}})); - inspector.refresh(model, "owner-thread"); + refresh(inspector, model, "owner-thread"); spin(20); QPushButton *acceptButton = nullptr; for (QPushButton *button : inspector.findChildren()) { @@ -1880,9 +1908,12 @@ bool testInspectorDetailParity() { } result &= expect( acceptButton && - acceptButton->property("kind").toString() == QStringLiteral("request") && - hasLabelContaining(inspector, QStringLiteral("Command: gh auth status")) && + acceptButton->property("kind").toString() == + QStringLiteral("request") && hasLabelContaining(inspector, + QStringLiteral("Command: gh auth status")) && + hasLabelContaining( + inspector, QStringLiteral("Reason: Verify GitHub authentication")), "simple approval requests show decision details and direct accept"); qApp->setStyleSheet(previousStyleSheet); @@ -1910,19 +1941,20 @@ bool testTerminalPlanStatusReconciliation() { {{"threadId", "plan-thread"}, {"turnId", "plan-turn"}})); InspectorPane inspector; - inspector.refresh(model, "plan-thread"); + refresh(inspector, model, "plan-thread"); const auto hasExactLabel = [&inspector](const QString &value) { return std::ranges::any_of( - inspector.findChildren(), [&value](const QLabel *label) { - return label->text() == value; - }); + inspector.findChildren(), + [&value](const QLabel *label) { return label->text() == value; }); }; bool result = expect(hasExactLabel(QStringLiteral("Running")) && hasExactLabel(QStringLiteral("pending")), "active plans preserve Running and Pending statuses"); const auto markdownLabels = inspector.findChildren(); - result &= expect( - std::ranges::any_of(markdownLabels, [](const QLabel *label) { + result &= + expect(std::ranges::any_of( + markdownLabels, + [](const QLabel *label) { return label->textFormat() == Qt::RichText && label->text().contains(QStringLiteral("href=")) && label->textInteractionFlags().testFlag( @@ -1930,25 +1962,26 @@ bool testTerminalPlanStatusReconciliation() { }), "Inspector Markdown links are keyboard accessible"); - const auto setThreadStatus = [&](std::uint64_t sequence, - const char *status) { + const auto setThreadStatus = [&](std::uint64_t sequence, const char *status) { model.applyEvent(presentation::event( sequence, 1, "thread.upsert", {{"thread", {{"id", "plan-thread"}, {"status", status}}}}, presentation::Authority::Merge, {{"threadId", "plan-thread"}})); - inspector.refresh(model, "plan-thread"); + refresh(inspector, model, "plan-thread"); }; setThreadStatus(4, "completed"); result &= expect(!hasExactLabel(QStringLiteral("Running")) && hasExactLabel(QStringLiteral("Completed")) && hasExactLabel(QStringLiteral("pending")), - "a terminal thread reconciles stale Running to Completed without changing Pending"); + "a terminal thread reconciles stale Running to Completed " + "without changing Pending"); setThreadStatus(5, "failed"); result &= expect(hasExactLabel(QStringLiteral("Failed")) && hasExactLabel(QStringLiteral("pending")), "a failed thread reconciles stale Running to Failed"); setThreadStatus(6, "interrupted"); - result &= expect(hasExactLabel(QStringLiteral("Interrupted")) && + result &= + expect(hasExactLabel(QStringLiteral("Interrupted")) && hasExactLabel(QStringLiteral("pending")), "an interrupted thread reconciles stale Running to Interrupted"); return result; @@ -1961,8 +1994,8 @@ bool testGitDiffScopes() { return false; GitDiffProvider provider; git_repository *repository = nullptr; - if (!expect(git_repository_init(&repository, - repositoryDirectory.path().toUtf8().constData(), + if (!expect(git_repository_init( + &repository, repositoryDirectory.path().toUtf8().constData(), 0) == 0, "Git diff test initializes an in-process repository")) return false; @@ -1982,15 +2015,14 @@ bool testGitDiffScopes() { received = snapshot; ready = true; }); - const auto request = [&](const QString &workspace, - const QStringList &directories, - const QStringList &paths, - const QString &selectedRepository, - GitDiffScope scope, - bool includeHiddenRepositories = false) { + 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); + includeHiddenRepositories, scope, + GitDiffContext::Compact); QElapsedTimer timeout; timeout.start(); while (!ready && timeout.elapsed() < 3000) @@ -1998,14 +2030,12 @@ bool testGitDiffScopes() { return ready; }; - bool result = expect(request(repositoryDirectory.path(), {}, {}, {}, - GitDiffScope::Unstaged) && + 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")), + received.files.front().status == QStringLiteral("Untracked") && + received.files.front().patch.contains(QStringLiteral("+first line")), "Unstaged scope includes untracked file content"); git_index *index = nullptr; @@ -2014,21 +2044,21 @@ bool testGitDiffScopes() { git_index_write(index); git_index_free(index); } - result &= expect(request(repositoryDirectory.path(), {}, {}, {}, - GitDiffScope::Staged) && + result &= expect( + request(repositoryDirectory.path(), {}, {}, {}, GitDiffScope::Staged) && received.files.size() == 1 && - received.files.front().status == - QStringLiteral("Added"), + received.files.front().status == QStringLiteral("Added"), "Staged scope compares the index with HEAD"); - result &= expect(request(repositoryDirectory.path(), {}, {}, {}, + result &= expect( + request(repositoryDirectory.path(), {}, {}, {}, GitDiffScope::Uncommitted) && received.files.size() == 1 && - received.files.front().patch.contains( - QStringLiteral("+second line")), + received.files.front().patch.contains(QStringLiteral("+second line")), "Since-HEAD scope combines index and worktree state"); QTemporaryDir ordinaryDirectory; - result &= expect(ordinaryDirectory.isValid() && + result &= + expect(ordinaryDirectory.isValid() && request(ordinaryDirectory.path(), {}, {}, {}, GitDiffScope::Unstaged) && !received.repository && @@ -2062,7 +2092,8 @@ bool testGitDiffScopes() { {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"); + "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, @@ -2079,15 +2110,15 @@ bool testGitDiffScopes() { "repository selection filters files without losing the candidate set"); result &= expect( request(multiWorkspace.path(), {firstRoot, secondRoot}, - {QStringLiteral("first-only.txt")}, {}, - GitDiffScope::Unstaged) && + {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) && + "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, @@ -2097,7 +2128,8 @@ bool testGitDiffScopes() { {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"); + "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)); @@ -2109,8 +2141,7 @@ bool testGitDiffScopes() { secondPriority.open(QIODevice::WriteOnly | QIODevice::Truncate) && secondPriority.write("baseline\n") > 0; secondPriority.close(); - const bool priorityCommitted = - priorityFiles && secondPriorityFile && + const bool priorityCommitted = priorityFiles && secondPriorityFile && commitPath(firstRepository, "priority.txt") && commitPath(secondRepository, "priority.txt"); if (firstPriority.open(QIODevice::WriteOnly | QIODevice::Truncate)) @@ -2151,7 +2182,8 @@ bool testGitDiffScopes() { return file.path == priorityPath && file.status == QStringLiteral("Deleted"); }), - "a deleted path is resolved from Git state and preferred over a clean tracked match"); + "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); @@ -2177,6 +2209,7 @@ int main(int argc, char **argv) { result &= testThreadCreatedSort(); result &= testThreadLastChangedSort(); result &= testThreadRecencySort(); + result &= testThreadLastActivityRetention(); result &= testPromptAdmissionPromotesThread(); result &= testOptimisticThreadRowLifecycle(); result &= testThreadRowReorderOwnership(); diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index 96d2adc..2dda2b1 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -47,6 +47,8 @@ bool expect(bool condition, const char *message) { return false; } +std::string utf8(const QString &value) { return value.toUtf8().toStdString(); } + class LayoutRequestProbe final : public QObject { public: explicit LayoutRequestProbe(QWidget *root) : root_(root) { @@ -99,7 +101,7 @@ VisibleCardData agentCard(const std::string &threadId, threadId, turnId, itemId, - AgentMessageData{std::move(text), index % 3 == 0}}; + AgentMessageData{utf8(text), index % 3 == 0}}; } VisibleCardData cardForAppearanceAudit(const std::string &threadId, @@ -112,21 +114,20 @@ VisibleCardData cardForAppearanceAudit(const std::string &threadId, CardPayload payload = GenericActivityData{}; switch (kind) { case CardKind::UserMessage: - payload = UserMessageData{QStringLiteral("User appearance audit"), {}}; + payload = UserMessageData{"User appearance audit", {}}; break; case CardKind::AgentMessage: - payload = AgentMessageData{QStringLiteral("Agent appearance audit"), true}; + payload = AgentMessageData{"Agent appearance audit", true}; break; case CardKind::CommandExecution: - payload = CommandExecutionData{ - QStringLiteral("printf audit"), {}, QStringLiteral("inProgress"), - QStringLiteral("/workspace"), {}, {}}; + payload = CommandExecutionData{"printf audit", {}, "inProgress", + "/workspace", {}, {}}; break; case CardKind::AgentActivity: - payload = AgentActivityData{QStringLiteral("spawn_agent"), - QStringLiteral("inProgress"), - QStringLiteral("tool"), - QStringLiteral("Inspect appearance"), + payload = AgentActivityData{"spawn_agent", + "inProgress", + "tool", + "Inspect appearance", {}, {}, {}, @@ -136,32 +137,25 @@ VisibleCardData cardForAppearanceAudit(const std::string &threadId, {}}; break; case CardKind::Reasoning: - payload = ReasoningData{QStringLiteral("Initial reasoning summary")}; + payload = ReasoningData{"Initial reasoning summary"}; break; case CardKind::FileChanges: - payload = FileChangesData{ - QStringLiteral("inProgress"), - {{QStringLiteral("src/a.cpp"), QStringLiteral("update"), 1, 0}}}; + payload = FileChangesData{"inProgress", {{"src/a.cpp", "update", 1, 0}}}; break; case CardKind::ImageGeneration: - payload = ImageGenerationData{{}, - QStringLiteral("inProgress"), - QStringLiteral("Initial image prompt")}; + payload = ImageGenerationData{{}, "inProgress", "Initial image prompt"}; break; case CardKind::Plan: - payload = - PlanData{QStringLiteral("Initial plan"), - {{QStringLiteral("Inspect"), QStringLiteral("inProgress")}}, - {}}; + payload = PlanData{"Initial plan", {{"Inspect", "inProgress"}}, {}}; break; case CardKind::GenericActivity: payload = GenericActivityData{ - QStringLiteral("unknownActivity"), + "unknownActivity", {{"type", "unknownActivity"}, {"status", "inProgress"}}}; break; case CardKind::LocalPrompt: payload = LocalPromptData{9000U + static_cast(index), - QStringLiteral("Local prompt appearance audit"), + "Local prompt appearance audit", PromptState::InFlight, 0, {}, @@ -188,27 +182,18 @@ ConversationSnapshot conversation(const std::string &threadId, int count) { bool testMessageIdentityPalette() { const QString originalStyleSheet = qApp->styleSheet(); qApp->setStyleSheet(codexui::UiStyle::applicationStyleSheet()); - ConversationCard user(VisibleCardData{ - AuthoritativeItemKey{"identity-palette", "turn", "user"}, - CardKind::UserMessage, - "identity-palette", - "turn", - "user", - UserMessageData{QStringLiteral("Prompt"), {}}}); + ConversationCard user( + VisibleCardData{AuthoritativeItemKey{"identity-palette", "turn", "user"}, + CardKind::UserMessage, "identity-palette", "turn", "user", + UserMessageData{"Prompt", {}}}); ConversationCard update(VisibleCardData{ AuthoritativeItemKey{"identity-palette", "turn", "update"}, - CardKind::AgentMessage, - "identity-palette", - "turn", - "update", - AgentMessageData{QStringLiteral("Working"), false}}); - ConversationCard final(VisibleCardData{ - AuthoritativeItemKey{"identity-palette", "turn", "final"}, - CardKind::AgentMessage, - "identity-palette", - "turn", - "final", - AgentMessageData{QStringLiteral("Response"), true}}); + CardKind::AgentMessage, "identity-palette", "turn", "update", + AgentMessageData{"Working", false}}); + ConversationCard final( + VisibleCardData{AuthoritativeItemKey{"identity-palette", "turn", "final"}, + CardKind::AgentMessage, "identity-palette", "turn", + "final", AgentMessageData{"Response", true}}); for (ConversationCard *card : {&user, &update, &final}) { card->resize(600, card->sizeHint().height()); card->show(); @@ -223,8 +208,7 @@ bool testMessageIdentityPalette() { }; const auto surfaceColor = [](ConversationCard &card) { const QImage rendered = card.grab().toImage(); - return rendered.pixelColor(rendered.width() - 10, - rendered.height() - 10); + return rendered.pixelColor(rendered.width() - 10, rendered.height() - 10); }; const bool result = expect( titleColor(user) == @@ -248,29 +232,25 @@ bool testActiveWorkBordersFollowStatus() { "active-border", "turn", "command", - CommandExecutionData{QStringLiteral("sleep 1"), {}, - QStringLiteral("inProgress"), {}, {}, {}}}; + CommandExecutionData{"sleep 1", {}, "inProgress", {}, {}, {}}}; ConversationCard commandCard(command); bool result = expect(commandCard.property("activeWork").toBool(), "a running command uses the emphasized card border"); - std::get(command.payload).status = - QStringLiteral("completed"); + std::get(command.payload).status = "completed"; result &= expect(commandCard.apply(command) && !commandCard.property("activeWork").toBool(), "a completed command returns to the normal card border"); - VisibleCardData image{ - AuthoritativeItemKey{"active-border", "turn", "image"}, + VisibleCardData image{AuthoritativeItemKey{"active-border", "turn", "image"}, CardKind::ImageGeneration, "active-border", "turn", "image", - ImageGenerationData{{}, QStringLiteral("inProgress"), {}}}; + ImageGenerationData{{}, "inProgress", {}}}; ConversationCard imageCard(image); result &= expect(imageCard.property("activeWork").toBool(), "a loading figure uses the emphasized card border"); - std::get(image.payload).status = - QStringLiteral("completed"); + std::get(image.payload).status = "completed"; result &= expect(imageCard.apply(image) && !imageCard.property("activeWork").toBool(), "a loaded figure returns to the normal card border"); @@ -302,9 +282,8 @@ std::vector visualCardKeys(ConversationView &view) { std::vector keys; keys.reserve(cards.size()); for (ConversationCard *candidate : cards) - keys.push_back(candidate->property("conversationAnchorKey") - .toString() - .toStdString()); + keys.push_back( + candidate->property("conversationAnchorKey").toString().toStdString()); return keys; } @@ -319,9 +298,9 @@ QToolButton *copyButton(ConversationCard *card) { return nullptr; QWidget *header = card->findChild( QStringLiteral("conversationCardHeader"), Qt::FindDirectChildrenOnly); - return header ? header->findChild( - QStringLiteral("cardCopyButton"), - Qt::FindDirectChildrenOnly) + return header + ? header->findChild( + QStringLiteral("cardCopyButton"), Qt::FindDirectChildrenOnly) : nullptr; } @@ -438,7 +417,8 @@ bool testStructuralOrderAndIdentity() { std::unordered_map identities; for (const TurnSection §ion : snapshot.sections) for (const VisibleCardData &value : section.cards) - identities.emplace(stableKey(value.key), card(view, stableKey(value.key))); + identities.emplace(stableKey(value.key), + card(view, stableKey(value.key))); for (TurnSection §ion : snapshot.sections) std::ranges::reverse(section.cards); @@ -448,8 +428,8 @@ bool testStructuralOrderAndIdentity() { for (const VisibleCardData &value : section.cards) expectedKeys.push_back(stableKey(value.key)); - bool result = expect(view.reconcile(snapshot), - "structural order changes reconcile"); + bool result = + expect(view.reconcile(snapshot), "structural order changes reconcile"); spin(); result &= expect(visualCardKeys(view) == expectedKeys, "section and card order follows the projection exactly"); @@ -466,7 +446,7 @@ bool testStructuralOrderAndIdentity() { pagingThread, "turn", "later-user", - UserMessageData{QStringLiteral("Later prompt"), {}}}; + UserMessageData{"Later prompt", {}}}; VisibleCardData activity = agentCard(pagingThread, "turn", 50); ConversationSnapshot paged{ pagingThread, @@ -486,16 +466,16 @@ bool testStructuralOrderAndIdentity() { pagingThread, "turn", "earlier-user", - UserMessageData{QStringLiteral("Earlier prompt"), {}}}; + UserMessageData{"Earlier prompt", {}}}; paged.sections.front().cards.insert(paged.sections.front().cards.begin(), earlierPrompt); paged.sections.front().rootCardKey = earlierPrompt.key; result &= expect(pagedView.reconcile(paged), "older history can introduce the real turn prompt"); spin(); - ConversationCard *earlierRoot = - card(pagedView, stableKey(earlierPrompt.key)); - result &= expect(earlierRoot && laterRoot && activityCard && + ConversationCard *earlierRoot = card(pagedView, stableKey(earlierPrompt.key)); + result &= + expect(earlierRoot && laterRoot && activityCard && earlierRoot->isAncestorOf(laterRoot) && earlierRoot->isAncestorOf(activityCard) && !laterRoot->isAncestorOf(activityCard) && @@ -507,8 +487,8 @@ bool testStructuralOrderAndIdentity() { result &= expect(pagedView.reconcile(paged), "a transient projection can omit the declared root"); spin(); - result &= expect( - card(pagedView, stableKey(laterPrompt.key)) == laterRoot && + result &= + expect(card(pagedView, stableKey(laterPrompt.key)) == laterRoot && card(pagedView, stableKey(activity.key)) == activityCard && !laterRoot->property("turnContainer").toBool() && !laterRoot->isAncestorOf(activityCard), @@ -516,18 +496,19 @@ bool testStructuralOrderAndIdentity() { paged.sections.front().cards.insert(paged.sections.front().cards.begin(), earlierPrompt); - result &= expect(pagedView.reconcile(paged), - "the declared turn root can return"); + result &= + expect(pagedView.reconcile(paged), "the declared turn root can return"); spin(); ConversationCard *restoredRoot = card(pagedView, stableKey(earlierPrompt.key)); - result &= expect( - restoredRoot && restoredRoot->property("turnContainer").toBool() && + result &= + expect(restoredRoot && restoredRoot->property("turnContainer").toBool() && restoredRoot->isAncestorOf(laterRoot) && restoredRoot->isAncestorOf(activityCard) && card(pagedView, stableKey(laterPrompt.key)) == laterRoot && card(pagedView, stableKey(activity.key)) == activityCard, - "root restoration reparents retained cards without changing their identity"); + "root restoration reparents retained cards without changing their " + "identity"); return result; } @@ -594,8 +575,8 @@ bool testFollowPauseAndStableAnchor() { if (anchorPosition != snapshot.sections.front().cards.begin() && anchorPosition != snapshot.sections.front().cards.end()) { auto &message = std::get((anchorPosition - 1)->payload); - message.text += QStringLiteral( - "\nA reflowing upstream update.\nA second line.\nA third line."); + message.text += + "\nA reflowing upstream update.\nA second line.\nA third line."; } snapshot.sections.back().cards.push_back(agentCard("thread-a", "turn-2", 35)); LayoutRequestProbe layoutRequests(&view); @@ -633,8 +614,7 @@ bool testFollowPauseAndStableAnchor() { "a scrollbar page action pauses at its resulting anchor"); auto &upstream = std::get( snapshot.sections.front().cards.front().payload); - upstream.text += QStringLiteral( - "\nTrack-action upstream reflow.\nSecond line.\nThird line."); + upstream.text += "\nTrack-action upstream reflow.\nSecond line.\nThird line."; result &= expect(view.reconcile(snapshot), "page-action coverage applies an upstream reflow"); spin(); @@ -660,9 +640,8 @@ bool testPausedExpandedCommandStaysPainted() { thread, "turn-2", "completed-command", - CommandExecutionData{QStringLiteral("run completed command"), output, - QStringLiteral("completed"), - QStringLiteral("/workspace"), 0, 1250}}; + CommandExecutionData{"run completed command", utf8(output), "completed", + "/workspace", 0, 1250}}; snapshot.sections.back().cards.insert( snapshot.sections.back().cards.begin(), cardForAppearanceAudit(thread, CardKind::UserMessage, 99)); @@ -813,14 +792,13 @@ bool testPromptAdmissionFollowOwnership() { bool result = expect(view.mode() == ConversationView::Mode::Paused, "composer growth preserves the painted viewport"); view.prepareForLocalPromptAdmission(); - VisibleCardData pending{ - LocalPromptKey{1001}, + VisibleCardData pending{LocalPromptKey{1001}, CardKind::LocalPrompt, "prompt-follow", {}, {}, LocalPromptData{1001, - QStringLiteral("a newly admitted pending prompt"), + "a newly admitted pending prompt", PromptState::InFlight, 0, {}}}; @@ -848,7 +826,7 @@ bool testPromptAdmissionFollowOwnership() { later.key = LocalPromptKey{1002}; std::get(later.payload).submissionId = 1002; std::get(later.payload).prompt = - QStringLiteral("must not displace a user-owned reading position"); + "must not displace a user-owned reading position"; snapshot.sections.back().cards.push_back(later); view.reconcile(snapshot); view.setTrailingSpaceHeight(0); @@ -872,79 +850,70 @@ bool testCardCopyControls() { const std::vector cases{ {{AuthoritativeItemKey{thread, "turn", "user"}, CardKind::UserMessage, thread, "turn", "user", - UserMessageData{QStringLiteral("# Prompt\n\n**bold**"), - {QStringLiteral("/tmp/first.png"), - QStringLiteral("/tmp/second.png")}}}, + UserMessageData{"# Prompt\n\n**bold**", + {"/tmp/first.png", "/tmp/second.png"}}}, QStringLiteral("# Prompt\n\n**bold**"), true}, {{AuthoritativeItemKey{thread, "turn", "image-only-user"}, CardKind::UserMessage, thread, "turn", "image-only-user", - UserMessageData{{}, {QStringLiteral("/tmp/only-image.png")}}}, + UserMessageData{{}, {"/tmp/only-image.png"}}}, QStringLiteral("/tmp/only-image.png"), false}, - {{AuthoritativeItemKey{thread, "turn", "agent"}, - CardKind::AgentMessage, thread, "turn", "agent", - AgentMessageData{QStringLiteral("## Answer\n\n- item"), true}}, + {{AuthoritativeItemKey{thread, "turn", "agent"}, CardKind::AgentMessage, + thread, "turn", "agent", AgentMessageData{"## Answer\n\n- item", true}}, QStringLiteral("## Answer\n\n- item"), true}, {{AuthoritativeItemKey{thread, "turn", "command"}, CardKind::CommandExecution, thread, "turn", "command", - CommandExecutionData{QStringLiteral("printf copy\n\n"), - QStringLiteral("one\n\n"), - QStringLiteral("completed"), - {}, - 0}}, + CommandExecutionData{"printf copy\n\n", "one\n\n", "completed", {}, 0}}, QStringLiteral("printf copy\n\none"), false}, {{AuthoritativeItemKey{thread, "turn", "activity"}, CardKind::AgentActivity, thread, "turn", "activity", - AgentActivityData{QStringLiteral("tool"), - QStringLiteral("completed"), - {}, - QStringLiteral("Inspect"), - QStringLiteral("**result**")}}, + AgentActivityData{"tool", "completed", {}, "Inspect", "**result**"}}, QStringLiteral("Inspect\n\n**result**"), true}, - {{AuthoritativeItemKey{thread, "turn", "reasoning"}, - CardKind::Reasoning, thread, "turn", "reasoning", - ReasoningData{QStringLiteral("Reasoning *summary*")}}, + {{AuthoritativeItemKey{thread, "turn", "reasoning"}, CardKind::Reasoning, + thread, "turn", "reasoning", ReasoningData{"Reasoning *summary*"}}, QStringLiteral("Reasoning *summary*"), true}, - {{AuthoritativeItemKey{thread, "turn", "files"}, - CardKind::FileChanges, thread, "turn", "files", - FileChangesData{QStringLiteral("completed"), - {{QStringLiteral("src/card.cpp"), - QStringLiteral("update"), 2, 1}}}}, + {{AuthoritativeItemKey{thread, "turn", "files"}, CardKind::FileChanges, + thread, "turn", "files", + FileChangesData{"completed", {{"src/card.cpp", "update", 2, 1}}}}, QStringLiteral("src/card.cpp · Update +2 −1"), false}, - {{TurnPlanKey{thread, "turn"}, CardKind::Plan, thread, "turn", {}, - PlanData{QStringLiteral("Plan explanation"), - {{QStringLiteral("Inspect"), QStringLiteral("completed")}, - {QStringLiteral("Implement"), - QStringLiteral("inProgress")}}, + {{TurnPlanKey{thread, "turn"}, + CardKind::Plan, + thread, + "turn", + {}, + PlanData{"Plan explanation", + {{"Inspect", "completed"}, {"Implement", "inProgress"}}, {}}}, QStringLiteral("Plan explanation\n\n✓ Inspect \n◉ Implement "), true}, {{AuthoritativeItemKey{thread, "turn", "image"}, CardKind::ImageGeneration, thread, "turn", "image", - ImageGenerationData{QStringLiteral("/tmp/generated.png"), - QStringLiteral("completed"), - QStringLiteral("A revised prompt")}}, + ImageGenerationData{"/tmp/generated.png", "completed", + "A revised prompt"}}, QStringLiteral("A revised prompt\n\n/tmp/generated.png"), false}, {{AuthoritativeItemKey{thread, "turn", "generic"}, CardKind::GenericActivity, thread, "turn", "generic", - GenericActivityData{QStringLiteral("custom"), - {{"detail", "value"}}}}, + GenericActivityData{"custom", {{"detail", "value"}}}}, QStringLiteral("{\n \"detail\": \"value\"\n}"), false}, - {{LocalPromptKey{99}, CardKind::LocalPrompt, thread, {}, {}, + {{LocalPromptKey{99}, + CardKind::LocalPrompt, + thread, + {}, + {}, LocalPromptData{99, - QStringLiteral("Pending `prompt`"), + "Pending `prompt`", PromptState::InFlight, 0, {}, - {QStringLiteral("/tmp/pending.png")}}}, + {"/tmp/pending.png"}}}, QStringLiteral("Pending `prompt`"), true}, }; @@ -993,7 +962,7 @@ bool testCardCopyControls() { VisibleCardData mutableMessage = cases.front().card; ConversationCard mutableCard(mutableMessage); std::get(mutableMessage.payload).text = - QStringLiteral("Updated **Markdown**"); + "Updated **Markdown**"; result &= expect(mutableCard.apply(mutableMessage), "copy fixture accepts an in-place content update"); QApplication::clipboard()->clear(); @@ -1003,10 +972,9 @@ bool testCardCopyControls() { QStringLiteral("Updated **Markdown**"), "copy reads the latest retained card data after an in-place update"); - ConversationCard emptyReasoning( - VisibleCardData{AuthoritativeItemKey{thread, "turn", "empty"}, - CardKind::Reasoning, thread, "turn", "empty", - ReasoningData{}}); + ConversationCard emptyReasoning(VisibleCardData{ + AuthoritativeItemKey{thread, "turn", "empty"}, CardKind::Reasoning, + thread, "turn", "empty", ReasoningData{}}); emptyReasoning.show(); spin(); result &= expect(copyButton(&emptyReasoning) && @@ -1014,16 +982,15 @@ bool testCardCopyControls() { "contentless cards omit the Copy control"); VisibleCardData populatedReasoning = emptyReasoning.data(); std::get(populatedReasoning.payload).summary = - QStringLiteral("Late **summary**"); + "Late **summary**"; result &= expect(emptyReasoning.apply(populatedReasoning) && !copyButton(&emptyReasoning)->isHidden(), "Copy appears when retained card content arrives later"); QApplication::clipboard()->clear(); copyButton(&emptyReasoning)->click(); - result &= expect(QApplication::clipboard()->text() == - QStringLiteral("Late **summary**") && - QApplication::clipboard()->mimeData()->hasFormat( - "text/markdown"), + result &= expect( + QApplication::clipboard()->text() == QStringLiteral("Late **summary**") && + QApplication::clipboard()->mimeData()->hasFormat("text/markdown"), "late Markdown content copies from the updated source"); return result; } @@ -1036,53 +1003,39 @@ bool testMutableCardsAndCommandOutput() { section.cards = { {AuthoritativeItemKey{thread, "turn", "user"}, CardKind::UserMessage, thread, "turn", "user", - UserMessageData{QStringLiteral("hello **Markdown**\n\n| Value | Rating " + UserMessageData{"hello **Markdown**\n\n| Value | Rating " "|\n|---|---|\n| State | 10 |\n\n" - "[Docs](https://example.com)")}}, + "[Docs](https://example.com)"}}, {AuthoritativeItemKey{thread, "turn", "agent"}, CardKind::AgentMessage, - thread, "turn", "agent", - AgentMessageData{QStringLiteral("answer"), false}}, + thread, "turn", "agent", AgentMessageData{"answer", false}}, {AuthoritativeItemKey{thread, "turn", "command"}, CardKind::CommandExecution, thread, "turn", "command", - CommandExecutionData{QStringLiteral("printf test\n\n \t"), - QStringLiteral(" \n\t"), - QStringLiteral("inProgress"), - {}, - std::nullopt}}, + CommandExecutionData{ + "printf test\n\n \t", " \n\t", "inProgress", {}, std::nullopt}}, {AuthoritativeItemKey{thread, "turn", "activity"}, CardKind::AgentActivity, thread, "turn", "activity", - AgentActivityData{QStringLiteral("tool"), - QStringLiteral("inProgress"), - {}, - QStringLiteral("prompt"), - {}, - {}}}, + AgentActivityData{"tool", "inProgress", {}, "prompt", {}, {}}}, {AuthoritativeItemKey{thread, "turn", "reasoning"}, CardKind::Reasoning, - thread, "turn", "reasoning", ReasoningData{QStringLiteral("summary")}}, + thread, "turn", "reasoning", ReasoningData{"summary"}}, {AuthoritativeItemKey{thread, "turn", "files"}, CardKind::FileChanges, thread, "turn", "files", - FileChangesData{ - QStringLiteral("inProgress"), - {{QStringLiteral("src/card.cpp"), QStringLiteral("update"), 2, 1}}}}, + FileChangesData{"inProgress", {{"src/card.cpp", "update", 2, 1}}}}, {AuthoritativeItemKey{thread, "turn", "plan"}, CardKind::Plan, thread, "turn", "plan", - PlanData{ - QStringLiteral("Keep the card compact"), - {{QStringLiteral("Inspect data"), QStringLiteral("completed")}, - {QStringLiteral("Render cards"), QStringLiteral("inProgress")}}, + PlanData{"Keep the card compact", + {{"Inspect data", "completed"}, {"Render cards", "inProgress"}}, {}}}, {AuthoritativeItemKey{thread, "turn", "generic"}, CardKind::GenericActivity, thread, "turn", "generic", - GenericActivityData{QStringLiteral("custom activity"), - {{"detail", "initial"}}}}, + GenericActivityData{"custom activity", {{"detail", "initial"}}}}, {LocalPromptKey{77}, CardKind::LocalPrompt, thread, {}, {}, LocalPromptData{77, - QStringLiteral("pending\n\nAttached files:\n" - "- [report.pdf](file:///tmp/report.pdf)"), + "pending\n\nAttached files:\n" + "- [report.pdf](file:///tmp/report.pdf)", PromptState::InFlight, 0, {}}}, @@ -1126,8 +1079,8 @@ bool testMutableCardsAndCommandOutput() { CardKey{AuthoritativeItemKey{thread, "turn", "agent"}})]; auto *agentPhaseSeparator = agentCardWidget->findChild( QStringLiteral("agentMessagePhaseSeparator")); - auto *agentPhase = agentCardWidget->findChild( - QStringLiteral("agentMessagePhase")); + auto *agentPhase = + agentCardWidget->findChild(QStringLiteral("agentMessagePhase")); const auto userLabels = userCard->findChildren(); result &= expect(std::ranges::any_of( @@ -1188,28 +1141,24 @@ bool testMutableCardsAndCommandOutput() { "pending prompts render file links before authoritative replacement"); auto &cards = snapshot.sections.front().cards; - std::get(cards[0].payload).text += - QStringLiteral(" updated"); + std::get(cards[0].payload).text += " updated"; auto &agent = std::get(cards[1].payload); - agent.text += QStringLiteral(" updated"); + agent.text += " updated"; agent.finalAnswer = true; auto &command = std::get(cards[2].payload); command.output = - QString(120, QLatin1Char('x')) + QStringLiteral("\nvisible\n\n \t"); - command.status = QStringLiteral("completed"); - std::get(cards[3].payload).resultText = - QStringLiteral("result"); - std::get(cards[4].payload).summary += QStringLiteral(" more"); + utf8(QString(120, QLatin1Char('x')) + QStringLiteral("\nvisible\n\n \t")); + command.status = "completed"; + std::get(cards[3].payload).resultText = "result"; + std::get(cards[4].payload).summary += " more"; std::get(cards[5].payload) - .changes.push_back( - {QStringLiteral("tests/card.cpp"), QStringLiteral("add"), 3, 0}); - std::get(cards[6].payload).steps[1].status = - QStringLiteral("completed"); + .changes.push_back({"tests/card.cpp", "add", 3, 0}); + std::get(cards[6].payload).steps[1].status = "completed"; auto &generic = std::get(cards[7].payload); - generic.type = QStringLiteral("updated custom activity"); + generic.type = "updated custom activity"; generic.raw["detail"] = "updated"; std::get(cards[8].payload).state = PromptState::Failed; - std::get(cards[8].payload).error = QStringLiteral("error"); + std::get(cards[8].payload).error = "error"; result &= expect(view.reconcile(snapshot), "all card types accept visible updates"); const int immediateOuterRange = view.verticalScrollBar()->maximum(); @@ -1225,8 +1174,8 @@ bool testMutableCardsAndCommandOutput() { result &= expect(card(view, stableKey(value.key)) == identities[stableKey(value.key)], "same-key same-kind card updates in place"); - result &= expect( - titleText(agentCardWidget) == QStringLiteral("Codex") && + result &= + expect(titleText(agentCardWidget) == QStringLiteral("Codex") && agentPhaseSeparator && agentPhase && agentPhase->text() == QStringLiteral("final answer") && agentPhase->property("tone").toString() == @@ -1277,7 +1226,7 @@ bool testMutableCardsAndCommandOutput() { result &= expect(output->followsLatest(), "inner output following resumes at its real bottom"); - command.output = QStringLiteral("\x1b]0;terminal title\x07\x1b[0m \n\t"); + command.output = "\x1b]0;terminal title\x07\x1b[0m \n\t"; result &= expect(view.reconcile(snapshot), "non-presentable replacement updates the command card"); const int hiddenOuterRange = view.verticalScrollBar()->maximum(); @@ -1305,81 +1254,73 @@ bool testCardFoldingGeometryAndRetention() { thread, "turn", "user", - UserMessageData{QStringLiteral("Keep this message initially expanded."), - {}}}; + UserMessageData{"Keep this message initially expanded.", {}}}; const VisibleCardData agent{ AuthoritativeItemKey{thread, "turn", "agent"}, CardKind::AgentMessage, thread, "turn", "agent", - AgentMessageData{QStringLiteral("Codex also starts expanded."), true}}; + AgentMessageData{"Codex also starts expanded.", true}}; const VisibleCardData reasoning{ AuthoritativeItemKey{thread, "turn", "reasoning"}, CardKind::Reasoning, thread, "turn", "reasoning", - ReasoningData{QStringLiteral("A retained public summary with enough " + ReasoningData{"A retained public summary with enough " "detail to create real height.\n\n" "The second paragraph proves expansion uses " - "the final wrapped size.")}}; + "the final wrapped size."}}; const VisibleCardData command{ AuthoritativeItemKey{thread, "turn", "command"}, CardKind::CommandExecution, thread, "turn", "command", - CommandExecutionData{ - QStringLiteral("produce output"), QStringLiteral("initial output"), - QStringLiteral("completed"), QStringLiteral("/workspace"), 0}}; + CommandExecutionData{"produce output", "initial output", "completed", + "/workspace", 0}}; const VisibleCardData files{ AuthoritativeItemKey{thread, "turn", "files"}, CardKind::FileChanges, thread, "turn", "files", - FileChangesData{ - QStringLiteral("completed"), - {{QStringLiteral("src/card.cpp"), QStringLiteral("update"), 4, 1}}}}; + FileChangesData{"completed", {{"src/card.cpp", "update", 4, 1}}}}; const VisibleCardData activity{ AuthoritativeItemKey{thread, "turn", "activity"}, CardKind::AgentActivity, thread, "turn", "activity", - AgentActivityData{QStringLiteral("spawn_agent"), - QStringLiteral("completed"), + AgentActivityData{"spawn_agent", + "completed", {}, - QStringLiteral("Inspect folding"), - QStringLiteral("Inspection complete"), + "Inspect folding", + "Inspection complete", {}}}; - const VisibleCardData image{ - AuthoritativeItemKey{thread, "turn", "image"}, + const VisibleCardData image{AuthoritativeItemKey{thread, "turn", "image"}, CardKind::ImageGeneration, thread, "turn", "image", - ImageGenerationData{QStringLiteral("/tmp/folding-preview.png"), - QStringLiteral("completed"), - QStringLiteral("A folding preview")}}; - const VisibleCardData plan{AuthoritativeItemKey{thread, "turn", "plan"}, + ImageGenerationData{"/tmp/folding-preview.png", + "completed", + "A folding preview"}}; + const VisibleCardData plan{ + AuthoritativeItemKey{thread, "turn", "plan"}, CardKind::Plan, thread, "turn", "plan", - PlanData{QStringLiteral("Verify folding"), - {{QStringLiteral("Inspect geometry"), - QStringLiteral("completed")}}, - {}}}; + PlanData{"Verify folding", {{"Inspect geometry", "completed"}}, {}}}; const VisibleCardData generic{ AuthoritativeItemKey{thread, "turn", "generic"}, CardKind::GenericActivity, thread, "turn", "generic", - GenericActivityData{QStringLiteral("Unknown activity"), - {{"detail", "bounded"}}}}; + GenericActivityData{"Unknown activity", {{"detail", "bounded"}}}}; const VisibleCardData emptyReasoning{ AuthoritativeItemKey{thread, "turn", "empty-reasoning"}, CardKind::Reasoning, @@ -1387,11 +1328,11 @@ bool testCardFoldingGeometryAndRetention() { "turn", "empty-reasoning", ReasoningData{}}; - ConversationSnapshot snapshot{thread, + ConversationSnapshot snapshot{ + thread, {{"turn:folding", "turn", - {user, agent, reasoning, command, files, - activity, image, plan, generic, + {user, agent, reasoning, command, files, activity, image, plan, generic, emptyReasoning}, user.key}}, 0, @@ -1439,8 +1380,7 @@ bool testCardFoldingGeometryAndRetention() { const QRect collapsedDisclosure = paintedDisclosureBounds(disclosure(reasoningCard)); result &= expect( - collapsedDisclosure.isValid() && - collapsedDisclosure.width() <= 8 && + collapsedDisclosure.isValid() && collapsedDisclosure.width() <= 8 && collapsedDisclosure.right() >= disclosure(reasoningCard)->width() - 3, "collapsed disclosure paints only a right-inset left chevron"); result &= expect( @@ -1458,11 +1398,11 @@ bool testCardFoldingGeometryAndRetention() { !filesCard || !emptyReasoningCard) return false; - result &= expect(userCard->property("turnContainer").toBool() && + result &= + expect(userCard->property("turnContainer").toBool() && userCard->isAncestorOf(agentCardWidget) && userCard->isAncestorOf(reasoningCard) && - agentCardWidget->property("nestedConversationCard") - .toBool(), + agentCardWidget->property("nestedConversationCard").toBool(), "the first You card structurally owns its turn activity"); const LocalPromptKey steeringKey{4343}; @@ -1472,12 +1412,8 @@ bool testCardFoldingGeometryAndRetention() { thread, "turn", {}, - LocalPromptData{4343, - QStringLiteral("A steering prompt"), - PromptState::InFlight, - 0, - {}, - {}}}; + LocalPromptData{ + 4343, "A steering prompt", PromptState::InFlight, 0, {}, {}}}; snapshot.sections.front().cards.push_back(steering); result &= expect(view.reconcile(snapshot), "a steering prompt joins the active turn"); @@ -1487,28 +1423,24 @@ bool testCardFoldingGeometryAndRetention() { ? steeringCard->findChild( QString{}, Qt::FindDirectChildrenOnly) : nullptr; - result &= expect( - steeringCard && userCard->isAncestorOf(steeringCard) && + result &= + expect(steeringCard && userCard->isAncestorOf(steeringCard) && steeringCard->property("nestedConversationCard").toBool() && steeringAnimation && steeringAnimation->isActive(), "a pending steering You card is nested and keeps its animation"); snapshot.sections.front().cards.back() = { - steeringKey, - CardKind::UserMessage, - thread, - "turn", - "steering-user", - UserMessageData{QStringLiteral("A steering prompt"), {}}}; + steeringKey, CardKind::UserMessage, + thread, "turn", + "steering-user", UserMessageData{"A steering prompt", {}}}; result &= expect(view.reconcile(snapshot), "the steering prompt receives authoritative content"); spin(); - ConversationCard *authoritativeSteering = - card(view, stableKey(steeringKey)); - result &= expect(authoritativeSteering == steeringCard && + ConversationCard *authoritativeSteering = card(view, stableKey(steeringKey)); + result &= + expect(authoritativeSteering == steeringCard && userCard->isAncestorOf(authoritativeSteering) && - authoritativeSteering->cardKind() == - CardKind::UserMessage && + authoritativeSteering->cardKind() == CardKind::UserMessage && steeringAnimation && !steeringAnimation->isActive(), "steering acknowledgement morphs the same nested card"); @@ -1517,12 +1449,13 @@ bool testCardFoldingGeometryAndRetention() { return value.key == emptyReasoning.key; }); std::get(retainedEmptyReasoning->payload).summary = - QStringLiteral("Public reasoning summary arrived"); + "Public reasoning summary arrived"; result &= expect(view.reconcile(snapshot), "empty reasoning accepts later public content"); - result &= expect(!disclosure(emptyReasoningCard)->isHidden() && - disclosure(emptyReasoningCard) - ->property("chevronDirection") == "left", + result &= + expect(!disclosure(emptyReasoningCard)->isHidden() && + disclosure(emptyReasoningCard)->property("chevronDirection") == + "left", "reasoning disclosure appears collapsed when detail arrives"); wheel(view, 10000); @@ -1545,16 +1478,15 @@ bool testCardFoldingGeometryAndRetention() { const QRect expandedDisclosure = paintedDisclosureBounds(disclosure(reasoningCard)); result &= expect( - expandedDisclosure.isValid() && - expandedDisclosure.width() <= 10 && + expandedDisclosure.isValid() && expandedDisclosure.width() <= 10 && expandedDisclosure.right() >= disclosure(reasoningCard)->width() - 3, "expanded disclosure paints only a right-inset down chevron"); const int commandHeight = commandCard->height(); auto &execution = std::get( snapshot.sections.front().cards[3].payload); - execution.output = QStringLiteral( - "streamed line 1\nstreamed line 2\nstreamed line 3\nstreamed line 4"); + execution.output = + "streamed line 1\nstreamed line 2\nstreamed line 3\nstreamed line 4"; result &= expect(view.reconcile(snapshot), "folded command accepts a streamed content update"); auto *output = dynamic_cast( @@ -1595,12 +1527,8 @@ bool testCardFoldingGeometryAndRetention() { promptThread, {}, {}, - LocalPromptData{4242, - QStringLiteral("A temporary prompt"), - PromptState::InFlight, - 0, - {}, - {}}}; + LocalPromptData{ + 4242, "A temporary prompt", PromptState::InFlight, 0, {}, {}}}; VisibleCardData promptActivity = agentCard(promptThread, "turn", 77); ConversationSnapshot promptSnapshot{ promptThread, @@ -1612,8 +1540,8 @@ bool testCardFoldingGeometryAndRetention() { ConversationCard *promptCard = card(view, stableKey(promptKey)); ConversationCard *const promptActivityCard = card(view, stableKey(promptActivity.key)); - result &= expect(promptCard && !promptCard->isCollapsed() && - promptActivityCard && + result &= + expect(promptCard && !promptCard->isCollapsed() && promptActivityCard && promptCard->isAncestorOf(promptActivityCard) && setFolded(promptCard, true), "temporary You prompts start expanded and can be folded"); @@ -1631,7 +1559,7 @@ bool testCardFoldingGeometryAndRetention() { promptSnapshot.sections.front().cards.front() = { promptKey, CardKind::UserMessage, promptThread, "turn", - "user", UserMessageData{QStringLiteral("A temporary prompt"), {}}}; + "user", UserMessageData{"A temporary prompt", {}}}; promptSnapshot.sections.front().key = "turn:folding-prompt"; promptSnapshot.sections.front().turnId = "turn"; view.reconcile(promptSnapshot); @@ -1667,11 +1595,8 @@ bool testCardFoldingGeometryAndRetention() { edgeThread, "turn-2", "edge-command", - CommandExecutionData{QStringLiteral("produce capped output"), - longOutput, - QStringLiteral("completed"), - {}, - 0}}; + CommandExecutionData{ + "produce capped output", utf8(longOutput), "completed", {}, 0}}; edge.sections.back().cards.push_back(edgeCommand); ConversationView edgeView; edgeView.resize(650, 520); @@ -1684,8 +1609,8 @@ bool testCardFoldingGeometryAndRetention() { result &= expect(setFolded(edgeCard, false), "bottom-edge command expands from its compact default"); result &= expect( - edgeCard && edgeCard->mapTo(edgeView.viewport(), QPoint{}).y() < - collapsedTop && + edgeCard && + edgeCard->mapTo(edgeView.viewport(), QPoint{}).y() < collapsedTop && edgeCard->mapTo(edgeView.viewport(), QPoint{}).y() + edgeCard->height() <= edgeView.viewport()->height(), @@ -1700,7 +1625,8 @@ bool testCardFoldingGeometryAndRetention() { "expanded bottom-edge command collapses"); spin(120); result &= expect( - edgeCard && edgeCard->mapTo(edgeView.viewport(), QPoint{}).y() > + edgeCard && + edgeCard->mapTo(edgeView.viewport(), QPoint{}).y() > followedTitleTop && edgeView.verticalScrollBar()->maximum() < expandedScrollMaximum && edgeView.isAtBottom() && @@ -1708,8 +1634,8 @@ bool testCardFoldingGeometryAndRetention() { "bottom-edge collapse accepts the natural range without a blank tail"); constexpr int ComposerOverlayHeight = 80; edgeView.setTrailingSpaceHeight(ComposerOverlayHeight); - result &= expect(setFolded(edgeCard, false), - "bottom-edge command expands again"); + result &= + expect(setFolded(edgeCard, false), "bottom-edge command expands again"); spin(120); result &= expect( edgeView.verticalScrollBar()->maximum() == @@ -1735,7 +1661,7 @@ bool testPresentationOptionsRetainCardsAndInitialFolding() { {"turn:nested-presentation-options:turn", "turn", {{nestedUserKey, CardKind::UserMessage, nestedThread, "turn", "user", - UserMessageData{QStringLiteral("Prompt"), {}}}}, + UserMessageData{"Prompt", {}}}}, nestedUserKey}); ConversationView nestedView; nestedView.setPresentationOptions({false, true, true, true}); @@ -1745,7 +1671,7 @@ bool testPresentationOptionsRetainCardsAndInitialFolding() { spin(); nestedSnapshot.sections.front().cards.push_back( {nestedReasoningKey, CardKind::Reasoning, nestedThread, "turn", - "reasoning", ReasoningData{QStringLiteral("Hidden reasoning")}}); + "reasoning", ReasoningData{"Hidden reasoning"}}); nestedResult &= nestedView.reconcile(nestedSnapshot); spin(); ConversationCard *nestedReasoning = @@ -1759,12 +1685,12 @@ bool testPresentationOptionsRetainCardsAndInitialFolding() { const std::string followingThread = "following-nested-insertion"; ConversationSnapshot followingSnapshot; followingSnapshot.threadId = followingThread; - TurnSection followingSection{"turn:following-nested-insertion:turn", - "turn", {}}; + TurnSection followingSection{ + "turn:following-nested-insertion:turn", "turn", {}}; followingSection.cards.push_back( {AuthoritativeItemKey{followingThread, "turn", "user"}, CardKind::UserMessage, followingThread, "turn", "user", - UserMessageData{QStringLiteral("Prompt"), {}}}); + UserMessageData{"Prompt", {}}}); followingSection.rootCardKey = followingSection.cards.front().key; for (int index = 0; index < 12; ++index) followingSection.cards.push_back( @@ -1773,7 +1699,7 @@ bool testPresentationOptionsRetainCardsAndInitialFolding() { followingSection.cards.begin() + 6, {AuthoritativeItemKey{followingThread, "turn", "reasoning"}, CardKind::Reasoning, followingThread, "turn", "reasoning", - ReasoningData{QStringLiteral("Initially hidden reasoning detail")}}); + ReasoningData{"Initially hidden reasoning detail"}}); followingSnapshot.sections.push_back(std::move(followingSection)); ConversationView followingView; @@ -1782,15 +1708,14 @@ bool testPresentationOptionsRetainCardsAndInitialFolding() { followingView.show(); bool followingResult = followingView.reconcile(followingSnapshot); spin(); - const AuthoritativeItemKey incomingKey{followingThread, "turn", - "incoming"}; + const AuthoritativeItemKey incomingKey{followingThread, "turn", "incoming"}; followingSnapshot.sections.front().cards.push_back( {incomingKey, CardKind::AgentActivity, followingThread, "turn", "incoming", - AgentActivityData{QStringLiteral("tool"), - QStringLiteral("completed"), - QStringLiteral("tool"), - QStringLiteral("New nested activity"), + AgentActivityData{"tool", + "completed", + "tool", + "New nested activity", {}, {}, {}, @@ -1838,23 +1763,17 @@ bool testPresentationOptionsRetainCardsAndInitialFolding() { {"turn:presentation-options:turn", "turn", {{updateKey, CardKind::AgentMessage, thread, "turn", "update", - AgentMessageData{QStringLiteral("First retained update"), false}}, + AgentMessageData{"First retained update", false}}, {finalKey, CardKind::AgentMessage, thread, "turn", "final", - AgentMessageData{QStringLiteral("Final answer remains visible"), - true}}, + AgentMessageData{"Final answer remains visible", true}}, {reasoningKey, CardKind::Reasoning, thread, "turn", "reasoning", - ReasoningData{QStringLiteral("First retained reasoning")}}, + ReasoningData{"First retained reasoning"}}, {firstCommandKey, CardKind::CommandExecution, thread, "turn", "command-1", - CommandExecutionData{QStringLiteral("printf first"), - {}, - QStringLiteral("completed"), - {}, - 0}}, + CommandExecutionData{"printf first", {}, "completed", {}, 0}}, {firstImageKey, CardKind::ImageGeneration, thread, "turn", "image-1", - ImageGenerationData{QStringLiteral("/missing/image-1.png"), - QStringLiteral("completed"), - QStringLiteral("First image")}}}}); + ImageGenerationData{"/missing/image-1.png", "completed", + "First image"}}}}); const auto containsText = [](QWidget *widget, const QString &needle) { return std::ranges::any_of( widget->findChildren(), @@ -1873,11 +1792,10 @@ bool testPresentationOptionsRetainCardsAndInitialFolding() { ConversationCard *reasoning = card(view, stableKey(reasoningKey)); ConversationCard *firstCommand = card(view, stableKey(firstCommandKey)); ConversationCard *firstImage = card(view, stableKey(firstImageKey)); - result &= - expect(update && final && reasoning && firstCommand && firstImage && - !update->isHidden() && !final->isHidden() && - reasoning->isHidden() && !firstCommand->isCollapsed() && - !firstImage->isCollapsed(), + result &= expect( + update && final && reasoning && firstCommand && firstImage && + !update->isHidden() && !final->isHidden() && reasoning->isHidden() && + !firstCommand->isCollapsed() && !firstImage->isCollapsed(), "default presentation hides reasoning and opens commands and images"); if (!update || !final || !reasoning || !firstCommand || !firstImage) return false; @@ -1890,24 +1808,19 @@ bool testPresentationOptionsRetainCardsAndInitialFolding() { "answers or existing folds"); std::get(snapshot.sections.front().cards[0].payload).text = - QStringLiteral("Updated while hidden"); + "Updated while hidden"; std::get(snapshot.sections.front().cards[2].payload).summary = - QStringLiteral("Reasoning updated while hidden"); + "Reasoning updated while hidden"; const AuthoritativeItemKey secondCommandKey{thread, "turn", "command-2"}; const AuthoritativeItemKey secondImageKey{thread, "turn", "image-2"}; snapshot.sections.front().cards.push_back( {secondCommandKey, CardKind::CommandExecution, thread, "turn", "command-2", - CommandExecutionData{QStringLiteral("printf second"), - {}, - QStringLiteral("completed"), - {}, - 0}}); + CommandExecutionData{"printf second", {}, "completed", {}, 0}}); snapshot.sections.front().cards.push_back( {secondImageKey, CardKind::ImageGeneration, thread, "turn", "image-2", - ImageGenerationData{QStringLiteral("/missing/image-2.png"), - QStringLiteral("completed"), - QStringLiteral("Second image")}}); + ImageGenerationData{"/missing/image-2.png", "completed", + "Second image"}}); result &= expect(view.reconcile(snapshot), "hidden cards and a new command accept updates"); spin(); @@ -1939,11 +1852,7 @@ bool testPresentationOptionsRetainCardsAndInitialFolding() { const AuthoritativeItemKey thirdCommandKey{thread, "turn", "command-3"}; snapshot.sections.front().cards.push_back( {thirdCommandKey, CardKind::CommandExecution, thread, "turn", "command-3", - CommandExecutionData{QStringLiteral("printf third"), - {}, - QStringLiteral("completed"), - {}, - 0}}); + CommandExecutionData{"printf third", {}, "completed", {}, 0}}); result &= expect(view.reconcile(snapshot), "a command arrives after restoring expanded-by-default"); spin(); @@ -1967,11 +1876,7 @@ bool testInitialCommandGeometrySettlement() { thread, "turn", "command", - CommandExecutionData{QStringLiteral("printf output"), - output, - QStringLiteral("completed"), - {}, - 0}}; + CommandExecutionData{"printf output", utf8(output), "completed", {}, 0}}; ConversationSnapshot snapshot{ thread, {{"turn:initial-command", "turn", {command}}}, 0, false}; @@ -2010,7 +1915,7 @@ bool testInitialCommandGeometrySettlement() { std::max(1, outputView->viewport()->width() / glyphWidth); auto &execution = std::get( snapshot.sections.front().cards.front().payload); - execution.output = QString(charactersPerLine + 1, QLatin1Char('W')); + execution.output = utf8(QString(charactersPerLine + 1, QLatin1Char('W'))); result &= expect(view.reconcile(snapshot), "single logical output line changes to two visual lines"); spin(); @@ -2034,11 +1939,8 @@ bool testBottomAnchoredCommandOutputGrowth() { thread, "turn-2", "live-command", - CommandExecutionData{QStringLiteral("run live command"), - {}, - QStringLiteral("inProgress"), - {}, - std::nullopt}}; + CommandExecutionData{ + "run live command", {}, "inProgress", {}, std::nullopt}}; snapshot.sections.back().cards.push_back(command); ConversationView view; @@ -2069,9 +1971,9 @@ bool testBottomAnchoredCommandOutputGrowth() { auto &live = std::get( snapshot.sections.back().cards.back().payload); - live.output = QStringLiteral( + live.output = "first wrapped output line with enough words to use real width\n" - "second output line\nthird output line\n\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(); @@ -2084,7 +1986,7 @@ bool testBottomAnchoredCommandOutputGrowth() { QString cappedOutput; for (int line = 0; line < 80; ++line) cappedOutput += QStringLiteral("scrollable line %1\n").arg(line); - live.output = cappedOutput; + live.output = utf8(cappedOutput); result &= expect(view.reconcile(snapshot), "live output reaches its cap"); result &= expect( output->height() == 220 && output->verticalScrollBar()->maximum() > 0 && @@ -2105,11 +2007,7 @@ bool testCommandOutputStateAcrossNavigation() { thread, "turn", "command", - CommandExecutionData{QStringLiteral("produce output"), - output, - QStringLiteral("completed"), - {}, - 0}}; + CommandExecutionData{"produce output", utf8(output), "completed", {}, 0}}; const ConversationSnapshot commandThread{ thread, {{"turn:command-navigation", "turn", {command}}}, 0, false}; @@ -2161,8 +2059,7 @@ bool testPendingPromptAnimation() { "prompt-thread", {}, {}, - LocalPromptData{ - 901, QStringLiteral("pending prompt"), PromptState::InFlight, 0, {}}}; + LocalPromptData{901, "pending prompt", PromptState::InFlight, 0, {}}}; ConversationCard card(pending); card.resize(560, 92); card.show(); @@ -2173,8 +2070,7 @@ bool testPendingPromptAnimation() { bool result = expect(first != second, "an unacknowledged prompt visibly animates its blue sweep"); - result &= expect( - first.pixelColor(10, first.height() - 10).blue() > + result &= expect(first.pixelColor(10, first.height() - 10).blue() > first.pixelColor(10, first.height() - 10).red() && first.pixelColor(10, first.height() - 10).blue() > first.pixelColor(10, first.height() - 10).green(), @@ -2216,8 +2112,8 @@ bool testMessageImagePresentation() { "images", "turn", "message", - UserMessageData{QStringLiteral("attached images"), - {path, portraitPath, squarePath}}}; + UserMessageData{"attached images", + {utf8(path), utf8(portraitPath), utf8(squarePath)}}}; auto *card = new ConversationCard(message); card->resize(430, 400); card->show(); @@ -2232,8 +2128,8 @@ bool testMessageImagePresentation() { }); auto *thumbnail = thumbnails.empty() ? nullptr : thumbnails.front(); const QPixmap thumbnailPixmap = thumbnail ? thumbnail->pixmap() : QPixmap{}; - result &= - expect(ribbon && thumbnails.size() == 3 && thumbnail && + result &= expect( + ribbon && thumbnails.size() == 3 && thumbnail && thumbnail->property("imageAvailable").toBool() && !thumbnailPixmap.isNull() && thumbnailPixmap.width() <= 280 && thumbnailPixmap.height() <= 180 && @@ -2249,8 +2145,7 @@ bool testMessageImagePresentation() { ribbon->verticalScrollBar()->maximum() == 0 && ribbon->frameWidth() == 1 && ribbon->widget() && ribbon->widget()->layout() && - ribbon->widget()->layout()->contentsMargins() == - QMargins(4, 4, 4, 4), + ribbon->widget()->layout()->contentsMargins() == QMargins(4, 4, 4, 4), "multiple bounded thumbnails form one horizontally scrollable " "and canonically bounded ribbon"); const int narrowRibbonHeight = ribbon ? ribbon->height() : 0; @@ -2264,7 +2159,7 @@ bool testMessageImagePresentation() { result &= expect(ribbon && ribbon->horizontalScrollBar()->maximum() > 0, "narrowing restores accessible horizontal overflow"); auto &payload = std::get(message.payload); - payload.text = QStringLiteral("attached image with edited text"); + payload.text = "attached image with edited text"; result &= expect(card->apply(message), "message text updates with an unchanged attachment"); auto *retainedThumbnail = @@ -2297,7 +2192,7 @@ bool testMessageImagePresentation() { const QString missingPath = directory.filePath(QStringLiteral("missing.png")); QPointer retainedGuard(retainedThumbnail); - payload.imagePaths = {missingPath}; + payload.imagePaths = {utf8(missingPath)}; result &= expect(card->apply(message), "changing the image list invalidates card presentation"); auto *missingThumbnail = @@ -2313,7 +2208,7 @@ bool testMessageImagePresentation() { result &= expect(source.save(missingPath), "the missing attachment can be recreated"); QPointer missingGuard(missingThumbnail); - payload.text += QStringLiteral(" after recreation"); + payload.text += " after recreation"; card->apply(message); auto *recreatedThumbnail = card->findChild(QStringLiteral("messageImageThumbnail")); @@ -2324,7 +2219,7 @@ bool testMessageImagePresentation() { result &= expect(QFile::remove(missingPath), "the recreated attachment can be deleted"); QPointer recreatedGuard(recreatedThumbnail); - payload.text += QStringLiteral(" after deletion"); + payload.text += " after deletion"; card->apply(message); auto *deletedThumbnail = card->findChild(QStringLiteral("messageImageThumbnail")); @@ -2332,7 +2227,7 @@ bool testMessageImagePresentation() { !deletedThumbnail->property("imageAvailable").toBool(), "deleting an attachment restores its placeholder"); - payload.imagePaths = {path}; + payload.imagePaths = {utf8(path)}; card->apply(message); thumbnail = card->findChild(QStringLiteral("messageImageThumbnail")); @@ -2372,8 +2267,7 @@ bool testGeneratedImagePresentationAndGenericBound() { "generated", "turn", "image", - ImageGenerationData{path, QStringLiteral("completed"), - QStringLiteral("A generated UI proposal")}}; + ImageGenerationData{utf8(path), "completed", "A generated UI proposal"}}; ConversationCard generatedCard(generated); generatedCard.show(); spin(); @@ -2404,7 +2298,7 @@ bool testGeneratedImagePresentationAndGenericBound() { "generated", "turn", "view", - ImageGenerationData{path, {}, {}}}; + ImageGenerationData{utf8(path), {}, {}}}; ConversationCard viewedCard(viewed); viewedCard.show(); spin(); @@ -2431,7 +2325,7 @@ bool testGeneratedImagePresentationAndGenericBound() { "generated", "turn", "unknown", - GenericActivityData{QStringLiteral("contextCompaction"), + GenericActivityData{"contextCompaction", {{"type", "contextCompaction"}, {"large", std::string(100000, 'x')}}}}; ConversationCard genericCard(generic); @@ -2449,7 +2343,7 @@ bool testGeneratedImagePresentationAndGenericBound() { QStringLiteral("Context compaction"); }) && std::get(generic.payload).type == - QStringLiteral("contextCompaction") && + "contextCompaction" && details && details->text().size() < 4200 && details->text().endsWith( QStringLiteral("[Activity details truncated]")), diff --git a/tests/codex/ConversationProjectionTest.cpp b/tests/codex/ConversationProjectionTest.cpp index a6233dd..f369f3f 100644 --- a/tests/codex/ConversationProjectionTest.cpp +++ b/tests/codex/ConversationProjectionTest.cpp @@ -3,14 +3,13 @@ #include "codex/middle/ConversationProjection.h" #include "codex/middle/PromptCoordinator.h" -#include - #include #include #include #include #include #include +#include namespace codexui::codex::middle { namespace { @@ -86,7 +85,7 @@ bool testCanonicalGroupingAndProjection() { "thread, turn, and item order are retained"); const auto *command = std::get_if(&snapshot.sections[1].cards[0].payload); - result &= expect(command && command->output.isEmpty(), + result &= expect(command && command->output.empty(), "non-presentable command output is projected as absent"); result &= expect(std::holds_alternative( snapshot.sections[0].cards[0].key) && @@ -111,7 +110,7 @@ bool testCanonicalGroupingAndProjection() { const auto *generic = std::get_if(&emptyPlanCard.payload); result &= expect(emptyPlanCard.kind == CardKind::GenericActivity && generic && - generic->type == QStringLiteral("plan"), + generic->type == "plan", "an empty plan retains the generic raw-data fallback"); return result; } @@ -132,13 +131,14 @@ bool testStreamTruncationIsVisible() { thread, {}, ConversationProjection::DefaultAuthoritativeItemLimit, 10); const auto *projected = std::get_if( &snapshot.sections.front().cards.front().payload); - return expect( - projected && - projected->output.startsWith( - QStringLiteral("[Earlier command output was truncated ")) && - projected->output.contains(QStringLiteral("4096 bytes omitted")) && - projected->output.endsWith(QStringLiteral("retained tail")), - "bounded stream projection visibly discloses omitted output before its retained tail"); + return expect(projected && + projected->output.starts_with( + "[Earlier command output was truncated ") && + projected->output.find("4096 bytes omitted") != + std::string::npos && + projected->output.ends_with("retained tail"), + "bounded stream projection visibly discloses omitted output " + "before its retained tail"); } bool testTurnRootSurvivesHistoryPaging() { @@ -157,8 +157,8 @@ bool testTurnRootSurvivesHistoryPaging() { appendItem(thread, "turn-long", item("steering-user", {{"type", "userMessage"}, - {"content", {{{"type", "text"}, - {"text", "Later steering prompt"}}}}})); + {"content", + {{{"type", "text"}, {"text", "Later steering prompt"}}}}})); for (int index = 0; index < 45; ++index) appendItem(thread, "turn-long", item("after-steer-" + std::to_string(index), @@ -181,21 +181,22 @@ bool testTurnRootSurvivesHistoryPaging() { thread.turns.at("turn-long").status = "completed"; const ConversationSnapshot completed = ConversationProjection::project(thread, {}, 80, 101); - result &= expect( - completed.sections.front().rootCardKey == CardKey{rootKey} && + result &= + expect(completed.sections.front().rootCardKey == CardKey{rootKey} && completed.find(rootKey) && completed.find(steeringKey) && completed.hiddenAuthoritativeItemCount == 11, "turn completion cannot release the retained activity's root"); const ConversationSnapshot loaded = ConversationProjection::project(thread, {}, 160, 102); - result &= expect( - loaded.sections.size() == 1 && + result &= + expect(loaded.sections.size() == 1 && loaded.sections.front().rootCardKey == CardKey{rootKey} && std::ranges::count(loaded.cardKeys(), CardKey{rootKey}) == 1 && loaded.find(steeringKey) && !loaded.hasMore && loaded.hiddenAuthoritativeItemCount == 0, - "loading older activity retains one stable root and ordinary paging semantics"); + "loading older activity retains one stable root and ordinary " + "paging semantics"); const auto indexed = indexAuthoritativeItems(thread.id, &thread); result &= expect( @@ -207,16 +208,14 @@ bool testTurnRootSurvivesHistoryPaging() { addTurn(rootOnlyHidden, "turn"); appendItem( rootOnlyHidden, "turn", - item("root", - {{"type", "userMessage"}, + item("root", {{"type", "userMessage"}, {"content", {{{"type", "text"}, {"text", "Root"}}}}})); appendItem(rootOnlyHidden, "turn", - item("answer", {{"type", "agentMessage"}, - {"text", "Answer"}})); + item("answer", {{"type", "agentMessage"}, {"text", "Answer"}})); const ConversationSnapshot rootOnlyPinned = ConversationProjection::project(rootOnlyHidden, {}, 1, 103); - result &= expect( - rootOnlyPinned.cardKeys().size() == 2 && + result &= + expect(rootOnlyPinned.cardKeys().size() == 2 && rootOnlyPinned.hiddenAuthoritativeItemCount == 0 && !rootOnlyPinned.hasMore, "pinning the only earlier root leaves no hidden activity to load"); @@ -224,31 +223,31 @@ bool testTurnRootSurvivesHistoryPaging() { ThreadPresentation conflicting; conflicting.id = "thread-unique-root"; PromptCoordinator prompts; - const auto localId = prompts.admit( - conflicting.id, QStringLiteral("Locally admitted start"), {}, + const auto localId = + prompts.admit(conflicting.id, "Locally admitted start", {}, nlohmann::json::object(), nullptr, std::nullopt, 200); result &= expect(prompts.beginNext(conflicting.id).has_value() && prompts.acknowledge(conflicting.id, localId, std::string("turn"), 201), "a locally admitted turn start reaches acknowledgment"); addTurn(conflicting, "turn"); - appendItem( - conflicting, "turn", + appendItem(conflicting, "turn", item("authoritative-root", {{"type", "userMessage"}, - {"content", {{{"type", "text"}, + {"content", + {{{"type", "text"}, {"text", "Different authoritative prompt"}}}}})); prompts.reconcile(conflicting.id, conflicting, 202); const ConversationSnapshot uniqueRoot = ConversationProjection::project( conflicting, prompts.submissions(conflicting.id), 80, 202); const AuthoritativeItemKey authoritativeRoot{conflicting.id, "turn", "authoritative-root"}; - result &= expect( - uniqueRoot.sections.size() == 1 && + result &= expect(uniqueRoot.sections.size() == 1 && uniqueRoot.sections.front().rootCardKey == CardKey{authoritativeRoot} && uniqueRoot.find(LocalPromptKey{localId}), - "an unmatched local start cannot compete with an existing authoritative root"); + "an unmatched local start cannot compete with an existing " + "authoritative root"); return result; } @@ -257,11 +256,11 @@ bool testQueueIsolationAndRealAcknowledgement() { ThreadPresentation second = baseThread("thread-b"); PromptCoordinator prompts; const auto firstId = - prompts.admit(first.id, QStringLiteral("same"), {}, - nlohmann::json::object(), &first, std::nullopt, 100); + prompts.admit(first.id, "same", {}, nlohmann::json::object(), &first, + std::nullopt, 100); const auto secondId = - prompts.admit(second.id, QStringLiteral("other"), {}, - nlohmann::json::object(), &second, std::nullopt, 101); + prompts.admit(second.id, "other", {}, nlohmann::json::object(), &second, + std::nullopt, 101); const auto firstDispatch = prompts.beginNext(first.id); bool result = expect(firstDispatch && firstDispatch->id == firstId, @@ -334,9 +333,9 @@ bool testQueueIsolationAndRealAcknowledgement() { bool testDispatchChoiceAndPreHydrationTail() { ThreadPresentation thread = baseThread("thread-dispatch"); PromptCoordinator prompts; - const auto id = prompts.admit( - thread.id, QStringLiteral("queued while active"), {}, - nlohmann::json::object(), &thread, std::string("turn-1"), 300); + const auto id = prompts.admit(thread.id, "queued while active", {}, + nlohmann::json::object(), &thread, + std::string("turn-1"), 300); const auto dispatch = prompts.beginNext(thread.id, std::nullopt); bool result = expect(dispatch && dispatch->id == id && !dispatch->expectedTurnId, @@ -344,8 +343,8 @@ bool testDispatchChoiceAndPreHydrationTail() { PromptCoordinator beforeHydration; const auto tailId = beforeHydration.admit( - "thread-tail", QStringLiteral("after retained history"), {}, - nlohmann::json::object(), nullptr, std::nullopt, 400); + "thread-tail", "after retained history", {}, nlohmann::json::object(), + nullptr, std::nullopt, 400); ThreadPresentation retained = baseThread("thread-tail"); beforeHydration.reconcile(retained.id, retained, 401); const ConversationSnapshot atTail = ConversationProjection::project( @@ -359,7 +358,7 @@ bool testDispatchChoiceAndPreHydrationTail() { ThreadPresentation empty; empty.id = "thread-recovering"; const auto recoveringId = - recovering.admit(empty.id, QStringLiteral("retry after resume"), {}, + recovering.admit(empty.id, "retry after resume", {}, nlohmann::json::object(), &empty, std::nullopt, 500); result &= expect(recovering.beginNext(empty.id).has_value() && recovering.requeue(empty.id, recoveringId), @@ -378,8 +377,8 @@ bool testClientIdentityBindsBeforeAcknowledgement() { ThreadPresentation thread = baseThread("thread-client-id"); PromptCoordinator prompts; const auto id = - prompts.admit(thread.id, QStringLiteral("identity matched"), {}, - nlohmann::json::object(), &thread, std::nullopt, 500); + prompts.admit(thread.id, "identity matched", {}, nlohmann::json::object(), + &thread, std::nullopt, 500); const auto dispatch = prompts.beginNext(thread.id); bool result = expect(dispatch && !dispatch->clientUserMessageId.empty(), "every dispatch carries a stable client message id"); @@ -405,7 +404,7 @@ bool testClientIdentityBindsBeforeAcknowledgement() { snapshot.cardKeys().size() == 3 && snapshot.find(LocalPromptKey{id}) && snapshot.find(LocalPromptKey{id})->kind == CardKind::LocalPrompt, "early materialization keeps one awaiting visual card"); - result &= expect(prompts.fail(thread.id, id, QStringLiteral("rejected")), + result &= expect(prompts.fail(thread.id, id, "rejected"), "the exact terminal callback can fail a bound prompt"); const ConversationSnapshot failed = ConversationProjection::project( thread, prompts.submissions(thread.id), 80, 502); @@ -414,7 +413,7 @@ bool testClientIdentityBindsBeforeAcknowledgement() { failedCard ? std::get_if(&failedCard->payload) : nullptr; result &= expect(failed.cardKeys().size() == 3 && failedPrompt && failedPrompt->state == PromptState::Failed && - failedPrompt->error == QStringLiteral("rejected"), + failedPrompt->error == "rejected", "a failure remains explicit after early materialization"); return result; } @@ -423,9 +422,9 @@ bool testFirstResponseOrderIsAdmissionStable() { ThreadPresentation reasoningFirst; reasoningFirst.id = "thread-reasoning-first"; PromptCoordinator prompts; - const auto promptId = prompts.admit( - reasoningFirst.id, QStringLiteral("new prompt"), {}, - nlohmann::json::object(), &reasoningFirst, std::nullopt, 600); + const auto promptId = prompts.admit(reasoningFirst.id, "new prompt", {}, + nlohmann::json::object(), &reasoningFirst, + std::nullopt, 600); const auto dispatch = prompts.beginNext(reasoningFirst.id); bool result = expect(dispatch.has_value(), "an empty-thread prompt begins dispatch"); @@ -486,8 +485,8 @@ bool testFirstResponseOrderIsAdmissionStable() { ThreadPresentation continued = baseThread("thread-continued"); PromptCoordinator continuedPrompts; const auto continuedId = continuedPrompts.admit( - continued.id, QStringLiteral("continued prompt"), {}, - nlohmann::json::object(), &continued, std::nullopt, 750); + continued.id, "continued prompt", {}, nlohmann::json::object(), + &continued, std::nullopt, 750); const auto continuedDispatch = continuedPrompts.beginNext(continued.id); result &= expect(continuedDispatch.has_value(), "a continued-thread prompt begins dispatch"); @@ -530,9 +529,9 @@ bool testFirstResponseOrderIsAdmissionStable() { ThreadPresentation userFirst; userFirst.id = "thread-user-first"; PromptCoordinator ordinaryPrompts; - const auto ordinaryId = ordinaryPrompts.admit( - userFirst.id, QStringLiteral("ordinary prompt"), {}, - nlohmann::json::object(), &userFirst, std::nullopt, 800); + const auto ordinaryId = ordinaryPrompts.admit(userFirst.id, "ordinary prompt", + {}, nlohmann::json::object(), + &userFirst, std::nullopt, 800); const auto ordinaryDispatch = ordinaryPrompts.beginNext(userFirst.id); result &= expect(ordinaryDispatch.has_value(), "the user-first prompt begins dispatch"); @@ -565,11 +564,11 @@ bool testAnchoredDuplicatePrompts() { ThreadPresentation thread = baseThread("thread-duplicates"); PromptCoordinator prompts; const auto firstId = - prompts.admit(thread.id, QStringLiteral("repeat"), {}, - nlohmann::json::object(), &thread, std::nullopt, 1000); + prompts.admit(thread.id, "repeat", {}, nlohmann::json::object(), &thread, + std::nullopt, 1000); const auto secondId = - prompts.admit(thread.id, QStringLiteral("repeat"), {}, - nlohmann::json::object(), &thread, std::nullopt, 1001); + prompts.admit(thread.id, "repeat", {}, nlohmann::json::object(), &thread, + std::nullopt, 1001); bool result = expect(prompts.beginNext(thread.id).has_value(), "first duplicate dispatches"); @@ -630,8 +629,7 @@ bool testAnchoredDuplicatePrompts() { "acknowledged duplicates share one turn while steering stays nested"); PromptCoordinator moved; - const auto draftId = - moved.admit("", QStringLiteral("draft"), {}, nlohmann::json::object(), + const auto draftId = moved.admit("", "draft", {}, nlohmann::json::object(), nullptr, std::nullopt, 1); result &= expect(moved.reassignThread("", "assigned") && moved.submission("assigned", draftId) && @@ -642,27 +640,21 @@ bool testAnchoredDuplicatePrompts() { } bool testCommandOutputVisibility() { - bool result = expect(!terminalOutputHasVisibleText(QStringView{}), - "empty output is not visible"); - result &= expect( - !terminalOutputHasVisibleText(QStringView{QStringLiteral(" \n\t")}), + bool result = + expect(!terminalOutputHasVisibleText({}), "empty output is not visible"); + result &= expect(!terminalOutputHasVisibleText(" \n\t"), "whitespace output is not visible"); - result &= expect(!terminalOutputHasVisibleText( - QStringView{QStringLiteral("\x1b[0m\x1b]0;title\x07")}), + result &= expect(!terminalOutputHasVisibleText("\x1b[0m\x1b]0;title\x07"), "ANSI and control output is not visible"); - result &= expect( - terminalOutputHasVisibleText(QStringView{QStringLiteral("done\n")}), + result &= expect(terminalOutputHasVisibleText("done\n"), "printable command output is visible"); - result &= expect(trimTrailingEmptyLines(QStringView{ - QStringLiteral("first\nsecond\n\n \t\r\n")}) == - QStringLiteral("first\nsecond"), + result &= expect(trimTrailingEmptyLines("first\nsecond\n\n \t\r\n") == + "first\nsecond", "trailing empty terminal lines are removed"); - result &= expect(trimTrailingEmptyLines( - QStringView{QStringLiteral(" meaningful spacing ")}) == - QStringLiteral(" meaningful spacing "), + result &= expect(trimTrailingEmptyLines(" meaningful spacing ") == + " meaningful spacing ", "spacing on a non-empty final line is retained"); - result &= expect( - trimTrailingEmptyLines(QStringView{QStringLiteral(" \t\r\n")}).isEmpty(), + result &= expect(trimTrailingEmptyLines(" \t\r\n").empty(), "an entirely empty-line display normalizes to zero lines"); return result; } @@ -688,25 +680,22 @@ bool testUserMessageImages() { const auto *mixed = std::get_if(&cards[2].payload); const auto *imageOnly = std::get_if(&cards[3].payload); bool result = expect( - mixed && mixed->text == QStringLiteral("image prompt") && - mixed->imagePaths == QStringList{QStringLiteral("/tmp/first.png"), - QStringLiteral("/tmp/second.jpg")}, + mixed && mixed->text == "image prompt" && + mixed->imagePaths == + std::vector{"/tmp/first.png", "/tmp/second.jpg"}, "authoritative user messages retain text and local image paths"); - result &= expect(imageOnly && imageOnly->text.isEmpty() && + result &= expect(imageOnly && imageOnly->text.empty() && imageOnly->imagePaths == - QStringList{QStringLiteral("/tmp/only.png")}, + std::vector{"/tmp/only.png"}, "an image-only user message remains presentable"); PromptSubmission pending; pending.id = 41; pending.threadId = thread.id; - pending.prompt = QStringLiteral("pending image"); + pending.prompt = "pending image"; pending.state = PromptState::InFlight; - pending.attachments = { - {QStringLiteral("/tmp/pending.png"), QStringLiteral("pending.png"), - QStringLiteral("image/png"), 10}, - {QStringLiteral("/tmp/note.txt"), QStringLiteral("note.txt"), - QStringLiteral("text/plain"), 10}}; + pending.attachments = {{"/tmp/pending.png", "pending.png", "image/png", 10}, + {"/tmp/note.txt", "note.txt", "text/plain", 10}}; const std::array submissions{pending}; const ConversationSnapshot local = ConversationProjection::project( thread, submissions, @@ -716,16 +705,15 @@ bool testUserMessageImages() { localCard ? std::get_if(&localCard->payload) : nullptr; result &= expect(localPrompt && localPrompt->imagePaths == - QStringList{QStringLiteral("/tmp/pending.png")}, + std::vector{"/tmp/pending.png"}, "temporary prompts expose only their image attachment paths"); ThreadPresentation replacement = baseThread("replacement-thread"); addTurn(replacement, "turn-image"); PromptCoordinator prompts; const auto submissionId = prompts.admit( - replacement.id, QStringLiteral("replacement image"), - {{QStringLiteral("/tmp/replacement.png"), - QStringLiteral("replacement.png"), QStringLiteral("image/png"), 10}}, + replacement.id, "replacement image", + {{"/tmp/replacement.png", "replacement.png", "image/png", 10}}, nlohmann::json::object(), &replacement, std::nullopt, 100); const auto dispatch = prompts.beginNext(replacement.id); result &= @@ -753,7 +741,7 @@ bool testUserMessageImages() { replacedCard && replacedCard->kind == CardKind::UserMessage && replacedMessage && replacedMessage->imagePaths == - QStringList{QStringLiteral("/tmp/replacement.png")} && + std::vector{"/tmp/replacement.png"} && !prompts.submission(replacement.id, submissionId), "authoritative image presentation survives local payload compaction"); return result; @@ -768,9 +756,9 @@ bool testGeneratedImageProjection() { {"savedPath", "/tmp/generated.png"}, {"revisedPrompt", "A restrained CodexUI color proposal"}, {"result", std::string(100000, 'A')}})); - appendItem(thread, "turn-1", - item("image-view", {{"type", "imageView"}, - {"path", "/tmp/review.png"}})); + appendItem( + thread, "turn-1", + item("image-view", {{"type", "imageView"}, {"path", "/tmp/review.png"}})); const ConversationSnapshot snapshot = ConversationProjection::project( thread, {}, ConversationProjection::DefaultAuthoritativeItemLimit, 10); @@ -778,22 +766,19 @@ bool testGeneratedImageProjection() { AuthoritativeItemKey{thread.id, "turn-1", "generated-image"}); const auto *image = card ? std::get_if(&card->payload) : nullptr; - const VisibleCardData *viewCard = snapshot.find( - AuthoritativeItemKey{thread.id, "turn-1", "image-view"}); + const VisibleCardData *viewCard = + snapshot.find(AuthoritativeItemKey{thread.id, "turn-1", "image-view"}); const auto *viewImage = viewCard ? std::get_if(&viewCard->payload) : nullptr; bool result = expect( card && card->kind == CardKind::ImageGeneration && image && - image->path == QStringLiteral("/tmp/generated.png") && - image->status == QStringLiteral("completed") && - image->revisedPrompt == - QStringLiteral("A restrained CodexUI color proposal"), + image->path == "/tmp/generated.png" && image->status == "completed" && + image->revisedPrompt == "A restrained CodexUI color proposal", "generated images project their saved path without exposing base64"); - result &= expect(viewCard && viewCard->kind == CardKind::ImageGeneration && - viewImage && - viewImage->path == QStringLiteral("/tmp/review.png") && - viewImage->status.isEmpty() && - viewImage->revisedPrompt.isEmpty(), + result &= + expect(viewCard && viewCard->kind == CardKind::ImageGeneration && + viewImage && viewImage->path == "/tmp/review.png" && + viewImage->status.empty() && viewImage->revisedPrompt.empty(), "image-view items reuse the local image presentation"); return result; } @@ -868,13 +853,12 @@ bool testTruthfulActivityProjection() { !structured, "structured plan state remains Inspector-only in production projection"); result &= - expect(textPlan && - textPlan->legacyText == - QStringLiteral("A textual plan-mode response"), + expect(textPlan && textPlan->legacyText == "A textual plan-mode response", "textual plan items remain supported conversation content"); const auto *reasoningData = reasoning ? std::get_if(&reasoning->payload) : nullptr; - result &= expect(reasoningData && reasoningData->summary.isEmpty(), + result &= expect( + reasoningData && reasoningData->summary.empty(), "reasoning remains a stable progress card without a public summary"); result &= expect(execution && execution->durationMilliseconds == 2400, "command duration is retained when supplied"); @@ -885,30 +869,26 @@ bool testTruthfulActivityProjection() { fileData->changes[1].additions == 1 && fileData->changes[1].deletions == 0, "file-change rows and unified-diff counts are projected truthfully"); - result &= expect( - agentData && agentData->childThreadId == QStringLiteral("child-thread") && - agentData->model == QStringLiteral("gpt-current") && - agentData->reasoningEffort == QStringLiteral("medium") && - agentData->senderThreadId == QStringLiteral("activity-thread"), + result &= + expect(agentData && agentData->childThreadId == "child-thread" && + agentData->model == "gpt-current" && + agentData->reasoningEffort == "medium" && + agentData->senderThreadId == "activity-thread", "available agent identity and execution settings are retained"); return result; } bool testFileLinksArePartOfTheCanonicalPrompt() { const std::vector attachments{ - {QStringLiteral("/tmp/review notes [final] (2).pdf"), - QStringLiteral("review notes [final] (2).pdf"), - QStringLiteral("application/pdf"), 10}, - {QStringLiteral("/tmp/image.png"), QStringLiteral("image.png"), - QStringLiteral("image/png"), 10}, - {QStringLiteral("/tmp/audio.ogg"), QStringLiteral("audio.ogg"), - QStringLiteral("audio/ogg"), 10}}; - const QString composed = - promptWithFileLinks(QStringLiteral("Review this"), attachments); - const QString expected = QStringLiteral( + {"/tmp/review notes [final] (2).pdf", "review notes [final] (2).pdf", + "application/pdf", 10}, + {"/tmp/image.png", "image.png", "image/png", 10}, + {"/tmp/audio.ogg", "audio.ogg", "audio/ogg", 10}}; + const std::string composed = promptWithFileLinks("Review this", attachments); + const std::string expected = "Review this\n\nAttached files:\n" "- [review notes \\[final\\] (2).pdf]" - "(file:///tmp/review%20notes%20%5Bfinal%5D%20%282%29.pdf)"); + "(file:///tmp/review%20notes%20%5Bfinal%5D%20%282%29.pdf)"; bool result = expect(composed == expected, "ordinary files become escaped durable Markdown links"); diff --git a/tests/codex/PendingRequestPolicyTest.cpp b/tests/codex/PendingRequestPolicyTest.cpp new file mode 100644 index 0000000..f5043b1 --- /dev/null +++ b/tests/codex/PendingRequestPolicyTest.cpp @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/PendingRequestPolicy.h" + +#include +#include +#include +#include +#include + +namespace { + +using codexui::codex::PendingRequestPolicy; +using codexui::codex::PendingRequestResponse; + +bool expect(bool condition, std::string_view message) { + std::cout << (condition ? "PASS " : "FAIL ") << message << '\n'; + return condition; +} + +nlohmann::json error(std::string message) { + return {{"code", -32601}, {"message", std::move(message)}}; +} + +nlohmann::json denied() { + return {{"decision", {{"denied", {{"rejection", "Denied by user"}}}}}}; +} + +nlohmann::json failedTool(std::string text) { + return {{"contentItems", + nlohmann::json::array( + {{{"type", "inputText"}, {"text", std::move(text)}}})}, + {"success", false}}; +} + +bool expectResponse(std::string_view name, const PendingRequestResponse &actual, + const nlohmann::json &result, + const nlohmann::json &responseError = nullptr) { + const bool matches = actual.result == result && actual.error == responseError; + if (!matches) { + std::cerr << "Expected result " << result.dump() << " and error " + << responseError.dump() << ", got result " << actual.result.dump() + << " and error " << actual.error.dump() << '\n'; + } + return expect(matches, name); +} + +bool verifyPresentationMetadata() { + struct TitleCase { + std::string_view kind; + std::string_view title; + std::string_view dialogTitle; + }; + constexpr std::array titles{ + TitleCase{"command-approval", "Command approval requested", + "Command approval"}, + TitleCase{"file-change-approval", "File-change approval requested", + "File-change approval"}, + TitleCase{"user-input", "Codex needs input", "Codex needs input"}, + TitleCase{"mcp-elicitation", "MCP server request", "MCP server request"}, + TitleCase{"permissions-approval", "Permission request", + "Permission request"}, + TitleCase{"dynamic-tool-call", "Codex request needs attention", + "Dynamic tool request"}, + TitleCase{"authentication-refresh", "Codex request needs attention", + "Authentication refresh"}, + TitleCase{"attestation", "Codex request needs attention", + "Attestation request"}, + TitleCase{"legacy-patch-approval", "Legacy patch approval", + "Legacy patch approval"}, + TitleCase{"legacy-command-approval", "Legacy command approval", + "Legacy command approval"}, + TitleCase{"unsupported", "Codex request needs attention", + "Unsupported Codex request"}, + }; + + bool passed = true; + for (const TitleCase &entry : titles) { + passed &= expect(PendingRequestPolicy::title(entry.kind) == entry.title && + PendingRequestPolicy::dialogTitle(entry.kind) == + entry.dialogTitle, + std::string("titles: ") + std::string(entry.kind)); + } + + constexpr std::array directAcceptKinds{ + "command-approval", "file-change-approval", "permissions-approval", + "legacy-patch-approval", "legacy-command-approval"}; + for (const std::string_view kind : directAcceptKinds) + passed &= expect(PendingRequestPolicy::supportsDirectAccept(kind), + std::string("direct accept: ") + std::string(kind)); + passed &= expect(!PendingRequestPolicy::supportsDirectAccept("user-input") && + PendingRequestPolicy::directAcceptLabel( + "permissions-approval") == "Allow this turn" && + PendingRequestPolicy::directAcceptLabel( + "command-approval") == "Accept", + "direct-accept capability and labels are exact"); + + const nlohmann::json request{{"command", "make test"}, + {"reason", "review"}, + {"message", "Need confirmation"}, + {"cwd", "/repo"}, + {"grantRoot", "/repo"}, + {"permissions", {{"network", true}}}, + {"questions", nlohmann::json::array({1, 2})}}; + const std::string actualDetail = + PendingRequestPolicy::detail("request-1", "thread-1", request); + const std::string expectedDetail = + "Command: make test | Reason: review | Need confirmation | " + "Directory: /repo | Grant root: /repo | Permissions: " + "{\n\"network\": true\n} | 2 questions"; + if (actualDetail != expectedDetail) + std::cerr << "Expected detail " << expectedDetail << ", got " + << actualDetail << '\n'; + passed &= + expect(actualDetail == expectedDetail, + "request detail preserves the existing field order and labels"); + passed &= expect(PendingRequestPolicy::detail("request-2", "thread-2", + nlohmann::json::object()) == + "Request request-2 for thread thread-2", + "empty request detail uses identity fallback"); + return passed; +} + +bool verifySubmissionResponses() { + bool passed = true; + const nlohmann::json permissions{{"network", {{"enabled", true}}}}; + const nlohmann::json request{{"permissions", permissions}}; + const nlohmann::json answers{ + {"question", {{"answers", nlohmann::json::array({"yes"})}}}}; + const nlohmann::json content{{"accepted", true}}; + + passed &= expectResponse( + "command submission response", + PendingRequestPolicy::responseForSubmission( + "command-approval", nlohmann::json::object(), "cancel"), + {{"decision", "cancel"}}); + passed &= expectResponse( + "file-change submission response", + PendingRequestPolicy::responseForSubmission( + "file-change-approval", nlohmann::json::object(), "acceptForSession"), + {{"decision", "acceptForSession"}}); + passed &= + expectResponse("user-input submission response", + PendingRequestPolicy::responseForSubmission( + "user-input", nlohmann::json::object(), {}, answers), + {{"answers", answers}}); + passed &= expectResponse( + "MCP accepted submission response", + PendingRequestPolicy::responseForSubmission( + "mcp-elicitation", nlohmann::json::object(), "accept", content), + {{"action", "accept"}, {"content", content}, {"_meta", nullptr}}); + passed &= expectResponse( + "MCP cancelled submission response", + PendingRequestPolicy::responseForSubmission( + "mcp-elicitation", nlohmann::json::object(), "cancel", content), + {{"action", "cancel"}, {"content", nullptr}, {"_meta", nullptr}}); + passed &= + expectResponse("permission accepted submission response", + PendingRequestPolicy::responseForSubmission( + "permissions-approval", request, "session"), + {{"permissions", permissions}, {"scope", "session"}}); + passed &= expectResponse("permission declined submission response", + PendingRequestPolicy::responseForSubmission( + "permissions-approval", request, "decline"), + nlohmann::json::object(), + error("Permission request declined by user")); + passed &= + expectResponse("legacy patch approved-for-session response", + PendingRequestPolicy::responseForSubmission( + "legacy-patch-approval", nlohmann::json::object(), + "approved_for_session"), + {{"decision", "approved_for_session"}}); + passed &= expectResponse( + "legacy patch denied response", + PendingRequestPolicy::responseForSubmission( + "legacy-patch-approval", nlohmann::json::object(), "denied"), + denied()); + passed &= expectResponse( + "legacy command abort response", + PendingRequestPolicy::responseForSubmission( + "legacy-command-approval", nlohmann::json::object(), "abort"), + {{"decision", "abort"}}); + passed &= + expectResponse("dynamic-tool unavailable response", + PendingRequestPolicy::responseForSubmission( + "dynamic-tool-call", nlohmann::json::object()), + failedTool("CodexUI does not provide this dynamic tool")); + for (const std::string_view kind : + {"authentication-refresh", "attestation", "unsupported"}) { + passed &= + expectResponse(std::string(kind) + " unsupported submission response", + PendingRequestPolicy::responseForSubmission( + kind, nlohmann::json::object()), + nlohmann::json::object(), + error("CodexUI does not support this server request")); + } + return passed; +} + +bool verifyCanonicalResponses() { + struct ResponseCase { + std::string_view kind; + nlohmann::json request; + nlohmann::json positiveResult; + nlohmann::json positiveError; + nlohmann::json negativeResult; + nlohmann::json negativeError; + }; + const nlohmann::json cannotApprove = + error("CodexUI cannot directly approve this server request"); + const nlohmann::json declined = error("Request declined by user"); + const nlohmann::json empty = nlohmann::json::object(); + const nlohmann::json permissions{{"network", true}}; + const std::array cases{ + ResponseCase{"command-approval", + empty, + {{"decision", "accept"}}, + nullptr, + {{"decision", "decline"}}, + nullptr}, + ResponseCase{"file-change-approval", + empty, + {{"decision", "accept"}}, + nullptr, + {{"decision", "decline"}}, + nullptr}, + ResponseCase{"user-input", empty, empty, cannotApprove, empty, declined}, + ResponseCase{ + "mcp-elicitation", + empty, + empty, + cannotApprove, + {{"action", "decline"}, {"content", nullptr}, {"_meta", nullptr}}, + nullptr}, + ResponseCase{"permissions-approval", + {{"permissions", permissions}}, + {{"permissions", permissions}, {"scope", "turn"}}, + nullptr, + empty, + declined}, + ResponseCase{"legacy-patch-approval", + empty, + {{"decision", "approved"}}, + nullptr, + denied(), + nullptr}, + ResponseCase{"legacy-command-approval", + empty, + {{"decision", "approved"}}, + nullptr, + denied(), + nullptr}, + ResponseCase{"dynamic-tool-call", empty, empty, cannotApprove, + failedTool("Request declined by user"), nullptr}, + ResponseCase{"authentication-refresh", empty, empty, cannotApprove, empty, + declined}, + ResponseCase{"attestation", empty, empty, cannotApprove, empty, declined}, + ResponseCase{"unsupported", empty, empty, cannotApprove, empty, declined}, + }; + + bool passed = true; + for (const ResponseCase &entry : cases) { + passed &= expectResponse( + std::string(entry.kind) + " direct positive response", + PendingRequestPolicy::positiveResponse(entry.kind, entry.request), + entry.positiveResult, entry.positiveError); + passed &= expectResponse( + std::string(entry.kind) + " negative response", + PendingRequestPolicy::negativeResponse(entry.kind, entry.request), + entry.negativeResult, entry.negativeError); + } + return passed; +} + +} // namespace + +int main() { + const bool passed = verifyPresentationMetadata() && + verifySubmissionResponses() && verifyCanonicalResponses(); + return passed ? 0 : 1; +} diff --git a/tests/codex/ShellIntegrationTest.cpp b/tests/codex/ShellIntegrationTest.cpp index a579163..60afc06 100644 --- a/tests/codex/ShellIntegrationTest.cpp +++ b/tests/codex/ShellIntegrationTest.cpp @@ -48,6 +48,15 @@ class FrontendSessionTestPeer final { static int takeClientDescriptor(FrontendSession &session) { return std::exchange(session.clientDescriptor, -1); } + + static void receive(FrontendSession &session, nlohmann::json frame) { + session.receiveMessage(std::move(frame)); + } + + static void failOutstanding(FrontendSession &session, int code, + std::string message) { + session.failAllPending(code, std::move(message)); + } }; namespace { @@ -61,6 +70,64 @@ bool expect(bool condition, const char *message) { return false; } +bool verifyFrontendBoundaryOrdering(Configuration &configuration) { + FrontendSession session(configuration); + std::vector order; + std::vector activity; + nlohmann::json completed; + session.setEventHandler( + [&order](const nlohmann::json &) { order.emplace_back("event"); }); + session.setActivityHandler([&activity](const std::string &threadId) { + activity.push_back(threadId); + }); + const std::string correlation = session.request( + "thread.read", {{"threadId", "ordering"}}, + [&order, &completed](const nlohmann::json &result) { + order.emplace_back("completion"); + completed = result; + }); + FrontendSessionTestPeer::receive( + session, presentation::result(1, 1, "thread.read", correlation, true, + {{"thread", {{"id", "ordering"}}}}, + Authority::Replace, + {{"threadId", "ordering"}})); + bool result = expect( + order == std::vector{"completion", "event"}, + "a correlated completion remains ordered before global frame delivery"); + result &= expect(presentation::isPresentationFrame(completed) && + completed.value("action", std::string{}) == + "thread.read", + "a successful completion receives a complete presentation result"); + result &= expect( + activity.empty(), + "selection hydration requests and results do not report activity"); + + const std::string renameCorrelation = session.request( + "thread.rename", {{"threadId", "ordering"}, {"name", "Renamed"}}); + FrontendSessionTestPeer::receive( + session, presentation::result(2, 1, "thread.rename", renameCorrelation, + true, nlohmann::json::object(), + Authority::Merge, + {{"threadId", "ordering"}})); + result &= expect( + activity == std::vector{"ordering", "ordering"}, + "meaningful thread requests and results both report activity"); + + nlohmann::json failed; + const std::string failedCorrelation = session.request( + "thread.resume", {{"threadId", "ordering"}}, + [&failed](const nlohmann::json &response) { failed = response; }); + FrontendSessionTestPeer::failOutstanding(session, -32020, + "test connection loss"); + result &= expect( + presentation::isPresentationFrame(failed) && + failed.value("action", std::string{}) == "thread.resume" && + failed.value("correlationId", std::string{}) == failedCorrelation && + !failed.value("ok", true), + "locally failed operations preserve the complete result contract"); + return result; +} + void spin(int milliseconds = 0) { milliseconds = std::max(milliseconds, 20); QElapsedTimer timer; @@ -1036,7 +1103,7 @@ bool verifyPendingRequestTextBoundaries() { (*command)->textFormat() == Qt::PlainText; dialog->reject(); }); - const PendingRequestPresentation request{ + const PendingRequestDescriptor request{ "unsafe-command", "command-approval", "thread-a", 1, {{"command", "untrusted command"}}}; static_cast(PendingRequestDialog::present(request, nullptr)); @@ -1054,7 +1121,7 @@ bool verifyPendingRequestTextBoundaries() { }); dialog->reject(); }); - const PendingRequestPresentation elicitation{ + const PendingRequestDescriptor elicitation{ "unsafe-link", "mcp-elicitation", "thread-a", 1, {{"url", "https://example.invalid/\">"}}}; static_cast(PendingRequestDialog::present(elicitation, nullptr)); @@ -1093,7 +1160,7 @@ bool verifyPendingRequestValidationRetainsInput() { edits.back()->setText(QStringLiteral("Second answer")); submit->click(); }); - const PendingRequestPresentation questions{ + const PendingRequestDescriptor questions{ "questions", "user-input", "thread-a", 1, {{"questions", nlohmann::json::array( @@ -1139,7 +1206,7 @@ bool verifyPendingRequestValidationRetainsInput() { editor->setPlainText(QStringLiteral("{\"accepted\":true}")); submit->click(); }); - const PendingRequestPresentation elicitation{ + const PendingRequestDescriptor elicitation{ "elicitation", "mcp-elicitation", "thread-a", 1, {{"message", "Structured response"}, {"requestedSchema", {{"type", "object"}}}}}; @@ -1185,7 +1252,7 @@ bool verifyPermissionRequestDisclosure() { QStringLiteral("futureCapability / mode: bounded")); dialog->accept(); }); - const PendingRequestPresentation request{ + const PendingRequestDescriptor request{ "permissions", "permissions-approval", "thread-a", 1, {{"permissions", permissions}, {"reason", "test disclosure"}}}; const auto response = PendingRequestDialog::present(request, nullptr); @@ -1207,12 +1274,15 @@ int main(int argc, char **argv) { QApplication application(argc, argv); core::SNodeC::init(argc, argv); + const bool frontendBoundary = + codexui::codex::verifyFrontendBoundaryOrdering(*configuration); codexui::codex::FrontendSession session(*configuration); codexui::codex::PresentationPeer peer( codexui::codex::FrontendSessionTestPeer::takeClientDescriptor(session)); const bool validationRetainsInput = codexui::codex::verifyPendingRequestValidationRetainsInput(); - const bool result = codexui::codex::verifyPendingRequestTextBoundaries() && + const bool result = frontendBoundary && + codexui::codex::verifyPendingRequestTextBoundaries() && validationRetainsInput && codexui::codex::verifyPermissionRequestDisclosure() && codexui::codex::runShellFlow(session, peer); diff --git a/tests/codex/UiSessionTest.cpp b/tests/codex/UiSessionTest.cpp new file mode 100644 index 0000000..258abc2 --- /dev/null +++ b/tests/codex/UiSessionTest.cpp @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/PendingRequestPolicy.h" +#include "codex/PresentationProtocol.h" +#include "codex/UiSession.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using codexui::codex::PresentationClient; +using codexui::codex::UiEffect; +using codexui::codex::UiNewThreadDraft; +using codexui::codex::UiPendingRequestView; +using codexui::codex::UiPromptDraft; +using codexui::codex::UiSession; +using codexui::codex::UiConversationMode; +using codexui::codex::presentation::Authority; + +struct Request { + std::string id; + std::string action; + nlohmann::json data; + PresentationClient::Completion completion; +}; + +struct Response { + nlohmann::json id; + nlohmann::json result; + nlohmann::json error; +}; + +class FakeBoundary final { +public: + PresentationClient client() { + return PresentationClient{ + [this](std::string action, nlohmann::json data, + PresentationClient::Completion completion) { + const std::string id = "request-" + std::to_string(nextId++); + requests.push_back( + {id, std::move(action), std::move(data), std::move(completion)}); + return id; + }, + [this](std::string action, nlohmann::json data) { + commands.emplace_back(std::move(action), std::move(data)); + return true; + }, + [this](nlohmann::json id, nlohmann::json result, + nlohmann::json error) { + responses.push_back( + {std::move(id), std::move(result), std::move(error)}); + return true; + }}; + } + + Request *latest(std::string_view action) { + const auto found = std::find_if( + requests.rbegin(), requests.rend(), [action](const Request &request) { + return request.action == action; + }); + return found == requests.rend() ? nullptr : &*found; + } + + std::size_t count(std::string_view action) const { + return static_cast(std::count_if( + requests.begin(), requests.end(), [action](const Request &request) { + return request.action == action; + })); + } + + std::uint64_t nextId = 1; + std::vector requests; + std::vector> commands; + std::vector responses; +}; + +bool expect(bool condition, std::string_view message) { + std::cout << (condition ? "PASS " : "FAIL ") << message << '\n'; + return condition; +} + +nlohmann::json thread(std::string id, std::string title) { + return {{"id", std::move(id)}, + {"preview", std::move(title)}, + {"cwd", "/workspace"}, + {"status", {{"type", "idle"}}}, + {"turns", nlohmann::json::array()}}; +} + +void complete(UiSession &session, Request &request, std::uint64_t sequence, + nlohmann::json data, Authority authority = Authority::None, + nlohmann::json scope = nlohmann::json::object()) { + const nlohmann::json result = codexui::codex::presentation::result( + sequence, 1, request.action, request.id, true, std::move(data), + authority, std::move(scope)); + if (request.completion) + request.completion(result); + session.onPresentationFrame(result); +} + +} // namespace + +int main() { + bool passed = true; + std::int64_t now = 1'000'000; + FakeBoundary boundary; + UiSession session(boundary.client(), "/workspace", [&now] { return now; }); + std::size_t changeCount = 0; + std::optional wakeup; + session.setChangedHandler([&changeCount] { ++changeCount; }); + session.setWakeupHandler( + [&wakeup](std::int64_t atMilliseconds) { wakeup = atMilliseconds; }); + + std::uint64_t sequence = 1; + session.onPresentationFrame(codexui::codex::presentation::event( + sequence++, 1, "connection.lifecycle", {{"state", "connected"}}, + Authority::Merge)); + session.onPresentationFrame(codexui::codex::presentation::event( + sequence++, 1, "connection.bridge", + {{"state", "opened"}, + {"connectionId", "ui-controller"}, + {"role", "controller"}}, + Authority::Merge)); + session.onPresentationFrame(codexui::codex::presentation::event( + sequence++, 1, "connection.provider", + {{"generation", std::uint64_t{1}}, {"state", "ready"}}, + Authority::Replace)); + + passed &= expect(boundary.count("threads.list") == 1 && + boundary.count("models.list") == 1 && + boundary.count("permission-profiles.list") == 1, + "provider readiness hydrates through the generic boundary"); + + nlohmann::json listedThread = thread("thread-a", "Boundary thread"); + listedThread["updatedAt"] = 20; + listedThread["recencyAt"] = 30; + session.onPresentationFrame(codexui::codex::presentation::result( + sequence++, 1, "threads.list", "catalog", true, + {{"threads", nlohmann::json::array({listedThread})}}, Authority::Merge)); + session.selectThread("thread-a"); + Request *read = boundary.latest("thread.read"); + passed &= expect(read && read->data.value("threadId", std::string{}) == + "thread-a" && + read->data.value("includeTurns", false), + "selection requests authoritative thread hydration"); + if (!read) + return 1; + complete(session, *read, sequence++, + {{"thread", thread("thread-a", "Boundary thread")}}, + Authority::Replace, {{"threadId", "thread-a"}}); + + Request *resume = boundary.latest("thread.resume"); + passed &= expect(resume && resume->data.value("excludeTurns", false), + "settings hydration remains a logic-layer operation"); + if (!resume) + return 1; + complete(session, *resume, sequence++, + {{"thread", {{"id", "thread-a"}}}, {"model", "gpt-test"}}, + Authority::Merge, {{"threadId", "thread-a"}}); + + const auto &selected = session.refreshView(true, "/workspace"); + passed &= expect(selected.selectedThreadId == "thread-a" && + selected.conversation.mode == UiConversationMode::Thread && + selected.conversation.title == "Boundary thread" && + selected.status.canSubmit && + selected.threads.canControl, + "one neutral snapshot projects the selected UI state"); + passed &= expect(selected.conversation.lastActivityAt == 30, + "selection hydration preserves authoritative thread activity"); + + UiPromptDraft prompt; + prompt.text = " inspect the boundary "; + prompt.turnStartOptions = {{"model", "gpt-test"}}; + prompt.threadStartOptions = {{"ephemeral", false}}; + prompt.workspace = "/workspace"; + prompt.visiblySelectedThreadId = "thread-a"; + passed &= expect(session.submitPrompt(std::move(prompt)), + "prompt admission is accepted by UiSession"); + passed &= expect(wakeup == now, + "transport dispatch is deferred without a new scheduler"); + session.tick(); + Request *turnStart = boundary.latest("turn.start"); + const nlohmann::json input = + turnStart ? turnStart->data.value("input", nlohmann::json::array()) + : nlohmann::json::array(); + passed &= expect(turnStart && + turnStart->data.value("threadId", std::string{}) == + "thread-a" && + input.is_array() && input.size() == 1 && + input[0].value("text", std::string{}) == + "inspect the boundary", + "queued prompt becomes the exact protocol turn operation"); + + session.onPresentationFrame(codexui::codex::presentation::event( + sequence++, 1, "pending-request.upsert", + {{"requestId", 77}, + {"category", "command-approval"}, + {"request", {{"command", "make test"}}}}, + Authority::Merge, {{"threadId", "thread-a"}, {"requestId", 77}})); + const auto &pendingView = session.refreshView(true, "/workspace"); + passed &= expect(pendingView.selectedPendingRequest && + pendingView.selectedPendingRequest->id == "77" && + pendingView.selectedPendingRequest->actionable && + pendingView.selectedPendingRequest->supportsDirectAccept, + "pending capability and eligibility cross the neutral API"); + if (!pendingView.selectedPendingRequest) + return 1; + const UiPendingRequestView pending = *pendingView.selectedPendingRequest; + passed &= expect(session.resolvePending( + pending, + codexui::codex::PendingRequestPolicy::positiveResponse( + pending.kind, pending.raw)) && + boundary.responses.size() == 1 && + boundary.responses.front().id == 77, + "typed pending response returns through the same boundary"); + + session.onPresentationFrame(codexui::codex::presentation::event( + sequence++, 1, "pending-request.upsert", + {{"requestId", 78}, + {"category", "command-approval"}, + {"request", {{"command", "stale"}}}}, + Authority::Merge, {{"threadId", "thread-a"}, {"requestId", 78}})); + const auto &staleView = session.refreshView(true, "/workspace"); + const auto staleFound = std::find_if( + staleView.pendingRequests.begin(), staleView.pendingRequests.end(), + [](const UiPendingRequestView &request) { return request.id == "78"; }); + if (staleFound == staleView.pendingRequests.end()) + return 1; + const UiPendingRequestView stale = *staleFound; + session.onPresentationFrame(codexui::codex::presentation::event( + sequence++, 1, "pending-request.removed", nlohmann::json::object(), + Authority::Remove, {{"threadId", "thread-a"}, {"requestId", 78}})); + passed &= expect( + !session.resolvePending( + stale, codexui::codex::PendingRequestPolicy::positiveResponse( + stale.kind, stale.raw)), + "stale dialog responses are rejected against the current snapshot"); + + session.beginNewThread(UiNewThreadDraft{ + "/workspace/new", "Neutral draft", {}, {}, true}); + const auto effects = session.takeEffects(); + const auto &draft = session.refreshView(true, "/workspace/new"); + passed &= expect( + draft.newThreadIntent && + draft.conversation.mode == UiConversationMode::NewThread && + draft.conversation.title == "Neutral draft" && + std::find(effects.begin(), effects.end(), + UiEffect::ClearComposerDraft) != effects.end() && + std::find(effects.begin(), effects.end(), UiEffect::FocusComposer) != + effects.end(), + "new-thread intent exposes state plus narrow renderer effects"); + passed &= expect(changeCount != 0, + "state changes notify the existing GUI-thread adapter"); + + return passed ? 0 : 1; +} diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index fb79b66..fc144e8 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -117,6 +117,14 @@ function storedConversationPresentation(): ConversationPresentationOptions { }; } +export function lastActivityText(timestamp: number, now = new Date()): string { + const activity = new Date(timestamp * 1000); + const sameDate = activity.getFullYear() === now.getFullYear() + && activity.getMonth() === now.getMonth() && activity.getDate() === now.getDate(); + const time = activity.toLocaleTimeString([], {hour: "2-digit", minute: "2-digit", second: "2-digit"}); + return `Last activity: ${sameDate ? time : `${activity.toLocaleDateString()} ${time}`}`; +} + function persistConversationPresentation(options: ConversationPresentationOptions): void { writeBrowserStorage("codexui.conversation.showReasoning", String(options.showReasoning)); writeBrowserStorage("codexui.conversation.showCodexUpdates", String(options.showCodexUpdates)); @@ -633,8 +641,10 @@ function Conversation({session, revision, paneControls}: {session: BrowserFronte }; return
Conversation -

{thread?.title ?? (snapshot.newThreadIntent ? snapshot.newThreadDraft?.name || "New thread" : "Select a thread")}

-

{thread ? `${thread.cwd} · ${classifyStatus(thread.status).text}` : snapshot.newThreadIntent ? `${snapshot.newThreadDraft?.workspace ?? ""} · Send a message to create this thread.` : "Choose a thread from the left."}

+

{thread?.title ?? (snapshot.newThreadIntent ? snapshot.newThreadDraft?.name || "New thread" : "Select a thread")}

+

{thread ? [thread.cwd, classifyStatus(thread.status).text].filter(Boolean).join(" | ") + : snapshot.newThreadIntent ? `${snapshot.newThreadDraft?.workspace ?? ""} | Send a message to create this thread.` : "Choose a thread from the left."}

+ {thread?.lastActivityAt !== undefined &&

{lastActivityText(thread.lastActivityAt)}

}
{paneControls &&
{paneControls}
}
diff --git a/web/src/app/BrowserFrontendSession.ts b/web/src/app/BrowserFrontendSession.ts index 3ab55d1..e7bd0ed 100644 --- a/web/src/app/BrowserFrontendSession.ts +++ b/web/src/app/BrowserFrontendSession.ts @@ -20,6 +20,10 @@ import {readBrowserStorage, writeBrowserStorage} from "./BrowserStorage.js"; const DraftThreadId = "__codexui_new_thread__"; const MaximumProtocolFrames = 500; +function isThreadHydrationAction(action: string): boolean { + return action === "thread.read" || action === "thread.resume"; +} + function retainedProtocolFrame(frame: JsonObject): unknown { if (stringMember(frame, "type") !== "pending-request.upsert") return structuredClone(frame); const data = isObject(frame.data) ? frame.data : {}; @@ -137,6 +141,12 @@ export class BrowserFrontendSession { this.createWebSocket = createWebSocket; this.normalizer = new ProtocolNormalizer(frame => { this.model.applyEvent(frame); + const scope = isObject(frame.scope) ? frame.scope : {}; + const threadId = stringMember(scope, "threadId"); + const hydrationResult = stringMember(frame, "kind") === "result" + && isThreadHydrationAction(stringMember(frame, "action")); + if (threadId !== "" && !hydrationResult) + this.model.noteThreadActivity(threadId, Math.floor(Date.now() / 1000)); this.protocolFrames.push(retainedProtocolFrame(frame)); if (this.protocolFrames.length > MaximumProtocolFrames) this.protocolFrames.shift(); this.reconcilePromptsForFrame(frame); @@ -431,6 +441,8 @@ export class BrowserFrontendSession { this.setNotice("The pending response could not be sent."); return false; } + if (request.threadId !== "") + this.model.noteThreadActivity(request.threadId, Math.floor(Date.now() / 1000)); this.publish(); return true; } @@ -551,8 +563,18 @@ export class BrowserFrontendSession { if (!method) { this.normalizer.operationRejected(action, correlation, -32601, "unsupported CodexUI presentation action"); return correlation; } const startedAtSequence = this.normalizer.sequence; const request = this.sdk.request.bind(this.sdk) as unknown as RawRequest; + const threadId = stringMember(parameters, "threadId"); + const recordsActivity = threadId !== "" && !isThreadHydrationAction(action); + if (recordsActivity) { + this.model.noteThreadActivity(threadId, Math.floor(Date.now() / 1000)); + this.schedulePublish(); + } request(method, parameters, response => { if (!acceptResult()) { callback?.({}, true); return; } + if (recordsActivity) { + this.model.noteThreadActivity(threadId, Math.floor(Date.now() / 1000)); + this.schedulePublish(); + } const envelope = isObject(response) ? response : {}; this.normalizer.operationResult(action, correlation, parameters, envelope, startedAtSequence); callback?.(envelope, false); diff --git a/web/src/presentation/PresentationModel.ts b/web/src/presentation/PresentationModel.ts index 2f28239..839eecc 100644 --- a/web/src/presentation/PresentationModel.ts +++ b/web/src/presentation/PresentationModel.ts @@ -69,6 +69,7 @@ export interface ThreadPresentation { createdAt?: number; updatedAt?: number; recencyAt?: number; + lastActivityAt?: number; commandCwds: string[]; changedPaths: string[]; turnOrder: string[]; @@ -398,6 +399,12 @@ export class PresentationModel { threadOrder(): readonly string[] { return this.orderedThreads; } thread(threadId: string): ThreadPresentation | undefined { return this.threads.get(threadId); } + noteThreadActivity(threadId: string, timestamp: number): void { + const thread = this.threads.get(threadId); + if (thread && Number.isSafeInteger(timestamp) + && (thread.lastActivityAt === undefined || timestamp > thread.lastActivityAt)) + thread.lastActivityAt = timestamp; + } childOwnership(childThreadId: string): ChildThreadOwnership | undefined { return this.childOwnerships.get(childThreadId); } @@ -715,6 +722,8 @@ export class PresentationModel { for (const key of ["createdAt", "updatedAt", "recencyAt"] as const) { if (typeof raw[key] === "number" && Number.isInteger(raw[key])) result[key] = raw[key]; } + if (result.updatedAt !== undefined) this.noteThreadActivity(id, result.updatedAt); + if (result.recencyAt !== undefined) this.noteThreadActivity(id, result.recencyAt); result.archived = boolValue(raw, "archived", result.archived); if (Array.isArray(raw.turns)) { let previouslyOwnedChildren: string[] = []; diff --git a/web/src/styles.css b/web/src/styles.css index 03bb157..71e5425 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -64,9 +64,11 @@ h1, h2, h3, p { margin: 0; } .refresh-button { margin: 8px; color: #667085; background: transparent; } .conversation-pane { --composer-overlay-height: 112px; position: relative; min-width: 0; min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); background: #f2f5f9; } .conversation-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 16px 26px 14px; background: #fff; border-bottom: 1px solid #e0e5ed; } -.conversation-title { min-width: 0; } -.conversation-heading h1 { margin: 2px 0 3px; font-size: 20px; letter-spacing: -.02em; } -.conversation-heading p { color: #667085; font-size: 11px; } +.conversation-title { flex: 1 1 auto; min-width: 0; } +.conversation-lockup { display: flex; min-width: 0; align-items: baseline; gap: 10px; } +.conversation-heading h1 { flex: 0 1 auto; min-width: 0; margin: 2px 0 3px; font-size: 20px; letter-spacing: -.02em; } +.conversation-heading p { flex: 1 1 auto; min-width: 0; margin: 0; color: #667085; font-size: 11px; overflow-wrap: anywhere; } +.conversation-heading .conversation-activity { flex: 0 0 auto; margin-left: auto; white-space: nowrap; text-align: right; } .conversation-heading-actions { display: contents; } .conversation-heading-actions.responsive { display: flex; flex: 0 0 auto; align-items: center; justify-content: flex-end; gap: 8px; } .responsive-pane-controls { display: flex; align-items: center; gap: 5px; } @@ -199,6 +201,9 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible .brand small { display: none; } .conversation-heading { flex-wrap: wrap; gap: 8px; padding: 10px 12px; } .conversation-title { flex: 1 1 100%; } + .conversation-lockup { flex-wrap: wrap; gap: 2px 8px; } + .conversation-lockup .conversation-meta { flex-basis: 100%; } + .conversation-lockup .conversation-activity { flex-basis: 100%; } .conversation-heading h1 { font-size: 18px; overflow-wrap: anywhere; } .conversation-heading p { overflow-wrap: anywhere; } .conversation-heading-actions.responsive { flex: 1 1 100%; justify-content: space-between; flex-wrap: wrap; } diff --git a/web/tests/browser-session-parity.test.mjs b/web/tests/browser-session-parity.test.mjs index 6670996..8a16f9b 100644 --- a/web/tests/browser-session-parity.test.mjs +++ b/web/tests/browser-session-parity.test.mjs @@ -303,6 +303,48 @@ test("thread ordering uses newest recency and retires prompt promotion on author session.dispose(); }); +test("thread activity preserves provider time during hydration and advances for meaningful traffic", async () => { + const socket = new FakeSocket(); + const session = new BrowserFrontendSession("ws://bridge.test/", () => socket); + session.connect(); socket.open(); await readyProvider(socket, "activity"); + const listed = requests(socket, "thread/list").at(-1); + respond(socket, listed, {data: [ + {id: "tracked", updatedAt: 20, recencyAt: 30}, + {id: "updated-only", updatedAt: 25}, + ]}); + assert.equal(session.model.thread("tracked").lastActivityAt, 30); + assert.equal(session.model.thread("updated-only").lastActivityAt, 25); + + session.selectThread("tracked"); + assert.equal(session.model.thread("tracked").lastActivityAt, 30, + "selection-driven read does not replace authoritative activity"); + + const read = requests(socket, "thread/read").at(-1); + respond(socket, read, {thread: {id: "tracked", updatedAt: 20, recencyAt: 30, turns: []}}); + assert.equal(session.model.thread("tracked").lastActivityAt, 30, + "authoritative read response remains the activity source during hydration"); + + const beforeOutbound = Math.floor(Date.now() / 1000); + session.renameThread("tracked", "Renamed tracked thread"); + assert.ok(session.model.thread("tracked").lastActivityAt >= beforeOutbound, + "meaningful thread requests advance local activity immediately"); + const rename = requests(socket, "thread/name/set").at(-1); + session.model.thread("tracked").lastActivityAt = 1; + const beforeResponse = Math.floor(Date.now() / 1000); + respond(socket, rename, {}); + assert.ok(session.model.thread("tracked").lastActivityAt >= beforeResponse, + "meaningful thread responses advance local activity"); + + session.model.thread("tracked").lastActivityAt = 1; + const beforeInbound = Math.floor(Date.now() / 1000); + socket.receive(appserver({jsonrpc: "2.0", method: "thread/status/changed", params: { + threadId: "tracked", status: {type: "idle"}, + }})); + assert.ok(session.model.thread("tracked").lastActivityAt >= beforeInbound, + "thread-scoped app-server frames advance local activity"); + session.dispose(); +}); + test("browser transport reconnects cleanly across provider generations", async () => { const sockets = []; const session = new BrowserFrontendSession("ws://bridge.test/", () => { diff --git a/web/tests/responsive-layout.test.mjs b/web/tests/responsive-layout.test.mjs index f96657c..84fff0b 100644 --- a/web/tests/responsive-layout.test.mjs +++ b/web/tests/responsive-layout.test.mjs @@ -64,13 +64,14 @@ test("responsive shell exposes only the panes that fit and accessible drawer tri test("thread hierarchy exposes selected tree-item semantics", () => { const session = new BrowserFrontendSession("ws://bridge.test/", () => { throw new Error("not connected"); }); session.model.applyEvent(result(1, 1, "threads.list", "list", true, {threads: [{ - id: "thread-1", preview: "Accessible thread", cwd: "/workspace", status: {type: "idle"}, + id: "thread-1", preview: "Accessible thread", cwd: "/workspace", status: {type: "idle"}, updatedAt: 10, }]}, "replace")); session.selectThread("thread-1"); const markup = renderToStaticMarkup(createElement(App, {session})); assert.match(markup, /class="thread-list" role="tree" aria-label="Threads"/u); assert.match(markup, /role="treeitem" aria-level="1" aria-selected="true"/u); assert.match(markup, /aria-current="true" aria-label="Open Accessible thread, \/workspace"/u); + assert.match(markup, /class="conversation-lockup"[\s\S]*Last activity:/u); session.dispose(); }); @@ -87,6 +88,9 @@ test("responsive CSS keeps the desktop grid and removes the old document-width f assert.match(css, /button:focus-visible[\s\S]*outline:\s*2px solid #6f98e8/u); assert.match(css, /@media \(pointer:\s*coarse\)[\s\S]*min-height:\s*44px/u); assert.match(css, /\.composer-actions span\s*\{[^}]*color:\s*#667085/u); + assert.match(css, /\.conversation-lockup\s*\{[^}]*align-items:\s*baseline/u); + assert.match(css, /\.conversation-heading \.conversation-activity\s*\{[^}]*margin-left:\s*auto[^}]*text-align:\s*right/u); + assert.match(css, /@media \(max-width:\s*520px\)[\s\S]*\.conversation-lockup \.conversation-activity\s*\{[^}]*flex-basis:\s*100%/u); assert.match(css, /\.composer-dock\s*\{[^}]*bottom:\s*0[^}]*padding:\s*8px 0 16px[^}]*background:\s*#f2f5f9/u); assert.match(css, /\.composer-dock::before\s*\{[^}]*bottom:\s*100%[^}]*height:\s*8px[^}]*background:\s*#f2f5f9/u); assert.match(css, /\.composer-dock::after\s*\{[^}]*top:\s*-1px[^}]*height:\s*1px[^}]*background:\s*#d7dee8/u);