diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba4f449..ea79d77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: uses: actions/checkout@v5 with: repository: SNodeC/AISuite - ref: 60c81c7f1fb7938e03725cdea4a87e617efae57e + ref: 5aeedb2c21d7da0d611219365294cc3fb052cddf path: _deps/aisuite - name: Install build dependencies @@ -136,3 +136,59 @@ jobs: else git diff --check HEAD^ HEAD fi + + web: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + + steps: + - name: Check out exact CodexUI head + uses: actions/checkout@v5 + with: + fetch-depth: 0 + ref: >- + ${{ github.event_name == 'pull_request' && + github.event.pull_request.head.sha || github.sha }} + path: CodexUI/codexui + + - name: Check out pinned AISuite frontend SDK + uses: actions/checkout@v5 + with: + repository: SNodeC/AISuite + ref: 5aeedb2c21d7da0d611219365294cc3fb052cddf + path: AISuite-extraction/AISuite-final + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + AISuite-extraction/AISuite-final/packages/codex-frontend/package-lock.json + CodexUI/codexui/web/package-lock.json + + - name: Verify the paired SDK revision + working-directory: CodexUI/codexui + run: | + test "$(git -C ../../AISuite-extraction/AISuite-final rev-parse HEAD)" = \ + "$(cat web/AISUITE_REVISION)" + + - name: Test the frontend SDK + working-directory: AISuite-extraction/AISuite-final/packages/codex-frontend + run: | + npm ci + npm test + npm pack --dry-run + + - name: Test and qualify CodexWebUI + working-directory: CodexUI/codexui + run: | + npm ci --prefix web + npm run release --prefix web + + - name: Upload CodexWebUI production artifact + uses: actions/upload-artifact@v4 + with: + name: codexui-web + path: CodexUI/codexui/web/app-dist + if-no-files-found: error diff --git a/CMakeLists.txt b/CMakeLists.txt index 52a6a02..5832d5b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.20) -project(CodexUI LANGUAGES CXX) +project(CodexUI VERSION 1.0.0 LANGUAGES CXX) include(GNUInstallDirs) include(CTest) @@ -120,6 +120,11 @@ endfunction() configure_codexui_target(codex-ui) +set(CODEXUI_WEB_DIST_DIR "${CMAKE_CURRENT_SOURCE_DIR}/web/app-dist" CACHE PATH + "Directory containing the built CodexWebUI static artifact") +install(DIRECTORY "${CODEXUI_WEB_DIST_DIR}/" + DESTINATION "${CMAKE_INSTALL_DATADIR}/codexui/web" OPTIONAL) + if(TARGET snodec::net-in-stream-tls AND TARGET snodec::net-in6-stream-tls) target_compile_definitions(codex-ui PRIVATE CODEXUI_CODEX_FRONTEND_TLS=1) target_link_libraries( diff --git a/README.md b/README.md index 836daf9..df9baf8 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # CodexUI -CodexUI is a native Qt 6 Widgets frontend for the AISuite `codex-bridge`. -It presents the Codex app-server protocol without introducing another backend, -semantic cache, snapshot store, or persistence authority. +CodexUI 1.0 is a native Qt 6 Widgets and browser frontend for the AISuite +`codex-bridge`. Both applications present the same Codex app-server behavior +without introducing another backend, semantic cache, snapshot store, or +persistence authority. The canonical process has two threads: @@ -19,12 +20,18 @@ event loop, selected transport, `AISuite::OpenAICodex` frontend proxy SDK, native protocol normalization, and connection/controller telemetry. They exchange only bounded `codexui.presentation` JSONL commands and events. -## Application +## 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. +`CodexWebUI` is the browser presentation. It uses the framework-neutral +`@snodec/codex-frontend` SDK from AISuite, connects directly to the bridge over +WebSocket, and follows the same controller, prompt, thread, turn, projection, +and reconnect rules as the native application. Browser-only limitations are +listed in the [1.0 contract](docs/web-1.0-contract.md). + ## Build Qt 6 Widgets, Threads, libgit2 development files (discoverable as `libgit2` @@ -46,6 +53,30 @@ The executable name, application ID, `StartupWMClass`, desktop entry, and icon name intentionally match so Linux launchers and taskbars associate the window with the installed CodexUI application. +## Browser build + +Node.js 20 or newer and the exact AISuite revision recorded in +[`web/AISUITE_REVISION`](web/AISUITE_REVISION) are required. The source +dependency expects the release/CI checkout layout shown below. + +```text +workspace/ +├── AISuite-extraction/AISuite-final/ +└── CodexUI/codexui/ +``` + +```sh +npm ci --prefix ../../AISuite-extraction/AISuite-final/packages/codex-frontend +npm test --prefix ../../AISuite-extraction/AISuite-final/packages/codex-frontend +npm ci --prefix web +npm run release --prefix web +``` + +The production artifact is `web/app-dist/`. CMake installs it below +`${CMAKE_INSTALL_DATADIR}/codexui/web`; `codex-bridge` serves those files and +its `/codex` WebSocket endpoint from the same listener. Node is not part of the +installed runtime. Deployment details are in [`web/README.md`](web/README.md). + ## Architecture The complete thread model, presentation protocol, authority rules, normalized @@ -58,6 +89,14 @@ geometry, shell-output, Inspector, and desktop-integration decisions are documented in [`docs/ui-behavior.md`](docs/ui-behavior.md). +The browser architecture, native/web parity boundary, state ownership, and +version 1.0 delivery gates are documented in +[`docs/web-1.0-contract.md`](docs/web-1.0-contract.md). + +Measured performance, equality evidence, packaging, and the release gate are +recorded in [`docs/web-qualification.md`](docs/web-qualification.md) and +[`docs/web-release.md`](docs/web-release.md). + ## License CodexUI is available under the LGPL-3.0-or-later OR MIT dual license. diff --git a/docs/codex-architecture.md b/docs/codex-architecture.md index 544038d..6531367 100644 --- a/docs/codex-architecture.md +++ b/docs/codex-architecture.md @@ -449,7 +449,10 @@ The compact More menu contains the named permission profile, approval reviewer, service tier, reasoning summary, and collaboration mode. Model, effort, service-tier, and permission-profile choices are populated from fresh app-server catalogs. A named permission profile and a sandbox policy are -mutually exclusive, matching the native app-server contract. +mutually exclusive, matching the native app-server contract. An explicit Access +or Network choice therefore returns the permission-profile control to Thread +default and submits the selected sandbox policy. Other individual controls keep +the active permission profile and submit their supported app-server overrides. The settings object is a transient draft bound to the stable selected thread identity. User changes are serialized into native `thread/start` and diff --git a/docs/web-1.0-contract.md b/docs/web-1.0-contract.md new file mode 100644 index 0000000..2a89d21 --- /dev/null +++ b/docs/web-1.0-contract.md @@ -0,0 +1,250 @@ +# CodexWebUI 1.0 Contract + +## Purpose + +CodexWebUI is the browser presentation of the existing CodexUI product. It is +not another backend and does not replace or reimplement `codex-bridge`. +Version 1.0 requires a production-built browser application that can perform +the complete web parity scope below against the canonical bridge. A web +feature is complete only after it independently proves behavioral equality +with the corresponding current C++ feature. + +The native Qt application remains supported. Native and browser frontends may +be connected to the same bridge at the same time and follow the same +controller/observer rules. + +## Fixed architecture + +```text +CodexWebUI (React presentation) + -> TypeScript presentation projection + -> framework-neutral TypeScript frontend SDK + -> browser WebSocket using the `codex` subprotocol + -> existing codex-bridge + -> Codex app-server +``` + +There is no web-specific application server, proxy controller, repository, +cache, event bus, or retained Codex-domain store. + +`codex-bridge` remains the only multi-frontend router. Its C++ implementation +continues to own controller policy, observer restrictions, upstream request-ID +translation, response ownership, server-request routing, provider lifecycle, +and notification fanout. None of that server-side routing is copied into +TypeScript. + +The TypeScript SDK implements only frontend responsibilities already present +in `ai::openai::codex::frontend::CodexBridge` and `ClientConnection`: + +- bridge-envelope classification and connection telemetry; +- frontend request/response correlation; +- notification and server-request dispatch; +- provider-generation and disconnect retirement; +- controller claim, release, and transfer commands; +- one attached browser WebSocket transport and its lifecycle. + +## Repository boundary + +AISuite owns a publishable, framework-neutral package: + +```text +packages/codex-frontend/ + src/protocol/generated.ts + src/protocol/envelope.ts + src/CodexBridgeClient.ts + src/WebSocketTransport.ts + src/errors.ts + src/index.ts +``` + +Its working package name is `@snodec/codex-frontend`. The existing pinned +Codex schema and generator are the only source for generated C++ and +TypeScript protocol declarations. + +CodexUI owns the browser application: + +```text +web/ + src/presentation/ + src/conversation/ + src/components/ + src/app/ +``` + +The web presentation normalizer and reducer live here because they express +CodexUI behavior, not bridge semantics. During development and CI CodexUI pins +the AISuite package to an exact revision, as the native build already does for +the C++ package. + +## Frontend technology + +CodexWebUI is a client-rendered TypeScript application using React and Vite. +It does not require server rendering: the product is an interactive remote +client, has no public content routes, and acquires its state after connecting +to a bridge. + +The initial application has one shell and no URL-routing dependency. Thread, +Inspector, disclosure, selection, draft, and scroll state are application +state, not pages. A router is added only if a later user-facing navigation +requirement proves one necessary. + +The application uses: + +- React components for compositional presentation and keyed card identity; +- ordinary CSS with CodexUI-owned tokens for visual styling; +- the Node.js test runner for SDK, reducer, projection, and lifecycle tests; +- React server rendering plus production-bundle qualification for shell and + accessibility/deployment boundaries; +- npm lockfiles and reproducible production builds. + +No general-purpose state framework is introduced. A typed presentation model +is the sole retained normalized store. React subscribes to snapshots from that +model; components retain only interaction state they own. + +## Presentation boundary + +The semantic vocabulary and authority rules of `codexui.presentation` version +1 remain the shared native/web contract. In the browser they form an internal +TypeScript boundary rather than an additional network hop: + +```text +bridge/app-server input + -> TypeScript normalizer + -> codexui.presentation frame + -> TypeScript presentation model + -> typed visible projections + -> React +``` + +The TypeScript implementation must match the existing rules for stable IDs, +merge/replace/remove authority, generation retirement, unknown events, +incomplete reconstruction, child-thread ownership, and ordered items. Shared +JSON fixtures verify equivalent C++ and TypeScript normalization/reduction. + +Equality is judged by observable behavior and state transitions, not source +structure or pixel identity. For the same ordered inputs, native and web must +produce equivalent commands, authority decisions, retained identities, +ordering, lifecycle outcomes, visible projections, and user-action +eligibility. Platform-native geometry may differ only where the behavioral +contract explicitly permits it. + +The presentation model is an in-memory view of app-server publications. It is +not persistence or semantic authority and is rebuilt by discovery and +hydration after a new connection generation. + +## Version 1.0 parity + +### Required + +The following behavior is required before the combined product is called +version 1.0. + +| Area | Required web behavior | +| --- | --- | +| Connection | Configure a WebSocket URL, connect, disconnect, reconnect, and present bridge/provider state | +| Roles | Present controller/observer state and claim or release control | +| Threads | Discover, sort, select, hydrate, reload, create, rename, fork, archive, unarchive, and delete | +| Hierarchy | Preserve parent/child ownership and expandable thread branches | +| Turns | Start, steer, interrupt, resume when required, and recover after provider generations | +| Composer | Retain drafts, admit prompts locally, queue independently per thread, and acknowledge by correlated result | +| Settings | Model, reasoning, access, network, workspace path, approval, personality, profile, reviewer, service tier, summary, and collaboration mode | +| Conversation | User, assistant, reasoning, command, file-change, agent, image-metadata, plan, and fallback activity cards | +| Streaming | Update retained cards without reordering, rebuilding unchanged cards, or moving a paused reading position | +| Attention | Present and resolve every supported pending-request category | +| Inspector | Plan, Agents, Requests, State, and Protocol views | +| Diagnostics | Bounded notices and human-readable event labels without changing protocol values | +| Usability | Responsive desktop layout, keyboard operation, accessible names, contrast, and reduced-motion behavior | + +Prompt admission, stable first-response placement, per-thread scroll ownership, +card folding, safe Markdown, bounded command output, controller eligibility, +and reconnect hydration follow the native behavior documents. Visual geometry +may be browser-native; the behavioral invariant may not silently change. + +### Browser-specific representation + +These capabilities remain required but use an honest browser representation: + +- Workspace is an app-server path entered or selected from provider-supplied + data; it is not inferred from the browser machine. +- File-change conversation cards show the app-server publication. +- Image items show status and saved-path metadata when the saved file is not + available through a browser URL. +- Connection configuration exposes WebSocket/WSS only. + +### Native-only in 1.0 + +The following Qt capabilities depend on direct access to the native machine +and are explicitly outside browser parity: + +- Unix, raw TCP/TLS, IPv6, and RFCOMM frontend transports; +- the native filesystem picker and local-path attachment admission; +- libgit2 worktree discovery, filesystem watching, local Changes snapshots, + and the modeless native diff window; +- opening provider-side files with a desktop application; +- desktop entry, window manager, taskbar, and native icon integration; +- direct display of provider-local generated-image files. + +These exclusions must not be represented by controls that appear functional. +Adding remote file transfer, an asset endpoint, or provider-side Git service +would require a separately designed protocol capability and is not part of the +web implementation. + +## State ownership + +| State | Owner | +| --- | --- | +| Codex account, configuration, thread, turn, item, plan, request, and persistence | Codex app-server | +| Multi-client routing, provider generation, controller policy, request ownership | `codex-bridge` | +| JSON-RPC callbacks and connection snapshot | TypeScript frontend SDK | +| Normalized threads, turns, items, plans, agents, requests, and telemetry | Web presentation model | +| Selected thread/tab, drafts, folding, scroll anchors, focus, transient menus | React application | + +State never appears in two owners in the same layer. Components receive typed +projections and commands; they do not parse app-server methods or retain a +second copy of normalized domain collections. + +## Delivery commits and equality gates + +1. **Contract and parity**: this document fixes scope, ownership, repository + boundaries, and frontend structure. +2. **Generated TypeScript protocol**: deterministic TypeScript output from the + existing pinned schema, with generator drift tests. +3. **TypeScript frontend SDK**: frontend proxy and browser WebSocket transport, + with the C++ SDK lifecycle/routing invariants ported as tests. +4. **Web presentation model**: normalizer, reducer, command facade, and shared + native/web fixture corpus. +5. **Core web shell**: connection, thread navigation, conversation, composer, + streaming, and controller state. +6. **Advanced conversation behavior**: prompt admission, scrolling, folding, + tools, reasoning, agents, plans, Markdown, and interruption. +7. **Required supporting surfaces**: settings, pending requests, complete + thread actions, and the required Inspector tabs. +8. **Qualification**: browser workflows, resilience, accessibility, + responsive behavior, and measured large-thread/streaming performance. +9. **Release**: production packaging, documentation, complete native and web + suites, and the version 1.0 checklist. + +Every implementation commit must be independently reviewable and must add the +focused equality proof for the behavior it introduces. Depending on the layer, +that proof is one or more of: + +- the same JSON fixture corpus executed by C++ and TypeScript; +- equivalent C++ and TypeScript request/lifecycle scenario tests; +- visible-projection snapshots derived from the same normalized state; +- browser interaction tests that assert the corresponding native behavior + contract rather than only DOM structure. + +A commit is not complete while its focused equality test, the accumulated web +suite, or the repository's complete native suite fails. Cross-repository +contract changes pin compatible revisions and test both sides before either +dependency is advanced. + +## Explicit exclusions + +- a JavaScript/TypeScript port of the server-side `codex-bridge` router; +- a Node application server or browser-specific backend; +- protocol-value changes made for presentation convenience; +- duplicated controller, repository, provider, or Codex-domain state; +- speculative caches, controllers, repositories, or event systems; +- URL routing without an actual navigation requirement; +- native-only controls with simulated or incomplete browser behavior. diff --git a/docs/web-qualification.md b/docs/web-qualification.md new file mode 100644 index 0000000..14942bc --- /dev/null +++ b/docs/web-qualification.md @@ -0,0 +1,71 @@ +# CodexWebUI qualification + +This record qualifies the browser frontend against the observable native +behavior fixed in [the web 1.0 contract](web-1.0-contract.md). It does not +claim pixel identity with Qt. + +## Equality gates + +- Generated protocol names, operation maps, and source-schema digest match the + checked-in C++ protocol. +- SDK routing, pending callbacks, bridge roles, provider generations, + disconnect failure, WebSocket framing, and backpressure use equivalent C++ + lifecycle scenarios. +- Normalizer and presentation-model tests replay the native authority, + generation, sequence, hydration, terminal-state, repository-hint, and child + ownership scenarios. +- Conversation tests replay native grouping, prompt admission, + materialization, reasoning-first order, history, terminal output, image + metadata, and agent correlation scenarios. +- Browser-session tests use a scripted bridge endpoint and assert the same + C++ presentation-action methods, resume-before-turn ordering, provider + recovery, and malformed-input containment. +- Settings and pending-request tests assert the exact native request payloads. +- Server rendering verifies the shell landmarks and accessible connection and + disclosure controls without requiring a second UI state implementation. + +## Measured presentation cost + +Measurement command: + +```sh +npm run profile --prefix web +``` + +Representative input: one authoritative thread containing 100 turns and +10,000 mixed conversation items, followed by 2,000 deltas applied to one live +item. Five fresh Node processes were run on 2026-08-28 with Node 24.19.0 on an +Intel Core i7-14700HX (x86-64). + +The five-process run was repeated after the lifecycle and release increments +with the same runtime, machine, and input. Negative change is faster. + +| Operation | Initial median | 1.0 median | Change | 1.0 range | +| --- | ---: | ---: | ---: | ---: | +| Authoritative hydration, 10,000 items | 44.09 ms | 43.85 ms | −0.5% | 43.16–44.33 ms | +| Full projection, 10,000 visible cards | 33.11 ms | 32.28 ms | −2.5% | 31.19–36.25 ms | +| Apply 2,000 streaming deltas | 9.85 ms | 8.21 ms | −16.6% | 4.98–9.14 ms | + +The production view starts with the native 80-item history window, schedules +at most one React publication per animation frame, retains keyed cards, and +increases the effective window while a reader is paused. The 10,000-card full +projection is therefore a qualification stress case, not the initial DOM +surface. + +No speculative cache or alternate reducer was introduced: the measured pure +model and projection remained comfortably below one display interval for the +normal 80-item window, so the native authority/index structure was retained. + +## Browser representation and resilience + +- WebSocket and WSS are the only exposed transports. +- The bridge URL is explicit and retained in browser local storage. +- Provider-local paths are presented as metadata; no local browser filesystem + capability is implied. +- Provider generation changes clear provider-owned presentation state and + trigger discovery/hydration through the existing bridge authority. +- Notices are bounded to one visible message and follow the native 10-second + error and 6-second informational auto-dismiss policy. +- Reduced-motion preference disables shimmer, spinner, and smooth scrolling. +- The center pane owns per-thread follow/paused scroll state; a paused anchor + is not evicted when streamed items arrive. diff --git a/docs/web-release.md b/docs/web-release.md new file mode 100644 index 0000000..83860d8 --- /dev/null +++ b/docs/web-release.md @@ -0,0 +1,46 @@ +# CodexWebUI 1.0 release + +## Release manifest + +| Component | Version or revision | Release output | +| --- | --- | --- | +| CodexUI native application | 1.0.0 | installed `codex-ui`, desktop entry, and icon | +| CodexWebUI | 1.0.0 | relocatable `web/app-dist/` static artifact | +| AISuite frontend SDK | 1.0.0 at `5aeedb2c21d7da0d611219365294cc3fb052cddf` | publishable `@snodec/codex-frontend` package | + +The SDK revision is machine-readable in `web/AISUITE_REVISION` and is checked +by CI before either SDK or application tests run. The source layout and build +commands are documented in `web/README.md`. + +## Equality and release gate + +A release candidate is eligible only when one CI revision proves all of the +following: + +- the pinned SDK installs from its lockfile and passes protocol generation, + routing/lifecycle, and WebSocket transport equality tests; +- the browser application installs from its lockfile and passes all seven web + test files; +- the production Vite artifact builds and is uploaded without source-tree or + development-only files; +- the complete seven-test native CTest suite passes against the same AISuite + revision; +- `git diff --check` passes and the recorded performance profile shows no + material regression from `web-qualification.md`. + +The CI artifact is named `codexui-web`. Installation copies that static +directory into the CodexUI data directory, and the existing `codex-bridge` +listener serves it alongside `/codex`. No Node process, web-specific backend, +or protocol extension is part of the release. + +## Operator checks + +Before promoting the artifact, connect once over `wss://` in a supported +desktop browser and confirm discovery, one prompt/response, reconnect +hydration, a pending request, thread actions, settings, and each Inspector +tab. This final smoke check validates deployment origin/TLS policy rather than +creating a second behavioral test authority. + +Native-only capabilities and honest browser representations remain exactly as +listed in the 1.0 contract. A failed or unavailable gate is reported as such; +it is not converted into a release pass by documentation. diff --git a/src/codex/PendingRequestDialog.cpp b/src/codex/PendingRequestDialog.cpp index a62ad11..ccc00ac 100644 --- a/src/codex/PendingRequestDialog.cpp +++ b/src/codex/PendingRequestDialog.cpp @@ -233,9 +233,7 @@ PendingRequestDialog::present(const PendingRequestPresentation &request, structuredContent->setMinimumHeight(150); structuredContent->setLineWrapMode(QPlainTextEdit::WidgetWidth); structuredContent->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - structuredContent->setStyleSheet(QStringLiteral( - "background:#f8fafc;border:1px solid #d7dee8;border-radius:6px;" - "padding:7px;font-family:monospace;")); + structuredContent->setProperty("kind", "dialogEditor"); contentLayout->addWidget(structuredContent); } } else if (request.kind == "permissions-approval") { @@ -385,4 +383,25 @@ PendingRequestResponse PendingRequestDialog::negativeResponse( return response; } +PendingRequestResponse PendingRequestDialog::positiveResponse( + const PendingRequestPresentation &request) { + PendingRequestResponse response; + if (request.kind == "command-approval" || + request.kind == "file-change-approval") { + response.result = {{"decision", "accept"}}; + } else if (request.kind == "permissions-approval") { + response.result = { + {"permissions", + request.raw.value("permissions", nlohmann::json::object())}, + {"scope", "turn"}}; + } 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"); + } + return response; +} + } // namespace codexui::codex diff --git a/src/codex/PendingRequestDialog.h b/src/codex/PendingRequestDialog.h index ff51626..41c7662 100644 --- a/src/codex/PendingRequestDialog.h +++ b/src/codex/PendingRequestDialog.h @@ -25,6 +25,9 @@ class PendingRequestDialog final { [[nodiscard]] static PendingRequestResponse negativeResponse(const PendingRequestPresentation &request); + + [[nodiscard]] static PendingRequestResponse + positiveResponse(const PendingRequestPresentation &request); }; } // namespace codexui::codex diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index 406028b..ce15948 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -37,6 +37,7 @@ #include #include #include +#include #include #include #include @@ -81,6 +82,72 @@ std::string safeMessage(const nlohmann::json &value) { : std::string{}; } +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; @@ -283,6 +350,7 @@ struct ShellWidget::Impl final { 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); @@ -491,6 +559,7 @@ void ShellWidget::Impl::connectUi() { }; composerActions.stop = [this] { 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)); @@ -508,6 +577,7 @@ void ShellWidget::Impl::connectUi() { }); middleRegion->inspector().setRequestActions( [this](const std::string &id) { reviewPending(id); }, + [this](const std::string &id) { acceptPending(id); }, [this](const std::string &id) { rejectPending(id); }); middleRegion->setPaneVisibilityAction( [this](bool sidebarVisible, bool inspectorVisible) { @@ -844,7 +914,18 @@ void ShellWidget::Impl::refreshStatus() { QStringLiteral("Requests (%1)") .arg(static_cast(snapshot.totalPending))); requestButton->setVisible(snapshot.totalPending != 0); - middleRegion->composer().setAttentionVisible(snapshot.selectedPending != 0); + const auto selectedRequest = std::ranges::find_if( + model.pendingRequestPresentations(), [this](const auto &entry) { + return entry.second.threadId == selectedThreadId; + }); + if (selectedRequest != model.pendingRequestPresentations().end()) { + const PendingRequestPresentation &request = selectedRequest->second; + middleRegion->composer().setAttentionRequest( + requestTitle(request), requestDetail(request), + requestSupportsDirectAccept(request), directAcceptLabel(request)); + } + middleRegion->composer().setAttentionVisible(selectedRequest != + model.pendingRequestPresentations().end()); QString globalStatus = QStringLiteral("Ready"); QString globalTone = QStringLiteral("success"); @@ -1523,7 +1604,7 @@ void ShellWidget::Impl::respondToFirstPending(bool approve) { if (request == pending.end()) return; if (approve) - reviewPending(request->first); + acceptPending(request->first); else rejectPending(request->first); } @@ -1539,6 +1620,21 @@ void ShellWidget::Impl::reviewPending(const std::string &requestKey) { response->result, response->error); } +void ShellWidget::Impl::acceptPending(const std::string &requestKey) { + const auto request = model.pendingRequestPresentations().find(requestKey); + if (request == model.pendingRequestPresentations().end()) + return; + if (!requestSupportsDirectAccept(request->second)) { + reviewPending(requestKey); + return; + } + PendingRequestResponse response = + PendingRequestDialog::positiveResponse(request->second); + session.respondToServerRequest(nlohmann::json::parse(requestKey), + std::move(response.result), + std::move(response.error)); +} + void ShellWidget::Impl::rejectPending(const std::string &requestKey) { const auto request = model.pendingRequestPresentations().find(requestKey); if (request == model.pendingRequestPresentations().end()) diff --git a/src/codex/TurnSettingsWidget.cpp b/src/codex/TurnSettingsWidget.cpp index dfb34b1..277cfa4 100644 --- a/src/codex/TurnSettingsWidget.cpp +++ b/src/codex/TurnSettingsWidget.cpp @@ -176,6 +176,17 @@ QString optionalString(const nlohmann::json &object, const char *key) { return value.empty() ? QString::fromLatin1(DefaultValue) : text(value); } +QString permissionProfileLabel(const QString &id) { + if (id == QStringLiteral(":workspace")) + return QStringLiteral("Workspace"); + if (id == QStringLiteral(":read-only")) + return QStringLiteral("Read only"); + if (id == QStringLiteral(":danger-full-access") || + id == QStringLiteral(":full-access")) + return QStringLiteral("Full access"); + return id; +} + } // namespace TurnSettingsWidget::TurnSettingsWidget(QWidget *parent) : QWidget(parent) { @@ -492,6 +503,17 @@ nlohmann::json TurnSettingsWidget::turnStartOptions() const { void TurnSettingsWidget::markTouched(Field field) { touchedFields[static_cast(field)] = true; + if ((field == Field::Sandbox || field == Field::Network) && + value(permissionProfile) != DefaultValue) { + const QSignalBlocker blocker(permissionProfile); + selectValue(permissionProfile, QString::fromLatin1(DefaultValue), + QStringLiteral("Thread default")); + touchedFields[static_cast(Field::PermissionProfile)] = false; + } else if (field == Field::PermissionProfile && + value(permissionProfile) != DefaultValue) { + touchedFields[static_cast(Field::Sandbox)] = false; + touchedFields[static_cast(Field::Network)] = false; + } refreshMoreIndicator(); } @@ -558,7 +580,7 @@ void TurnSettingsWidget::refreshFromCanonical( profile["id"].is_string()) activeProfile = text(profile["id"].get()); selectValue(permissionProfile, activeProfile, - QStringLiteral("Current permission profile")); + permissionProfileLabel(activeProfile)); } if (refresh(Field::ServiceTier)) selectValue(serviceTier, optionalString(canonical, "serviceTier"), @@ -728,16 +750,21 @@ void TurnSettingsWidget::refreshPermissionProfiles( const std::string id = stringValue(entry, "id"); if (id.empty() || !entry.value("allowed", true)) continue; - addChoice(permissionProfile, text(id), text(id)); - const int index = permissionProfile->findData(text(id)); + const QString profileId = text(id); + addChoice(permissionProfile, permissionProfileLabel(profileId), + profileId); + const int index = permissionProfile->findData(profileId); if (index >= 0) permissionProfile->setItemData( index, text(stringValue(entry, "description")), Qt::ToolTipRole); } } - selectValue(permissionProfile, - selected.isEmpty() ? QString::fromLatin1(DefaultValue) : selected, - QStringLiteral("Current permission profile")); + const QString selectedProfile = + selected.isEmpty() ? QString::fromLatin1(DefaultValue) : selected; + selectValue(permissionProfile, selectedProfile, + selectedProfile == DefaultValue + ? QStringLiteral("Thread default") + : permissionProfileLabel(selectedProfile)); } void TurnSettingsWidget::refreshAccessCompatibility() { diff --git a/src/codex/middle/ComposerPane.cpp b/src/codex/middle/ComposerPane.cpp index c83e519..4cf31b2 100644 --- a/src/codex/middle/ComposerPane.cpp +++ b/src/codex/middle/ComposerPane.cpp @@ -88,23 +88,41 @@ ComposerPane::ComposerPane(QWidget *anchor) attention_->setProperty("kind", "orangeBadge"); auto *attentionLayout = new QHBoxLayout(attention_); attentionLayout->setContentsMargins(10, 6, 10, 6); - attentionLayout->addWidget(makeLabel( - QStringLiteral("A Codex request needs attention"), "attentionSection")); + attentionLayout->setSpacing(8); + auto *attentionText = new QWidget(attention_); + auto *attentionTextLayout = new QVBoxLayout(attentionText); + attentionTextLayout->setContentsMargins(0, 0, 0, 0); + attentionTextLayout->setSpacing(2); + attentionTitle_ = makeLabel(QStringLiteral("A Codex request needs attention"), + "attentionSection"); + attentionDetail_ = makeLabel(QStringLiteral("Review the pending request."), + "meta"); + attentionTextLayout->addWidget(attentionTitle_); + attentionTextLayout->addWidget(attentionDetail_); + attentionLayout->addWidget(attentionText, 1); attentionLayout->addStretch(); - auto *deny = new QPushButton(QStringLiteral("Deny"), attention_); - auto *review = new QPushButton(QStringLiteral("Review"), attention_); - deny->setProperty("kind", "destructive"); - review->setProperty("kind", "request"); - attentionLayout->addWidget(deny); - attentionLayout->addWidget(review); - connect(deny, &QPushButton::clicked, this, [this] { + attentionRejectButton_ = new QPushButton(QStringLiteral("Reject"), attention_); + attentionAcceptButton_ = new QPushButton(QStringLiteral("Accept"), attention_); + attentionReviewButton_ = new QPushButton(QStringLiteral("Review"), attention_); + attentionRejectButton_->setProperty("kind", "destructive"); + attentionAcceptButton_->setProperty("kind", "request"); + attentionReviewButton_->setProperty("kind", "request"); + attentionLayout->addWidget(attentionRejectButton_); + attentionLayout->addWidget(attentionAcceptButton_); + attentionLayout->addWidget(attentionReviewButton_); + connect(attentionRejectButton_, &QPushButton::clicked, this, [this] { if (actions_.deny) actions_.deny(); }); - connect(review, &QPushButton::clicked, this, [this] { + connect(attentionAcceptButton_, &QPushButton::clicked, this, [this] { + if (actions_.accept) + actions_.accept(); + }); + connect(attentionReviewButton_, &QPushButton::clicked, this, [this] { if (actions_.review) actions_.review(); }); + attentionReviewButton_->hide(); attention_->hide(); surfacesLayout->addWidget(attention_); @@ -228,6 +246,30 @@ void ComposerPane::setAttentionVisible(bool visible) { synchronizeGeometry(); } +void ComposerPane::setAttentionRequest(QString title, QString detail, + 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 && + attentionAcceptButton_->isVisible() == directAccept && + attentionReviewButton_->isVisible() != directAccept && + attentionAcceptButton_->text() == acceptLabel; + if (unchanged) + return; + attentionTitle_->setText(std::move(title)); + attentionDetail_->setText(std::move(detail)); + attentionAcceptButton_->setText(std::move(acceptLabel)); + attentionAcceptButton_->setVisible(directAccept); + attentionReviewButton_->setVisible(!directAccept); + synchronizeGeometry(); +} + void ComposerPane::setActiveTurn(bool active) { if (activeTurn_ == active) return; diff --git a/src/codex/middle/ComposerPane.h b/src/codex/middle/ComposerPane.h index d6ab838..b4236ee 100644 --- a/src/codex/middle/ComposerPane.h +++ b/src/codex/middle/ComposerPane.h @@ -14,6 +14,7 @@ class QEvent; class QFrame; class QGridLayout; +class QLabel; class QPushButton; class QScrollArea; class QToolButton; @@ -37,6 +38,7 @@ class ComposerPane final : public QWidget { std::function)> submit; std::function stop; std::function attach; + std::function accept; std::function review; std::function deny; }; @@ -49,6 +51,8 @@ class ComposerPane final : public QWidget { [[nodiscard]] const std::vector & attachments() const noexcept; void setAttentionVisible(bool visible); + void setAttentionRequest(QString title, QString detail, bool directAccept, + QString acceptLabel); void setActiveTurn(bool active); void setCanSubmit(bool canSubmit); void setSettingsEnabled(bool enabled); @@ -80,6 +84,11 @@ class ComposerPane final : public QWidget { QWidget *anchor_ = nullptr; QWidget *reserve_ = nullptr; QFrame *attention_ = nullptr; + QLabel *attentionTitle_ = nullptr; + QLabel *attentionDetail_ = nullptr; + QPushButton *attentionRejectButton_ = nullptr; + QPushButton *attentionAcceptButton_ = nullptr; + QPushButton *attentionReviewButton_ = nullptr; TurnSettingsWidget *turnSettings_ = nullptr; QFrame *composer_ = nullptr; QFrame *attachmentPanel_ = nullptr; diff --git a/src/codex/middle/InspectorPane.cpp b/src/codex/middle/InspectorPane.cpp index 4309dcd..e4a84ae 100644 --- a/src/codex/middle/InspectorPane.cpp +++ b/src/codex/middle/InspectorPane.cpp @@ -53,6 +53,18 @@ std::string stringValue(const nlohmann::json &object, const char *key) { : std::string{}; } +bool supportsDirectAccept(std::string_view kind) { + return kind == "command-approval" || kind == "file-change-approval" || + kind == "permissions-approval" || kind == "legacy-patch-approval" || + kind == "legacy-command-approval"; +} + +QString directAcceptText(std::string_view kind) { + if (kind == "permissions-approval") + return QStringLiteral("Allow this turn"); + return QStringLiteral("Accept"); +} + QLabel *makeLabel(QString value, const char *kind = "body") { auto *label = new QLabel(std::move(value)); label->setProperty("kind", kind); @@ -397,8 +409,10 @@ void InspectorPane::setHideAction(std::function hide) { } void InspectorPane::setRequestActions(RequestAction review, + RequestAction accept, RequestAction reject) { reviewRequest = std::move(review); + acceptRequest = std::move(accept); rejectRequest = std::move(reject); } @@ -632,25 +646,42 @@ void InspectorPane::refreshRequests() { makeLabel(QStringLiteral("%1 questions") .arg(static_cast(*request.questionCount)), "meta")); + if (request.command.empty() && request.reason.empty() && + request.message.empty() && !request.questionCount) + layout->addWidget( + makeLabel(QStringLiteral("Request %1 needs a decision.") + .arg(text(request.id)), + "meta")); auto *actions = new QHBoxLayout; actions->setContentsMargins(0, 2, 0, 0); - auto *deny = new QPushButton(QStringLiteral("Deny")); - auto *review = new QPushButton(QStringLiteral("Review")); - deny->setProperty("kind", "destructive"); - review->setProperty("kind", "request"); - deny->setFixedHeight(28); - review->setFixedHeight(28); - connect(deny, &QPushButton::clicked, this, [this, id = request.id] { + auto *reject = new QPushButton(QStringLiteral("Reject")); + reject->setProperty("kind", "destructive"); + reject->setFixedHeight(28); + connect(reject, &QPushButton::clicked, this, [this, id = request.id] { if (rejectRequest) rejectRequest(id); }); - connect(review, &QPushButton::clicked, this, [this, id = request.id] { - if (reviewRequest) - reviewRequest(id); - }); actions->addStretch(); - actions->addWidget(deny); - actions->addWidget(review); + actions->addWidget(reject); + if (supportsDirectAccept(request.kind)) { + auto *accept = new QPushButton(directAcceptText(request.kind)); + accept->setProperty("kind", "request"); + accept->setFixedHeight(28); + connect(accept, &QPushButton::clicked, this, [this, id = request.id] { + if (acceptRequest) + acceptRequest(id); + }); + actions->addWidget(accept); + } else { + auto *review = new QPushButton(QStringLiteral("Review")); + review->setProperty("kind", "request"); + review->setFixedHeight(28); + connect(review, &QPushButton::clicked, this, [this, id = request.id] { + if (reviewRequest) + reviewRequest(id); + }); + actions->addWidget(review); + } layout->addLayout(actions); requestsLayout->addWidget(frame); } diff --git a/src/codex/middle/InspectorPane.h b/src/codex/middle/InspectorPane.h index b151bd6..d94bdcd 100644 --- a/src/codex/middle/InspectorPane.h +++ b/src/codex/middle/InspectorPane.h @@ -40,7 +40,8 @@ class InspectorPane final : public QFrame { explicit InspectorPane(QWidget *parent = nullptr); void setHideAction(std::function hide); - void setRequestActions(RequestAction review, RequestAction reject); + void setRequestActions(RequestAction review, RequestAction accept, + RequestAction reject); void refresh(const PresentationModel &model, const std::string &selectedThreadId); void appendProtocolFrame(const nlohmann::json &frame); @@ -117,6 +118,7 @@ class InspectorPane final : public QFrame { const PresentationModel *currentModel = nullptr; std::string currentThreadId; RequestAction reviewRequest; + RequestAction acceptRequest; RequestAction rejectRequest; std::function hideAction; diff --git a/tests/codex/ApplicationLayoutTest.cpp b/tests/codex/ApplicationLayoutTest.cpp index 0c64f74..e12e396 100644 --- a/tests/codex/ApplicationLayoutTest.cpp +++ b/tests/codex/ApplicationLayoutTest.cpp @@ -114,6 +114,14 @@ bool hasLabelContaining(const QWidget &root, const QString &text) { return false; } +bool hasButtonText(const QWidget &root, const QString &text) { + for (const QPushButton *button : root.findChildren()) { + if (button->text() == text) + return true; + } + return false; +} + void spin(int milliseconds = 0) { QElapsedTimer timer; timer.start(); @@ -377,6 +385,25 @@ bool testOverlayGeometryAndRegionRouting() { "prompt contraction restores canonical layout, gaps, and trailing space"); region.composer().setActiveTurn(false); spin(20); + result &= expect(view.isAtBottom(), "conversation begins at the bottom"); + region.composer().setAttentionRequest( + QStringLiteral("Command approval requested"), + QStringLiteral("Command: gh auth status | Reason: Verify GitHub " + "authentication"), + true, QStringLiteral("Accept")); + region.composer().setAttentionVisible(true); + spin(20); + result &= expect( + hasLabelContaining(region.composer(), + QStringLiteral("Command approval requested")) && + hasLabelContaining(region.composer(), + QStringLiteral("Command: gh auth status")) && + hasButtonText(region.composer(), QStringLiteral("Reject")) && + hasButtonText(region.composer(), QStringLiteral("Accept")), + "composer attention requests show details and direct semantic actions"); + region.composer().setAttentionVisible(false); + view.verticalScrollBar()->setValue(view.verticalScrollBar()->maximum()); + spin(10); ComposerPane::Actions rejected; rejected.submit = [](QString, std::vector) { return false; }; @@ -396,7 +423,6 @@ bool testOverlayGeometryAndRegionRouting() { result &= expect(region.composer().promptEditor()->toPlainText().isEmpty(), "successful local admission clears the draft exactly once"); - result &= expect(view.isAtBottom(), "conversation begins at the bottom"); QWheelEvent overLeftHandle = wheelFor(splitter->handle(1), 180); result &= expect(region.routeScrollEvent(splitter->handle(1), &overLeftHandle) && @@ -553,11 +579,16 @@ bool testIncrementalThreadSettings() { auto *model = settings.findChild(QStringLiteral("codexModel")); auto *approval = settings.findChild(QStringLiteral("codexApproval")); + auto *personality = + settings.findChild(QStringLiteral("codexPersonality")); auto *access = settings.findChild(QStringLiteral("codexSandbox")); auto *network = settings.findChild(QStringLiteral("codexNetwork")); - if (!model || !approval || !access || !network) + auto *permissionProfile = settings.findChild( + QStringLiteral("codexPermissionProfile")); + if (!model || !approval || !personality || !access || !network || + !permissionProfile) return expect(false, "thread settings controls are discoverable"); bool canonicalSettingsStyle = settings.styleSheet().isEmpty(); @@ -624,6 +655,71 @@ bool testIncrementalThreadSettings() { QStringLiteral("enabled"), "only logically redundant network selection is disabled"); + settings.setContext( + "workspace-profile-thread", + {{"sandboxPolicy", + {{"type", "workspaceWrite"}, {"networkAccess", false}}}, + {"activePermissionProfile", {{"id", ":workspace"}}}}, + nlohmann::json::array(), + {{"data", nlohmann::json::array( + {{{"id", ":workspace"}, {"allowed", true}}, + {{"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(":danger-full-access"))) == + QStringLiteral("Full access"), + "built-in permission profiles have user-facing labels"); + + access->setCurrentIndex( + access->findData(QStringLiteral("danger-full-access"))); + const nlohmann::json explicitAccessTurn = settings.turnStartOptions(); + const nlohmann::json explicitAccessThread = settings.threadStartOptions(); + result &= expect( + permissionProfile->currentData().toString() == + QStringLiteral("default") && + !explicitAccessTurn.contains("permissions") && + explicitAccessTurn.value("sandboxPolicy", nlohmann::json(nullptr)) == + nlohmann::json({{"type", "dangerFullAccess"}}) && + !explicitAccessThread.contains("permissions") && + explicitAccessThread.value("sandbox", nlohmann::json(nullptr)) == + nlohmann::json("danger-full-access"), + "an explicit access choice replaces the active permission profile"); + + settings.setContext( + "individual-overrides-thread", + {{"model", "gpt-a"}, + {"approvalPolicy", "never"}, + {"personality", "friendly"}, + {"sandboxPolicy", + {{"type", "workspaceWrite"}, {"networkAccess", false}}}, + {"activePermissionProfile", {{"id", ":workspace"}}}}, + models, + {{"data", nlohmann::json::array( + {{{"id", ":workspace"}, {"allowed", true}}})}}); + model->setCurrentIndex(model->findData(QStringLiteral("gpt-b"))); + approval->setCurrentIndex( + approval->findData(QStringLiteral("on-request"))); + personality->setCurrentIndex( + personality->findData(QStringLiteral("pragmatic"))); + const nlohmann::json individualOverrides = settings.turnStartOptions(); + result &= expect( + permissionProfile->currentData().toString() == + QStringLiteral(":workspace") && + individualOverrides.value("model", "") == "gpt-b" && + individualOverrides.value("approvalPolicy", "") == "on-request" && + individualOverrides.value("personality", "") == "pragmatic" && + !individualOverrides.contains("sandboxPolicy") && + !individualOverrides.contains("permissions"), + "supported individual settings override retained thread values without " + "discarding its permission profile"); + return result; } @@ -1344,19 +1440,45 @@ bool testInspectorDetailParity() { break; } } - QPushButton *denyButton = nullptr; + QPushButton *rejectButton = nullptr; QPushButton *reviewButton = nullptr; for (QPushButton *button : inspector.findChildren()) { - if (button->text() == QStringLiteral("Deny")) - denyButton = button; + if (button->text() == QStringLiteral("Reject")) + rejectButton = button; else if (button->text() == QStringLiteral("Review")) reviewButton = button; } result &= expect( - requestFrame && denyButton && reviewButton && - denyButton->property("kind") == "destructive" && + requestFrame && rejectButton && reviewButton && + rejectButton->property("kind") == "destructive" && reviewButton->property("kind") == "request", - "pending requests use warning surfaces and semantic actions"); + "complex pending requests use warning surfaces and a review action"); + model.applyEvent(presentation::event( + 6, 1, "pending-request.upsert", + {{"requestId", "request-two"}, + {"category", "command-approval"}, + {"request", + {{"command", "gh auth status"}, + {"reason", "Verify GitHub authentication"}, + {"cwd", "/home/voc/projects/drafts"}}}}, + presentation::Authority::Merge, + {{"threadId", "owner-thread"}, {"requestId", "request-two"}})); + inspector.refresh(model, "owner-thread"); + spin(20); + QPushButton *acceptButton = nullptr; + for (QPushButton *button : inspector.findChildren()) { + if (button->text() == QStringLiteral("Accept")) { + acceptButton = button; + break; + } + } + result &= expect( + acceptButton && + 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); return result; } diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..55a4d2f --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,3 @@ +/dist/ +/app-dist/ +/node_modules/ diff --git a/web/AISUITE_REVISION b/web/AISUITE_REVISION new file mode 100644 index 0000000..ac20dfd --- /dev/null +++ b/web/AISUITE_REVISION @@ -0,0 +1 @@ +5aeedb2c21d7da0d611219365294cc3fb052cddf diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..25af18b --- /dev/null +++ b/web/README.md @@ -0,0 +1,57 @@ +# CodexWebUI + +CodexWebUI is the static browser application for CodexUI 1.0. It connects +directly to an AISuite `codex-bridge` WebSocket endpoint with the `codex` +subprotocol. It contains no web server, bridge router, controller authority, +or persistent Codex state. + +## Reproducible source layout + +The application consumes the exact AISuite source revision in +[`AISUITE_REVISION`](AISUITE_REVISION). Check out the repositories as: + +```text +workspace/ +├── AISuite-extraction/AISuite-final/ +└── CodexUI/codexui/ +``` + +Verify the pin and build both sides: + +```sh +test "$(git -C ../../../AISuite-extraction/AISuite-final rev-parse HEAD)" = "$(cat AISUITE_REVISION)" +npm ci --prefix ../../../AISuite-extraction/AISuite-final/packages/codex-frontend +npm test --prefix ../../../AISuite-extraction/AISuite-final/packages/codex-frontend +npm ci +npm run release +``` + +`npm run profile` repeats the large-thread presentation measurement. There is +no standalone Node development or production server. + +## Deployment + +`npm run build:app` creates the relocatable static artifact in `app-dist/`. +The CodexUI CMake install places it in `share/codexui/web`. The existing +`codex-bridge` HTTP/WebSocket listener serves `/`, `/index.html`, and the +generated `/assets/` files from that directory while `/codex` remains the +WebSocket upgrade route. A page delivered over HTTPS automatically derives the +corresponding same-origin `wss://.../codex` URL. + +The configured bridge URL is retained in browser local storage. Provider-side +workspace paths and generated-image paths are displayed as remote metadata; +the application does not imply access to the browser machine's filesystem. + +## Verification + +- `npm test` builds TypeScript and runs all presentation, projection, + lifecycle, viewport, supporting-surface, and server-render qualification + tests. +- `npm run build:app` verifies the production Vite bundle. +- `npm run verify:artifact` proves that the output is non-empty and relocatable + below an arbitrary static base path. +- The repository CI checks the pinned SDK independently, runs the web suite, + records the performance profile, and uploads `app-dist` as `codexui-web`. + +The authoritative scope and exceptions are in +[`../docs/web-1.0-contract.md`](../docs/web-1.0-contract.md). diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..e668125 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + CodexUI + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..78eb1ab --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1022 @@ +{ + "name": "@snodec/codex-ui-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@snodec/codex-ui-web", + "version": "1.0.0", + "dependencies": { + "@snodec/codex-frontend": "file:../../../AISuite-extraction/AISuite-final/packages/codex-frontend", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/react": "18.3.27", + "@types/react-dom": "18.3.7", + "typescript": "5.9.3", + "vite": "5.4.21" + } + }, + "../../../AISuite-extraction/AISuite-final/packages/codex-frontend": { + "name": "@snodec/codex-frontend", + "version": "1.0.0", + "license": "LGPL-3.0-or-later OR MIT", + "devDependencies": { + "typescript": "5.9.3" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@snodec/codex-frontend": { + "resolved": "../../../AISuite-extraction/AISuite-final/packages/codex-frontend", + "link": true + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/rollup": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.54.0", + "@rollup/rollup-android-arm64": "4.54.0", + "@rollup/rollup-darwin-arm64": "4.54.0", + "@rollup/rollup-darwin-x64": "4.54.0", + "@rollup/rollup-freebsd-arm64": "4.54.0", + "@rollup/rollup-freebsd-x64": "4.54.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", + "@rollup/rollup-linux-arm-musleabihf": "4.54.0", + "@rollup/rollup-linux-arm64-gnu": "4.54.0", + "@rollup/rollup-linux-arm64-musl": "4.54.0", + "@rollup/rollup-linux-loong64-gnu": "4.54.0", + "@rollup/rollup-linux-ppc64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-musl": "4.54.0", + "@rollup/rollup-linux-s390x-gnu": "4.54.0", + "@rollup/rollup-linux-x64-gnu": "4.54.0", + "@rollup/rollup-linux-x64-musl": "4.54.0", + "@rollup/rollup-openharmony-arm64": "4.54.0", + "@rollup/rollup-win32-arm64-msvc": "4.54.0", + "@rollup/rollup-win32-ia32-msvc": "4.54.0", + "@rollup/rollup-win32-x64-gnu": "4.54.0", + "@rollup/rollup-win32-x64-msvc": "4.54.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..f511f19 --- /dev/null +++ b/web/package.json @@ -0,0 +1,25 @@ +{ + "name": "@snodec/codex-ui-web", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json", + "build:app": "vite build", + "profile": "npm run build && node tools/profile-presentation.mjs", + "release": "npm test && npm run profile && npm run build:app && npm run verify:artifact", + "test": "npm run build && node --test tests/*.test.mjs", + "verify:artifact": "node tools/verify-artifact.mjs" + }, + "dependencies": { + "@snodec/codex-frontend": "file:../../../AISuite-extraction/AISuite-final/packages/codex-frontend", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/react": "18.3.27", + "@types/react-dom": "18.3.7", + "typescript": "5.9.3", + "vite": "5.4.21" + } +} diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx new file mode 100644 index 0000000..e513c05 --- /dev/null +++ b/web/src/app/App.tsx @@ -0,0 +1,353 @@ +import {useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore} from "react"; +import type {FormEvent, ReactNode} from "react"; +import { + ConversationViewportState, DefaultSetting, applySettingChange, canonicalSettingValues, canonicalThreadSettings, classifyStatus, + indexAuthoritativeItems, negativePendingResponse, permissionProfileLabel, positivePendingResponse, projectConversation, stableKey, + threadStartOptions, turnStartOptions, +} from "../index.js"; +import type {PendingRequestPresentation, SettingField, SettingValues} from "../index.js"; +import type { + AgentActivityData, CommandExecutionData, FileChangesData, LocalPromptData, + ReasoningData, UserMessageData, AgentMessageData, VisibleCardData, +} from "../index.js"; +import type {BrowserFrontendSession} from "./BrowserFrontendSession.js"; +import {humanizeProtocolLabel as humanize} from "./Humanize.js"; + +const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; + +function StatusDot({tone}: {tone: string}) { return