diff --git a/.github/workflows/desktop-qt.yml b/.github/workflows/desktop-qt.yml new file mode 100644 index 000000000000..e5c1c776ef00 --- /dev/null +++ b/.github/workflows/desktop-qt.yml @@ -0,0 +1,117 @@ +name: desktop-qt + +on: + push: + paths: + - "apps/desktop-qt/**" + - "apps/server/**" + - "apps/web/**" + - "packages/**" + - "patches/**" + - "assets/prod/black-universal-1024.png" + - "scripts/lib/cli-external-packages.ts" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "vite.config.ts" + - ".github/workflows/desktop-qt.yml" + pull_request: + paths: + - "apps/desktop-qt/**" + - "apps/server/**" + - "apps/web/**" + - "packages/**" + - "patches/**" + - "assets/prod/black-universal-1024.png" + - "scripts/lib/cli-external-packages.ts" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "vite.config.ts" + - ".github/workflows/desktop-qt.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + qt_host: linux + qt_arch: linux_gcc_64 + - os: macos-15 + qt_host: mac + qt_arch: clang_64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=t3... + + # Official Qt binaries on every platform so WebEngine is always present + # (distro packages often ship without it). + - uses: jurplel/install-qt-action@v4 + with: + version: "6.9.*" + host: ${{ matrix.qt_host }} + arch: ${{ matrix.qt_arch }} + modules: qtwebengine qtwebchannel qtpositioning + cache: true + + - name: Install build tools (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y ninja-build libgl1-mesa-dev libxkbcommon-dev libfuse2 \ + libxcb-cursor0 libnss3 libxcomposite1 libxdamage1 libxrandr2 libasound2t64 + + - name: Install build tools (macOS) + if: runner.os == 'macOS' + run: brew install ninja + + - name: Configure + run: > + cmake -S apps/desktop-qt -B apps/desktop-qt/build/release -G Ninja + -DCMAKE_BUILD_TYPE=Release + -DCMAKE_PREFIX_PATH="${{ env.QT_ROOT_DIR }}" + + - name: Build + run: cmake --build apps/desktop-qt/build/release + + - name: Build server runtime + run: vp run --filter t3 build + + - name: Test QML + run: node apps/desktop-qt/scripts/test-qml.mjs + + - name: Package (Linux AppImage) + if: runner.os == 'Linux' + run: apps/desktop-qt/scripts/package-linux.sh apps/desktop-qt/build/release + + - name: Package (macOS app bundle) + if: runner.os == 'macOS' + run: | + node apps/desktop-qt/scripts/stage-runtime.mjs apps/desktop-qt/build/release/t3code-qt.app/Contents/Resources/t3code + "${QT_ROOT_DIR}/bin/macdeployqt" apps/desktop-qt/build/release/t3code-qt.app -qmldir=apps/desktop-qt/qml + ditto -c -k --keepParent apps/desktop-qt/build/release/t3code-qt.app apps/desktop-qt/build/release/t3code-qt-macos.zip + + - uses: actions/upload-artifact@v4 + with: + name: t3code-qt-${{ matrix.os }} + path: | + apps/desktop-qt/build/release/*.AppImage + apps/desktop-qt/build/release/*.zip + if-no-files-found: error diff --git a/apps/desktop-qt/CMakeLists.txt b/apps/desktop-qt/CMakeLists.txt new file mode 100644 index 000000000000..3180cebae914 --- /dev/null +++ b/apps/desktop-qt/CMakeLists.txt @@ -0,0 +1,120 @@ +cmake_minimum_required(VERSION 3.21) + +project(t3code-qt VERSION 0.0.1 LANGUAGES CXX) + +if(APPLE) + enable_language(OBJCXX) +endif() + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) + +find_package(Qt6 6.9 REQUIRED COMPONENTS Core Gui GuiPrivate Quick WebEngineQuick WebChannel) +qt_standard_project_setup(REQUIRES 6.9) +qt_policy(SET QTP0001 NEW) + +# Where the QML bricks live on disk. Non-release builds load them from here so +# edits hot-reload without a rebuild; release builds only use the compiled-in +# copies. T3_QML_SOURCE_DIR="" disables disk loading for any build type. +if(NOT DEFINED T3_QML_SOURCE_DIR) + set(T3_QML_SOURCE_DIR "$,,${CMAKE_CURRENT_SOURCE_DIR}/qml>") +endif() + +# The Node desktop host entry. In-repo builds point at the TypeScript source. +# Release builds resolve an installed path relative to the executable. +if(NOT DEFINED T3_HOST_ENTRY) + if(APPLE) + set(_t3_installed_host "../Resources/t3code/host/main.ts") + else() + set(_t3_installed_host "../share/t3code/host/main.ts") + endif() + set(T3_HOST_ENTRY "$,${_t3_installed_host},${CMAKE_CURRENT_SOURCE_DIR}/host/main.ts>") +endif() + +# Release packages carry the same Node executable that installed their native +# dependencies. Development builds keep using the command from PATH. +if(NOT DEFINED T3_NODE_ENTRY) + if(APPLE) + set(_t3_installed_node "../Resources/t3code/bin/node") + elseif(WIN32) + set(_t3_installed_node "../share/t3code/bin/node.exe") + else() + set(_t3_installed_node "../share/t3code/bin/node") + endif() + set(T3_NODE_ENTRY "$,${_t3_installed_node},node>") +endif() + +# qwebchannel.js is the browser half of Qt WebChannel. Qt installs it as data +# (share/qt6/webchannel); bundling it makes the injected script independent of +# whether the runtime Qt exposes it as a qrc resource. +get_filename_component(_qt_prefix "${QT6_INSTALL_PREFIX}" ABSOLUTE) +find_file(T3_QWEBCHANNEL_JS qwebchannel.js + HINTS + "${_qt_prefix}/${QT6_INSTALL_DATA}/webchannel" + "${_qt_prefix}/share/qt6/webchannel" + "${_qt_prefix}/share/qt/webchannel" + "${_qt_prefix}/../qtwebchannel/share/qt6/webchannel" +) + +qt_add_executable(t3code-qt + src/main.cpp + src/BackendProcess.cpp + src/BackendProcess.h + src/ShellBridge.cpp + src/ShellBridge.h + src/ShellRuntime.cpp + src/ShellRuntime.h + src/ThemeStore.cpp + src/ThemeStore.h + src/WebProfile.cpp + src/WebProfile.h + src/PlatformWindow.h +) + +if(T3_QWEBCHANNEL_JS) + set_source_files_properties("${T3_QWEBCHANNEL_JS}" PROPERTIES QT_RESOURCE_ALIAS "qwebchannel.js") + qt_add_resources(t3code-qt "t3_webchannel" PREFIX "/t3" FILES "${T3_QWEBCHANNEL_JS}") + target_compile_definitions(t3code-qt PRIVATE T3_WEBCHANNEL_SCRIPT_URL="qrc:/t3/qwebchannel.js") +else() + message(STATUS "qwebchannel.js not found next to Qt; relying on qrc:///qtwebchannel/qwebchannel.js at runtime") + target_compile_definitions(t3code-qt PRIVATE T3_WEBCHANNEL_SCRIPT_URL="qrc:///qtwebchannel/qwebchannel.js") +endif() + +target_compile_definitions(t3code-qt PRIVATE + T3_QML_SOURCE_DIR="${T3_QML_SOURCE_DIR}" + T3_HOST_ENTRY="${T3_HOST_ENTRY}" + T3_NODE_ENTRY="${T3_NODE_ENTRY}" + T3_APP_VERSION="${PROJECT_VERSION}" +) + +if(APPLE) + target_sources(t3code-qt PRIVATE src/PlatformWindow.mm) + target_link_libraries(t3code-qt PRIVATE "-framework AppKit") +else() + target_sources(t3code-qt PRIVATE src/PlatformWindow.cpp) +endif() + +add_subdirectory(qml/T3/Bricks) + +target_link_libraries(t3code-qt PRIVATE + Qt6::Core + Qt6::Gui + Qt6::GuiPrivate + Qt6::Quick + Qt6::WebEngineQuick + Qt6::WebChannel + t3bricksplugin +) + +set_target_properties(t3code-qt PROPERTIES + MACOSX_BUNDLE TRUE + MACOSX_BUNDLE_GUI_IDENTIFIER "codes.t3.desktop-qt" + MACOSX_BUNDLE_BUNDLE_NAME "T3 Code" + WIN32_EXECUTABLE TRUE +) + +install(TARGETS t3code-qt + BUNDLE DESTINATION . + RUNTIME DESTINATION bin +) diff --git a/apps/desktop-qt/README.md b/apps/desktop-qt/README.md new file mode 100644 index 000000000000..b2a2df5f4c97 --- /dev/null +++ b/apps/desktop-qt/README.md @@ -0,0 +1,25 @@ +# @t3tools/desktop-qt + +Qt/QML shell for T3 Code. Hosts the web app in a `WebEngineView` and lets the +window chrome be rearranged and themed from `~/.t3/shell/`. + +Architecture, setup, and the QML/theme contracts: `docs/internals/desktop-qt.md`. + +```sh +vp run dev # terminal 1: server + web +vp run dev:qt # terminal 2: build the shell, pair, launch +``` + +Focused native checks use temporary data and run offscreen: + +```sh +vp run --filter @t3tools/desktop-qt test:qml +cmake -S apps/desktop-qt/tests/native -B apps/desktop-qt/build/tests/native +cmake --build apps/desktop-qt/build/tests/native +ctest --test-dir apps/desktop-qt/build/tests/native --output-on-failure +``` + +`ShellRuntime` covers reload and theme ownership. `ShellExamples` loads all +four examples at 640, 1000, and 1400 pixels, checking header text and dashboard +card bounds, long branch names, clipped icons, and scrolling to the last card. It uses a local +view-model fixture and a blank web page; no running server or pairing is needed. diff --git a/apps/desktop-qt/examples/README.md b/apps/desktop-qt/examples/README.md new file mode 100644 index 000000000000..ab31ca35e34d --- /dev/null +++ b/apps/desktop-qt/examples/README.md @@ -0,0 +1,40 @@ +# Shell examples + +Each folder is a complete rice: a `shell.qml` layout composed from the +`T3.Bricks` module and a `theme.json` in the web app's theme format (plus the +shell-only `window`, `radius` and `fonts` keys). Copy one into your config dir +and the app reloads on save: + +```sh +cp examples/glass/*.* ~/.t3/shell/ +``` + +| Example | Idea | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `minimal` | The default layout with the title bar moved to the bottom, on the web app's own dark palette (`theme.json` here is the page's default theme, so it is the one to copy when a rice should start from stock colours). | +| `dashboard` | Rosé light theme, an icon rail that also carries settings, and a Dashboard button that pulls a widget drawer over the page: today, the month, the open workspace, thread meters, and the agent, with a cat at play (a Lottie animation; needs the Qt Lottie module, `qt6-lottie` on Arch, and shows the agent's initial without it). | +| `glass` | The shape of a Mac app: one rounded window with a hairline edge, traffic lights, a translucent sidebar for the compositor to blur behind, and a unified toolbar over an opaque content column. | +| `terminal` | Status line with the wordmark, a prompt mark, counts and the model; mono type, sharp corners. Made for a tiling desktop such as Omarchy, with flags at the top of `shell.qml` for a clock, window buttons and a chip of the palette's sixteen colours. Its `theme.json` is Tokyo Night as `vp run theme:qt` writes it from a terminal in those colours. | + +Pick up your own terminal's colours (it asks the terminal for its palette and +writes `theme.json` into the shell directory, `~/.t3/shell` by default): + +```sh +vp run theme:qt +vp run theme:qt ~/.t3/shell +``` + +What you can reach from `shell.qml`: `ShellWindow` as the root (window +colour, opacity and frame from the theme, `sidebarCollapsed` and +`settingsActive` from the page, the error overlay and the page's window +commands built in), the bricks (`TitleBar`, `Sidebar`, `SettingsNav`, +`Workspace`, `GitActions`, `WebSurface`, `Composer`, `RightPanel`, `TerminalDrawer`, +`Notifications`), the themed controls (`ShellCard`, `ShellButton`, +`ShellComboBox`, `ShellTextField`, `ShellMenu`, `ShellMenuItem`, `ShellIcon`, +`WindowControls`), and the `T3.Shell` singletons: `Shell.state.` for +everything the page publishes, `Shell.dispatch(action, payload)` to act, +`Theme.color(role, fallback)` / `Theme.radius` / `Theme.fontUi` / +`Theme.fontMono`, and `Runtime.reload()`. + +A broken `shell.qml` never locks you out: the built-in shell takes over with +the error shown in an overlay. diff --git a/apps/desktop-qt/examples/dashboard/CatPlaying.qml b/apps/desktop-qt/examples/dashboard/CatPlaying.qml new file mode 100644 index 000000000000..55103a3aa608 --- /dev/null +++ b/apps/desktop-qt/examples/dashboard/CatPlaying.qml @@ -0,0 +1,20 @@ +import QtQuick +import Qt.labs.lottieqt + +// The cat at play (cat-playing.json), fitted into whatever box it is given. +// Qt Lottie paints at the frame size the file declares, so the painter is +// scaled instead of the item. Needs the Qt Lottie module (qt6-lottie); the +// dashboard falls back to the agent's initial when the import fails. +LottieAnimation { + id: cat + + // The file's frame (its "w" and "h"); the host resizes the item. + readonly property size frame: Qt.size(1070, 456) + + implicitHeight: width * frame.height / frame.width + source: "cat-playing.json" + autoPlay: true + loops: LottieAnimation.Infinite + quality: LottieAnimation.HighQuality + contentsScale: Math.min(width / frame.width, height / frame.height) +} diff --git a/apps/desktop-qt/examples/dashboard/cat-playing.json b/apps/desktop-qt/examples/dashboard/cat-playing.json new file mode 100644 index 000000000000..078757525221 --- /dev/null +++ b/apps/desktop-qt/examples/dashboard/cat-playing.json @@ -0,0 +1 @@ +{"v":"5.9.4","fr":30,"ip":0,"op":121,"w":1070,"h":456,"nm":"Cat playing animation","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"Null 1","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[465.25,202.25,0],"ix":2,"l":2},"a":{"a":0,"k":[50,50,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ip":0,"op":180,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Ellipse 6","parent":1,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[50.25,47,0],"ix":2,"l":2},"a":{"a":0,"k":[-64.5,385.25,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-17.673,0],[0,-17.673],[17.673,0],[0,17.673]],"o":[[17.673,0],[0,17.673],[-17.673,0],[0,-17.673]],"v":[[0,-32.25],[30.75,0.25],[0,32.75],[-28.75,-0.75]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.996078491211,0.996078491211,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-65.5,385],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 3","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":180,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 5","parent":1,"tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.72,"y":1},"o":{"x":0.728,"y":0},"t":8,"s":[-34.5,53,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.264,"y":1},"o":{"x":0.28,"y":0},"t":15,"s":[-2.5,53,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.72,"y":0.72},"o":{"x":0.28,"y":0.28},"t":22,"s":[-34.5,53,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.72,"y":1},"o":{"x":0.728,"y":0},"t":27,"s":[-34.5,53,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.264,"y":1},"o":{"x":0.28,"y":0},"t":34,"s":[-2.5,53,0],"to":[0,0,0],"ti":[0,0,0]},{"t":41,"s":[-34.5,53,0]}],"ix":2,"l":2},"a":{"a":0,"k":[-17.75,396.25,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[102.5,107.5],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"st","c":{"a":0,"k":[0.210103816612,0.211211021274,0.211764705882,1],"ix":3},"o":{"a":0,"k":50,"ix":4},"w":{"a":0,"k":1.5,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.780392216701,0.58431372549,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-17.75,396.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":180,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Ellipse 5","parent":1,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[50.25,47,0],"ix":2,"l":2},"a":{"a":0,"k":[-64.5,385.25,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-17.673,0],[0,-17.673],[17.673,0],[0,17.673]],"o":[[17.673,0],[0,17.673],[-17.673,0],[0,-17.673]],"v":[[0,-32.25],[30.75,0.25],[0,32.75],[-28.75,-0.75]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.996078491211,0.996078491211,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-65.5,385],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 3","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":180,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Shape Layer 4","parent":1,"tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.72,"y":1},"o":{"x":0.728,"y":0},"t":8,"s":[134.5,53,0],"to":[-5.5,0,0],"ti":[0,0,0]},{"i":{"x":0.264,"y":1},"o":{"x":0.28,"y":0},"t":15,"s":[101.5,53,0],"to":[0,0,0],"ti":[-5.5,0,0]},{"i":{"x":0.72,"y":0.72},"o":{"x":0.28,"y":0.28},"t":22,"s":[134.5,53,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.72,"y":1},"o":{"x":0.728,"y":0},"t":27,"s":[134.5,53,0],"to":[-5.5,0,0],"ti":[0,0,0]},{"i":{"x":0.264,"y":1},"o":{"x":0.28,"y":0},"t":34,"s":[101.5,53,0],"to":[0,0,0],"ti":[-5.5,0,0]},{"t":41,"s":[134.5,53,0]}],"ix":2,"l":2},"a":{"a":0,"k":[-17.75,396.25,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[102.5,107.5],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"st","c":{"a":0,"k":[0.210103816612,0.211211021274,0.211764705882,1],"ix":3},"o":{"a":0,"k":50,"ix":4},"w":{"a":0,"k":1.5,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.780392216701,0.58431372549,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-17.75,396.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":180,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"Ellipse 4","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[465.5,301.25,0],"ix":2,"l":2},"a":{"a":0,"k":[-64.5,385.25,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-17.673,0],[0,-17.673],[17.673,0],[0,17.673]],"o":[[17.673,0],[0,17.673],[-17.673,0],[0,-17.673]],"v":[[0,-32.25],[30.75,0.25],[0,32.75],[-28.75,-0.75]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.996078491211,0.996078491211,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-65.5,385],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 3","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":180,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"Shape Layer 3","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.72,"y":1},"o":{"x":0.728,"y":0},"t":8,"s":[380.75,307.25,0],"to":[5.333,0,0],"ti":[0,0,0]},{"i":{"x":0.264,"y":1},"o":{"x":0.28,"y":0},"t":15,"s":[412.75,307.25,0],"to":[0,0,0],"ti":[5.333,0,0]},{"i":{"x":0.72,"y":0.72},"o":{"x":0.28,"y":0.28},"t":22,"s":[380.75,307.25,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.72,"y":1},"o":{"x":0.728,"y":0},"t":27,"s":[380.75,307.25,0],"to":[5.333,0,0],"ti":[0,0,0]},{"i":{"x":0.264,"y":1},"o":{"x":0.28,"y":0},"t":34,"s":[412.75,307.25,0],"to":[0,0,0],"ti":[5.333,0,0]},{"t":41,"s":[380.75,307.25,0]}],"ix":2,"l":2},"a":{"a":0,"k":[-17.75,396.25,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[102.5,107.5],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"st","c":{"a":0,"k":[0.210103816612,0.211211021274,0.211764705882,1],"ix":3},"o":{"a":0,"k":50,"ix":4},"w":{"a":0,"k":1.5,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.780392216701,0.58431372549,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-17.75,396.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":180,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"Ellipse 3","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[465.5,301.25,0],"ix":2,"l":2},"a":{"a":0,"k":[-64.5,385.25,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-17.673,0],[0,-17.673],[17.673,0],[0,17.673]],"o":[[17.673,0],[0,17.673],[-17.673,0],[0,-17.673]],"v":[[0,-32.25],[30.75,0.25],[0,32.75],[-28.75,-0.75]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.996078491211,0.996078491211,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-65.5,385],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 3","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":180,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"Shape Layer 2","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.72,"y":1},"o":{"x":0.728,"y":0},"t":8,"s":[549.75,307.25,0],"to":[-5.5,0,0],"ti":[0,0,0]},{"i":{"x":0.264,"y":1},"o":{"x":0.28,"y":0},"t":15,"s":[516.75,307.25,0],"to":[0,0,0],"ti":[-5.5,0,0]},{"i":{"x":0.72,"y":0.72},"o":{"x":0.28,"y":0.28},"t":22,"s":[549.75,307.25,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.72,"y":1},"o":{"x":0.728,"y":0},"t":27,"s":[549.75,307.25,0],"to":[-5.5,0,0],"ti":[0,0,0]},{"i":{"x":0.264,"y":1},"o":{"x":0.28,"y":0},"t":34,"s":[516.75,307.25,0],"to":[0,0,0],"ti":[-5.5,0,0]},{"t":41,"s":[549.75,307.25,0]}],"ix":2,"l":2},"a":{"a":0,"k":[-17.75,396.25,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[102.5,107.5],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"st","c":{"a":0,"k":[0.210103816612,0.211211021274,0.211764705882,1],"ix":3},"o":{"a":0,"k":50,"ix":4},"w":{"a":0,"k":1.5,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.780392216701,0.58431372549,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-17.75,396.25],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":180,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"ball","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.267,"y":0.289},"o":{"x":0.095,"y":0},"t":63,"s":[361,336,0],"to":[-42.167,-17,0],"ti":[48.5,-43.333,0]},{"i":{"x":0.926,"y":0.931},"o":{"x":0.288,"y":0.29},"t":74,"s":[195,330,0],"to":[-91,-42.667,0],"ti":[12.991,-18.029,0]},{"i":{"x":0.549,"y":0.553},"o":{"x":0.126,"y":0.148},"t":84,"s":[52,329,0],"to":[50.509,-34.471,0],"ti":[-11.333,-12.667,0]},{"i":{"x":0.667,"y":0.732},"o":{"x":0.333,"y":0.33},"t":92,"s":[139,328,0],"to":[34.333,-28.833,0],"ti":[0,0,0]},{"i":{"x":0.628,"y":0.995},"o":{"x":0.333,"y":0.438},"t":101,"s":[243,331.5,0],"to":[0,0,0],"ti":[-93.573,-13.398,0]},{"t":117,"s":[361,336,0]}],"ix":2,"l":2},"a":{"a":0,"k":[-168,420,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":63,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":66,"s":[93,105,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":69,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":74,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":77,"s":[95,103,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":80,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":84,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":87,"s":[95,103,100]},{"t":90,"s":[100,100,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[102,102],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.458823529412,0.032387538985,0.152792896944,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-168,420],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":179,"st":-1,"ct":1,"bm":0},{"ddd":0,"ind":11,"ty":4,"nm":"r hand","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[511.909,358.311,0],"ix":2,"l":2},"a":{"a":0,"k":[-18.091,442.311,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":55,"s":[{"i":[[0,0],[-20.875,1.625],[2.875,-12.75],[0,0]],"o":[[0,0],[20.042,-1.56],[-3.761,16.681],[0,0]],"v":[[-51.75,441],[-13.875,419.625],[16.5,440.375],[6.875,458.75]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":56,"s":[{"i":[[0,0],[-20.938,-0.145],[3.942,-12.461],[0,0]],"o":[[0,0],[20.102,0.139],[-5.158,16.303],[0,0]],"v":[[-70.352,433.39],[-34.556,419.418],[-6.044,442.661],[-41.312,462.656]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":58,"s":[{"i":[[0,0],[-1.944,-0.168],[4.896,-12.118],[0,0]],"o":[[0,0],[20.028,1.728],[-4.581,11.339],[0,0]],"v":[[-73.852,424.765],[-68.181,424.168],[-42.794,446.911],[-63.562,457.031]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":59,"s":[{"i":[[0,0],[-0.208,0.064],[0.787,-11.318],[0,0]],"o":[[0,0],[-10.902,-1.334],[-1.29,18.548],[0,0]],"v":[[-81.811,423.723],[-91.723,424.334],[-111.085,434.827],[-93.854,460.115]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":60,"s":[{"i":[[0,0],[1.529,0.295],[5.128,-13.729],[0,0]],"o":[[0,0],[-8.298,-1.126],[-1.56,15.881],[0,0]],"v":[[-94.457,419.119],[-107.765,415.688],[-131.565,425.744],[-116.958,454.261]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":61,"s":[{"i":[[0,0],[3.265,0.526],[9.469,-16.14],[0,0]],"o":[[0,0],[-5.694,-0.918],[-8.706,14.839],[0,0]],"v":[[-107.102,414.515],[-123.806,407.043],[-152.044,416.661],[-139.062,447.156]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":63,"s":[{"i":[[0,0],[3.303,-0.155],[5.957,-17.739],[0,0]],"o":[[0,0],[-5.761,0.27],[-5.477,16.31],[0,0]],"v":[[-115.086,422.211],[-132.967,418.324],[-158.631,433.53],[-139.67,460.714]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[0,0],[1.737,2.814],[18.403,-3.39],[0,0]],"o":[[0,0],[-3.029,-4.908],[-16.92,3.117],[0,0]],"v":[[-68.568,404.337],[-73.837,386.813],[-99.579,371.738],[-114.162,401.501]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":67,"s":[{"i":[[0,0],[0.914,3.117],[13.301,1.479],[0,0]],"o":[[0,0],[-1.593,-5.436],[-10.971,-1.288],[0,0]],"v":[[-48.252,427.436],[-49.439,391.05],[-65.404,371.663],[-81.412,376.69]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":68,"s":[{"i":[[0,0],[0.502,3.268],[10.75,3.914],[0,0]],"o":[[0,0],[-0.876,-5.7],[-3.434,-1.25],[0,0]],"v":[[-41.095,438.61],[-37.239,393.168],[-48.316,371.625],[-60.725,370.471]],"c":false}]},{"t":70,"s":[{"i":[[0,0],[-0.587,3.254],[11.066,6.5],[0,0]],"o":[[0,0],[0.864,-4.793],[-3.151,-1.851],[0,0]],"v":[[-19.595,447.61],[-7.989,415.168],[-16.816,389.125],[-29.725,387.471]],"c":false}],"h":1},{"t":72,"s":[{"i":[[0,0],[-20.875,1.625],[2.875,-12.75],[0,0]],"o":[[0,0],[20.042,-1.56],[-3.761,16.681],[0,0]],"v":[[-51.75,441],[-13.875,419.625],[16.5,440.375],[6.875,458.75]],"c":false}],"h":1}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.819607902976,0.411764735802,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":3.5,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 2","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":55,"s":[{"i":[[21.5,-6],[3.556,-3.639],[0,0],[-19.5,-3],[-5,2.5],[-2.85,3.461],[3.733,8.555]],"o":[[-15.934,4.447],[-1.242,1.271],[0,0],[19.5,3],[1.889,-0.945],[4.693,-5.699],[-5.235,-11.996]],"v":[[-20.75,422.25],[-49.172,437.941],[-51,440],[-40.5,463.5],[2.5,462.5],[10.377,455.616],[15.25,433]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":56,"s":[{"i":[[21.93,-4.161],[2.785,-2.153],[0,0],[-19.177,-4.637],[-5.193,2.068],[-3.91,2.475],[2.997,8.84]],"o":[[-16.253,3.084],[-0.973,0.752],[0,0],[19.177,4.637],[1.962,-0.781],[6.438,-4.075],[-4.202,-12.396]],"v":[[-41.629,421.452],[-68.149,431.247],[-69.521,432.457],[-64.794,460.885],[-26.239,459.398],[-16.406,454.526],[-6.666,435.206]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":58,"s":[{"i":[[21.775,-4.909],[0.717,-2.134],[0,0],[-19.454,-3.284],[-6.511,0.477],[-4.418,3.228],[5.983,5.98]],"o":[[-7.964,1.796],[-0.251,0.746],[0,0],[11.044,1.865],[2.431,-0.178],[5.713,-4.174],[-5.834,-5.831]],"v":[[-78.379,425.702],[-89.477,433.987],[-89.771,435.207],[-80.044,461.51],[-60.864,462.523],[-8.623,454.798],[-30.166,435.831]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":59,"s":[{"i":[[21.956,-3.124],[6.309,-0.126],[-0.91,-2.486],[-21.538,-3.276],[-6.511,0.477],[-4.418,3.228],[5.983,5.98]],"o":[[-8.683,1.13],[-6.34,-0.876],[2.222,6.071],[11.067,1.713],[2.431,-0.178],[5.713,-4.174],[-5.834,-5.831]],"v":[[-81.212,424.202],[-102.149,424.861],[-110.687,432.457],[-89.044,461.76],[-60.864,462.523],[-8.623,454.798],[-30.166,435.831]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":60,"s":[{"i":[[22.136,-1.339],[11.902,1.882],[-1.82,-4.971],[-23.622,-3.268],[-6.511,0.477],[-4.418,3.228],[5.983,5.98]],"o":[[-9.402,0.464],[-12.43,-2.497],[4.444,12.141],[11.089,1.561],[2.431,-0.178],[5.713,-4.174],[-5.834,-5.831]],"v":[[-84.045,422.702],[-114.821,415.735],[-131.604,429.707],[-98.044,462.01],[-60.864,462.523],[-8.623,454.798],[-30.166,435.831]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":61,"s":[{"i":[[22.317,0.446],[17.494,3.891],[-2.729,-7.457],[-25.706,-3.26],[-6.511,0.477],[-4.418,3.228],[5.983,5.98]],"o":[[-10.121,-0.202],[-18.52,-4.119],[6.666,18.212],[11.112,1.409],[2.431,-0.178],[5.713,-4.174],[-5.834,-5.831]],"v":[[-86.879,421.202],[-127.494,406.609],[-152.521,426.957],[-107.044,462.26],[-60.864,462.523],[-8.623,454.798],[-30.166,435.831]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":63,"s":[{"i":[[22.317,0.446],[17.92,0.219],[-4.201,-6.739],[-25.706,-3.26],[-6.511,0.477],[-4.418,3.228],[5.983,5.98]],"o":[[-10.121,-0.202],[-18.971,-0.232],[10.26,16.457],[11.112,1.409],[2.431,-0.178],[5.713,-4.174],[-5.834,-5.831]],"v":[[-87.379,426.202],[-136.665,418.656],[-156.985,443.705],[-107.544,467.26],[-61.364,467.523],[-8.623,454.798],[-30.166,435.831]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[3.627,7.995],[8.496,15.779],[3.857,-6.941],[-6.96,-24.959],[-6.517,-0.386],[-4.418,3.228],[7.36,4.171]],"o":[[-4.182,-9.219],[-8.994,-16.705],[-9.42,16.952],[7.313,26.227],[33.364,1.977],[5.713,-4.174],[-8.084,-4.581]],"v":[[-58.627,430.505],[-75.921,383.74],[-107.681,378.111],[-106.063,433.273],[-61.364,466.023],[-8.623,454.798],[-28.166,436.081]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":67,"s":[{"i":[[1.221,6.776],[3.946,17.155],[5.458,-5.572],[-0.105,-25.452],[-4.844,-0.81],[-4.418,3.228],[7.396,4.105]],"o":[[-1.414,-9.822],[-4.178,-18.162],[-13.329,13.609],[0.11,26.745],[28.697,5.143],[5.713,-4.174],[-4.917,-2.748]],"v":[[-47.054,436.557],[-50.613,387.597],[-79.234,374.01],[-92.374,424.682],[-59.53,463.19],[-8.623,454.798],[-22.999,435.081]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":68,"s":[{"i":[[0.018,6.167],[1.671,17.843],[6.258,-4.888],[3.323,-25.698],[-4.008,-1.023],[-4.418,3.228],[7.415,4.073]],"o":[[-0.03,-10.123],[-1.769,-18.89],[-15.284,11.938],[-3.492,27.003],[26.364,6.727],[5.713,-4.174],[-3.334,-1.831]],"v":[[-41.268,439.583],[-37.96,389.526],[-65.011,371.959],[-85.53,420.386],[-58.614,461.773],[-8.623,454.798],[-20.416,434.581]],"c":true}]},{"t":70,"s":[{"i":[[0.018,1.417],[0.43,17.916],[7.941,-0.039],[0.299,-30.403],[-4.386,-3.023],[-4.418,3.228],[7.415,4.073]],"o":[[-0.13,-10.123],[-0.54,-22.526],[-8.239,0.041],[-0.22,22.364],[18.639,12.847],[5.713,-4.174],[-3.334,-1.831]],"v":[[-19.768,448.583],[-7.21,410.026],[-29.261,387.959],[-58.78,422.636],[-47.114,451.523],[0.377,456.798],[-11.541,451.331]],"c":true}],"h":1},{"t":72,"s":[{"i":[[21.5,-6],[3.556,-3.639],[0,0],[-19.5,-3],[-5,2.5],[-2.85,3.461],[3.733,8.555]],"o":[[-15.934,4.447],[-1.242,1.271],[0,0],[19.5,3],[1.889,-0.945],[4.693,-5.699],[-5.235,-11.996]],"v":[[-20.75,422.25],[-49.172,437.941],[-51,440],[-40.5,463.5],[2.5,462.5],[10.377,455.616],[15.25,433]],"c":true}],"h":1}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.650980392157,0.098039223166,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":180,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":12,"ty":4,"nm":"l hand","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[544.637,144.509,0],"ix":2,"l":2},"a":{"a":0,"k":[14.637,228.509,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":55,"s":[{"i":[[0,0],[0.471,-21.002],[-10.215,-11.028],[-5.25,18.5],[2.226,6.233],[0,0]],"o":[[0,0],[-0.294,13.105],[10.094,10.897],[1.786,-6.295],[-3.75,-10.5],[0,0]],"v":[[0.625,188.875],[-16.471,210.502],[4,254.75],[40.25,261.25],[27.75,223.5],[26.875,212]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":56,"s":[{"i":[[0,0],[2.971,-16.502],[-10.215,-11.028],[-5.25,18.5],[2.226,6.233],[0,0]],"o":[[0,0],[-2.322,12.901],[10.094,10.897],[1.786,-6.295],[-3.75,-10.5],[0,0]],"v":[[0.625,188.875],[-23.471,216.002],[-11.75,262.75],[22.5,270],[18.5,229.75],[17.625,218.25]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":58,"s":[{"i":[[0,0],[6.721,-26.877],[-10.215,-11.028],[-5.25,18.5],[0.342,6.61],[0,0]],"o":[[0,0],[-3.18,12.717],[10.094,10.897],[1.786,-6.295],[-0.375,-7.25],[0,0]],"v":[[0.625,188.875],[-39.721,223.002],[-29.25,269.75],[6.875,264.375],[0.375,228.875],[15,218]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":61,"s":[{"i":[[0,0],[6.721,-26.877],[-10.215,-11.028],[-5.25,18.5],[-1.278,6.495],[0,0]],"o":[[0,0],[-3.18,12.717],[10.094,10.897],[1.786,-6.295],[1.5,-7.625],[0,0]],"v":[[0.625,188.875],[-47.471,227.252],[-37,274],[-7.625,266.375],[-4,231.125],[25.375,217]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":63,"s":[{"i":[[0,0],[10.471,-19.752],[-3.383,-8.838],[-7.576,10.869],[-6.75,6.875],[0,0]],"o":[[0,0],[-6.139,11.582],[4.74,12.384],[2.875,-4.125],[5.444,-5.545],[0,0]],"v":[[-2,198.625],[-47.471,227.252],[-57.5,264.75],[-23.125,263.125],[-1,235.375],[25.625,217.75]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[0,0],[6.721,-26.877],[-5.875,-9.125],[-4.75,13.625],[-0.659,6.586],[0,0]],"o":[[0,0],[-3.18,12.717],[8.041,12.489],[2.154,-6.178],[1,-10],[0,0]],"v":[[0.625,188.875],[-30.471,222.502],[-26.875,260.25],[3.125,256.75],[9.125,229.25],[22.625,216.625]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":68,"s":[{"i":[[0,0],[6.721,-26.877],[-5.25,-9],[-4.75,13.625],[-0.659,6.586],[0,0]],"o":[[0,0],[-3.18,12.717],[7.484,12.83],[2.154,-6.178],[1,-10],[0,0]],"v":[[4,190.625],[-26.096,223.752],[-21.25,262.25],[8.75,259.25],[14.75,231.75],[22.625,216.625]],"c":false}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":70,"s":[{"i":[[0,0],[2.971,-16.502],[-7.542,-13.004],[-5.25,18.5],[2.226,6.233],[0,0]],"o":[[0,0],[-2.322,12.901],[7.25,12.5],[1.786,-6.295],[-3.75,-10.5],[0,0]],"v":[[0.625,188.875],[-23.471,216.502],[-10.75,262.75],[22.5,270],[18.5,229.75],[17.625,218.25]],"c":false}]},{"t":72,"s":[{"i":[[0,0],[0.471,-21.002],[-10.215,-11.028],[-5.25,18.5],[2.226,6.233],[0,0]],"o":[[0,0],[-0.294,13.105],[10.094,10.897],[1.786,-6.295],[-3.75,-10.5],[0,0]],"v":[[0.625,188.875],[-16.471,210.502],[4,254.75],[40.25,261.25],[27.75,223.5],[26.875,212]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.819607902976,0.411764735802,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":3.5,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 2","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":55,"s":[{"i":[[24.619,-9.186],[-3.5,-17.5],[-8.5,-6],[0,0],[3.73,18.593],[1.432,3.958],[-11.25,2.75],[0,0]],"o":[[-16.75,6.25],[3.5,17.5],[8.5,6],[0,0],[-1.252,-6.239],[-4.25,-11.75],[9.823,-2.401],[0,0]],"v":[[2,188.5],[-15.25,218.25],[15.5,266],[35.5,270],[39.02,247.907],[30.5,229],[32.5,209.5],[45.75,208.25]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":56,"s":[{"i":[[24.619,-9.186],[-1.716,-17.23],[-6.932,-7.262],[0,0],[-1.27,18.593],[0.692,4.152],[-11,2],[0,0]],"o":[[-16.75,6.25],[1.77,17.779],[10.5,11],[0,0],[0.369,-5.399],[-1.75,-10.5],[9.949,-1.809],[0,0]],"v":[[1,188.25],[-23.75,227.75],[-7.25,269.5],[16.25,278],[23.02,257.407],[19.5,235.75],[23.25,215.75],[45.75,208.25]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":58,"s":[{"i":[[25.694,-5.506],[-1.716,-17.23],[-7.829,-6.284],[0,0],[-1.145,18.718],[0.535,4.175],[-10.937,2],[0,0]],"o":[[-24.5,5.25],[1.77,17.779],[9.5,7.625],[0,0],[0.33,-5.402],[-1.345,-10.5],[9.947,-1.819],[0,0]],"v":[[1,188.25],[-40,234.75],[-21.75,274.25],[1.25,273.625],[6.645,254.282],[-0.75,232],[23.25,215.75],[45.75,208.25]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":61,"s":[{"i":[[25.694,-5.506],[-1.716,-17.23],[-7.829,-6.284],[0,0],[-1.145,18.718],[0.535,4.175],[-10.937,2],[0,0]],"o":[[-24.5,5.25],[1.77,17.779],[9.5,7.625],[0,0],[0.33,-5.402],[-1.345,-10.5],[9.947,-1.819],[0,0]],"v":[[1,188.25],[-47.75,239],[-29.5,278.5],[-13.25,275.625],[-7.355,256.657],[-4.75,234],[22.125,216.125],[48.25,210.25]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":63,"s":[{"i":[[25.461,-6.501],[3.531,-16.951],[-5.25,-3.75],[0,0],[-12.449,14.025],[-3.103,2.844],[-12.625,6.875],[0,0]],"o":[[-23.5,6],[-3.75,18],[9.913,7.08],[0,0],[4.355,-4.907],[9,-8.25],[8.881,-4.836],[0,0]],"v":[[0.5,197.5],[-54.75,243.75],[-51.25,271.5],[-34.25,271.625],[-19.855,259.157],[-4.75,241.25],[23.875,219.125],[48.25,210.25]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[25.694,-5.506],[-1.716,-17.23],[-8.813,-4.807],[0,0],[-5.018,18.07],[0.535,4.175],[-10.937,2],[0,0]],"o":[[-24.5,5.25],[1.77,17.779],[8.25,4.5],[0,0],[1.605,-5.782],[-1.345,-10.5],[9.947,-1.819],[0,0]],"v":[[1,188.25],[-30.75,234.25],[-19.75,265.75],[-9.75,268.375],[4.77,250.782],[7.75,235.25],[23.25,215.75],[45.75,208.25]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":68,"s":[{"i":[[25.694,-5.506],[-1.716,-17.23],[-8.813,-4.807],[0,0],[-5.018,18.07],[0.535,4.175],[-4.25,1.25],[0,0]],"o":[[-24.5,5.25],[1.77,17.779],[8.25,4.5],[0,0],[1.605,-5.782],[-1.345,-10.5],[9.701,-2.853],[0,0]],"v":[[5.5,190.25],[-26.375,235.5],[-16.125,267.625],[-6.125,270.25],[10.395,253.282],[13.375,237.75],[23.25,215.75],[46.25,211.625]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":70,"s":[{"i":[[24.619,-9.186],[-1.716,-17.23],[-6.932,-7.262],[0,0],[-1.27,18.593],[0.692,4.152],[-11,2],[0,0]],"o":[[-16.75,6.25],[1.77,17.779],[10.5,11],[0,0],[0.369,-5.399],[-1.75,-10.5],[9.949,-1.809],[0,0]],"v":[[1,188.25],[-23.75,228.25],[-4,268.75],[16.25,278],[23.02,257.407],[19.5,235.75],[23.25,215.75],[45.75,208.25]],"c":true}]},{"t":72,"s":[{"i":[[24.619,-9.186],[-3.5,-17.5],[-8.5,-6],[0,0],[3.73,18.593],[1.432,3.958],[-11.25,2.75],[0,0]],"o":[[-16.75,6.25],[3.5,17.5],[8.5,6],[0,0],[-1.252,-6.239],[-4.25,-11.75],[9.823,-2.401],[0,0]],"v":[[2,188.5],[-15.25,218.25],[15.5,266],[35.5,270],[39.02,247.907],[30.5,229],[32.5,209.5],[45.75,208.25]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.650980392157,0.098039223166,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":180,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":13,"ty":4,"nm":"eye r","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[465.5,199,0],"ix":2,"l":2},"a":{"a":0,"k":[-64.5,385,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[2.25,-0.375],[-5.188,-1.563],[0.186,4.268]],"o":[[-3.244,0.541],[4.817,1.451],[-0.188,-4.313]],"v":[[-68.938,377.188],[-68.375,388.813],[-61.25,381.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.247058823529,0.245121106914,0.245121106914,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":3,"s":[-79.533,383.083],"to":[2.167,0],"ti":[-2.167,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.168,"y":0},"t":8,"s":[-66.533,383.083],"to":[0,0],"ti":[2.167,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":45,"s":[-66.533,383.083],"to":[-2.167,0],"ti":[2.167,0]},{"t":50,"s":[-79.533,383.083]}],"ix":2},"a":{"a":0,"k":[-66.533,383.083],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-17.673,0],[0,-17.673],[17.673,0],[0,17.673]],"o":[[17.673,0],[0,17.673],[-17.673,0],[0,-17.673]],"v":[[0,-32.25],[30.75,0.25],[0,32.75],[-28.75,-0.75]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.996078491211,0.996078491211,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-65.5,385],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-17.673,0],[0,-17.673],[17.673,0],[0,17.673]],"o":[[17.673,0],[0,17.673],[-17.673,0],[0,-17.673]],"v":[[0,-32],[32,0],[0,32],[-29.375,-0.125]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.247058823529,0.245121106914,0.245121106914,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-63.5,385],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":30,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 2","np":3,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":180,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":14,"ty":4,"nm":"eye l","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[465.5,301,0],"ix":2,"l":2},"a":{"a":0,"k":[-64.5,385,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[2.25,-0.375],[-5.188,-1.563],[0.186,4.268]],"o":[[-3.244,0.541],[4.817,1.451],[-0.188,-4.313]],"v":[[-68.938,377.188],[-68.375,388.813],[-61.25,381.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.247058823529,0.245121106914,0.245121106914,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":3,"s":[-79.533,383.083],"to":[2.167,0],"ti":[-2.167,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.168,"y":0},"t":8,"s":[-66.533,383.083],"to":[0,0],"ti":[2.167,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":45,"s":[-66.533,383.083],"to":[-2.167,0],"ti":[2.167,0]},{"t":50,"s":[-79.533,383.083]}],"ix":2},"a":{"a":0,"k":[-66.533,383.083],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-17.673,0],[0,-17.673],[17.673,0],[0,17.673]],"o":[[17.673,0],[0,17.673],[-17.673,0],[0,-17.673]],"v":[[0,-32.25],[30.75,0.25],[0,32.75],[-28.75,-0.75]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.996078491211,0.996078491211,0.996078491211,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-65.5,385],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-17.673,0],[0,-17.673],[17.673,0],[0,17.673]],"o":[[17.673,0],[0,17.673],[-17.673,0],[0,-17.673]],"v":[[0,-32],[32,0],[0,32],[-29.375,-0.125]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.247058823529,0.245121106914,0.245121106914,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-63.5,385],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":30,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 2","np":3,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":180,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":15,"ty":4,"nm":"body","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[672.775,233.399,0],"ix":2,"l":2},"a":{"a":0,"k":[142.775,317.399,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[4.125,0.125],[-0.25,-6.375],[-6.75,4.375],[3.75,8.5]],"o":[[-4.768,-0.144],[0.267,6.818],[4.365,-2.829],[-3.75,-8.5]],"v":[[-30,317],[-34.125,330.125],[-23.25,342.75],[-14.5,326.75]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.376470618154,0.564705882353,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 7","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[4.388,4.937],[0,0]],"o":[[0,0],[-2,-2.25],[0,0]],"v":[[14.75,347.75],[11.75,334.5],[6.5,331.75]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.007843137255,0.007843137255,0.007843137255,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":3.5,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 6","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-6,8],[0,0]],"o":[[0,0],[6.801,-9.069],[0,0]],"v":[[-14,329.5],[15.25,327.5],[18,311]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.007843137255,0.007843137255,0.007843137255,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":3.5,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 5","np":3,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.251,-8.53],[3,-12.5],[-3.25,1.5],[-0.25,39]],"o":[[0,0],[-1.25,42.5],[-3,12.5],[3.25,-1.5],[0.25,-39]],"v":[[329.75,362],[336.75,390.25],[328.75,452.75],[329,466.5],[341.25,408.25]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.819607902976,0.411764735802,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 4","np":3,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[26.75,12],[23.242,-9.413],[0,0],[-29.5,23.75],[-37.167,-8.01],[2.25,-25],[-2.75,9.75],[6.25,21.25],[3.033,4.22]],"o":[[-11.352,-5.093],[-50,20.25],[0,0],[11.705,-9.424],[29,6.25],[-2.611,29.013],[2.21,-7.834],[-3.174,-10.79],[-1.237,-1.721]],"v":[[318.75,324.75],[251.75,328.5],[186.5,410.5],[234,345],[313,330],[364.5,402.75],[364.75,426.25],[364.25,373.5],[350.467,347.53]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.819607902976,0.411764735802,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 3","np":3,"cix":2,"bm":0,"ix":5,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-19.002,-12.853],[6.375,5.25]],"o":[[0,0],[9.998,6.397],[-57.25,-42]],"v":[[182.75,212],[301.002,273.228],[301,269]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.819607902976,0.411764735802,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 2","np":3,"cix":2,"bm":0,"ix":6,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[23.75,10.75],[14.684,-17.462],[0,0],[71.5,-4.5],[15.386,-9.738],[3.141,-42.932],[-17.97,-23.798],[-27,-9],[-18,0],[0,0],[-20,15],[-4.659,19.256],[21,0],[0,-9],[4.971,19.008],[-10,27],[15.063,2.812],[0,-6]],"o":[[-16.399,-7.423],[-9.25,11],[0,0],[-39.898,2.511],[-19.75,12.5],[-2.25,30.75],[18.5,24.5],[37.205,12.402],[18,0],[0,0],[20,-15],[3.75,-15.5],[-21,0],[0,9],[-4.25,-16.25],[10,-27],[-18.75,-3.5],[0,6]],"v":[[260.5,167.75],[194.5,179.75],[180.5,211.75],[48.5,207],[-60.25,236],[-114.25,318.25],[-96,405],[-31.5,449],[59,466],[325,469],[366,462],[409.25,396],[399.5,353.5],[369.5,381.5],[317.5,291],[342.5,241],[332,192.25],[296,217.25]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.650980392157,0.098039223166,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":7,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[14.314,-0.325],[-2,-2.5],[0,0]],"o":[[-11,0.25],[2,2.5],[0,0]],"v":[[-125.5,238.5],[-107.5,299.5],[-70,251.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.780392216701,0.58431372549,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 8","np":3,"cix":2,"bm":0,"ix":8,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-3.5,-25],[0,0]],"o":[[0,0],[8,5.75],[0,0]],"v":[[-105.5,356.5],[-126,427],[-68,424.5]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.780392216701,0.58431372549,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 9","np":3,"cix":2,"bm":0,"ix":9,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":180,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":16,"ty":4,"nm":"tail","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[852.53,353.751,0],"ix":2,"l":2},"a":{"a":0,"k":[322.53,437.751,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":3,"s":[{"i":[[9.708,0],[5,-16],[5.305,-14.589],[6.5,-5],[13.554,-3.812],[8.25,-1.75],[0,0],[0,0],[0,0],[-38,13.5],[-9.232,15.167],[1.25,37.75]],"o":[[-6.25,0],[-3.629,11.612],[-2,5.5],[-8.36,6.431],[-8,2.25],[-8.542,1.812],[0,0],[0,0],[0,0],[17.483,-6.211],[7,-11.5],[-0.728,-21.994]],"v":[[480.5,316.5],[452.5,336.5],[440.5,373.5],[426.25,394.75],[396,410.75],[358.75,418],[323,423.5],[317.5,462.5],[358.75,465],[423.5,452],[479,407.5],[503.5,339.5]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":4,"s":[{"i":[[8.335,4.978],[14.418,-8.552],[12.035,-9.806],[8.287,-6.163],[10.591,-9.278],[8.25,-1.75],[0,0],[0,0],[0,0],[-40.324,0.485],[-15.702,8.288],[-11.114,24.416]],"o":[[-5.366,-3.204],[-11.615,6.89],[-4.537,3.697],[-8.464,6.294],[-8.503,7.449],[-8.542,1.812],[0,0],[0,0],[0,0],[18.553,-0.223],[11.906,-6.285],[9.117,-20.028]],"v":[[496.909,309.045],[460.365,308.36],[431.092,325.725],[411.213,339.413],[385.503,357.301],[356,374.5],[323,423.5],[317.5,462.5],[358.75,465],[400.934,416.728],[454.465,384.655],[509.614,343.584]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":6,"s":[{"i":[[8.335,4.978],[16.746,-0.746],[14.658,-4.475],[9.161,-4.769],[10.247,-7.301],[6.5,-2.5],[0,0],[0,0],[0,0],[-38.684,6.272],[-15.702,8.288],[-10.614,22.166]],"o":[[-5.366,-3.204],[-14.365,0.64],[-6.937,2.118],[-11.213,5.837],[-9.206,6.559],[-8.15,3.135],[0,0],[0,0],[0,0],[18.315,-2.969],[11.906,-6.285],[9.504,-19.848]],"v":[[495.159,306.795],[457.865,302.86],[424.342,307.475],[397.963,317.413],[366.503,333.801],[342,349.75],[312.5,365.5],[301.5,390.5],[334.25,407],[382.684,397.728],[447.215,361.155],[508.114,344.584]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":7,"s":[{"i":[[6.17,5.053],[15.959,-0.117],[13.824,-3.558],[9.161,-4.769],[10.247,-7.301],[6.5,-2.5],[0,0],[0,0],[0,0],[-32.684,13.355],[-15.048,5.512],[-9.531,17.416]],"o":[[-4.272,-4.1],[-14.355,0.048],[-7.019,1.751],[-11.213,5.837],[-9.206,6.559],[-8.15,3.135],[0,0],[0,0],[0,0],[14.316,-24.645],[12.642,-4.63],[8.959,-16.052]],"v":[[488.242,300.628],[455.532,293.777],[421.092,297.141],[392.879,306.663],[358.669,325.884],[334.75,342.583],[312.5,365.5],[301.5,390.5],[334.25,407],[374.184,389.645],[440.048,352.988],[495.947,334.501]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":9,"s":[{"i":[[1.841,5.205],[14.385,1.14],[12.158,-1.725],[9.161,-4.769],[10.247,-7.301],[6.5,-2.5],[0,0],[0,0],[0,0],[-30.922,24.075],[-42.715,6.345],[-7.364,7.916]],"o":[[-2.084,-5.892],[-14.335,-1.136],[-7.181,1.019],[-11.213,5.837],[-9.206,6.559],[-8.15,3.135],[0,0],[0,0],[0,0],[12.816,-9.978],[13.317,-1.978],[7.87,-8.46]],"v":[[474.409,288.295],[450.865,275.61],[414.592,276.475],[383.213,287.163],[343.003,310.051],[320.25,328.25],[312.5,365.5],[301.5,390.5],[334.25,407],[357.184,373.478],[423.715,333.155],[471.614,314.334]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":11,"s":[{"i":[[5.591,5.955],[14.385,1.14],[7.033,-1.85],[8.73,-5.518],[10.247,-7.301],[6.5,-2.5],[0,0],[0,0],[0,0],[-36.346,14.655],[-31.965,12.595],[-6.864,7.166]],"o":[[-4.278,-4.557],[-14.335,-1.136],[-7.015,1.845],[-11.213,7.087],[-9.206,6.559],[-8.15,3.135],[0,0],[0,0],[0,0],[16.066,-6.478],[12.526,-4.935],[7.993,-8.345]],"v":[[438.659,253.295],[409.365,240.36],[369.717,246.85],[342.213,263.413],[307.503,294.301],[294.75,309.25],[276.5,331.5],[279.5,352],[289.75,380.5],[340.434,362.978],[392.965,306.405],[439.864,289.584]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":12,"s":[{"i":[[4.466,6.705],[14.385,1.14],[5.658,-3.1],[7.524,-6.843],[10.247,-7.301],[6.5,-2.5],[0,0],[0,0],[0,0],[-36.346,14.655],[-28.465,20.47],[-8.25,9.536]],"o":[[-3.417,-5.13],[-14.335,-1.136],[-8.092,6.15],[-10.088,9.337],[-9.206,6.559],[-8.15,3.135],[0,0],[0,0],[0,0],[16.066,-6.478],[10.712,-7.519],[9.19,-10.589]],"v":[[439.159,254.42],[409.115,239.61],[373.342,255.1],[345.088,287.163],[313.753,317.801],[291.5,326.25],[268.75,348.75],[278,363.75],[294.25,404],[343.434,388.853],[396.34,318.78],[437.114,291.084]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":13,"s":[{"i":[[3.341,7.455],[14.385,1.14],[4.283,-4.35],[6.319,-8.169],[10.247,-7.301],[6.5,-2.5],[0,0],[0,0],[0,0],[-36.346,14.655],[-24.965,28.345],[-9.636,11.907]],"o":[[-2.556,-5.703],[-14.335,-1.136],[-7.281,7.395],[-8.963,11.587],[-9.206,6.559],[-8.15,3.135],[0,0],[0,0],[0,0],[16.066,-6.478],[8.898,-10.103],[10.386,-12.834]],"v":[[439.659,255.545],[408.865,238.86],[373.967,256.85],[342.963,305.413],[315.003,335.801],[288.25,343.25],[261,366],[276.5,375.5],[298.75,427.5],[346.434,414.728],[399.715,331.155],[434.364,292.584]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":16,"s":[{"i":[[4.841,12.705],[14.43,0.089],[1.283,-8.85],[2.28,-10.073],[16.247,-5.801],[6.5,-2.5],[0,0],[0,0],[0,0],[-36.346,14.655],[-24.965,28.345],[-2.864,26.416]],"o":[[-3.066,-8.047],[-17.865,-0.11],[-4.383,30.241],[-2.963,13.087],[-10.646,3.801],[-8.15,3.135],[0,0],[0,0],[0,0],[16.066,-6.478],[8.898,-10.103],[1.78,-16.414]],"v":[[434.159,265.545],[401.865,247.11],[367.717,272.6],[368.963,353.663],[340.503,391.051],[290.75,365.25],[254,384.5],[281.5,414.5],[298.75,427.5],[349.434,428.728],[417.215,384.155],[435.364,311.084]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":18,"s":[{"i":[[1.817,12.891],[14.43,0.089],[1.283,-8.85],[7.537,-9.913],[16.247,-5.801],[6.5,-2.5],[0,0],[0,0],[0,0],[-36.346,14.655],[-31.215,24.845],[-6.629,25.73]],"o":[[-1.909,-13.545],[-17.865,-0.11],[-4.383,30.241],[-8.121,10.681],[-10.646,3.801],[-8.15,3.135],[0,0],[0,0],[0,0],[16.066,-6.478],[10.534,-8.384],[3.886,-15.084]],"v":[[470.409,299.545],[439.365,276.36],[413.717,314.6],[377.463,377.163],[340.503,391.051],[290.75,365.25],[254,384.5],[281.5,414.5],[298.75,427.5],[345.434,440.228],[414.715,423.655],[468.114,346.084]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":21,"s":[{"i":[[9.708,0],[5,-16],[5.305,-14.589],[6.5,-5],[13.554,-3.812],[8.25,-1.75],[0,0],[0,0],[0,0],[-38,13.5],[-9.232,15.167],[1.25,37.75]],"o":[[-6.25,0],[-3.629,11.612],[-2,5.5],[-8.36,6.431],[-8,2.25],[-8.542,1.812],[0,0],[0,0],[0,0],[17.483,-6.211],[7,-11.5],[-0.728,-21.994]],"v":[[480.5,316.5],[452.5,336.5],[440.5,373.5],[426.25,394.75],[396,410.75],[358.75,418],[323,423.5],[317.5,462.5],[358.75,465],[423.5,452],[479,407.5],[503.5,339.5]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":55,"s":[{"i":[[9.708,0],[5,-16],[5.305,-14.589],[6.5,-5],[13.554,-3.812],[8.25,-1.75],[0,0],[0,0],[0,0],[-38,13.5],[-9.232,15.167],[1.25,37.75]],"o":[[-6.25,0],[-3.629,11.612],[-2,5.5],[-8.36,6.431],[-8,2.25],[-8.542,1.812],[0,0],[0,0],[0,0],[17.483,-6.211],[7,-11.5],[-0.728,-21.994]],"v":[[480.5,316.5],[452.5,336.5],[440.5,373.5],[426.25,394.75],[396,410.75],[358.75,418],[323,423.5],[317.5,462.5],[358.75,465],[423.5,452],[479,407.5],[503.5,339.5]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":56,"s":[{"i":[[8.335,4.978],[14.418,-8.552],[12.035,-9.806],[8.287,-6.163],[10.591,-9.278],[8.25,-1.75],[0,0],[0,0],[0,0],[-40.324,0.485],[-15.702,8.288],[-11.114,24.416]],"o":[[-5.366,-3.204],[-11.615,6.89],[-4.537,3.697],[-8.464,6.294],[-8.503,7.449],[-8.542,1.812],[0,0],[0,0],[0,0],[18.553,-0.223],[11.906,-6.285],[9.117,-20.028]],"v":[[496.909,309.045],[460.365,308.36],[431.092,325.725],[411.213,339.413],[385.503,357.301],[356,374.5],[323,423.5],[317.5,462.5],[358.75,465],[400.934,416.728],[454.465,384.655],[509.614,343.584]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":58,"s":[{"i":[[8.335,4.978],[16.746,-0.746],[14.658,-4.475],[9.161,-4.769],[10.247,-7.301],[6.5,-2.5],[0,0],[0,0],[0,0],[-38.684,6.272],[-15.702,8.288],[-10.614,22.166]],"o":[[-5.366,-3.204],[-14.365,0.64],[-6.937,2.118],[-11.213,5.837],[-9.206,6.559],[-8.15,3.135],[0,0],[0,0],[0,0],[18.315,-2.969],[11.906,-6.285],[9.504,-19.848]],"v":[[495.159,306.795],[457.865,302.86],[424.342,307.475],[397.963,317.413],[366.503,333.801],[342,349.75],[312.5,365.5],[301.5,390.5],[334.25,407],[382.684,397.728],[447.215,361.155],[508.114,344.584]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":59,"s":[{"i":[[6.17,5.053],[15.959,-0.117],[13.824,-3.558],[9.161,-4.769],[10.247,-7.301],[6.5,-2.5],[0,0],[0,0],[0,0],[-32.684,13.355],[-15.048,5.512],[-9.531,17.416]],"o":[[-4.272,-4.1],[-14.355,0.048],[-7.019,1.751],[-11.213,5.837],[-9.206,6.559],[-8.15,3.135],[0,0],[0,0],[0,0],[14.316,-24.645],[12.642,-4.63],[8.959,-16.052]],"v":[[488.242,300.628],[455.532,293.777],[421.092,297.141],[392.879,306.663],[358.669,325.884],[334.75,342.583],[312.5,365.5],[301.5,390.5],[334.25,407],[374.184,389.645],[440.048,352.988],[495.947,334.501]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":61,"s":[{"i":[[1.841,5.205],[14.385,1.14],[12.158,-1.725],[9.161,-4.769],[10.247,-7.301],[6.5,-2.5],[0,0],[0,0],[0,0],[-30.922,24.075],[-42.715,6.345],[-7.364,7.916]],"o":[[-2.084,-5.892],[-14.335,-1.136],[-7.181,1.019],[-11.213,5.837],[-9.206,6.559],[-8.15,3.135],[0,0],[0,0],[0,0],[12.816,-9.978],[13.317,-1.978],[7.87,-8.46]],"v":[[474.409,288.295],[450.865,275.61],[414.592,276.475],[383.213,287.163],[343.003,310.051],[320.25,328.25],[312.5,365.5],[301.5,390.5],[334.25,407],[357.184,373.478],[423.715,333.155],[471.614,314.334]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":63,"s":[{"i":[[5.591,5.955],[14.385,1.14],[7.033,-1.85],[8.73,-5.518],[10.247,-7.301],[6.5,-2.5],[0,0],[0,0],[0,0],[-36.346,14.655],[-31.965,12.595],[-6.864,7.166]],"o":[[-4.278,-4.557],[-14.335,-1.136],[-7.015,1.845],[-11.213,7.087],[-9.206,6.559],[-8.15,3.135],[0,0],[0,0],[0,0],[16.066,-6.478],[12.526,-4.935],[7.993,-8.345]],"v":[[438.659,253.295],[409.365,240.36],[369.717,246.85],[342.213,263.413],[307.503,294.301],[294.75,309.25],[276.5,331.5],[279.5,352],[289.75,380.5],[340.434,362.978],[392.965,306.405],[439.864,289.584]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":64,"s":[{"i":[[4.466,6.705],[14.385,1.14],[5.658,-3.1],[7.524,-6.843],[10.247,-7.301],[6.5,-2.5],[0,0],[0,0],[0,0],[-36.346,14.655],[-28.465,20.47],[-8.25,9.536]],"o":[[-3.417,-5.13],[-14.335,-1.136],[-8.092,6.15],[-10.088,9.337],[-9.206,6.559],[-8.15,3.135],[0,0],[0,0],[0,0],[16.066,-6.478],[10.712,-7.519],[9.19,-10.589]],"v":[[439.159,254.42],[409.115,239.61],[373.342,255.1],[345.088,287.163],[313.753,317.801],[291.5,326.25],[268.75,348.75],[278,363.75],[294.25,404],[343.434,388.853],[396.34,318.78],[437.114,291.084]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":65,"s":[{"i":[[3.341,7.455],[14.385,1.14],[4.283,-4.35],[6.319,-8.169],[10.247,-7.301],[6.5,-2.5],[0,0],[0,0],[0,0],[-36.346,14.655],[-24.965,28.345],[-9.636,11.907]],"o":[[-2.556,-5.703],[-14.335,-1.136],[-7.281,7.395],[-8.963,11.587],[-9.206,6.559],[-8.15,3.135],[0,0],[0,0],[0,0],[16.066,-6.478],[8.898,-10.103],[10.386,-12.834]],"v":[[439.659,255.545],[408.865,238.86],[373.967,256.85],[342.963,305.413],[315.003,335.801],[288.25,343.25],[261,366],[276.5,375.5],[298.75,427.5],[346.434,414.728],[399.715,331.155],[434.364,292.584]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":68,"s":[{"i":[[4.841,12.705],[14.43,0.089],[1.283,-8.85],[2.28,-10.073],[16.247,-5.801],[6.5,-2.5],[0,0],[0,0],[0,0],[-36.346,14.655],[-24.965,28.345],[-2.864,26.416]],"o":[[-3.066,-8.047],[-17.865,-0.11],[-4.383,30.241],[-2.963,13.087],[-10.646,3.801],[-8.15,3.135],[0,0],[0,0],[0,0],[16.066,-6.478],[8.898,-10.103],[1.78,-16.414]],"v":[[434.159,265.545],[401.865,247.11],[367.717,272.6],[368.963,353.663],[340.503,391.051],[290.75,365.25],[254,384.5],[281.5,414.5],[298.75,427.5],[349.434,428.728],[417.215,384.155],[435.364,311.084]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":70,"s":[{"i":[[1.817,12.891],[14.43,0.089],[1.283,-8.85],[7.537,-9.913],[16.247,-5.801],[6.5,-2.5],[0,0],[0,0],[0,0],[-36.346,14.655],[-31.215,24.845],[-6.629,25.73]],"o":[[-1.909,-13.545],[-17.865,-0.11],[-4.383,30.241],[-8.121,10.681],[-10.646,3.801],[-8.15,3.135],[0,0],[0,0],[0,0],[16.066,-6.478],[10.534,-8.384],[3.886,-15.084]],"v":[[470.409,299.545],[439.365,276.36],[413.717,314.6],[377.463,377.163],[340.503,391.051],[290.75,365.25],[254,384.5],[281.5,414.5],[298.75,427.5],[345.434,440.228],[414.715,423.655],[468.114,346.084]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":73,"s":[{"i":[[9.708,0],[5,-16],[5.305,-14.589],[6.5,-5],[13.554,-3.812],[8.25,-1.75],[0,0],[0,0],[0,0],[-38,13.5],[-9.232,15.167],[1.25,37.75]],"o":[[-6.25,0],[-3.629,11.612],[-2,5.5],[-8.36,6.431],[-8,2.25],[-8.542,1.812],[0,0],[0,0],[0,0],[17.483,-6.211],[7,-11.5],[-0.728,-21.994]],"v":[[480.5,316.5],[452.5,336.5],[440.5,373.5],[426.25,394.75],[396,410.75],[358.75,418],[323,423.5],[317.5,462.5],[358.75,465],[423.5,452],[479,407.5],[503.5,339.5]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":78,"s":[{"i":[[9.708,0],[5,-16],[5.305,-14.589],[6.5,-5],[13.554,-3.812],[8.25,-1.75],[0,0],[0,0],[0,0],[-38,13.5],[-9.232,15.167],[1.25,37.75]],"o":[[-6.25,0],[-3.629,11.612],[-2,5.5],[-8.36,6.431],[-8,2.25],[-8.542,1.812],[0,0],[0,0],[0,0],[17.483,-6.211],[7,-11.5],[-0.728,-21.994]],"v":[[480.5,316.5],[452.5,336.5],[440.5,373.5],[426.25,394.75],[396,410.75],[358.75,418],[323,423.5],[317.5,462.5],[358.75,465],[423.5,452],[479,407.5],[503.5,339.5]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":79,"s":[{"i":[[8.335,4.978],[14.418,-8.552],[12.035,-9.806],[8.287,-6.163],[10.591,-9.278],[8.25,-1.75],[0,0],[0,0],[0,0],[-40.324,0.485],[-15.702,8.288],[-11.114,24.416]],"o":[[-5.366,-3.204],[-11.615,6.89],[-4.537,3.697],[-8.464,6.294],[-8.503,7.449],[-8.542,1.812],[0,0],[0,0],[0,0],[18.553,-0.223],[11.906,-6.285],[9.117,-20.028]],"v":[[496.909,309.045],[460.365,308.36],[431.092,325.725],[411.213,339.413],[385.503,357.301],[356,374.5],[323,423.5],[317.5,462.5],[358.75,465],[400.934,416.728],[454.465,384.655],[509.614,343.584]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":81,"s":[{"i":[[8.335,4.978],[16.746,-0.746],[14.658,-4.475],[9.161,-4.769],[10.247,-7.301],[6.5,-2.5],[0,0],[0,0],[0,0],[-38.684,6.272],[-15.702,8.288],[-10.614,22.166]],"o":[[-5.366,-3.204],[-14.365,0.64],[-6.937,2.118],[-11.213,5.837],[-9.206,6.559],[-8.15,3.135],[0,0],[0,0],[0,0],[18.315,-2.969],[11.906,-6.285],[9.504,-19.848]],"v":[[495.159,306.795],[457.865,302.86],[424.342,307.475],[397.963,317.413],[366.503,333.801],[342,349.75],[312.5,365.5],[301.5,390.5],[334.25,407],[382.684,397.728],[447.215,361.155],[508.114,344.584]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":82,"s":[{"i":[[6.17,5.053],[15.959,-0.117],[13.824,-3.558],[9.161,-4.769],[10.247,-7.301],[6.5,-2.5],[0,0],[0,0],[0,0],[-32.684,13.355],[-15.048,5.512],[-9.531,17.416]],"o":[[-4.272,-4.1],[-14.355,0.048],[-7.019,1.751],[-11.213,5.837],[-9.206,6.559],[-8.15,3.135],[0,0],[0,0],[0,0],[14.316,-24.645],[12.642,-4.63],[8.959,-16.052]],"v":[[488.242,300.628],[455.532,293.777],[421.092,297.141],[392.879,306.663],[358.669,325.884],[334.75,342.583],[312.5,365.5],[301.5,390.5],[334.25,407],[374.184,389.645],[440.048,352.988],[495.947,334.501]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":84,"s":[{"i":[[1.841,5.205],[14.385,1.14],[12.158,-1.725],[9.161,-4.769],[10.247,-7.301],[6.5,-2.5],[0,0],[0,0],[0,0],[-30.922,24.075],[-42.715,6.345],[-7.364,7.916]],"o":[[-2.084,-5.892],[-14.335,-1.136],[-7.181,1.019],[-11.213,5.837],[-9.206,6.559],[-8.15,3.135],[0,0],[0,0],[0,0],[12.816,-9.978],[13.317,-1.978],[7.87,-8.46]],"v":[[474.409,288.295],[450.865,275.61],[414.592,276.475],[383.213,287.163],[343.003,310.051],[320.25,328.25],[312.5,365.5],[301.5,390.5],[334.25,407],[357.184,373.478],[423.715,333.155],[471.614,314.334]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":86,"s":[{"i":[[5.591,5.955],[14.385,1.14],[7.033,-1.85],[8.73,-5.518],[10.247,-7.301],[6.5,-2.5],[0,0],[0,0],[0,0],[-36.346,14.655],[-31.965,12.595],[-6.864,7.166]],"o":[[-4.278,-4.557],[-14.335,-1.136],[-7.015,1.845],[-11.213,7.087],[-9.206,6.559],[-8.15,3.135],[0,0],[0,0],[0,0],[16.066,-6.478],[12.526,-4.935],[7.993,-8.345]],"v":[[438.659,253.295],[409.365,240.36],[369.717,246.85],[342.213,263.413],[307.503,294.301],[294.75,309.25],[276.5,331.5],[279.5,352],[289.75,380.5],[340.434,362.978],[392.965,306.405],[439.864,289.584]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":87,"s":[{"i":[[4.466,6.705],[14.385,1.14],[5.658,-3.1],[7.524,-6.843],[10.247,-7.301],[6.5,-2.5],[0,0],[0,0],[0,0],[-36.346,14.655],[-28.465,20.47],[-8.25,9.536]],"o":[[-3.417,-5.13],[-14.335,-1.136],[-8.092,6.15],[-10.088,9.337],[-9.206,6.559],[-8.15,3.135],[0,0],[0,0],[0,0],[16.066,-6.478],[10.712,-7.519],[9.19,-10.589]],"v":[[439.159,254.42],[409.115,239.61],[373.342,255.1],[345.088,287.163],[313.753,317.801],[291.5,326.25],[268.75,348.75],[278,363.75],[294.25,404],[343.434,388.853],[396.34,318.78],[437.114,291.084]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":88,"s":[{"i":[[3.341,7.455],[14.385,1.14],[4.283,-4.35],[6.319,-8.169],[10.247,-7.301],[6.5,-2.5],[0,0],[0,0],[0,0],[-36.346,14.655],[-24.965,28.345],[-9.636,11.907]],"o":[[-2.556,-5.703],[-14.335,-1.136],[-7.281,7.395],[-8.963,11.587],[-9.206,6.559],[-8.15,3.135],[0,0],[0,0],[0,0],[16.066,-6.478],[8.898,-10.103],[10.386,-12.834]],"v":[[439.659,255.545],[408.865,238.86],[373.967,256.85],[342.963,305.413],[315.003,335.801],[288.25,343.25],[261,366],[276.5,375.5],[298.75,427.5],[346.434,414.728],[399.715,331.155],[434.364,292.584]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":91,"s":[{"i":[[4.841,12.705],[14.43,0.089],[1.283,-8.85],[2.28,-10.073],[16.247,-5.801],[6.5,-2.5],[0,0],[0,0],[0,0],[-36.346,14.655],[-24.965,28.345],[-2.864,26.416]],"o":[[-3.066,-8.047],[-17.865,-0.11],[-4.383,30.241],[-2.963,13.087],[-10.646,3.801],[-8.15,3.135],[0,0],[0,0],[0,0],[16.066,-6.478],[8.898,-10.103],[1.78,-16.414]],"v":[[434.159,265.545],[401.865,247.11],[367.717,272.6],[368.963,353.663],[340.503,391.051],[290.75,365.25],[254,384.5],[281.5,414.5],[298.75,427.5],[349.434,428.728],[417.215,384.155],[435.364,311.084]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":93,"s":[{"i":[[1.817,12.891],[14.43,0.089],[1.283,-8.85],[7.537,-9.913],[16.247,-5.801],[6.5,-2.5],[0,0],[0,0],[0,0],[-36.346,14.655],[-31.215,24.845],[-6.629,25.73]],"o":[[-1.909,-13.545],[-17.865,-0.11],[-4.383,30.241],[-8.121,10.681],[-10.646,3.801],[-8.15,3.135],[0,0],[0,0],[0,0],[16.066,-6.478],[10.534,-8.384],[3.886,-15.084]],"v":[[470.409,299.545],[439.365,276.36],[413.717,314.6],[377.463,377.163],[340.503,391.051],[290.75,365.25],[254,384.5],[281.5,414.5],[298.75,427.5],[345.434,440.228],[414.715,423.655],[468.114,346.084]],"c":true}]},{"t":96,"s":[{"i":[[9.708,0],[5,-16],[5.305,-14.589],[6.5,-5],[13.554,-3.812],[8.25,-1.75],[0,0],[0,0],[0,0],[-38,13.5],[-9.232,15.167],[1.25,37.75]],"o":[[-6.25,0],[-3.629,11.612],[-2,5.5],[-8.36,6.431],[-8,2.25],[-8.542,1.812],[0,0],[0,0],[0,0],[17.483,-6.211],[7,-11.5],[-0.728,-21.994]],"v":[[480.5,316.5],[452.5,336.5],[440.5,373.5],[426.25,394.75],[396,410.75],[358.75,418],[323,423.5],[317.5,462.5],[358.75,465],[423.5,452],[479,407.5],[503.5,339.5]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.650980392157,0.098039223166,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":180,"st":0,"ct":1,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/apps/desktop-qt/examples/dashboard/shell.qml b/apps/desktop-qt/examples/dashboard/shell.qml new file mode 100644 index 000000000000..135430f3bdbd --- /dev/null +++ b/apps/desktop-qt/examples/dashboard/shell.qml @@ -0,0 +1,1146 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import T3.Shell +import T3.Bricks + +// Rosé dashboard: an icon rail owns project scope, the app's places and +// settings, and the sidebar is just threads. The rail's Dashboard button +// pulls a widget drawer over the page: today and the calendar, the open +// workspace and its git state, thread meters, and the agent behind the +// composer. +ShellWindow { + id: root + + readonly property var sidebarState: Shell.state.sidebar ?? null + readonly property var projects: sidebarState ? sidebarState.projects : [] + readonly property var scopeKey: sidebarState ? sidebarState.scopeProjectKey : null + readonly property var workspace: Shell.state.workspace ?? null + readonly property var git: workspace ? workspace.git : null + readonly property var composerState: Shell.state.composer ?? null + readonly property bool composerReady: composerState !== null && composerState.target !== null + readonly property var instance: composerReady ? (composerState.instances.find(entry => entry.instanceId === composerState.selectedInstanceId) ?? null) : null + readonly property int attentionCount: composerReady ? composerState.pendingApprovalCount + composerState.pendingUserInputCount : 0 + readonly property int threadCount: sidebarState ? sidebarState.active.length + sidebarState.pinned.length : 0 + readonly property int meterPeak: sidebarState ? Math.max(1, threadCount, sidebarState.snoozed.length, sidebarState.settledTotal) : 1 + + // The sidebar's width is the one animated layout value; the title pill + // above it follows so the two collapse as one piece. + property real sidebarSlot: sidebarCollapsed ? 0 : 272 + property bool drawerOpen: false + property date now: new Date() + readonly property string dayKey: Qt.formatDate(now, "yyyy-MM-dd") + readonly property var calendarCells: buildCalendar(dayKey) + + // The floor under the cards is the theme's chrome; its canvas is the + // page's own background and matches the cards, so the page sits flush in + // its card. + readonly property color canvas: Theme.color("chrome", "#ecd6cc") + readonly property color card: Theme.color("surface", "#fbf1ed") + readonly property color raised: Theme.color("surfaceRaised", "#f2dcd5") + readonly property color line: Theme.color("border", "#dfc2b7") + readonly property color ink: Theme.color("text", "#4a3733") + readonly property color muted: Theme.color("textMuted", "#86655d") + readonly property color accent: Theme.color("accent", "#9a3e33") + readonly property color accentInk: Theme.color("accentForeground", "#fff4f0") + readonly property color accentSoft: Theme.color("accentSurface", "#ecc9c1") + readonly property color accentDeep: Theme.color("accentSurfaceForeground", "#7c2f27") + readonly property color warm: Theme.color("warning", "#c48a3f") + readonly property color leaf: Theme.color("update", "#6f9a6a") + + // Sheet-style deceleration for surfaces that slide into place. + readonly property var sheetCurve: [0.32, 0.72, 0, 1, 1, 1] + + width: 1400 + height: 880 + color: canvas + + function isoWeek(date) { + const utc = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + utc.setUTCDate(utc.getUTCDate() + 4 - (utc.getUTCDay() || 7)); + const yearStart = new Date(Date.UTC(utc.getUTCFullYear(), 0, 1)); + return Math.ceil(((utc - yearStart) / 86400000 + 1) / 7); + } + + // Six Monday-first weeks around the month of `key` (yyyy-MM-dd). + function buildCalendar(key) { + const [year, month, day] = key.split("-").map(Number); + const first = new Date(year, month - 1, 1); + const start = new Date(year, month - 1, 1 - ((first.getDay() + 6) % 7)); + const cells = []; + for (let index = 0; index < 42; index += 1) { + const date = new Date(start.getFullYear(), start.getMonth(), start.getDate() + index); + cells.push({ + day: date.getDate(), + inMonth: date.getMonth() === month - 1, + today: date.getMonth() === month - 1 && date.getDate() === day + }); + } + return cells; + } + + function initialOf(name) { + const trimmed = name.trim(); + return trimmed.length > 0 ? trimmed[0].toUpperCase() : "?"; + } + + onDrawerOpenChanged: if (drawerOpen) + now = new Date() + + Behavior on sidebarSlot { + NumberAnimation { + duration: 240 + easing.type: Easing.BezierSpline + easing.bezierCurve: root.sheetCurve + } + } + + Timer { + interval: 1000 + running: root.drawerOpen + repeat: true + onTriggered: root.now = new Date() + } + + Shortcut { + sequence: "Escape" + enabled: root.drawerOpen + onActivated: root.drawerOpen = false + } + + // A round rail button: press sinks it, hover tints it, active fills it. + component RailButton: AbstractButton { + id: button + + property string kind + property bool active: false + property bool round: false + + implicitWidth: 36 + implicitHeight: 36 + hoverEnabled: true + activeFocusOnTab: true + Accessible.role: Accessible.Button + Accessible.name: text + + background: Rectangle { + radius: button.round ? 18 : 12 + color: button.active ? root.accent : button.hovered || button.visualFocus ? root.accentSoft : button.round ? root.raised : Qt.alpha(root.accentSoft, 0) + scale: button.down ? 0.97 : 1 + + Behavior on color { + ColorAnimation { + duration: 120 + } + } + + Behavior on scale { + NumberAnimation { + duration: button.down ? 60 : 140 + easing.type: Easing.OutQuint + } + } + } + + contentItem: Item { + ShellIcon { + anchors.centerIn: parent + name: button.kind + visible: button.kind.length > 0 + color: button.active ? root.accentInk : root.accentDeep + } + + Text { + anchors.centerIn: parent + visible: button.kind.length === 0 + text: root.initialOf(button.text) + color: button.active ? root.accentInk : root.accentDeep + font.family: Theme.fontUi + font.pixelSize: 13 + font.weight: Font.DemiBold + } + } + + ToolTip.visible: hovered && text.length > 0 + ToolTip.delay: 500 + ToolTip.text: text + } + + // A drawer widget. Cards arrive a beat apart when the drawer opens and + // leave together with it. + component DashCard: ShellCard { + id: dash + + property int order: 0 + default property alias content: dashInner.data + + radius: 18 + + transform: Translate { + y: root.drawerOpen ? 0 : 18 + + Behavior on y { + SequentialAnimation { + PauseAnimation { + duration: root.drawerOpen ? dash.order * 35 : 0 + } + + NumberAnimation { + duration: root.drawerOpen ? 300 : 160 + easing.type: Easing.OutQuint + } + } + } + } + + Item { + id: dashInner + + anchors.fill: parent + anchors.margins: 13 + } + } + + component Chip: Rectangle { + id: chip + + property alias text: chipText.text + property alias kind: chipGlyph.name + property color tint: root.accentDeep + + implicitWidth: Math.min(Math.ceil(chipRow.implicitWidth) + 16, parent.width) + implicitHeight: 22 + radius: 11 + color: root.raised + + RowLayout { + id: chipRow + + anchors.centerIn: parent + width: chip.width - 16 + spacing: 5 + + ShellIcon { + id: chipGlyph + + Layout.alignment: Qt.AlignVCenter + size: 12 + visible: name.length > 0 + color: chip.tint + } + + Text { + id: chipText + + Layout.fillWidth: true + Layout.minimumWidth: 0 + elide: Text.ElideMiddle + color: chip.tint + font.family: Theme.fontUi + font.pixelSize: 11 + font.weight: Font.DemiBold + } + } + } + + component Meter: ColumnLayout { + id: meter + + property int value: 0 + property int peak: 1 + property int order: 0 + property color tint: root.accent + property string label + + // Bars rise once their card has landed, one after another, and drop + // back with the drawer so the next opening rises again. + readonly property real fill: root.drawerOpen ? value / Math.max(1, peak) : 0 + + spacing: 6 + + Item { + Layout.fillHeight: true + Layout.preferredWidth: 12 + Layout.alignment: Qt.AlignHCenter + + Rectangle { + anchors.fill: parent + radius: 6 + color: root.raised + } + + Rectangle { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + height: Math.max(12, parent.height * meter.fill) + radius: 6 + color: meter.value > 0 ? meter.tint : root.line + + Behavior on height { + SequentialAnimation { + PauseAnimation { + duration: root.drawerOpen ? 160 + meter.order * 50 : 0 + } + + NumberAnimation { + duration: root.drawerOpen ? 460 : 160 + easing.type: Easing.OutQuint + } + } + } + + Behavior on color { + ColorAnimation { + duration: 160 + } + } + } + } + + Text { + Layout.alignment: Qt.AlignHCenter + text: meter.value + color: root.ink + font.family: Theme.fontUi + font.pixelSize: 12 + font.weight: Font.DemiBold + } + + Text { + Layout.fillWidth: true + text: meter.label + color: root.muted + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + font.family: Theme.fontUi + font.pixelSize: 9 + font.letterSpacing: 0.4 + font.capitalization: Font.AllUppercase + } + } + + Rectangle { + anchors.fill: parent + + gradient: Gradient { + GradientStop { + position: 0 + color: Qt.lighter(root.canvas, 1.02) + } + + GradientStop { + position: 1 + color: Qt.darker(root.canvas, 1.04) + } + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 14 + spacing: 12 + + // Top strip: the title pill spans the rail and sidebar columns and + // shrinks with the sidebar; the workspace strip takes the rest. + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: false + Layout.preferredHeight: 44 + spacing: 12 + + ShellCard { + Layout.preferredWidth: Math.max(208, 64 + root.sidebarSlot) + Layout.fillHeight: true + + TitleBar { + anchors.fill: parent + window: root + color: "transparent" + } + } + + ShellCard { + Layout.fillWidth: true + Layout.fillHeight: true + + Workspace { + anchors.fill: parent + visible: ready + clip: true + sidebarToggle: root.sidebarCollapsed + color: "transparent" + } + } + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 12 + + // The rail: dashboard toggle, project scope, and the app's places. + ShellCard { + Layout.preferredWidth: 52 + Layout.fillHeight: true + + ColumnLayout { + anchors.fill: parent + anchors.topMargin: 10 + anchors.bottomMargin: 10 + spacing: 6 + + RailButton { + Layout.alignment: Qt.AlignHCenter + kind: "layout-grid" + text: qsTr("Dashboard") + active: root.drawerOpen + onClicked: root.drawerOpen = !root.drawerOpen + } + + Rectangle { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: 4 + Layout.bottomMargin: 4 + width: 18 + height: 1 + color: root.line + } + + RailButton { + Layout.alignment: Qt.AlignHCenter + round: true + kind: "star" + text: qsTr("All projects") + active: root.sidebarState !== null && root.scopeKey === null + onClicked: Shell.dispatch("sidebar.scope", { + "projectKey": null + }) + } + + Repeater { + model: root.projects + + RailButton { + required property var modelData + + Layout.alignment: Qt.AlignHCenter + round: true + text: modelData.displayName + active: root.scopeKey === modelData.key + onClicked: Shell.dispatch("sidebar.scope", { + "projectKey": modelData.key + }) + } + } + + RailButton { + Layout.alignment: Qt.AlignHCenter + kind: "plus" + text: qsTr("New thread") + enabled: root.projects.length > 0 + opacity: enabled ? 1 : 0.4 + onClicked: Shell.dispatch("thread.new", root.scopeKey !== null ? { + "projectKey": root.scopeKey + } : {}) + } + + Item { + Layout.fillHeight: true + } + + RailButton { + Layout.alignment: Qt.AlignHCenter + kind: "git-pull-request" + text: qsTr("Pull requests") + onClicked: Shell.dispatch("pullRequests.open") + } + + RailButton { + Layout.alignment: Qt.AlignHCenter + kind: "chart-no-axes-column" + text: qsTr("Usage") + onClicked: Shell.dispatch("usage.open") + } + + RailButton { + Layout.alignment: Qt.AlignHCenter + kind: "command" + text: qsTr("Command palette") + onClicked: Shell.dispatch("palette.open") + } + + RailButton { + Layout.alignment: Qt.AlignHCenter + kind: "folder-plus" + text: qsTr("Add project") + onClicked: Shell.dispatch("project.add") + } + + RailButton { + Layout.alignment: Qt.AlignHCenter + kind: "settings" + text: qsTr("Settings") + active: root.settingsActive + onClicked: { + root.drawerOpen = false; + Shell.dispatch(root.settingsActive ? "settings.back" : "settings.open"); + } + } + } + } + + ShellCard { + Layout.preferredWidth: root.sidebarSlot + Layout.minimumWidth: 0 + Layout.fillHeight: true + visible: root.sidebarSlot > 0 + clip: true + + Sidebar { + anchors.fill: parent + visible: !root.settingsActive + showScope: false + showFooter: false + color: "transparent" + } + + SettingsNav { + anchors.fill: parent + visible: root.settingsActive + color: "transparent" + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 12 + + // The page. + ShellCard { + id: surfaceCard + + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + + WebSurface { + anchors.fill: parent + url: Shell.pageUrl + radius: surfaceCard.radius - surfaceCard.border.width + } + + // The page dims under the drawer; clicking it closes the drawer. + Rectangle { + anchors.fill: parent + visible: opacity > 0 + opacity: root.drawerOpen ? 1 : 0 + color: Qt.rgba(root.canvas.r, root.canvas.g, root.canvas.b, 0.5) + + Behavior on opacity { + OpacityAnimator { + duration: root.drawerOpen ? 200 : 140 + } + } + + MouseArea { + anchors.fill: parent + onClicked: root.drawerOpen = false + } + } + + // The drawer settles in from just above its resting spot + // and leaves faster than it came. + Item { + id: drawer + objectName: "drawer" + + anchors.top: parent.top + anchors.topMargin: 12 + anchors.horizontalCenter: parent.horizontalCenter + width: Math.min(parent.width - 24, 1040) + readonly property int gridColumns: width - 24 >= 920 ? 4 : width - 24 >= 460 ? 2 : 1 + height: Math.min(gridColumns === 4 ? 336 : 660, parent.height - 24) + visible: opacity > 0 + opacity: root.drawerOpen ? 1 : 0 + + Behavior on opacity { + OpacityAnimator { + duration: root.drawerOpen ? 200 : 140 + } + } + + transform: [ + Scale { + origin.x: drawer.width / 2 + origin.y: 0 + xScale: root.drawerOpen ? 1 : 0.97 + yScale: xScale + + Behavior on xScale { + NumberAnimation { + duration: root.drawerOpen ? 320 : 200 + easing.type: Easing.BezierSpline + easing.bezierCurve: root.sheetCurve + } + } + }, + Translate { + y: root.drawerOpen ? 0 : -20 + + Behavior on y { + NumberAnimation { + duration: root.drawerOpen ? 320 : 200 + easing.type: Easing.BezierSpline + easing.bezierCurve: root.sheetCurve + } + } + } + ] + + Rectangle { + anchors.fill: parent + anchors.topMargin: 6 + radius: 24 + color: Qt.rgba(root.accentDeep.r, root.accentDeep.g, root.accentDeep.b, 0.12) + } + + Rectangle { + anchors.fill: parent + radius: 22 + color: root.canvas + border.color: root.line + border.width: 1 + + MouseArea { + anchors.fill: parent + } + } + + ScrollView { + id: drawerScroll + objectName: "drawerScroll" + anchors.fill: parent + anchors.margins: 12 + clip: true + // Bound vector-icon painting to the scroll viewport + // as well; some Shape renderers ignore ancestor clips. + layer.enabled: drawerGrid.implicitHeight > height + contentWidth: availableWidth + contentHeight: drawerGrid.implicitHeight + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + GridLayout { + id: drawerGrid + objectName: "drawerGrid" + width: drawerScroll.availableWidth + columns: drawer.gridColumns + rowSpacing: 10 + columnSpacing: 10 + + ColumnLayout { + Layout.preferredWidth: 196 + Layout.fillWidth: true + Layout.preferredHeight: 312 + Layout.fillHeight: true + spacing: 10 + + // Where you are: project, thread, branch, git. + DashCard { + objectName: "workspaceCard" + Layout.fillWidth: true + Layout.preferredHeight: whereColumn.implicitHeight + 26 + order: 0 + + ColumnLayout { + id: whereColumn + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: 4 + + Text { + Layout.fillWidth: true + text: root.workspace && root.workspace.projectTitle ? root.workspace.projectTitle : qsTr("No thread open") + color: root.ink + elide: Text.ElideRight + font.family: Theme.fontUi + font.pixelSize: 14 + font.weight: Font.Bold + } + + Text { + Layout.fillWidth: true + text: root.workspace ? root.workspace.threadTitle : qsTr("Pick one in the sidebar") + color: root.muted + elide: Text.ElideRight + font.family: Theme.fontUi + font.pixelSize: 11 + } + + Flow { + Layout.fillWidth: true + Layout.topMargin: 4 + spacing: 6 + + Chip { + objectName: "branchChip" + visible: root.workspace !== null && root.workspace.branch !== null + kind: "git-branch" + text: root.workspace && root.workspace.branch ? root.workspace.branch : "" + } + + Chip { + visible: root.git !== null && root.git.hasUpstream && (root.git.aheadCount > 0 || root.git.behindCount > 0) + text: root.git ? "↑%1 ↓%2".arg(root.git.aheadCount).arg(root.git.behindCount) : "" + tint: root.warm + } + + Chip { + visible: root.git !== null && root.git.hasWorkingTreeChanges + kind: "file-diff" + text: qsTr("Edits") + tint: root.leaf + } + + Chip { + visible: root.git !== null && root.git.pullRequest !== null + kind: "git-pull-request" + text: root.git && root.git.pullRequest ? "#" + root.git.pullRequest.number : "" + } + } + } + } + + // Today, in the big numerals the rail deserves. + DashCard { + Layout.fillWidth: true + Layout.fillHeight: true + order: 1 + + ColumnLayout { + anchors.centerIn: parent + spacing: 0 + + Text { + Layout.alignment: Qt.AlignHCenter + text: Qt.formatDate(root.now, "dd") + color: root.accentDeep + font.family: Theme.fontUi + font.pixelSize: 44 + font.weight: Font.Bold + font.letterSpacing: -1 + lineHeight: 0.9 + } + + Row { + Layout.alignment: Qt.AlignHCenter + spacing: 4 + + Repeater { + model: 3 + + Rectangle { + width: 4 + height: 4 + radius: 2 + color: root.accent + } + } + } + + Text { + Layout.alignment: Qt.AlignHCenter + text: Qt.formatDate(root.now, "MM") + color: root.accentDeep + font.family: Theme.fontUi + font.pixelSize: 44 + font.weight: Font.Bold + font.letterSpacing: -1 + lineHeight: 0.9 + } + + Text { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: 6 + text: qsTr("%1, wk %2").arg(Qt.formatDate(root.now, "ddd")).arg(root.isoWeek(root.now)) + color: root.muted + font.family: Theme.fontUi + font.pixelSize: 11 + font.weight: Font.Medium + } + + Text { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: 2 + text: Qt.formatTime(root.now, "HH:mm") + color: root.ink + font.family: Theme.fontMono + font.pixelSize: 12 + } + } + } + } + + // The month, today circled. + DashCard { + objectName: "calendarCard" + Layout.preferredWidth: 280 + Layout.preferredHeight: 312 + Layout.fillWidth: true + Layout.fillHeight: true + order: 2 + + ColumnLayout { + anchors.fill: parent + spacing: 6 + + Text { + Layout.alignment: Qt.AlignHCenter + text: Qt.formatDate(root.now, "MMMM yyyy") + color: root.accentDeep + font.family: Theme.fontUi + font.pixelSize: 12 + font.weight: Font.Bold + font.letterSpacing: 0.4 + } + + GridLayout { + Layout.fillWidth: true + Layout.fillHeight: true + columns: 7 + rowSpacing: 0 + columnSpacing: 0 + + Repeater { + model: 7 + + Text { + required property int index + + Layout.fillWidth: true + text: Qt.locale().dayName(index === 6 ? 7 : index + 1, Locale.ShortFormat) + color: root.accentDeep + horizontalAlignment: Text.AlignHCenter + font.family: Theme.fontUi + font.pixelSize: 10 + font.weight: Font.DemiBold + } + } + + Repeater { + model: root.calendarCells + + Item { + required property var modelData + + Layout.fillWidth: true + Layout.fillHeight: true + + Rectangle { + anchors.centerIn: parent + width: 24 + height: 24 + radius: 12 + color: root.accent + visible: parent.modelData.today + } + + Text { + anchors.centerIn: parent + text: parent.modelData.day + color: parent.modelData.today ? root.accentInk : parent.modelData.inMonth ? root.ink : root.line + font.family: Theme.fontUi + font.pixelSize: 11 + font.weight: parent.modelData.today ? Font.Bold : Font.Medium + } + } + } + } + } + } + + // Thread meters: what is moving, what is waiting on you. + DashCard { + objectName: "metersCard" + Layout.preferredWidth: 208 + Layout.preferredHeight: 312 + Layout.fillWidth: true + Layout.fillHeight: true + order: 3 + + RowLayout { + anchors.fill: parent + anchors.topMargin: 4 + spacing: 4 + + Meter { + Layout.fillWidth: true + Layout.fillHeight: true + label: qsTr("Active") + order: 0 + value: root.threadCount + peak: root.meterPeak + tint: root.accent + } + + Meter { + Layout.fillWidth: true + Layout.fillHeight: true + label: qsTr("Waiting") + order: 1 + value: root.attentionCount + peak: Math.max(1, root.attentionCount) + tint: root.warm + } + + Meter { + Layout.fillWidth: true + Layout.fillHeight: true + label: qsTr("Snoozed") + order: 2 + value: root.sidebarState ? root.sidebarState.snoozed.length : 0 + peak: root.meterPeak + tint: root.muted + } + + Meter { + Layout.fillWidth: true + Layout.fillHeight: true + label: qsTr("Settled") + order: 3 + value: root.sidebarState ? root.sidebarState.settledTotal : 0 + peak: root.meterPeak + tint: root.leaf + } + } + } + + // The agent behind the composer, and its transport controls. + DashCard { + objectName: "agentCard" + Layout.preferredWidth: 200 + Layout.preferredHeight: 312 + Layout.minimumWidth: 160 + Layout.fillWidth: true + Layout.fillHeight: true + order: 4 + + ColumnLayout { + anchors.fill: parent + spacing: 6 + + // The cat at play while the drawer is up; the agent's + // initial when Qt Lottie is not installed. + Item { + id: mascot + + Layout.fillWidth: true + Layout.preferredHeight: cat.status === Loader.Ready ? cat.item.implicitHeight : 84 + + Loader { + id: cat + + anchors.fill: parent + active: drawer.visible + source: "CatPlaying.qml" + } + + Item { + anchors.centerIn: parent + width: 84 + height: 84 + visible: cat.status !== Loader.Ready + + Rectangle { + anchors.fill: parent + radius: 42 + color: root.accentSoft + } + + Rectangle { + anchors.fill: parent + anchors.margins: 5 + radius: 37 + color: root.raised + + Text { + anchors.centerIn: parent + text: root.instance ? root.initialOf(root.instance.displayName) : "T3" + color: root.accentDeep + font.family: Theme.fontUi + font.pixelSize: 30 + font.weight: Font.Bold + } + } + } + + Rectangle { + anchors.right: parent.right + anchors.bottom: parent.bottom + width: 18 + height: 18 + radius: 9 + color: root.card + border.color: root.line + border.width: 1 + + Rectangle { + anchors.centerIn: parent + width: 10 + height: 10 + radius: 5 + color: root.attentionCount > 0 ? root.warm : root.composerReady && root.composerState.isRunning ? root.accent : root.leaf + + Behavior on color { + ColorAnimation { + duration: 160 + } + } + } + } + } + + Text { + Layout.fillWidth: true + Layout.topMargin: 4 + text: root.composerReady && root.composerState.selectedModel ? root.composerState.selectedModel : qsTr("No model picked") + color: root.accent + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideMiddle + font.family: Theme.fontUi + font.pixelSize: 13 + font.weight: Font.Bold + } + + Text { + Layout.fillWidth: true + text: root.instance ? root.instance.displayName : qsTr("Open a thread to pick an agent") + color: root.ink + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + font.family: Theme.fontUi + font.pixelSize: 11 + } + + Text { + Layout.fillWidth: true + text: { + if (!root.composerReady) { + return ""; + } + if (root.attentionCount > 0) { + return qsTr("%n request(s) waiting on you", "", root.attentionCount); + } + if (root.composerState.isRunning) { + return qsTr("Working"); + } + return root.composerState.runtimeMode.length > 0 ? root.composerState.runtimeMode : qsTr("Ready"); + } + color: root.attentionCount > 0 ? root.warm : root.muted + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + font.family: Theme.fontUi + font.pixelSize: 11 + font.weight: Font.Medium + } + + Item { + Layout.fillHeight: true + } + + RowLayout { + objectName: "agentActions" + Layout.alignment: Qt.AlignHCenter + spacing: 10 + + RailButton { + round: true + kind: "plus" + text: qsTr("New thread") + enabled: root.workspace !== null + opacity: enabled ? 1 : 0.4 + onClicked: { + Shell.dispatch("workspace.newThread"); + root.drawerOpen = false; + } + } + + RailButton { + round: true + kind: "square" + text: qsTr("Stop the agent") + active: true + enabled: root.composerReady && root.composerState.isRunning + opacity: enabled ? 1 : 0.4 + onClicked: Shell.dispatch("composer.interrupt") + } + + RailButton { + round: true + kind: "code" + text: qsTr("Open in editor") + enabled: root.workspace !== null && root.workspace.editors.length > 0 + opacity: enabled ? 1 : 0.4 + onClicked: Shell.dispatch("workspace.openInEditor", {}) + } + } + } + } + } + } + } + } + + // The composer draws its own card, so it floats on the floor + // between the page and the terminal like the page's does. + Composer { + id: composer + + Layout.fillWidth: true + visible: ready + color: "transparent" + } + + ShellCard { + id: terminalCard + + Layout.fillWidth: true + Layout.preferredHeight: terminal.implicitHeight + visible: terminal.open + + TerminalDrawer { + id: terminal + + anchors.fill: parent + radius: terminalCard.radius - terminalCard.border.width + } + } + } + + ShellCard { + id: panelCard + + Layout.preferredWidth: panel.implicitWidth + Layout.fillHeight: true + visible: panel.available && panel.open + + RightPanel { + id: panel + + anchors.fill: parent + color: "transparent" + radius: panelCard.radius - panelCard.border.width + } + } + } + } + + Notifications { + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.margins: 24 + } +} diff --git a/apps/desktop-qt/examples/dashboard/theme.json b/apps/desktop-qt/examples/dashboard/theme.json new file mode 100644 index 000000000000..fefe860c3f5d --- /dev/null +++ b/apps/desktop-qt/examples/dashboard/theme.json @@ -0,0 +1,76 @@ +{ + "version": 1, + "id": "rose-dashboard", + "name": "Rosé dashboard", + "appearance": "light", + "colors": { + "canvas": "#fbf1ed", + "chrome": "#ecd6cc", + "toolbar": "#fbf1ed", + "toolbarForeground": "#4a3733", + "toolbarBorder": "#dfc2b7", + "toolbarControl": "#fff7f4", + "toolbarControlForeground": "#4a3733", + "toolbarControlHover": "#f2dcd5", + "surface": "#fbf1ed", + "surfaceRaised": "#f2dcd5", + "surfaceOverlay": "#fff7f4", + "text": "#4a3733", + "textMuted": "#86655d", + "border": "#dfc2b7", + "input": "#fff7f4", + "focus": "#9a3e33", + "accent": "#9a3e33", + "accentForeground": "#fff4f0", + "secondary": "#f2dcd5", + "secondaryForeground": "#4a3733", + "muted": "#f2dcd5", + "mutedForeground": "#86655d", + "placeholder": "#957770", + "secondaryLabel": "#86655d", + "iconMuted": "#86655d", + "error": "#b8463a", + "errorForeground": "#fff4f0", + "errorSurface": "#f6dcd6", + "warning": "#c48a3f", + "warningForeground": "#fff4f0", + "warningSurface": "#f5e3d0", + "update": "#6f9a6a", + "updateForeground": "#fff4f0", + "updateSurface": "#e3ecdf", + "accentSurface": "#ecc9c1", + "accentSurfaceForeground": "#7c2f27", + "messageSurface": "#fff7f4", + "messageForeground": "#4a3733", + "messageAction": "#f2dcd5", + "messageActionForeground": "#4a3733", + "messageActionHover": "#e9d3cb", + "codeBackground": "#f7ebe6", + "codeForeground": "#4a3733", + "sidebar": "#fbf1ed", + "sidebarForeground": "#4a3733", + "sidebarMutedForeground": "#86655d", + "sidebarControlSurface": "#fff7f4", + "sidebarRowHover": "#fff7f4", + "sidebarRowActive": "#ecc9c1", + "sidebarRowSelected": "#f2dcd5", + "sidebarBorder": "#dfc2b7", + "terminalBackground": "#f7ebe6", + "terminalForeground": "#4a3733", + "terminalCursor": "#9a3e33", + "terminalSelection": "#ecc9c1", + "terminalScrollbar": "#dfc2b7", + "terminalScrollbarHover": "#86655d" + }, + "window": { + "opacity": 1.0, + "transparent": false, + "blur": false, + "frameless": true + }, + "radius": "18px", + "fonts": { + "ui": "Adwaita Sans, Avenir Next, Segoe UI, sans-serif", + "mono": "JetBrains Mono, Menlo, monospace" + } +} diff --git a/apps/desktop-qt/examples/glass/shell.qml b/apps/desktop-qt/examples/glass/shell.qml new file mode 100644 index 000000000000..17398ab814d7 --- /dev/null +++ b/apps/desktop-qt/examples/glass/shell.qml @@ -0,0 +1,225 @@ +import QtQuick +import QtQuick.Layouts +import T3.Shell +import T3.Bricks + +// Graphite glass: one rounded window with a hairline edge, traffic lights in +// the corner, a translucent sidebar for the compositor to blur behind, and +// an opaque content column with a unified toolbar — the shape of a Mac app. +ShellWindow { + id: root + + readonly property color canvas: Theme.color("canvas", "#1e1e1e") + readonly property color line: Theme.color("border", "#ffffff14") + readonly property real corner: Theme.radius + // How far the toolbar's leading edge stays clear of the traffic lights + // once the sidebar folds away and they end up over the content column. + readonly property real lightsInset: root.sidebarCollapsed && !root.settingsActive ? 66 : 0 + + width: 1360 + height: 860 + + Rectangle { + id: frame + + anchors.fill: parent + radius: root.corner + color: "transparent" + border.color: root.line + border.width: 1 + + RowLayout { + anchors.fill: parent + anchors.margins: 1 + spacing: 0 + + // Sidebar column: the theme's sidebar colour carries the alpha, so + // the desktop shows through the blur; the band under the lights + // drags the window. + Rectangle { + id: sidebarColumn + + Layout.fillHeight: true + Layout.preferredWidth: root.sidebarCollapsed && !root.settingsActive ? 0 : 248 + Layout.minimumWidth: 0 + visible: !(root.sidebarCollapsed && !root.settingsActive) || width > 0 + color: Theme.color("sidebar", "#202020b8") + topLeftRadius: root.corner - 1 + bottomLeftRadius: root.corner - 1 + clip: true + + Behavior on Layout.preferredWidth { + NumberAnimation { + duration: 220 + easing.type: Easing.OutCubic + } + } + + Item { + width: 248 + height: parent.height + + Item { + id: lightsBand + + width: parent.width + height: 52 + + DragHandler { + target: null + onActiveChanged: if (active) + root.startSystemMove() + } + + TapHandler { + onDoubleTapped: root.visibility === Window.Maximized ? root.showNormal() : root.showMaximized() + } + } + + Sidebar { + anchors.fill: parent + anchors.topMargin: lightsBand.height + visible: !root.settingsActive + color: "transparent" + } + + SettingsNav { + anchors.fill: parent + anchors.topMargin: lightsBand.height + visible: root.settingsActive + color: "transparent" + } + } + + Rectangle { + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 1 + color: root.line + } + } + + // Content column: toolbar, page, composer, all on one opaque + // canvas so text stays crisp over whatever the desktop shows. + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: root.canvas + topLeftRadius: sidebarColumn.visible ? 0 : root.corner - 1 + bottomLeftRadius: sidebarColumn.visible ? 0 : root.corner - 1 + topRightRadius: panelColumn.visible ? 0 : root.corner - 1 + bottomRightRadius: panelColumn.visible ? 0 : root.corner - 1 + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + Item { + Layout.fillWidth: true + Layout.preferredHeight: 52 + + DragHandler { + target: null + onActiveChanged: if (active) + root.startSystemMove() + } + + Workspace { + anchors.fill: parent + anchors.leftMargin: root.lightsInset + visible: ready + color: "transparent" + sidebarToggle: root.sidebarCollapsed + panelToggle: rightPanel.available ? rightPanel.open : null + + Behavior on anchors.leftMargin { + NumberAnimation { + duration: 220 + easing.type: Easing.OutCubic + } + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: root.line + } + + WebSurface { + Layout.fillWidth: true + Layout.fillHeight: true + url: Shell.pageUrl + backgroundColor: root.canvas + } + + Composer { + Layout.fillWidth: true + visible: ready + color: "transparent" + } + + TerminalDrawer { + Layout.fillWidth: true + } + } + } + + Rectangle { + id: panelColumn + + Layout.fillHeight: true + Layout.preferredWidth: rightPanel.implicitWidth + visible: rightPanel.available && rightPanel.implicitWidth > 0 + clip: true + color: Theme.color("chrome", "#262626") + topRightRadius: root.corner - 1 + bottomRightRadius: root.corner - 1 + + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 1 + color: root.line + } + + RightPanel { + id: rightPanel + + anchors.fill: parent + anchors.leftMargin: 1 + ownToggle: false + color: "transparent" + } + } + } + + // Inner highlight along the top edge, the way a Mac window catches light. + Rectangle { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 1 + height: 1 + color: Qt.rgba(1, 1, 1, 0.06) + } + + WindowControls { + x: 14 + y: 20 + window: root + trafficLights: true + order: ["close", "minimize", "maximize"] + } + } + + Notifications { + anchors.top: parent.top + anchors.right: parent.right + anchors.topMargin: 64 + anchors.rightMargin: 20 + } +} diff --git a/apps/desktop-qt/examples/glass/theme.json b/apps/desktop-qt/examples/glass/theme.json new file mode 100644 index 000000000000..4540dcd1bae6 --- /dev/null +++ b/apps/desktop-qt/examples/glass/theme.json @@ -0,0 +1,78 @@ +{ + "version": 1, + "id": "graphite-glass", + "name": "Graphite glass", + "appearance": "dark", + "colors": { + "canvas": "#1e1e1e", + "chrome": "#262626", + "toolbar": "#262626", + "toolbarForeground": "#e6e6e6", + "toolbarBorder": "#ffffff14", + "toolbarControl": "#3a3a3c", + "toolbarControlForeground": "#e6e6e6", + "toolbarControlHover": "#ffffff14", + "surface": "#262626", + "surfaceRaised": "#ffffff0f", + "surfaceOverlay": "#2c2c2e", + "text": "#e6e6e6", + "textMuted": "#9a9a9a", + "border": "#ffffff14", + "input": "#ffffff14", + "focus": "#0a84ff", + "accent": "#0a84ff", + "accentForeground": "#ffffff", + "secondary": "#ffffff0f", + "secondaryForeground": "#e6e6e6", + "muted": "#ffffff0f", + "mutedForeground": "#9a9a9a", + "placeholder": "#7a7a7a", + "secondaryLabel": "#9a9a9a", + "iconMuted": "#9a9a9a", + "info": "#64d2ff", + "success": "#30d158", + "error": "#ff453a", + "errorForeground": "#ff6961", + "errorSurface": "#ff453a29", + "warning": "#ffd60a", + "warningForeground": "#ffd60a", + "warningSurface": "#ffd60a29", + "update": "#30d158", + "updateForeground": "#30d158", + "updateSurface": "#30d15829", + "accentSurface": "#0a84ff33", + "accentSurfaceForeground": "#e6e6e6", + "messageSurface": "#ffffff0a", + "messageForeground": "#e6e6e6", + "messageAction": "#0a84ff", + "messageActionForeground": "#ffffff", + "messageActionHover": "#409cff", + "codeBackground": "#171717", + "codeForeground": "#e6e6e6", + "sidebar": "#202020b8", + "sidebarForeground": "#e6e6e6", + "sidebarMutedForeground": "#9a9a9a", + "sidebarControlSurface": "#ffffff14", + "sidebarRowHover": "#ffffff0f", + "sidebarRowActive": "#ffffff1f", + "sidebarRowSelected": "#ffffff1f", + "sidebarBorder": "#ffffff14", + "terminalBackground": "#171717", + "terminalForeground": "#e6e6e6", + "terminalCursor": "#0a84ff", + "terminalSelection": "#0a84ff40", + "terminalScrollbar": "#ffffff14", + "terminalScrollbarHover": "#ffffff29" + }, + "window": { + "opacity": 1, + "transparent": true, + "blur": true, + "frameless": true + }, + "radius": "10px", + "fonts": { + "ui": "SF Pro Text, -apple-system, Helvetica Neue, Inter, system-ui, sans-serif", + "mono": "SF Mono, Menlo, JetBrains Mono, monospace" + } +} diff --git a/apps/desktop-qt/examples/minimal/shell.qml b/apps/desktop-qt/examples/minimal/shell.qml new file mode 100644 index 000000000000..82a3ec37ab9f --- /dev/null +++ b/apps/desktop-qt/examples/minimal/shell.qml @@ -0,0 +1,96 @@ +import QtQuick +import QtQuick.Layouts +import T3.Shell +import T3.Bricks + +// Copy to ~/.t3/shell/shell.qml and edit; the app reloads on save. +// Bricks come from T3.Bricks, data from the T3.Shell singletons +// (Shell.state, Shell.dispatch, Theme.*, Runtime.*). ShellWindow brings the +// window boilerplate, the error overlay and the page's window commands. +ShellWindow { + id: root + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 0 + + Sidebar { + Layout.fillHeight: true + Layout.preferredWidth: root.sidebarCollapsed ? 0 : 256 + Layout.minimumWidth: 0 + visible: !root.settingsActive && (!root.sidebarCollapsed || width > 0) + showBrand: true + window: root + + Behavior on Layout.preferredWidth { + NumberAnimation { + duration: 220 + easing.type: Easing.OutCubic + } + } + } + + SettingsNav { + Layout.fillHeight: true + Layout.preferredWidth: 256 + visible: root.settingsActive + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 0 + + Workspace { + Layout.fillWidth: true + visible: ready + sidebarToggle: root.sidebarCollapsed + panelToggle: rightPanel.available ? rightPanel.open : null + } + + WebSurface { + Layout.fillWidth: true + Layout.fillHeight: true + url: Shell.pageUrl + } + + Composer { + Layout.fillWidth: true + visible: ready + } + + TerminalDrawer { + Layout.fillWidth: true + } + } + + RightPanel { + id: rightPanel + + Layout.fillHeight: true + Layout.preferredWidth: implicitWidth + ownToggle: false + visible: available + } + } + + // Title bar at the bottom instead of the top. + TitleBar { + Layout.fillWidth: true + visible: Theme.frameless + window: root + } + } + + Notifications { + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.bottomMargin: 180 + anchors.rightMargin: 16 + } +} diff --git a/apps/desktop-qt/examples/minimal/theme.json b/apps/desktop-qt/examples/minimal/theme.json new file mode 100644 index 000000000000..7c284757af52 --- /dev/null +++ b/apps/desktop-qt/examples/minimal/theme.json @@ -0,0 +1,78 @@ +{ + "version": 1, + "id": "t3-code-shell", + "name": "T3 Code (shell)", + "appearance": "dark", + "colors": { + "canvas": "#0a0a0a", + "chrome": "#0a0a0a", + "toolbar": "#0a0a0a", + "toolbarForeground": "#f5f5f5", + "toolbarBorder": "#ffffff0f", + "toolbarControl": "#191919", + "toolbarControlForeground": "#f5f5f5", + "toolbarControlHover": "#ffffff0a", + "surface": "#111111", + "surfaceRaised": "#ffffff0a", + "surfaceOverlay": "#191919", + "text": "#f5f5f5", + "textMuted": "#818181", + "border": "#ffffff0f", + "input": "#ffffff14", + "focus": "#346bf1", + "accent": "#346bf1", + "accentForeground": "#ffffff", + "secondary": "#ffffff0a", + "secondaryForeground": "#f5f5f5", + "muted": "#ffffff0a", + "mutedForeground": "#818181", + "placeholder": "#818181", + "secondaryLabel": "#818181", + "iconMuted": "#818181", + "info": "#2b7fff", + "success": "#00bc7d", + "error": "#fb414a", + "errorForeground": "#ff6467", + "errorSurface": "#f93e4b29", + "warning": "#fe9a00", + "warningForeground": "#ffb900", + "warningSurface": "#ff9b0029", + "update": "#346bf1", + "updateForeground": "#51a2ff", + "updateSurface": "#3269ee2e", + "accentSurface": "#ffffff0a", + "accentSurfaceForeground": "#f5f5f5", + "messageSurface": "#ffffff0a", + "messageForeground": "#f5f5f5", + "messageAction": "#346bf1", + "messageActionForeground": "#ffffff", + "messageActionHover": "#3061da", + "codeBackground": "#101010", + "codeForeground": "#f5f5f5", + "sidebar": "#111111", + "sidebarForeground": "#f5f5f5", + "sidebarMutedForeground": "#818181", + "sidebarControlSurface": "#ffffff0a", + "sidebarRowHover": "#ffffff0a", + "sidebarRowActive": "#ffffff0a", + "sidebarRowSelected": "#ffffff0a", + "sidebarBorder": "#ffffff0f", + "terminalBackground": "#0a0a0a", + "terminalForeground": "#f5f5f5", + "terminalCursor": "#b4cbff", + "terminalSelection": "#b4cbff40", + "terminalScrollbar": "#ffffff14", + "terminalScrollbarHover": "#ffffff29" + }, + "window": { + "opacity": 1.0, + "transparent": false, + "blur": false, + "frameless": true + }, + "radius": "10px", + "fonts": { + "ui": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", system-ui, sans-serif", + "mono": "ui-monospace, \"SF Mono\", \"SFMono-Regular\", Menlo, Consolas, \"Liberation Mono\", monospace" + } +} diff --git a/apps/desktop-qt/examples/terminal/shell.qml b/apps/desktop-qt/examples/terminal/shell.qml new file mode 100644 index 000000000000..3cb7a33ca0ec --- /dev/null +++ b/apps/desktop-qt/examples/terminal/shell.qml @@ -0,0 +1,274 @@ +import QtQuick +import QtQuick.Layouts +import T3.Shell +import T3.Bricks + +// Terminal: a status line on top (wordmark, prompt mark, thread counts and +// the model in the palette's colours), the sidebar on the darker chrome, +// sharp corners and mono type everywhere. Built for a tiling desktop such +// as Omarchy, where the bar has the clock and windows have no buttons; the +// flags below add them back for a floating one. `vp run theme:qt` writes a +// theme.json in the colours of whatever terminal you run it from; the one +// next to this file is Tokyo Night through that generator. +ShellWindow { + id: root + + property bool showClock: false + property bool showWindowControls: false + // The sixteen ANSI slots as a chip, to check what the theme carries. + property bool showSwatches: false + + readonly property var sidebarState: Shell.state.sidebar ?? null + readonly property var composerState: Shell.state.composer ?? null + readonly property color canvas: Theme.color("canvas", "#1a1b26") + readonly property color chrome: Theme.color("chrome", "#14151e") + readonly property color ink: Theme.color("text", "#c0caf5") + readonly property color dim: Theme.color("secondaryLabel", "#a9b1d6") + readonly property color line: Theme.color("border", "#3d4461") + // The ANSI slots land in the theme when it came from a terminal; a hand- + // written theme falls back to its semantic colours. + readonly property color blue: Theme.color("ansiBlue", Theme.color("accent", "#7aa2f7")) + readonly property color cyan: Theme.color("ansiCyan", Theme.color("info", "#7dcfff")) + readonly property color green: Theme.color("ansiGreen", Theme.color("update", "#9ece6a")) + readonly property color yellow: Theme.color("ansiYellow", Theme.color("warning", "#e0af68")) + readonly property color magenta: Theme.color("ansiMagenta", Theme.color("accent", "#bb9af7")) + readonly property var swatches: [Theme.color("ansiBlack", Theme.color("surfaceRaised", "#2e3247")), Theme.color("ansiRed", Theme.color("error", "#f7768e")), Theme.color("ansiGreen", Theme.color("update", "#9ece6a")), Theme.color("ansiYellow", Theme.color("warning", "#e0af68")), Theme.color("ansiBlue", Theme.color("accent", "#7aa2f7")), Theme.color("ansiMagenta", Theme.color("accentSurface", "#374465")), Theme.color("ansiCyan", Theme.color("info", "#7dcfff")), Theme.color("ansiWhite", Theme.color("textMuted", "#757b98")), Theme.color("ansiBrightBlack", Theme.color("border", "#31364e")), Theme.color("ansiBrightRed", Theme.color("errorForeground", "#f7768e")), Theme.color("ansiBrightGreen", Theme.color("updateForeground", "#9ece6a")), Theme.color("ansiBrightYellow", Theme.color("warningForeground", "#e0af68")), Theme.color("ansiBrightBlue", Theme.color("focus", "#7aa2f7")), Theme.color("ansiBrightMagenta", Theme.color("accent", "#bb9af7")), Theme.color("ansiBrightCyan", Theme.color("info", "#7dcfff")), Theme.color("ansiBrightWhite", Theme.color("text", "#c0caf5"))] + + property date now: new Date() + + width: 1360 + height: 860 + + Timer { + interval: 1000 + running: root.visible && root.showClock + repeat: true + onTriggered: root.now = new Date() + } + + component Segment: Text { + color: root.dim + font.family: Theme.fontMono + font.pixelSize: 12 + verticalAlignment: Text.AlignVCenter + Layout.fillHeight: true + } + + // A solid powerline block: palette colour behind bold chrome-coloured text. + component Block: Rectangle { + property alias text: label.text + + Layout.fillHeight: true + Layout.preferredWidth: label.implicitWidth + 24 + color: root.yellow + + Segment { + id: label + + anchors.fill: parent + horizontalAlignment: Text.AlignHCenter + color: root.chrome + font.bold: true + } + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + // Status line: the wordmark as the page draws it, then a prompt. + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 34 + color: root.chrome + + DragHandler { + target: null + onActiveChanged: if (active) + root.startSystemMove() + } + + TapHandler { + onDoubleTapped: root.visibility === Window.Maximized ? root.showNormal() : root.showMaximized() + } + + RowLayout { + anchors.fill: parent + spacing: 0 + + T3Wordmark { + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: 14 + size: 10 + color: root.ink + } + + Segment { + Layout.leftMargin: 6 + text: qsTr("Code") + } + + Segment { + Layout.leftMargin: 12 + text: "❯" + color: root.green + font.bold: true + } + + Segment { + Layout.leftMargin: 10 + color: root.cyan + text: root.sidebarState ? qsTr("%1 active").arg(root.sidebarState.active.length + root.sidebarState.pinned.length) : qsTr("connecting") + } + + Segment { + Layout.leftMargin: 10 + visible: root.sidebarState !== null + text: root.sidebarState ? qsTr("%1 settled").arg(root.sidebarState.settledTotal) : "" + } + + Segment { + Layout.leftMargin: 14 + visible: text.length > 0 + color: root.magenta + font.bold: true + text: root.composerState && root.composerState.selectedModel ? root.composerState.selectedModel : "" + } + + Item { + Layout.fillWidth: true + } + + // The sixteen slots as a chip: normal row over bright row. + Grid { + Layout.alignment: Qt.AlignVCenter + Layout.rightMargin: 14 + visible: root.showSwatches + columns: 8 + spacing: 2 + + Repeater { + model: root.swatches + + Rectangle { + required property color modelData + + width: 8 + height: 8 + color: modelData + } + } + } + + Segment { + Layout.rightMargin: 14 + visible: root.showClock + text: Qt.formatDateTime(root.now, "ddd dd MMM") + } + + Block { + visible: root.showClock + text: Qt.formatDateTime(root.now, "HH:mm:ss") + } + + WindowControls { + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: 6 + visible: root.showWindowControls + window: root + buttonWidth: 32 + buttonHeight: 26 + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: root.line + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: root.canvas + + RowLayout { + anchors.fill: parent + spacing: 0 + + Sidebar { + id: sidebar + + Layout.preferredWidth: root.sidebarCollapsed ? 0 : implicitWidth + Layout.minimumWidth: 0 + Layout.fillHeight: true + visible: !root.settingsActive && (!root.sidebarCollapsed || width > 0) + + Behavior on Layout.preferredWidth { + NumberAnimation { + duration: 220 + easing.type: Easing.OutCubic + } + } + } + + SettingsNav { + Layout.preferredWidth: sidebar.implicitWidth + Layout.fillHeight: true + visible: root.settingsActive + } + + Rectangle { + Layout.preferredWidth: 1 + Layout.fillHeight: true + color: root.line + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 0 + + Workspace { + Layout.fillWidth: true + visible: ready + sidebarToggle: root.sidebarCollapsed + panelToggle: rightPanel.available ? rightPanel.open : null + } + + WebSurface { + Layout.fillWidth: true + Layout.fillHeight: true + url: Shell.pageUrl + } + + Composer { + Layout.fillWidth: true + visible: ready + } + + TerminalDrawer { + Layout.fillWidth: true + } + } + + RightPanel { + id: rightPanel + + Layout.fillHeight: true + Layout.preferredWidth: implicitWidth + ownToggle: false + visible: available + } + } + } + } + + Notifications { + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.bottomMargin: 180 + anchors.rightMargin: 16 + } +} diff --git a/apps/desktop-qt/examples/terminal/theme.json b/apps/desktop-qt/examples/terminal/theme.json new file mode 100644 index 000000000000..399e98bbddf6 --- /dev/null +++ b/apps/desktop-qt/examples/terminal/theme.json @@ -0,0 +1,97 @@ +{ + "version": 1, + "id": "tokyo-night", + "name": "Tokyo Night", + "appearance": "dark", + "colors": { + "canvas": "#1a1b26", + "chrome": "#14151e", + "toolbar": "#14151e", + "toolbarForeground": "#c0caf5", + "toolbarBorder": "#3d4461", + "toolbarControl": "#374465", + "toolbarControlForeground": "#c0caf5", + "toolbarControlHover": "#4a5f8f", + "surface": "#242637", + "surfaceRaised": "#2a2d40", + "surfaceOverlay": "#26293b", + "text": "#c0caf5", + "textMuted": "#747ca0", + "border": "#3d4461", + "input": "#373d58", + "focus": "#7aa2f7", + "accent": "#7aa2f7", + "accentForeground": "#1a1b26", + "secondary": "#374465", + "secondaryForeground": "#c0caf5", + "muted": "#323d5a", + "mutedForeground": "#747ca0", + "placeholder": "#747ca0", + "secondaryLabel": "#a9b1d6", + "iconMuted": "#a9b1d6", + "info": "#7dcfff", + "success": "#9ece6a", + "error": "#f7768e", + "errorForeground": "#ef839d", + "errorSurface": "#4b2f3d", + "warning": "#e0af68", + "warningForeground": "#dbb37d", + "warningSurface": "#463c35", + "update": "#9ece6a", + "updateForeground": "#a3cd7f", + "updateSurface": "#374235", + "accentSurface": "#3b496d", + "accentSurfaceForeground": "#c0caf5", + "messageSurface": "#354161", + "messageForeground": "#c0caf5", + "messageAction": "#7aa2f7", + "messageActionForeground": "#1a1b26", + "messageActionHover": "#85a8f7", + "codeBackground": "#14151e", + "codeForeground": "#c0caf5", + "sidebar": "#14151e", + "sidebarForeground": "#c0caf5", + "sidebarMutedForeground": "#a9b1d6", + "sidebarControlSurface": "#2a2d40", + "sidebarRowHover": "#282b3d", + "sidebarRowActive": "#394669", + "sidebarRowSelected": "#333e5c", + "sidebarBorder": "#3d4461", + "projectForeground": "#7aa2f7", + "branchForeground": "#bb9af7", + "sidebarActiveIndicator": "#7aa2f7", + "terminalBackground": "#1a1b26", + "terminalForeground": "#c0caf5", + "terminalCursor": "#c0caf5", + "terminalSelection": "#33467c", + "terminalScrollbar": "#31364e", + "terminalScrollbarHover": "#3d4461", + "ansiBlack": "#15161e", + "ansiRed": "#f7768e", + "ansiGreen": "#9ece6a", + "ansiYellow": "#e0af68", + "ansiBlue": "#7aa2f7", + "ansiMagenta": "#bb9af7", + "ansiCyan": "#7dcfff", + "ansiWhite": "#a9b1d6", + "ansiBrightBlack": "#414868", + "ansiBrightRed": "#f7768e", + "ansiBrightGreen": "#9ece6a", + "ansiBrightYellow": "#e0af68", + "ansiBrightBlue": "#7aa2f7", + "ansiBrightMagenta": "#bb9af7", + "ansiBrightCyan": "#7dcfff", + "ansiBrightWhite": "#c0caf5" + }, + "window": { + "opacity": 1, + "transparent": false, + "blur": false, + "frameless": true + }, + "radius": "4px", + "fonts": { + "ui": "JetBrains Mono, Menlo, monospace", + "mono": "JetBrains Mono, Menlo, monospace" + } +} diff --git a/apps/desktop-qt/host/main.ts b/apps/desktop-qt/host/main.ts new file mode 100644 index 000000000000..95a71fd24850 --- /dev/null +++ b/apps/desktop-qt/host/main.ts @@ -0,0 +1,93 @@ +// @effect-diagnostics nodeBuiltinImport:off globalTimers:off - process launcher, deliberately Effect-free. +/** + * Node desktop host for the Qt shell. + * + * Spawned by t3code-qt (see src/BackendProcess.cpp). Starts the T3 server, + * announces its pairing URL on stdout as one JSON line, then keeps the server + * alive until the shell goes away. Everything TypeScript-owned (server + * lifecycle, and later SSH/Tailscale/secrets/updates) lives on this side; the + * Qt process only ever sees the URL. + * + * Protocol (stdout, newline-delimited JSON): + * {"type":"ready","url":"http://..."} server accepts connections at url + * {"type":"error","message":"..."} fatal, the host is exiting + * {"type":"exit","code":n} server process ended + * stdin closing means the shell is gone: shut the server down. + */ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeReadline from "node:readline"; +import * as NodeURL from "node:url"; + +import { parsePairingUrlLine } from "./pairingUrl.ts"; + +type HostMessage = + | { readonly type: "ready"; readonly url: string } + | { readonly type: "error"; readonly message: string } + | { readonly type: "exit"; readonly code: number | null; readonly signal: string | null }; + +function emit(message: HostMessage): void { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +const hostDir = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const installedServerEntry = NodePath.resolve(hostDir, "../server/bin.mjs"); +const repositoryServerEntry = NodePath.resolve(hostDir, "../../server/dist/bin.mjs"); +const serverEntry = + process.env.T3CODE_SERVER_ENTRY ?? + (NodeFS.existsSync(installedServerEntry) + ? installedServerEntry + : NodeFS.existsSync(repositoryServerEntry) + ? repositoryServerEntry + : NodePath.resolve(hostDir, "../../server/src/bin.ts")); + +const serverArgs = ["--no-browser", ...process.argv.slice(2)]; +const server = NodeChildProcess.spawn(process.execPath, [serverEntry, ...serverArgs], { + stdio: ["ignore", "pipe", "inherit"], + env: process.env, +}); + +let announced = false; +const lines = NodeReadline.createInterface({ input: server.stdout }); +lines.on("line", (line) => { + process.stderr.write(`${line}\n`); + if (announced) return; + const url = parsePairingUrlLine(line); + if (url !== undefined) { + announced = true; + emit({ type: "ready", url }); + } +}); + +server.on("error", (error) => { + emit({ type: "error", message: `Failed to start server: ${error.message}` }); + process.exit(1); +}); + +server.on("exit", (code, signal) => { + if (!announced) { + emit({ + type: "error", + message: `Server exited before announcing a pairing URL (code ${String(code)}).`, + }); + } + emit({ type: "exit", code, signal }); + process.exit(code ?? 0); +}); + +let stopping = false; +function stop(signal: NodeJS.Signals): void { + if (stopping) return; + stopping = true; + server.kill(signal); + setTimeout(() => server.kill("SIGKILL"), 5_000).unref(); +} + +for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) { + process.on(signal, () => stop("SIGTERM")); +} +// The shell holds our stdin open; EOF means it exited (cleanly or not). +process.stdin.on("end", () => stop("SIGTERM")); +process.stdin.on("error", () => stop("SIGTERM")); +process.stdin.resume(); diff --git a/apps/desktop-qt/host/pairingUrl.test.ts b/apps/desktop-qt/host/pairingUrl.test.ts new file mode 100644 index 000000000000..91c6b672269c --- /dev/null +++ b/apps/desktop-qt/host/pairingUrl.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { findPairingUrl, parsePairingUrlLine } from "./pairingUrl.ts"; + +describe("parsePairingUrlLine", () => { + it("extracts the URL from the server's startup line", () => { + expect(parsePairingUrlLine("Pairing URL: http://localhost:5733/pair?token=abc")).toBe( + "http://localhost:5733/pair?token=abc", + ); + }); + + it("accepts the web-mode log annotation form", () => { + expect(parsePairingUrlLine(" pairingUrl: http://localhost:5734/pair#token=VV5HFLLC3V38")).toBe( + "http://localhost:5734/pair#token=VV5HFLLC3V38", + ); + }); + + it("tolerates leading whitespace and trailing newlines", () => { + expect(parsePairingUrlLine(" Pairing URL: http://127.0.0.1:3773/#t=1\r")).toBe( + "http://127.0.0.1:3773/#t=1", + ); + }); + + it("ignores unrelated and malformed lines", () => { + expect(parsePairingUrlLine("Token: abc")).toBeUndefined(); + expect(parsePairingUrlLine("Pairing URL: not a url")).toBeUndefined(); + expect(parsePairingUrlLine("Pairing URL:")).toBeUndefined(); + }); +}); + +describe("findPairingUrl", () => { + it("returns the first pairing URL in multi-line output", () => { + const output = [ + "T3 Code server is ready.", + "Connection string: localhost:3773", + "Pairing URL: http://localhost:3773/pair?token=first", + "Pairing URL: http://localhost:3773/pair?token=second", + ].join("\n"); + expect(findPairingUrl(output)).toBe("http://localhost:3773/pair?token=first"); + }); + + it("returns undefined when no line matches", () => { + expect(findPairingUrl("nothing here")).toBeUndefined(); + }); +}); diff --git a/apps/desktop-qt/host/pairingUrl.ts b/apps/desktop-qt/host/pairingUrl.ts new file mode 100644 index 000000000000..e135370b87f3 --- /dev/null +++ b/apps/desktop-qt/host/pairingUrl.ts @@ -0,0 +1,30 @@ +/** + * The server announces its startup URL either as a headless `Pairing URL: ` + * line (apps/server/src/startupAccess.ts, also what `t3 pair` prints) or as a + * `pairingUrl: ` log annotation in web mode (serverRuntimeStartup.ts). + */ +export function parsePairingUrlLine(line: string): string | undefined { + const match = /^\s*(?:Pairing URL|pairingUrl):\s*(\S+)\s*$/.exec(line); + if (match === null) { + return undefined; + } + const candidate = match[1]; + if (candidate === undefined) { + return undefined; + } + try { + return new URL(candidate).href; + } catch { + return undefined; + } +} + +export function findPairingUrl(output: string): string | undefined { + for (const line of output.split(/\r?\n/)) { + const url = parsePairingUrlLine(line); + if (url !== undefined) { + return url; + } + } + return undefined; +} diff --git a/apps/desktop-qt/package.json b/apps/desktop-qt/package.json new file mode 100644 index 000000000000..e11460ebc816 --- /dev/null +++ b/apps/desktop-qt/package.json @@ -0,0 +1,18 @@ +{ + "name": "@t3tools/desktop-qt", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "build:qt": "node scripts/dev-qt.mjs --configure-only", + "build:qt:release": "node scripts/dev-qt.mjs --configure-only --release", + "dev": "node scripts/dev-qt.mjs", + "test": "vp test run --passWithNoTests", + "test:qml": "node scripts/test-qml.mjs", + "theme:terminal": "node scripts/theme-from-terminal.mjs", + "typecheck": "tsgo --noEmit" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/apps/desktop-qt/qml/T3/Bricks/CMakeLists.txt b/apps/desktop-qt/qml/T3/Bricks/CMakeLists.txt new file mode 100644 index 000000000000..472f52ba8dc7 --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/CMakeLists.txt @@ -0,0 +1,41 @@ +# Pure-QML bricks. The qmldir is hand-written (no `prefer` line) so the same +# directory works both compiled into the binary and as a disk import path for +# hot reload. +qt_add_qml_module(t3bricks + URI T3.Bricks + VERSION 1.0 + STATIC + NO_GENERATE_QMLDIR + NO_PLUGIN_OPTIONAL + QML_FILES + Composer.qml + ContextMenuHost.qml + DefaultShell.qml + GitActions.qml + Notifications.qml + RightPanel.qml + SettingsNav.qml + ShellErrorOverlay.qml + ShellButton.qml + ShellCard.qml + ShellChevron.qml + ShellComboBox.qml + ShellIcon.qml + ShellSplitButton.qml + ShellMenu.qml + ShellMenuItem.qml + ShellTextField.qml + ShellWindow.qml + Sidebar.qml + SidebarThreadRow.qml + T3Wordmark.qml + TerminalDrawer.qml + TitleBar.qml + WebSurface.qml + WindowControls.qml + Workspace.qml + js/lucide.js + RESOURCES + qmldir + js/shell-connect.js +) diff --git a/apps/desktop-qt/qml/T3/Bricks/Composer.qml b/apps/desktop-qt/qml/T3/Bricks/Composer.qml new file mode 100644 index 000000000000..6c420b6a4907 --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/Composer.qml @@ -0,0 +1,926 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Dialogs +import QtQuick.Layouts +import T3.Shell + +// The prompt: text input, model/effort/mode pickers, send/stop, with the +// checkout context strip welded under it. Rendered from Shell.state.composer +// and Shell.state.workspace (see packages/contracts/src/shell.ts); every +// change is dispatched back and the web app remains the owner of drafts and +// sending. +Rectangle { + id: composer + + readonly property var model: Shell.state.composer ?? null + readonly property var workspace: Shell.state.workspace ?? null + readonly property bool ready: model !== null && model.target !== null + readonly property string publishedTarget: ready ? model.target : "" + readonly property string publishedText: ready ? model.text : "" + readonly property int publishedCursor: ready ? model.cursor : 0 + readonly property var suggestions: ready ? model.suggestions : [] + readonly property bool suggesting: ready && model.triggerKind !== null && (suggestions.length > 0 || model.suggestionsEmptyText !== null) + readonly property color canvas: Theme.color("canvas", "#0f0f12") + readonly property color outline: Qt.alpha(Theme.color("text", "#e4e4e7"), 0.05) + readonly property color foreground: Theme.color("text", "#e4e4e7") + readonly property color muted: Theme.color("textMuted", "#8b8b93") + readonly property color secondary: Theme.color("secondaryLabel", "#a1a1aa") + readonly property color iconMuted: Theme.color("iconMuted", "#8b8b93") + readonly property color branchColor: Theme.color("branchForeground", Qt.alpha(muted, 0.7)) + readonly property var modelChoices: buildModelChoices(ready ? model.instances : []) + readonly property var effortOption: ready ? (model.options.find(option => option.type === "select") ?? null) : null + readonly property int maximumCardWidth: 768 + readonly property int gutter: 20 + + // The last text this brick sent; an echo of it from the page is not an edit. + property string lastSentText: "" + property int lastSentCursor: -1 + property string editingTarget: "" + readonly property string editClientId: Date.now().toString(36) + Math.random().toString(36).slice(2) + property int nextEditRevision: 0 + property int lastSentRevision: 0 + + implicitHeight: stack.implicitHeight + gutter + color: canvas + + // The page's model-picker keybinding lands here while this brick hosts + // the picker. + Connections { + target: Shell + function onActionRequested(action, payload) { + if (action !== "composer.modelPicker.toggle") { + return; + } + if (modelPicker.popup.visible) { + modelPicker.popup.close(); + } else if (modelPicker.enabled) { + modelPicker.forceActiveFocus(); + modelPicker.popup.open(); + } + } + } + + function buildModelChoices(instances) { + const choices = []; + for (const instance of instances) { + for (const entry of instance.models) { + choices.push({ + label: instances.length > 1 ? instance.displayName + " · " + entry.name : entry.name, + instanceId: instance.instanceId, + slug: entry.slug, + disabledReason: entry.disabledReason + }); + } + } + return choices; + } + + function runtimeIcon(mode) { + switch (mode) { + case "approval-required": + return "lock"; + case "auto-accept-edits": + return "pen-line"; + case "auto": + return "sparkles"; + default: + return "lock-open"; + } + } + + function nextEdit() { + lastSentRevision = ++nextEditRevision; + return { + clientId: editClientId, + revision: lastSentRevision + }; + } + + function flushText() { + textDebounce.stop(); + if (input.text !== composer.lastSentText || input.cursorPosition !== composer.lastSentCursor) { + composer.lastSentText = input.text; + composer.lastSentCursor = input.cursorPosition; + Shell.dispatch("composer.text.set", { + target: composer.publishedTarget, + edit: composer.nextEdit(), + text: input.text, + cursor: input.cursorPosition + }); + } + } + + function selectSuggestion(index) { + const item = composer.suggestions[index]; + if (!item) { + return; + } + Shell.dispatch("composer.suggest.select", { + id: item.id + }); + } + + function attach(urls) { + const files = Shell.readImageFiles(urls); + if (files.length === 0) { + return; + } + Shell.dispatch("composer.attach", { + files: files + }); + } + + function submit(intent) { + if (!composer.ready) { + return; + } + // canSend reflects the text the page has seen, which lags this input by + // the debounce; with local text, let the page validate the send (it + // echoes the prompt back if it declines). + if (!composer.model.canSend && input.text.trim().length === 0) { + return; + } + textDebounce.stop(); + const text = input.text; + // The page clears its published prompt only after accepting the send. + // Until then this remains the user's recoverable draft. + composer.lastSentText = text; + composer.lastSentCursor = input.cursorPosition; + Shell.dispatch("composer.submit", { + edit: composer.nextEdit(), + text: text, + intent: intent + }); + } + + onModelChanged: { + const target = model?.target ?? ""; + const text = target ? model.text : ""; + const cursor = Math.min(target ? model.cursor : 0, text.length); + if (target !== editingTarget) { + textDebounce.stop(); + editingTarget = target; + lastSentRevision = 0; + lastSentText = text; + lastSentCursor = cursor; + input.text = text; + input.cursorPosition = cursor; + return; + } + // Compare the publication's edit revision, not its text: returning to + // an earlier value and coalesced publications must still acknowledge + // the right edit. Older echoes cannot move the local text or caret. + const edit = model?.edit; + // An absent field means an older page without revision support. + if (edit !== undefined && lastSentRevision > 0 && (!edit || edit.clientId !== editClientId || edit.revision < lastSentRevision)) { + return; + } + if (text !== input.text && text !== lastSentText) { + lastSentText = text; + input.text = text; + input.cursorPosition = cursor; + lastSentCursor = cursor; + } + } + + onSuggestionsChanged: suggestionList.currentIndex = suggestions.length > 0 ? 0 : -1 + + Timer { + id: textDebounce + + interval: 120 + onTriggered: composer.flushText() + } + + ColumnLayout { + id: stack + + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + width: Math.min(parent.width - composer.gutter * 2, composer.maximumCardWidth) + spacing: 0 + + // @file, $skill and /command suggestions, computed by the page for the + // caret it was last told about; they sit on the card's top edge. + Rectangle { + Layout.fillWidth: true + Layout.leftMargin: 22 + Layout.rightMargin: 22 + visible: composer.suggesting + implicitHeight: visible ? Math.min(suggestionList.contentHeight, 240) + 8 : 0 + topLeftRadius: 16 + topRightRadius: 16 + color: Theme.color("surfaceOverlay", "#18181b") + border.color: composer.outline + border.width: 1 + + ListView { + id: suggestionList + + anchors.fill: parent + anchors.margins: 4 + clip: true + boundsBehavior: Flickable.StopAtBounds + model: composer.suggestions + highlightMoveDuration: 0 + keyNavigationWraps: true + + delegate: Rectangle { + id: suggestion + + required property var modelData + required property int index + + width: ListView.view.width + height: 34 + radius: 10 + color: ListView.isCurrentItem ? Theme.color("accentSurface", "#2a2a30") : suggestionHover.hovered ? Theme.color("sidebarRowHover", "#1c1c21") : "transparent" + + HoverHandler { + id: suggestionHover + } + + TapHandler { + onTapped: { + composer.flushText(); + composer.selectSuggestion(suggestion.index); + } + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 8 + + Text { + text: suggestion.modelData.label + color: composer.foreground + font.pixelSize: 12 + font.weight: Font.Medium + font.family: Theme.fontUi.length > 0 ? Theme.fontUi : Qt.application.font.family + elide: Text.ElideMiddle + Layout.maximumWidth: parent.width * 0.5 + } + + Text { + Layout.fillWidth: true + text: suggestion.modelData.description + color: composer.muted + font.pixelSize: 12 + font.family: Theme.fontUi.length > 0 ? Theme.fontUi : Qt.application.font.family + elide: Text.ElideMiddle + } + } + } + + Text { + anchors.centerIn: parent + visible: suggestionList.count === 0 + text: composer.ready && composer.model.suggestionsEmptyText ? composer.model.suggestionsEmptyText : "" + color: composer.muted + font.pixelSize: 12 + } + } + } + + // The glass card. + Rectangle { + id: card + + Layout.fillWidth: true + implicitHeight: cardColumn.implicitHeight + z: 1 + radius: 22 + color: Theme.color("surface", "#141416") + border.color: composer.outline + border.width: 1 + + DropArea { + anchors.fill: parent + keys: ["text/uri-list"] + onDropped: drop => { + if (drop.hasUrls) { + composer.attach(drop.urls); + drop.accept(Qt.CopyAction); + } + } + } + + ColumnLayout { + id: cardColumn + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: 0 + + // Attached images and terminal selections living on the draft. + Flow { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 12 + visible: composer.ready && (composer.model.attachments.length > 0 || composer.model.terminalContexts.length > 0) + spacing: 6 + + Repeater { + model: composer.ready ? composer.model.attachments : [] + + delegate: ShellButton { + required property var modelData + + implicitHeight: 24 + iconName: "image" + text: modelData.name + font.pixelSize: 12 + Accessible.name: qsTr("Remove %1").arg(modelData.name) + onClicked: Shell.dispatch("composer.attachment.remove", { + id: modelData.id + }) + } + } + + Repeater { + model: composer.ready ? composer.model.terminalContexts : [] + + delegate: ShellButton { + required property var modelData + + implicitHeight: 24 + iconName: "terminal" + text: modelData.label + " " + modelData.lineStart + "–" + modelData.lineEnd + font.pixelSize: 12 + Accessible.name: qsTr("Remove terminal selection %1").arg(modelData.label) + onClicked: Shell.dispatch("composer.terminalContext.remove", { + id: modelData.id + }) + } + } + } + + ScrollView { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 16 + Layout.bottomMargin: 8 + Layout.preferredHeight: Math.min(Math.max(input.implicitHeight, 54), 184) + clip: true + + TextArea { + id: input + objectName: "input" + + padding: 0 + enabled: composer.ready && !composer.model.editorDisabled + placeholderText: composer.ready ? composer.model.placeholder : qsTr("Open a thread to start") + placeholderTextColor: composer.muted + color: composer.foreground + wrapMode: TextEdit.Wrap + selectByMouse: true + background: null + font.pixelSize: 14 + font.family: Theme.fontUi.length > 0 ? Theme.fontUi : Qt.application.font.family + Accessible.name: qsTr("Message") + onTextChanged: { + if (text !== composer.lastSentText) { + textDebounce.restart(); + } + } + Keys.onPressed: event => { + if (composer.suggesting && !(event.modifiers & (Qt.ControlModifier | Qt.MetaModifier | Qt.AltModifier))) { + if (event.key === Qt.Key_Escape) { + event.accepted = true; + Shell.dispatch("composer.suggest.dismiss"); + return; + } + if (composer.suggestions.length > 0 && !(event.modifiers & Qt.ShiftModifier)) { + if (event.key === Qt.Key_Down || event.key === Qt.Key_Up) { + event.accepted = true; + if (event.key === Qt.Key_Down) suggestionList.incrementCurrentIndex(); + else suggestionList.decrementCurrentIndex(); + return; + } + if (event.key === Qt.Key_Tab || event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + event.accepted = true; + const index = suggestionList.currentIndex; + composer.flushText(); + composer.selectSuggestion(index); + return; + } + } + } + if (event.key !== Qt.Key_Return && event.key !== Qt.Key_Enter) { + return; + } + if (event.modifiers & Qt.ShiftModifier) { + return; + } + event.accepted = true; + composer.submit((event.modifiers & (Qt.ControlModifier | Qt.MetaModifier)) ? "background" : "foreground"); + } + } + } + + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 16 + spacing: 4 + + ShellButton { + subtle: true + iconName: "paperclip" + iconSize: 16 + iconTint: composer.iconMuted + Layout.leftMargin: -10 + enabled: composer.ready && !composer.model.editorDisabled + Accessible.name: qsTr("Attach image") + onClicked: imagePicker.open() + + FileDialog { + id: imagePicker + + title: qsTr("Attach images") + fileMode: FileDialog.OpenFiles + nameFilters: [qsTr("Images (*.png *.jpg *.jpeg *.gif *.webp *.heic *.heif)")] + onAccepted: composer.attach(selectedFiles) + } + } + + ShellComboBox { + id: modelPicker + + Layout.fillWidth: true + Layout.minimumWidth: 72 + Layout.maximumWidth: Math.min(implicitWidth, 224) + iconName: "sparkles" + enabled: composer.ready && composer.modelChoices.length > 0 + model: composer.modelChoices.map(choice => choice.label) + currentIndex: composer.ready ? composer.modelChoices.findIndex(choice => choice.instanceId === composer.model.selectedInstanceId && choice.slug === composer.model.selectedModel) : -1 + displayText: currentIndex < 0 ? qsTr("Model") : currentText + Accessible.name: qsTr("Model") + onActivated: index => { + const choice = composer.modelChoices[index]; + if (choice && choice.disabledReason === null) { + Shell.dispatch("composer.model.select", { + instanceId: choice.instanceId, + model: choice.slug + }); + } + } + } + + Separator { + visible: composer.effortOption !== null + } + + ShellComboBox { + visible: composer.effortOption !== null + model: composer.effortOption ? composer.effortOption.choices.map(choice => choice.label) : [] + currentIndex: composer.effortOption ? composer.effortOption.choices.findIndex(choice => choice.id === composer.effortOption.value) : -1 + displayText: currentIndex < 0 ? (composer.effortOption ? composer.effortOption.label : "") : currentText + Accessible.name: composer.effortOption ? composer.effortOption.label : "" + onActivated: index => Shell.dispatch("composer.option.set", { + id: composer.effortOption.id, + value: composer.effortOption.choices[index].id + }) + } + + Separator {} + + ShellComboBox { + iconName: composer.ready ? composer.runtimeIcon(composer.model.runtimeMode) : "lock" + enabled: composer.ready + model: composer.ready ? composer.model.runtimeModes.map(mode => mode.label) : [] + currentIndex: composer.ready ? composer.model.runtimeModes.findIndex(mode => mode.value === composer.model.runtimeMode) : -1 + Accessible.name: qsTr("Permissions") + onActivated: index => Shell.dispatch("composer.runtimeMode.set", { + mode: composer.model.runtimeModes[index].value + }) + } + + Separator { + visible: planToggle.visible + } + + ShellButton { + id: planToggle + + subtle: true + visible: composer.ready && composer.model.showInteractionModeToggle + checkable: true + checked: composer.ready && composer.model.interactionMode === "plan" + iconName: checked ? "pencil-ruler" : "bot" + iconSize: 16 + iconTint: checked ? composer.foreground : composer.secondary + tint: checked ? composer.foreground : composer.secondary + text: checked ? qsTr("Plan") : qsTr("Build") + font.pixelSize: 14 + leftPadding: 10 + rightPadding: 10 + onClicked: Shell.dispatch("composer.interactionMode.set", { + mode: checked ? "plan" : "default" + }) + } + + Item { + Layout.fillWidth: true + } + + Text { + visible: composer.ready && composer.model.pendingApprovalCount > 0 + text: composer.ready ? qsTr("%1 approval(s) waiting in the timeline").arg(composer.model.pendingApprovalCount) : "" + color: Theme.color("warning", "#e0af68") + font.pixelSize: 12 + font.family: Theme.fontUi.length > 0 ? Theme.fontUi : Qt.application.font.family + } + + ShellButton { + visible: composer.ready && composer.model.showPlanFollowUpPrompt && input.text.trim().length === 0 && !primaryAction.stopMode + implicitHeight: 28 + text: qsTr("Implement") + onClicked: composer.submit("foreground") + } + + // Round send / stop. + AbstractButton { + id: primaryAction + + readonly property bool stopMode: composer.ready && composer.model.isRunning && input.text.trim().length === 0 + + implicitWidth: 32 + implicitHeight: 32 + enabled: composer.ready && (stopMode || composer.model.canSend || input.text.trim().length > 0) + hoverEnabled: true + opacity: enabled ? 1 : 0.3 + scale: down ? 0.97 : hovered ? 1.05 : 1 + Accessible.role: Accessible.Button + Accessible.name: stopMode ? qsTr("Stop") : qsTr("Send") + onClicked: stopMode ? Shell.dispatch("composer.interrupt") : composer.submit("foreground") + + Behavior on scale { + NumberAnimation { + duration: 120 + easing.type: Easing.OutCubic + } + } + + background: Rectangle { + radius: 16 + color: primaryAction.stopMode ? Qt.alpha(Theme.color("error", "#ef4444"), 0.9) : Theme.color("messageAction", "#2563eb") + + Behavior on color { + ColorAnimation { + duration: 120 + } + } + } + + contentItem: Item { + ShellIcon { + anchors.centerIn: parent + visible: !primaryAction.stopMode + name: "arrow-up" + size: 16 + strokeWidth: 2.5 + color: Theme.color("messageActionForeground", "#ffffff") + } + + Rectangle { + anchors.centerIn: parent + visible: primaryAction.stopMode + width: 11 + height: 11 + radius: 2 + color: Theme.color("errorForeground", "#ffffff") + } + } + } + } + } + } + + // Context strip: environment, checkout mode, PR, branch — welded under + // the card and tucked 16px beneath it. + Item { + id: contextStrip + + readonly property var ws: composer.workspace + readonly property bool wsReady: ws !== null + readonly property string envModeIcon: !wsReady ? "folder" : ws.envMode === "worktree" ? "folder-git-2" : "folder-git" + + Layout.fillWidth: true + Layout.leftMargin: 22 + Layout.rightMargin: 22 + Layout.topMargin: -16 + implicitHeight: 16 + 4 + 24 + 4 + visible: wsReady + + Rectangle { + anchors.fill: parent + bottomLeftRadius: 16 + bottomRightRadius: 16 + color: "transparent" + border.color: composer.outline + border.width: 1 + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 4 + anchors.rightMargin: 8 + anchors.topMargin: 20 + anchors.bottomMargin: 4 + spacing: 8 + + ShellComboBox { + visible: contextStrip.wsReady && contextStrip.ws.environments.length > 1 + implicitHeight: 24 + leftPadding: 7 + iconSize + 6 + rightPadding: 7 + chevronSize + 4 + iconSize: 12 + chevronSize: 12 + font.pixelSize: 12 + iconName: "monitor" + enabled: contextStrip.wsReady && contextStrip.ws.environmentChangeable + model: contextStrip.wsReady ? contextStrip.ws.environments.map(env => env.label) : [] + currentIndex: contextStrip.wsReady ? contextStrip.ws.environments.findIndex(env => env.environmentId === contextStrip.ws.activeEnvironmentId) : -1 + Accessible.name: qsTr("Environment") + onActivated: index => Shell.dispatch("workspace.environment.set", { + environmentId: contextStrip.ws.environments[index].environmentId + }) + } + + Separator { + visible: contextStrip.wsReady && contextStrip.ws.environments.length > 1 + implicitHeight: 14 + } + + ShellComboBox { + visible: contextStrip.wsReady && contextStrip.ws.envModeChangeable + implicitHeight: 24 + leftPadding: 7 + iconSize + 6 + rightPadding: 7 + chevronSize + 4 + iconSize: 12 + chevronSize: 12 + font.pixelSize: 12 + iconName: contextStrip.envModeIcon + model: [qsTr("Current checkout"), qsTr("New worktree")] + currentIndex: contextStrip.wsReady && contextStrip.ws.envMode === "worktree" ? 1 : 0 + Accessible.name: qsTr("Checkout mode") + onActivated: index => Shell.dispatch("workspace.envMode.set", { + mode: index === 1 ? "worktree" : "local" + }) + } + + RowLayout { + visible: contextStrip.wsReady && !contextStrip.ws.envModeChangeable + spacing: 6 + Layout.leftMargin: 7 + + ShellIcon { + name: contextStrip.envModeIcon + size: 12 + color: composer.iconMuted + Layout.alignment: Qt.AlignVCenter + } + + Text { + text: contextStrip.wsReady ? contextStrip.ws.envModeLabel : "" + color: composer.secondary + font.pixelSize: 12 + font.weight: Font.Medium + font.family: Theme.fontUi.length > 0 ? Theme.fontUi : Qt.application.font.family + } + } + + Item { + Layout.fillWidth: true + } + + // PR badge. + Rectangle { + readonly property var pr: contextStrip.wsReady && contextStrip.ws.git ? contextStrip.ws.git.pullRequest : null + readonly property color prColor: pr === null ? "transparent" : pr.state === "merged" ? Theme.color("info", "#a78bfa") : pr.state === "closed" ? Theme.color("error", "#f87171") : Theme.color("success", "#34d399") + + visible: pr !== null && contextStrip.ws.canOpenPullRequest + implicitWidth: prLabel.implicitWidth + 8 + implicitHeight: 18 + radius: 4 + color: Qt.alpha(prColor, 0.15) + + HoverHandler { + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + onTapped: Shell.dispatch("workspace.openPullRequest") + } + + Text { + id: prLabel + + anchors.centerIn: parent + text: parent.pr ? "#" + parent.pr.number : "" + color: parent.prColor + font.pixelSize: 11 + font.weight: Font.Medium + font.family: Theme.fontUi.length > 0 ? Theme.fontUi : Qt.application.font.family + } + } + + ShellButton { + id: branchButton + + visible: contextStrip.wsReady && (contextStrip.ws.branch !== null || contextStrip.ws.branchChangeable) + enabled: contextStrip.wsReady && contextStrip.ws.branchChangeable && !contextStrip.ws.branchSwitchPending + subtle: true + implicitHeight: 24 + leftPadding: 7 + rightPadding: 7 + Layout.maximumWidth: 240 + iconName: "git-branch" + iconSize: 12 + iconTint: Qt.alpha(composer.iconMuted, 0.7) + tint: branchButton.hovered ? Qt.alpha(composer.foreground, 0.8) : composer.branchColor + chevron: contextStrip.wsReady && contextStrip.ws.branchChangeable + chevronSize: 12 + font.pixelSize: 12 + text: contextStrip.wsReady ? (contextStrip.ws.branch ?? qsTr("Pick branch")) : "" + Accessible.name: qsTr("Switch branch") + onClicked: branchPicker.open() + + Popup { + id: branchPicker + + x: parent.width - width + y: -height - 4 + width: 320 + height: 360 + padding: 4 + onOpened: { + branchSearch.text = ""; + branchSearch.forceActiveFocus(); + } + onClosed: Shell.dispatch("workspace.branch.search", { + query: "" + }) + + enter: Transition { + NumberAnimation { + property: "opacity" + from: 0 + to: 1 + duration: 120 + easing.type: Easing.OutCubic + } + } + + exit: Transition { + NumberAnimation { + property: "opacity" + from: 1 + to: 0 + duration: 90 + } + } + + background: Rectangle { + color: Theme.color("surfaceOverlay", "#18181b") + border.color: Qt.alpha(composer.foreground, 0.1) + radius: 10 + } + + ColumnLayout { + anchors.fill: parent + spacing: 4 + + ShellTextField { + id: branchSearch + + Layout.fillWidth: true + placeholderText: qsTr("Search or create a branch") + onTextEdited: Shell.dispatch("workspace.branch.search", { + query: text + }) + Keys.onReturnPressed: { + const name = text.trim(); + if (name.length === 0) { + return; + } + const exact = contextStrip.ws.branches.find(ref => ref.name === name); + Shell.dispatch(exact ? "workspace.branch.select" : "workspace.branch.create", { + name: name + }); + branchPicker.close(); + } + + background: Rectangle { + color: "transparent" + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: 1 + color: Qt.alpha(composer.foreground, 0.08) + } + } + } + + ListView { + id: branchList + + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + boundsBehavior: Flickable.StopAtBounds + model: contextStrip.wsReady ? contextStrip.ws.branches : [] + + delegate: Rectangle { + id: branchRow + + required property var modelData + + readonly property string badge: modelData.current ? qsTr("current") : modelData.isDefault ? qsTr("default") : modelData.isRemote ? qsTr("remote") : "" + + width: ListView.view.width + height: 28 + radius: 6 + color: rowHover.hovered ? Theme.color("accentSurface", "#1c1c21") : "transparent" + + HoverHandler { + id: rowHover + } + + TapHandler { + onTapped: { + Shell.dispatch("workspace.branch.select", { + name: branchRow.modelData.name + }); + branchPicker.close(); + } + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 8 + + Text { + Layout.fillWidth: true + text: branchRow.modelData.name + color: branchRow.modelData.isRemote ? composer.muted : composer.foreground + font.pixelSize: 13 + font.family: Theme.fontUi.length > 0 ? Theme.fontUi : Qt.application.font.family + elide: Text.ElideMiddle + } + + Text { + visible: branchRow.badge.length > 0 + text: branchRow.badge + color: Qt.alpha(composer.muted, 0.45) + font.pixelSize: 10 + font.family: Theme.fontUi.length > 0 ? Theme.fontUi : Qt.application.font.family + } + } + } + + Text { + anchors.centerIn: parent + visible: branchList.count === 0 + text: contextStrip.wsReady && contextStrip.ws.branchesLoading ? qsTr("Loading refs…") : qsTr("No matching refs — Enter creates one") + color: composer.muted + font.pixelSize: 12 + } + } + + Text { + Layout.fillWidth: true + Layout.leftMargin: 8 + Layout.bottomMargin: 4 + visible: contextStrip.wsReady && contextStrip.ws.branchesTotal > contextStrip.ws.branches.length + text: contextStrip.wsReady ? qsTr("Showing %1 of %2 refs — type to narrow").arg(contextStrip.ws.branches.length).arg(contextStrip.ws.branchesTotal) : "" + color: composer.muted + font.pixelSize: 11 + } + } + } + } + } + } + } + + component Separator: Rectangle { + implicitWidth: 1 + implicitHeight: 16 + Layout.alignment: Qt.AlignVCenter + Layout.leftMargin: 2 + Layout.rightMargin: 2 + color: Theme.color("border", "#27272a") + } +} diff --git a/apps/desktop-qt/qml/T3/Bricks/ContextMenuHost.qml b/apps/desktop-qt/qml/T3/Bricks/ContextMenuHost.qml new file mode 100644 index 000000000000..9bac2fc07e0a --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/ContextMenuHost.qml @@ -0,0 +1,102 @@ +import QtQuick +import QtQuick.Controls.Basic +import T3.Shell + +// Shows the page's pending context menu (Shell.state.contextMenu) when it +// targets this host's surface, and reports the choice back. One instance +// lives in each WebSurface (page coordinates) and one in the shell root +// (window coordinates, surfaceId "shell"). +Item { + id: host + + required property string surfaceId + + readonly property var request: Shell.state.contextMenu ?? null + readonly property bool mine: request !== null && request.surfaceId === surfaceId + property string shownRequestId: "" + + anchors.fill: parent + + function entries() { + // Submenus flatten into a labelled section: one level is all the app uses. + const out = []; + for (const item of host.request.items) { + if (item.children && item.children.length > 0) { + out.push({ + id: item.id, + label: item.label, + header: true, + separatorBefore: item.separatorBefore === true + }); + for (const child of item.children) { + out.push(Object.assign({}, child, { + separatorBefore: child.separatorBefore === true + })); + } + continue; + } + out.push(Object.assign({}, item, { + separatorBefore: item.separatorBefore === true + })); + } + return out; + } + + function choose(id) { + if (host.request === null) { + return; + } + const requestId = host.request.requestId; + Shell.dispatch("contextMenu.select", { + requestId: requestId, + id: id + }); + } + + onRequestChanged: { + if (!mine) { + if (menu.visible) { + menu.close(); + } + return; + } + if (request.requestId === shownRequestId) { + return; + } + shownRequestId = request.requestId; + menu.chosen = false; + menu.popup(Math.min(request.x, host.width - menu.implicitWidth - 8), Math.min(request.y, host.height - menu.implicitHeight - 8)); + } + + ShellMenu { + id: menu + + property bool chosen: false + + onClosed: { + if (!chosen && host.mine) { + host.choose(null); + } + } + + Instantiator { + model: host.mine ? host.entries() : [] + + delegate: ShellMenuItem { + required property var modelData + + text: modelData.label + enabled: modelData.disabled !== true && modelData.header !== true + destructive: modelData.destructive === true + font.bold: modelData.header === true + onTriggered: { + menu.chosen = true; + host.choose(modelData.id); + } + } + + onObjectAdded: (index, object) => menu.insertItem(index, object) + onObjectRemoved: (index, object) => menu.removeItem(object) + } + } +} diff --git a/apps/desktop-qt/qml/T3/Bricks/DefaultShell.qml b/apps/desktop-qt/qml/T3/Bricks/DefaultShell.qml new file mode 100644 index 000000000000..e779038d46a0 --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/DefaultShell.qml @@ -0,0 +1,87 @@ +import QtQuick +import QtQuick.Layouts +import T3.Shell +import T3.Bricks + +// Built-in layout, laid out like the page's own chrome: sidebar, header +// strip, timeline, composer. A user's ~/.t3/shell/shell.qml replaces this +// file wholesale; it is also the fallback when that file fails to load. +// Frameless windows get their drag handle and window buttons from the +// sidebar band and the header strip rather than a separate title bar. +ShellWindow { + id: root + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 0 + + Sidebar { + id: sidebar + + Layout.fillHeight: true + // Not animated: see RightPanel. + Layout.preferredWidth: root.sidebarCollapsed ? 0 : 256 + Layout.minimumWidth: 0 + visible: !root.settingsActive && !root.sidebarCollapsed + showBrand: true + window: root + } + + SettingsNav { + Layout.fillHeight: true + Layout.preferredWidth: 256 + visible: root.settingsActive + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 0 + + Workspace { + Layout.fillWidth: true + visible: ready + sidebarToggle: root.sidebarCollapsed + panelToggle: rightPanel.available ? rightPanel.open : null + window: root + } + + WebSurface { + Layout.fillWidth: true + Layout.fillHeight: true + url: Shell.pageUrl + } + + Composer { + Layout.fillWidth: true + visible: ready + } + + TerminalDrawer { + Layout.fillWidth: true + } + } + + RightPanel { + id: rightPanel + + Layout.fillHeight: true + ownToggle: false + Layout.preferredWidth: implicitWidth + visible: available + } + } + } + + Notifications { + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.bottomMargin: 180 + anchors.rightMargin: 16 + } +} diff --git a/apps/desktop-qt/qml/T3/Bricks/GitActions.qml b/apps/desktop-qt/qml/T3/Bricks/GitActions.qml new file mode 100644 index 000000000000..676628b8d4ab --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/GitActions.qml @@ -0,0 +1,368 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import T3.Shell + +// The git pill: quick action + menu, with the commit and +// default-branch dialogs rendered here from Shell.state.git. Progress and +// results are the page's toasts, shown by Notifications. +RowLayout { + id: git + + readonly property var model: Shell.state.git ?? null + readonly property bool ready: model !== null && model.available + readonly property color muted: Theme.color("textMuted", "#8b8b93") + readonly property color foreground: Theme.color("text", "#e4e4e7") + + // Icon-only pill, for narrow header strips. + property bool compact: false + + spacing: 0 + visible: ready + + readonly property string quickIcon: { + if (!ready) { + return "git-commit-horizontal"; + } + switch (model.quickAction.kind) { + case "open_pr": + return "git-pull-request"; + case "open_publish": + return "cloud-upload"; + case "run_pull": + return "git-merge"; + default: + return model.quickAction.label.toLowerCase().indexOf("push") >= 0 ? "cloud-upload" : "git-commit-horizontal"; + } + } + + ShellButton { + implicitHeight: 24 + font.pixelSize: 12 + iconName: "git-branch" + visible: git.ready && !git.model.isRepo + enabled: git.ready && !git.model.initPending + text: git.ready && git.model.initPending ? qsTr("Initializing…") : qsTr("Initialize Git") + onClicked: Shell.dispatch("git.init") + } + + ShellSplitButton { + visible: git.ready && git.model.isRepo + enabled: git.ready && !git.model.busy + actionEnabled: git.ready && git.model.quickAction.disabledReason === null + compact: git.compact + iconName: git.quickIcon + text: git.ready ? git.model.quickAction.label : "" + toolTip: git.ready ? (git.model.quickAction.disabledReason ?? "") : "" + onClicked: Shell.dispatch("git.quick") + onMenuRequested: { + Shell.dispatch("git.refresh"); + menu.open(); + } + + ShellMenu { + id: menu + + y: parent.height + 4 + x: parent.width - width + + Instantiator { + model: git.ready ? git.model.menu : [] + + delegate: ShellMenuItem { + required property var modelData + required property int index + + text: modelData.disabledReason ? modelData.label + " · " + modelData.disabledReason : modelData.label + enabled: modelData.disabledReason === null + onTriggered: { + if (modelData.id === "commit") { + commitDialog.reset(); + commitDialog.open(); + } else { + Shell.dispatch("git.menu", { + id: modelData.id + }); + } + } + } + + onObjectAdded: (index, object) => menu.insertItem(index, object) + onObjectRemoved: (index, object) => menu.removeItem(object) + } + + ShellMenuItem { + visible: git.ready && git.model.canPublish + height: visible ? implicitHeight : 0 + text: qsTr("Publish repository…") + iconName: "cloud-upload" + onTriggered: Shell.dispatch("git.publish") + } + + Instantiator { + model: git.ready ? git.model.hints : [] + + delegate: ShellMenuItem { + required property var modelData + + text: modelData + enabled: false + } + + onObjectAdded: (index, object) => menu.addItem(object) + onObjectRemoved: (index, object) => menu.removeItem(object) + } + } + } + + // ---- Commit dialog ------------------------------------------------- + Popup { + id: commitDialog + objectName: "commitDialog" + + property var excluded: ({}) + readonly property bool hasSelectedFiles: git.ready && git.model.files.some(file => !excluded[file.path]) + + function reset() { + message.text = ""; + excluded = {}; + } + function selectedPaths() { + const all = git.model.files.map(file => file.path); + const chosen = all.filter(path => !commitDialog.excluded[path]); + return chosen.length === all.length ? null : chosen; + } + function submit(featureBranch) { + const paths = selectedPaths(); + if (paths !== null && paths.length === 0) { + return; + } + Shell.dispatch("git.commit", { + message: message.text, + filePaths: paths, + featureBranch: featureBranch + }); + close(); + } + + parent: Overlay.overlay + x: Math.round((parent.width - width) / 2) + y: Math.round((parent.height - height) / 2) + width: 520 + modal: true + padding: 16 + + background: Rectangle { + radius: Theme.radius + color: Theme.color("surfaceOverlay", "#18181b") + border.color: Theme.color("border", "#27272a") + border.width: 1 + } + + contentItem: ColumnLayout { + spacing: 10 + + Text { + text: qsTr("Commit changes") + color: git.foreground + font.pixelSize: 15 + font.bold: true + } + + Text { + Layout.fillWidth: true + text: qsTr("Review and confirm your commit. Leave the message blank to auto-generate one.") + color: git.muted + font.pixelSize: 12 + wrapMode: Text.Wrap + } + + Text { + visible: git.ready && git.model.isDefaultRef + text: qsTr("Warning: committing on the default branch %1").arg(git.ready ? (git.model.branch ?? "") : "") + color: Theme.color("warning", "#e0af68") + font.pixelSize: 12 + } + + ListView { + id: fileList + + Layout.fillWidth: true + Layout.preferredHeight: Math.min(Math.max(count, 1) * 30, 220) + clip: true + model: git.ready ? git.model.files : [] + boundsBehavior: Flickable.StopAtBounds + + delegate: RowLayout { + required property var modelData + + width: fileList.width + height: 30 + spacing: 8 + + CheckBox { + objectName: "fileCheck-" + modelData.path + checked: !commitDialog.excluded[modelData.path] + onToggled: { + const next = Object.assign({}, commitDialog.excluded); + if (checked) { + delete next[modelData.path]; + } else { + next[modelData.path] = true; + } + commitDialog.excluded = next; + } + } + + Text { + Layout.fillWidth: true + text: modelData.path + color: git.foreground + font.pixelSize: 12 + elide: Text.ElideMiddle + } + + Text { + text: "+" + modelData.insertions + color: Theme.color("update", "#22c55e") + font.pixelSize: 11 + } + + Text { + text: "−" + modelData.deletions + color: Theme.color("error", "#ef4444") + font.pixelSize: 11 + } + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 84 + radius: Theme.radius + color: Theme.color("input", "#141416") + border.color: message.activeFocus ? Theme.color("focus", "#3b82f6") : Theme.color("border", "#27272a") + + ScrollView { + anchors.fill: parent + anchors.margins: 8 + + TextArea { + id: message + + placeholderText: qsTr("Commit message (optional)") + placeholderTextColor: git.muted + color: git.foreground + wrapMode: TextEdit.Wrap + background: null + font.pixelSize: 13 + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Item { + Layout.fillWidth: true + } + + ShellButton { + text: qsTr("Cancel") + onClicked: commitDialog.close() + } + + ShellButton { + objectName: "commitNewBranch" + text: qsTr("Commit on new branch") + enabled: commitDialog.hasSelectedFiles + onClicked: commitDialog.submit(true) + } + + ShellButton { + objectName: "commitSelected" + primary: true + text: qsTr("Commit") + enabled: commitDialog.hasSelectedFiles + onClicked: commitDialog.submit(false) + } + } + } + } + + // ---- Default-branch confirmation --------------------------------- + Popup { + id: confirmDialog + + readonly property var pending: git.ready ? git.model.pendingDefaultBranch : null + + parent: Overlay.overlay + x: Math.round((parent.width - width) / 2) + y: Math.round((parent.height - height) / 2) + width: 460 + modal: true + padding: 16 + visible: pending !== null + closePolicy: Popup.NoAutoClose + + background: Rectangle { + radius: Theme.radius + color: Theme.color("surfaceOverlay", "#18181b") + border.color: Theme.color("border", "#27272a") + border.width: 1 + } + + contentItem: ColumnLayout { + spacing: 10 + + Text { + Layout.fillWidth: true + text: confirmDialog.pending ? confirmDialog.pending.title : "" + color: git.foreground + font.pixelSize: 15 + font.bold: true + wrapMode: Text.Wrap + } + + Text { + Layout.fillWidth: true + text: confirmDialog.pending ? confirmDialog.pending.description : "" + color: git.muted + font.pixelSize: 12 + wrapMode: Text.Wrap + } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Item { + Layout.fillWidth: true + } + + ShellButton { + text: qsTr("Abort") + onClicked: Shell.dispatch("git.defaultBranch", { + choice: "abort" + }) + } + + ShellButton { + text: confirmDialog.pending ? confirmDialog.pending.featureBranchLabel : "" + onClicked: Shell.dispatch("git.defaultBranch", { + choice: "featureBranch" + }) + } + + ShellButton { + primary: true + text: confirmDialog.pending ? confirmDialog.pending.continueLabel : "" + onClicked: Shell.dispatch("git.defaultBranch", { + choice: "continue" + }) + } + } + } + } +} diff --git a/apps/desktop-qt/qml/T3/Bricks/Notifications.qml b/apps/desktop-qt/qml/T3/Bricks/Notifications.qml new file mode 100644 index 000000000000..4d49bf604e46 --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/Notifications.qml @@ -0,0 +1,177 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import T3.Shell + +// The page's toasts, rendered natively (Shell.state.notifications). Place it +// over the window; the page keeps timing and dismissal semantics. +Item { + id: host + + readonly property var model: Shell.state.notifications ?? null + readonly property var items: model ? model.items : [] + property int cardWidth: 340 + + implicitWidth: cardWidth + implicitHeight: column.implicitHeight + visible: items.length > 0 + + function typeColor(type) { + switch (type) { + case "error": + return Theme.color("error", "#ef4444"); + case "warning": + return Theme.color("warning", "#e0af68"); + case "success": + return Theme.color("update", "#22c55e"); + case "loading": + return Theme.color("textMuted", "#8b8b93"); + default: + return Theme.color("accent", "#3b82f6"); + } + } + + ColumnLayout { + id: column + + anchors.right: parent.right + anchors.top: parent.top + width: host.cardWidth + spacing: 8 + + Repeater { + model: host.items + + delegate: Rectangle { + id: card + + required property var modelData + + Layout.fillWidth: true + implicitHeight: body.implicitHeight + 28 + radius: Theme.radius + color: Theme.color("surfaceOverlay", "#18181b") + border.color: Theme.color("border", "#27272a") + border.width: 1 + transform: Translate { + id: slide + } + + // Slide in from the edge, like the page's own toasts. + ParallelAnimation { + running: true + + NumberAnimation { + target: card + property: "opacity" + from: 0 + to: 1 + duration: 180 + } + + NumberAnimation { + target: slide + property: "x" + from: 24 + to: 0 + duration: 220 + easing.type: Easing.OutCubic + } + } + + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.margins: 1 + anchors.topMargin: Math.max(card.border.width, Math.min(card.radius, card.height / 2)) + anchors.bottomMargin: anchors.topMargin + width: 3 + radius: 2 + color: host.typeColor(card.modelData.type) + } + + ColumnLayout { + id: body + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 14 + anchors.leftMargin: 18 + spacing: 6 + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + Text { + Layout.fillWidth: true + text: card.modelData.title + color: Theme.color("text", "#e4e4e7") + font.pixelSize: 13 + font.bold: true + font.family: Theme.fontUi.length > 0 ? Theme.fontUi : Qt.application.font.family + wrapMode: Text.Wrap + } + + ShellButton { + Layout.alignment: Qt.AlignTop + Layout.topMargin: -4 + Layout.rightMargin: -6 + subtle: true + implicitWidth: 26 + implicitHeight: 26 + leftPadding: 0 + rightPadding: 0 + text: "✕" + tint: Theme.color("textMuted", "#8b8b93") + font.pixelSize: 11 + Accessible.name: qsTr("Dismiss") + onClicked: Shell.dispatch("notification.dismiss", { + id: card.modelData.id + }) + } + } + + Text { + Layout.fillWidth: true + visible: card.modelData.description !== null && card.modelData.description.length > 0 + text: card.modelData.description ?? "" + color: Theme.color("textMuted", "#8b8b93") + font.pixelSize: 12 + font.family: Theme.fontUi.length > 0 ? Theme.fontUi : Qt.application.font.family + lineHeight: 1.2 + wrapMode: Text.Wrap + } + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 4 + visible: card.modelData.actions.length > 0 + spacing: 8 + + Item { + Layout.fillWidth: true + } + + Repeater { + model: card.modelData.actions + + delegate: ShellButton { + required property var modelData + + primary: modelData.primary + text: modelData.label + onClicked: Shell.dispatch("notification.action", { + id: card.modelData.id, + actionId: modelData.id + }) + } + } + } + } + } + } + } +} diff --git a/apps/desktop-qt/qml/T3/Bricks/RightPanel.qml b/apps/desktop-qt/qml/T3/Bricks/RightPanel.qml new file mode 100644 index 000000000000..14621eaea4d4 --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/RightPanel.qml @@ -0,0 +1,234 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import T3.Shell + +// The right panel: native tabs over an HTML body. The body is the app's +// embed route for the current thread, loaded in a second web surface that +// shares the primary surface's session; the tab model comes from +// Shell.state.rightPanel and every tab action goes back to the page. +Rectangle { + id: panel + + readonly property var model: Shell.state.rightPanel ?? null + readonly property bool available: model !== null + readonly property bool open: available && model.isOpen + readonly property int openWidth: 520 + readonly property color foreground: Theme.color("text", "#e4e4e7") + readonly property color muted: Theme.color("textMuted", "#8b8b93") + readonly property url embedUrl: { + if (!available) { + return ""; + } + const page = Shell.pageUrl.toString(); + const origin = page.match(/^(https?:\/\/[^/]+)/); + return origin ? origin[1] + model.embedPath : ""; + } + + // Whether the panel draws its own open/close button. A layout that puts + // the toggle in the header strip (Workspace.panelToggle) turns this off, + // and the closed panel then takes no width. + property bool ownToggle: true + + // Not animated: the web surface between the panels would be resized (a + // Chromium relayout and a new GPU surface) on every frame of it. + implicitWidth: open ? openWidth : ownToggle ? 36 : 0 + color: Theme.color("chrome", "#0b0b0d") + clip: true + + ColumnLayout { + id: column + + anchors.fill: parent + spacing: 0 + + Item { + id: header + + Layout.fillWidth: true + Layout.preferredHeight: 36 + Layout.minimumHeight: 36 + Layout.maximumHeight: 36 + + ShellButton { + id: toggleButton + subtle: true + + anchors.left: parent.left + anchors.top: parent.top + width: 36 + height: 36 + visible: panel.ownToggle + iconName: panel.open ? "panel-right-close" : "panel-right" + iconSize: 16 + iconTint: panel.muted + enabled: panel.available + Accessible.name: panel.open ? qsTr("Close panel") : qsTr("Open panel") + onClicked: Shell.dispatch("rightPanel.toggle") + } + + ListView { + id: tabs + + anchors.left: parent.left + anchors.leftMargin: panel.ownToggle ? 36 : 8 + anchors.right: addButton.left + anchors.top: parent.top + height: 36 + visible: panel.open + orientation: ListView.Horizontal + spacing: 2 + clip: true + boundsBehavior: Flickable.StopAtBounds + model: panel.open ? panel.model.surfaces : [] + + delegate: AbstractButton { + id: tab + + required property var modelData + objectName: "panelTab-" + modelData.id + + readonly property bool active: panel.model.activeSurfaceId === modelData.id + + width: tabRow.implicitWidth + 16 + height: 36 + hoverEnabled: true + Accessible.role: Accessible.PageTab + Accessible.name: modelData.title + Keys.onReturnPressed: clicked() + Keys.onEnterPressed: clicked() + onClicked: Shell.dispatch("rightPanel.activate", { id: tab.modelData.id }) + background: Rectangle { + color: tab.active ? Theme.color("surfaceRaised", "#1f1f24") : tab.hovered || tab.visualFocus ? Theme.color("surface", "#141416") : "transparent" + radius: 6 + } + + Row { + id: tabRow + + anchors.centerIn: parent + spacing: 6 + + Text { + text: tab.modelData.title + color: tab.active ? panel.foreground : panel.muted + font.pixelSize: 12 + anchors.verticalCenter: parent.verticalCenter + } + + ShellButton { + objectName: "panelClose-" + tab.modelData.id + subtle: true + iconName: "x" + iconSize: 12 + iconTint: panel.muted + width: 20 + height: 20 + padding: 4 + Accessible.name: qsTr("Close %1").arg(tab.modelData.title) + anchors.verticalCenter: parent.verticalCenter + onClicked: Shell.dispatch("rightPanel.close", { id: tab.modelData.id }) + } + } + } + } + + ShellButton { + id: addButton + subtle: true + + anchors.right: parent.right + anchors.top: parent.top + width: 36 + height: 36 + leftPadding: 10 + rightPadding: 10 + iconName: "plus" + iconSize: 16 + iconTint: panel.muted + visible: panel.open + Accessible.name: qsTr("Add panel") + onClicked: addMenu.open() + + ShellMenu { + id: addMenu + + y: parent.height + + ShellMenuItem { + text: qsTr("Diff") + iconName: "file-diff" + enabled: panel.open && panel.model.canAdd.diff + onTriggered: Shell.dispatch("rightPanel.add", { + kind: "diff" + }) + } + + ShellMenuItem { + text: qsTr("Files") + iconName: "files" + enabled: panel.open && panel.model.canAdd.files + onTriggered: Shell.dispatch("rightPanel.add", { + kind: "files" + }) + } + + ShellMenuItem { + text: qsTr("Terminal") + iconName: "terminal" + enabled: panel.open && panel.model.canAdd.terminal + onTriggered: Shell.dispatch("rightPanel.add", { + kind: "terminal" + }) + } + + ShellMenuItem { + text: qsTr("Pull request") + iconName: "git-pull-request" + enabled: panel.open && panel.model.canAdd.pullRequest + onTriggered: Shell.dispatch("rightPanel.add", { + kind: "pull-request" + }) + } + + ShellMenuItem { + text: qsTr("Agents") + iconName: "bot" + enabled: panel.open && panel.model.canAdd.agents + onTriggered: Shell.dispatch("rightPanel.add", { + kind: "agents" + }) + } + } + } + } + + Loader { + id: body + + // Once up, the document stays up: closing the panel or leaving the + // thread route (settings) hides it instead of destroying the + // terminals and scroll state it holds. + readonly property bool wanted: panel.open && panel.embedUrl.toString().length > 0 + + Layout.fillWidth: true + Layout.fillHeight: true + active: false + visible: panel.open + onWantedChanged: if (wanted) + active = true + Component.onCompleted: if (wanted) + active = true + + // The document follows thread changes itself (t3Shell.onState), so + // the URL is only the starting point; rebinding it would reload. + sourceComponent: WebSurface { + surfaceId: "rightPanel" + sleepsWhenHidden: true + // The panel's own radius rounds the document's corners too. + radius: panel.radius + Component.onCompleted: url = panel.embedUrl + } + } + } +} diff --git a/apps/desktop-qt/qml/T3/Bricks/SettingsNav.qml b/apps/desktop-qt/qml/T3/Bricks/SettingsNav.qml new file mode 100644 index 000000000000..dbb1ae3f672d --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/SettingsNav.qml @@ -0,0 +1,147 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import T3.Shell + +// Settings navigation: sections, search, and a way back. The settings pages +// stay HTML in the primary web surface; this only drives navigation. +Rectangle { + id: nav + + readonly property var model: Shell.state.settings ?? null + readonly property bool active: model !== null && model.active + readonly property color foreground: Theme.color("sidebarForeground", "#e4e4e7") + readonly property color muted: Theme.color("sidebarMutedForeground", "#8b8b93") + + implicitWidth: 260 + color: Theme.color("sidebar", "#0a0a0a") + + function focusRow(index) { + list.currentIndex = Math.max(0, Math.min(index, list.count - 1)); + if (list.currentItem) list.currentItem.forceActiveFocus(); + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + RowLayout { + Layout.fillWidth: true + Layout.margins: 10 + Layout.bottomMargin: 4 + spacing: 8 + + ShellButton { + subtle: true + text: "←" + Accessible.name: qsTr("Back") + onClicked: Shell.dispatch("settings.back") + } + + Label { + Layout.fillWidth: true + text: qsTr("Settings") + color: nav.foreground + font.bold: true + } + } + + ShellTextField { + id: search + objectName: "search" + + Layout.fillWidth: true + Layout.leftMargin: 10 + Layout.rightMargin: 10 + Layout.bottomMargin: 6 + placeholderText: qsTr("Search settings") + text: nav.model ? nav.model.searchQuery : "" + onTextEdited: Shell.dispatch("settings.search", { + query: text + }) + Keys.onEscapePressed: { + Shell.dispatch("settings.search", { + query: "" + }); + } + } + + ListView { + id: list + + readonly property bool searching: nav.model !== null && nav.model.searchQuery.trim().length > 0 + + Layout.fillWidth: true + Layout.fillHeight: true + Layout.topMargin: 8 + clip: true + boundsBehavior: Flickable.StopAtBounds + model: nav.model === null ? [] : searching ? nav.model.searchResults : nav.model.sections + + delegate: ItemDelegate { + id: row + + required property var modelData + required property int index + objectName: "settingsRow" + index + + readonly property bool isResult: list.searching + readonly property bool current: !isResult && nav.model.activeSection === modelData.to + + width: ListView.view.width + implicitHeight: isResult ? 48 : 36 + Accessible.name: isResult ? modelData.title : modelData.label + Keys.onReturnPressed: clicked() + Keys.onEnterPressed: clicked() + Keys.onDownPressed: nav.focusRow(index + 1) + Keys.onUpPressed: nav.focusRow(index - 1) + onClicked: row.isResult ? Shell.dispatch("settings.openResult", { + to: row.modelData.to, + targetId: row.modelData.targetId + }) : Shell.dispatch("settings.navigate", { + to: row.modelData.to + }) + + background: Rectangle { + anchors.fill: parent + anchors.leftMargin: 6 + anchors.rightMargin: 6 + radius: 6 + color: row.current ? Theme.color("sidebarRowSelected", "#2a2a30") : row.hovered || row.visualFocus ? Theme.color("sidebarRowHover", "#1c1c21") : "transparent" + } + + contentItem: ColumnLayout { + anchors.fill: parent + anchors.leftMargin: 16 + anchors.rightMargin: 16 + spacing: 1 + + Text { + Layout.fillWidth: true + text: row.isResult ? row.modelData.title : row.modelData.label + color: nav.foreground + font.pixelSize: 13 + elide: Text.ElideRight + } + + Text { + Layout.fillWidth: true + visible: row.isResult + text: row.isResult ? row.modelData.sectionLabel : "" + color: nav.muted + font.pixelSize: 11 + elide: Text.ElideRight + } + } + } + + Text { + anchors.centerIn: parent + visible: list.searching && list.count === 0 + text: qsTr("No matching settings") + color: nav.muted + font.pixelSize: 12 + } + } + } +} diff --git a/apps/desktop-qt/qml/T3/Bricks/ShellButton.qml b/apps/desktop-qt/qml/T3/Bricks/ShellButton.qml new file mode 100644 index 000000000000..5ebadb4669cd --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/ShellButton.qml @@ -0,0 +1,102 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import T3.Shell + +// A button in the page's clothes. Outline by default (the header pills), +// `subtle` for the ghost buttons, `primary` for the accent one; `iconName` is a +// lucide id drawn before the text, `chevron` appends the menu chevron. +Button { + id: control + + property bool primary: false + property bool subtle: false + property bool chevron: false + property string iconName: "" + property real iconSize: 14 + property real chevronSize: 14 + property color tint: primary ? Theme.color("accentForeground", "#ffffff") : Theme.color("text", "#e4e4e7") + property color iconTint: tint + property real radius: Math.min(Theme.radius, 8) + readonly property bool iconOnly: text.length === 0 && iconName.length > 0 && !chevron + readonly property bool chevronOnly: text.length === 0 && iconName.length === 0 && chevron + readonly property color hoverFill: Qt.alpha(Theme.color("accentSurface", "#27272a"), control.subtle ? 1 : 0.5) + readonly property color focusRing: Theme.color("focus", "#3b82f6") + // A lone glyph sits on the button's centre: the padding is what is left + // of the height, so a 24px chevron half stays square and centred. + readonly property real glyphPadding: iconOnly ? (height - iconSize) / 2 : (height - chevronSize) / 2 + + implicitHeight: 28 + topPadding: 0 + bottomPadding: 0 + leftPadding: iconOnly || chevronOnly ? glyphPadding : 8 + rightPadding: iconOnly || chevronOnly ? glyphPadding : 8 + font.family: Theme.fontUi.length > 0 ? Theme.fontUi : Qt.application.font.family + font.pixelSize: 13 + font.weight: Font.Medium + hoverEnabled: true + scale: down ? 0.97 : 1 + opacity: enabled ? 1 : 0.64 + + Behavior on scale { + NumberAnimation { + duration: 100 + easing.type: Easing.OutCubic + } + } + + background: Rectangle { + radius: control.radius + color: control.primary ? (control.down ? Qt.darker(Theme.color("accent", "#2563eb"), 1.15) : control.hovered ? Qt.lighter(Theme.color("accent", "#2563eb"), 1.08) : Theme.color("accent", "#2563eb")) : control.hovered || control.down || control.checked ? control.hoverFill : control.subtle ? Qt.alpha(control.hoverFill, 0) : Qt.alpha(Theme.color("input", "#27272a"), 0.32) + // Keyboard focus draws the page's ring; pointer focus stays quiet. + border.color: control.visualFocus ? control.focusRing : control.primary || control.subtle ? "transparent" : Theme.color("input", "#27272a") + border.width: control.visualFocus || !(control.primary || control.subtle) ? 1 : 0 + + Behavior on color { + ColorAnimation { + duration: 120 + } + } + } + + contentItem: Item { + implicitWidth: contents.implicitWidth + implicitHeight: contents.implicitHeight + + RowLayout { + id: contents + objectName: "contents" + + anchors.centerIn: parent + width: Math.min(parent.width, implicitWidth) + spacing: 6 + + ShellIcon { + visible: control.iconName.length > 0 + name: control.iconName + size: control.iconSize + color: control.iconTint + Layout.alignment: Qt.AlignVCenter + } + + Text { + visible: control.text.length > 0 + text: control.text + font: control.font + color: control.tint + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + Layout.fillWidth: true + Layout.minimumWidth: 0 + } + + ShellIcon { + visible: control.chevron + name: "chevron-down" + size: control.chevronSize + color: control.text.length > 0 || control.iconName.length > 0 ? Theme.color("iconMuted", "#8b8b93") : control.tint + Layout.alignment: Qt.AlignVCenter + } + } + } +} diff --git a/apps/desktop-qt/qml/T3/Bricks/ShellCard.qml b/apps/desktop-qt/qml/T3/Bricks/ShellCard.qml new file mode 100644 index 000000000000..dcba978dbf33 --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/ShellCard.qml @@ -0,0 +1,22 @@ +import QtQuick +import T3.Shell + +// A rounded, hairlined panel for rices that float bricks in cards. Children +// fill the inside of the border; override color, border or radius per card. +Rectangle { + id: card + + default property alias content: inner.data + + radius: Theme.radius + color: Theme.color("surface", "#141416") + border.color: Theme.color("border", "#27272a") + border.width: 1 + + Item { + id: inner + + anchors.fill: parent + anchors.margins: card.border.width + } +} diff --git a/apps/desktop-qt/qml/T3/Bricks/ShellChevron.qml b/apps/desktop-qt/qml/T3/Bricks/ShellChevron.qml new file mode 100644 index 000000000000..d14877a2780a --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/ShellChevron.qml @@ -0,0 +1,35 @@ +import QtQuick +import QtQuick.Shapes + +// A small down chevron drawn as a stroke, so it sits on the control's centre +// line instead of on a glyph baseline. +Shape { + id: chevron + + property color color: "#8b8b93" + property real strokeWidth: 1.5 + + width: 9 + height: 6 + preferredRendererType: Shape.CurveRenderer + + ShapePath { + strokeColor: chevron.color + strokeWidth: chevron.strokeWidth + fillColor: "transparent" + capStyle: ShapePath.RoundCap + joinStyle: ShapePath.RoundJoin + startX: 0.5 + startY: 0.5 + + PathLine { + x: chevron.width / 2 + y: chevron.height - 0.5 + } + + PathLine { + x: chevron.width - 0.5 + y: 0.5 + } + } +} diff --git a/apps/desktop-qt/qml/T3/Bricks/ShellComboBox.qml b/apps/desktop-qt/qml/T3/Bricks/ShellComboBox.qml new file mode 100644 index 000000000000..55ab1df3b434 --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/ShellComboBox.qml @@ -0,0 +1,131 @@ +import QtQuick +import QtQuick.Controls.Basic +import T3.Shell + +// A select in the page's clothes: the composer's ghost pickers by default +// (icon, muted label, chevron), `outline: true` for a bordered field. +ComboBox { + id: control + + property bool outline: false + property string iconName: "" + property real iconSize: 16 + property real chevronSize: 14 + readonly property color hoverFill: Qt.alpha(Theme.color("accentSurface", "#27272a"), outline ? 0.5 : 1) + readonly property color labelColor: control.hovered || control.down || control.popup.visible ? Theme.color("text", "#e4e4e7") : Theme.color("secondaryLabel", "#a1a1aa") + + implicitHeight: 28 + leftPadding: iconName.length > 0 ? 10 + iconSize + 6 : 10 + rightPadding: 10 + chevronSize + 4 + font.family: Theme.fontUi.length > 0 ? Theme.fontUi : Qt.application.font.family + font.pixelSize: 14 + font.weight: Font.Medium + hoverEnabled: true + opacity: enabled ? 1 : 0.64 + + background: Rectangle { + radius: Math.min(Theme.radius, 8) + color: control.down || control.hovered || control.popup.visible ? control.hoverFill : control.outline ? Qt.alpha(Theme.color("input", "#27272a"), 0.32) : Qt.alpha(control.hoverFill, 0) + border.color: control.outline ? (control.activeFocus ? Theme.color("focus", "#3b82f6") : Theme.color("input", "#27272a")) : "transparent" + border.width: control.outline ? 1 : 0 + + Behavior on color { + ColorAnimation { + duration: 120 + } + } + + ShellIcon { + x: 10 + y: (parent.height - height) / 2 + visible: control.iconName.length > 0 + name: control.iconName + size: control.iconSize + color: control.labelColor + } + } + + contentItem: Text { + text: control.displayText + font: control.font + color: control.labelColor + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + indicator: ShellIcon { + x: control.width - width - 10 + y: (control.height - height) / 2 + name: "chevron-down" + size: control.chevronSize + strokeWidth: 2.25 + color: Theme.color("iconMuted", "#8b8b93") + } + + delegate: ItemDelegate { + id: item + + required property var model + required property int index + + width: ListView.view.width + height: 28 + leftPadding: 8 + rightPadding: 8 + hoverEnabled: true + + contentItem: Text { + text: item.model[control.textRole] ?? item.model.modelData ?? item.model.display ?? "" + font: control.font + color: Theme.color("text", "#e4e4e7") + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + background: Rectangle { + radius: 6 + color: item.highlighted || item.hovered ? Theme.color("accentSurface", "#27272a") : "transparent" + } + } + + popup: Popup { + y: control.height + 4 + width: Math.max(control.width, 160) + implicitHeight: Math.min(contentItem.implicitHeight + 8, 320) + padding: 4 + + enter: Transition { + NumberAnimation { + property: "opacity" + from: 0 + to: 1 + duration: 120 + easing.type: Easing.OutCubic + } + } + + exit: Transition { + NumberAnimation { + property: "opacity" + from: 1 + to: 0 + duration: 90 + } + } + + contentItem: ListView { + clip: true + implicitHeight: contentHeight + model: control.popup.visible ? control.delegateModel : null + currentIndex: control.highlightedIndex + boundsBehavior: Flickable.StopAtBounds + } + + background: Rectangle { + radius: Math.min(Theme.radius, 10) + color: Theme.color("surfaceOverlay", "#18181b") + border.color: Qt.alpha(Theme.color("text", "#e4e4e7"), 0.1) + border.width: 1 + } + } +} diff --git a/apps/desktop-qt/qml/T3/Bricks/ShellErrorOverlay.qml b/apps/desktop-qt/qml/T3/Bricks/ShellErrorOverlay.qml new file mode 100644 index 000000000000..009d6e3d1343 --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/ShellErrorOverlay.qml @@ -0,0 +1,70 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import T3.Shell + +// Shown when the user's shell.qml failed and the built-in shell took over, +// or when the desktop host (the bundled server) went away. +Item { + id: overlay + + readonly property string backendError: Shell.state.backendError ?? "" + readonly property bool hasError: Runtime.lastError.length > 0 || backendError.length > 0 + + visible: hasError + + Rectangle { + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: 16 + width: Math.min(overlay.width - 32, 520) + height: content.implicitHeight + 24 + radius: 8 + color: Theme.color("surfaceOverlay", "#18181b") + border.color: Theme.color("error", "#ef4444") + border.width: 1 + + ColumnLayout { + id: content + + anchors.fill: parent + anchors.margins: 12 + spacing: 8 + + Label { + Layout.fillWidth: true + text: Runtime.lastError.length > 0 + ? (Runtime.usingUserShell ? qsTr("Shell warning") : qsTr("shell.qml failed — using the built-in shell")) + : qsTr("Desktop host stopped") + font.bold: true + color: Theme.color("text", "#e4e4e7") + } + + Label { + Layout.fillWidth: true + text: Runtime.lastError.length > 0 ? Runtime.lastError : overlay.backendError + wrapMode: Text.Wrap + font.family: "monospace" + font.pixelSize: 12 + color: Theme.color("textMuted", "#a1a1aa") + } + + RowLayout { + Layout.fillWidth: true + + Label { + Layout.fillWidth: true + text: Runtime.userShellPath + elide: Text.ElideMiddle + font.pixelSize: 11 + color: Theme.color("textMuted", "#a1a1aa") + } + + ShellButton { + text: qsTr("Reload") + onClicked: Runtime.reload() + } + } + } + } +} diff --git a/apps/desktop-qt/qml/T3/Bricks/ShellIcon.qml b/apps/desktop-qt/qml/T3/Bricks/ShellIcon.qml new file mode 100644 index 000000000000..d6a89f6ec60c --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/ShellIcon.qml @@ -0,0 +1,40 @@ +import QtQuick +import QtQuick.Shapes +import "js/lucide.js" as Lucide + +// A lucide icon drawn as a stroke, so it scales and tints like the page's +// SVG icons. `name` is the lucide id ("panel-left"); see js/lucide.js. +Item { + id: icon + + property string name: "" + property real size: 16 + property color color: "#e4e4e7" + property real strokeWidth: 2 + + implicitWidth: size + implicitHeight: size + Accessible.ignored: true + + Shape { + width: 24 + height: 24 + preferredRendererType: Shape.CurveRenderer + transform: Scale { + xScale: icon.size / 24 + yScale: icon.size / 24 + } + + ShapePath { + strokeColor: icon.color + strokeWidth: icon.strokeWidth + fillColor: "transparent" + capStyle: ShapePath.RoundCap + joinStyle: ShapePath.RoundJoin + + PathSvg { + path: Lucide.path(icon.name) + } + } + } +} diff --git a/apps/desktop-qt/qml/T3/Bricks/ShellMenu.qml b/apps/desktop-qt/qml/T3/Bricks/ShellMenu.qml new file mode 100644 index 000000000000..c8bbe86ff33d --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/ShellMenu.qml @@ -0,0 +1,37 @@ +import QtQuick +import QtQuick.Controls.Basic +import T3.Shell + +// A popup menu in the page's clothes; use ShellMenuItem for entries. +Menu { + id: control + + padding: 4 + implicitWidth: 200 + + enter: Transition { + NumberAnimation { + property: "opacity" + from: 0 + to: 1 + duration: 120 + easing.type: Easing.OutCubic + } + } + + exit: Transition { + NumberAnimation { + property: "opacity" + from: 1 + to: 0 + duration: 90 + } + } + + background: Rectangle { + radius: Math.min(Theme.radius, 10) + color: Theme.color("surfaceOverlay", "#18181b") + border.color: Qt.alpha(Theme.color("text", "#e4e4e7"), 0.1) + border.width: 1 + } +} diff --git a/apps/desktop-qt/qml/T3/Bricks/ShellMenuItem.qml b/apps/desktop-qt/qml/T3/Bricks/ShellMenuItem.qml new file mode 100644 index 000000000000..3c48cfe402d7 --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/ShellMenuItem.qml @@ -0,0 +1,55 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import T3.Shell + +MenuItem { + id: control + + property bool destructive: false + property string iconName: "" + // A trailing check mark, for menus that show the current choice. + property bool current: false + + implicitHeight: 28 + leftPadding: 8 + rightPadding: 8 + font.family: Theme.fontUi.length > 0 ? Theme.fontUi : Qt.application.font.family + font.pixelSize: 14 + hoverEnabled: true + + contentItem: RowLayout { + spacing: 8 + + ShellIcon { + visible: control.iconName.length > 0 + name: control.iconName + size: 16 + color: Qt.alpha(Theme.color("textMuted", "#8b8b93"), 0.8) + Layout.alignment: Qt.AlignVCenter + } + + Text { + text: control.text + font: control.font + color: control.destructive ? Theme.color("error", "#ef4444") : Theme.color("text", "#e4e4e7") + opacity: control.enabled ? 1 : 0.5 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + Layout.fillWidth: true + } + + ShellIcon { + visible: control.current + name: "check" + size: 14 + color: Theme.color("text", "#e4e4e7") + Layout.alignment: Qt.AlignVCenter + } + } + + background: Rectangle { + radius: 6 + color: control.highlighted || control.hovered ? Theme.color("accentSurface", "#27272a") : "transparent" + } +} diff --git a/apps/desktop-qt/qml/T3/Bricks/ShellSplitButton.qml b/apps/desktop-qt/qml/T3/Bricks/ShellSplitButton.qml new file mode 100644 index 000000000000..4e9aebd90838 --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/ShellSplitButton.qml @@ -0,0 +1,81 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import T3.Shell + +// The header's welded pill: an outline action with a chevron half that opens +// a menu. Attach the menu as a child and open it from `menuRequested`. +Item { + id: split + + property string text: "" + property string iconName: "" + property bool actionEnabled: true + property bool menuEnabled: true + property string toolTip: "" + property real maximumTextWidth: 200 + // Icon only, for narrow header strips. + property bool compact: false + + signal clicked + signal menuRequested + + implicitHeight: 24 + implicitWidth: row.implicitWidth + opacity: enabled ? 1 : 0.64 + + Rectangle { + anchors.fill: parent + radius: Math.min(Theme.radius, 8) + color: Qt.alpha(Theme.color("input", "#27272a"), 0.32) + border.color: Theme.color("input", "#27272a") + border.width: 1 + } + + RowLayout { + id: row + + anchors.fill: parent + spacing: 0 + + ShellButton { + id: action + + Layout.fillHeight: true + Layout.maximumWidth: split.maximumTextWidth + subtle: true + radius: 7 + leftPadding: 7 + rightPadding: 7 + font.pixelSize: 12 + iconName: split.iconName + enabled: split.actionEnabled + text: split.compact ? "" : split.text + Accessible.name: split.text + ToolTip.visible: hovered && (split.toolTip.length > 0 || split.compact) + ToolTip.text: split.toolTip.length > 0 ? split.toolTip : split.text + ToolTip.delay: 400 + onClicked: split.clicked() + } + + Rectangle { + Layout.fillHeight: true + Layout.topMargin: 1 + Layout.bottomMargin: 1 + implicitWidth: 1 + color: Theme.color("input", "#27272a") + } + + ShellButton { + Layout.fillHeight: true + Layout.preferredWidth: 24 + subtle: true + radius: 7 + chevron: true + chevronSize: 16 + enabled: split.menuEnabled + Accessible.name: qsTr("More options") + onClicked: split.menuRequested() + } + } +} diff --git a/apps/desktop-qt/qml/T3/Bricks/ShellTextField.qml b/apps/desktop-qt/qml/T3/Bricks/ShellTextField.qml new file mode 100644 index 000000000000..afc9ff20defd --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/ShellTextField.qml @@ -0,0 +1,25 @@ +import QtQuick +import QtQuick.Controls.Basic +import T3.Shell + +// A text input in the page's clothes. +TextField { + id: control + + implicitHeight: 30 + leftPadding: 10 + rightPadding: 10 + font.family: Theme.fontUi.length > 0 ? Theme.fontUi : Qt.application.font.family + font.pixelSize: 13 + color: Theme.color("text", "#e4e4e7") + placeholderTextColor: Theme.color("placeholder", "#71717a") + selectionColor: Theme.color("accent", "#2563eb") + selectedTextColor: Theme.color("accentForeground", "#ffffff") + + background: Rectangle { + radius: Math.min(Theme.radius, control.height / 2) + color: Theme.color("input", "#18181b") + border.color: control.activeFocus ? Theme.color("focus", "#3b82f6") : Theme.color("border", "#27272a") + border.width: 1 + } +} diff --git a/apps/desktop-qt/qml/T3/Bricks/ShellWindow.qml b/apps/desktop-qt/qml/T3/Bricks/ShellWindow.qml new file mode 100644 index 000000000000..57d186367a6b --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/ShellWindow.qml @@ -0,0 +1,94 @@ +import QtQuick +import T3.Shell + +// The window every rice starts from: theme-driven colour, opacity and frame, +// the shell's own context menus and error overlay, and the page's window +// commands (minimize / maximize / close / move). Children land in the body +// under the overlay, so a broken layout still shows its error. +Window { + id: root + + default property alias content: body.data + + // The page owns both of these (Mod+B collapses, Settings opens); the shell + // only animates and re-arranges around them. + readonly property bool sidebarCollapsed: Shell.state.layout ? Shell.state.layout.sidebarCollapsed : false + readonly property bool settingsActive: Shell.state.settings ? Shell.state.settings.active : false + // The page's keybindings (the configurable ones from Settings), as Qt + // sequences. They fire only while the chrome owns the keyboard: a + // focused page sees its own keydowns and handles them itself. + readonly property var keybindings: Shell.state.keybindings ?? [] + readonly property bool webFocused: isWebItem(root.activeFocusItem) + + function isWebItem(item) { + for (let node = item; node; node = node.parent) { + if (node.objectName === "T3WebSurface") { + return true; + } + } + return false; + } + + width: 1280 + height: 820 + minimumWidth: 640 + minimumHeight: 400 + visible: true + title: qsTr("T3 Code") + color: Theme.windowTransparent ? "transparent" : Theme.color("chrome", "#0b0b0d") + opacity: Theme.windowOpacity + flags: Theme.frameless ? Qt.Window | Qt.FramelessWindowHint : Qt.Window + + Item { + id: body + + anchors.fill: parent + } + + ContextMenuHost { + surfaceId: "shell" + } + + ShellErrorOverlay { + anchors.fill: parent + } + + Instantiator { + model: root.keybindings + + delegate: Shortcut { + required property var modelData + + sequence: modelData.sequence + context: Qt.WindowShortcut + enabled: !root.webFocused + onActivated: Shell.dispatch("keybinding.press", { + key: modelData.key, + ctrlKey: modelData.ctrlKey, + metaKey: modelData.metaKey, + shiftKey: modelData.shiftKey, + altKey: modelData.altKey + }) + } + } + + Connections { + target: Shell + function onWindowCommandRequested(command) { + switch (command) { + case "minimize": + root.showMinimized(); + break; + case "maximize": + root.visibility === Window.Maximized ? root.showNormal() : root.showMaximized(); + break; + case "close": + root.close(); + break; + case "move": + root.startSystemMove(); + break; + } + } + } +} diff --git a/apps/desktop-qt/qml/T3/Bricks/Sidebar.qml b/apps/desktop-qt/qml/T3/Bricks/Sidebar.qml new file mode 100644 index 000000000000..df73a483291f --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/Sidebar.qml @@ -0,0 +1,673 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import T3.Shell + +// The thread sidebar, rendered from the view model the web app publishes +// under Shell.state.sidebar (see packages/contracts/src/shell.ts). Every +// click is dispatched back to the page; nothing here talks to the server. +Rectangle { + id: sidebar + + readonly property var model: Shell.state.sidebar ?? null + // A rice that puts project scope and the app's places elsewhere (an icon + // rail, say) turns these off so the brick is just the thread list. + property bool showScope: true + property bool showFooter: true + // The brand band ("T3 Code" plus the collapse toggle) is what the page + // shows above its sidebar; a rice with its own title bar leaves it off. + // When frameless it doubles as the window's drag handle. + property bool showBrand: false + property Window window: null + readonly property var projects: model ? model.projects : [] + readonly property var projectNames: { + const names = {}; + for (const project of projects) { + names[project.key] = project.displayName; + } + return names; + } + readonly property string scopeLabel: { + if (!model || model.scopeProjectKey === null) { + return qsTr("All projects"); + } + return projectNames[model.scopeProjectKey] ?? qsTr("All projects"); + } + property var collapsed: ({}) + readonly property var rows: buildRows(model, collapsed) + readonly property color foreground: Theme.color("sidebarForeground", "#e4e4e7") + readonly property color muted: Theme.color("sidebarMutedForeground", "#8b8b93") + readonly property color iconColor: Theme.color("iconMuted", "#8b8b93") + readonly property color hairline: Theme.color("sidebarBorder", "#27272a") + + // Update rows in place so live publications keep hover, focus and scroll. + ListModel { + id: rowModel + dynamicRoles: true + } + + function syncRows() { + for (let index = 0; index < rows.length; ++index) { + const data = rows[index]; + const key = data.rowKey ?? data.kind; + let existing = index; + while (existing < rowModel.count && rowModel.get(existing).stableKey !== key) { + existing += 1; + } + if (existing === rowModel.count) { + rowModel.insert(index, { + stableKey: key, + rowData: data + }); + } else { + if (existing !== index) { + rowModel.move(existing, index, 1); + } + rowModel.setProperty(index, "rowData", data); + } + } + if (rowModel.count > rows.length) { + rowModel.remove(rows.length, rowModel.count - rows.length); + } + } + + onRowsChanged: syncRows() + Component.onCompleted: syncRows() + + implicitWidth: 256 + color: Theme.color("sidebar", "#0a0a0a") + // Content keeps its width while the shell animates ours. + clip: true + + function toggleSection(key) { + const next = Object.assign({}, collapsed); + next[key] = !next[key]; + collapsed = next; + } + + function buildRows(state, folded) { + if (!state) { + return []; + } + const out = []; + for (const draft of state.drafts) { + out.push({ + kind: "draft", + section: "draft", + rowKey: "draft:" + draft.draftId, + item: draft + }); + } + for (const item of state.pinned) { + out.push({ + kind: "thread", + section: "pinned", + rowKey: item.key, + item: item + }); + } + if (state.pinned.length > 0 && state.active.length > 0) { + out.push({ + kind: "divider" + }); + } + for (const item of state.active) { + out.push({ + kind: "thread", + section: "active", + rowKey: item.key, + item: item + }); + } + const section = (key, label, items) => { + if (items.length === 0) { + return; + } + const open = !folded[key]; + out.push({ + kind: "header", + key: key, + rowKey: "header:" + key, + label: label, + count: items.length, + open: open + }); + if (!open) { + return; + } + for (const item of items) { + out.push({ + kind: "slim", + section: key, + rowKey: item.key, + item: item + }); + } + }; + section("snoozed", qsTr("Snoozed"), state.snoozed); + section("settled", qsTr("Settled"), state.settled); + if (!folded.settled && state.settledTotal > state.settled.length) { + out.push({ + kind: "note", + label: qsTr("%1 more settled in the app").arg(state.settledTotal - state.settled.length) + }); + } + return out; + } + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: Math.min(0, sidebar.width - sidebar.implicitWidth) + spacing: 0 + + // Brand band: sidebar toggle and wordmark. + Item { + Layout.fillWidth: true + Layout.preferredHeight: 52 + visible: sidebar.showBrand + + DragHandler { + enabled: sidebar.window !== null && Theme.frameless + target: null + grabPermissions: PointerHandler.CanTakeOverFromAnything + onActiveChanged: if (active) + sidebar.window.startSystemMove() + } + + ShellButton { + x: 12 + y: 12 + subtle: true + implicitHeight: 28 + iconName: "panel-left-close" + iconSize: 16 + iconTint: sidebar.iconColor + Accessible.name: qsTr("Hide sidebar") + onClicked: Shell.dispatch("sidebar.toggle") + } + + Row { + x: 52 + anchors.verticalCenter: parent.verticalCenter + spacing: 6 + + T3Wordmark { + size: 10 + color: sidebar.foreground + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: qsTr("Code") + color: sidebar.muted + font.pixelSize: 14 + font.weight: Font.Medium + font.family: Theme.fontUi.length > 0 ? Theme.fontUi : Qt.application.font.family + anchors.verticalCenter: parent.verticalCenter + } + } + } + + // Search + new thread, scope + add project. + ColumnLayout { + Layout.fillWidth: true + Layout.margins: 8 + spacing: 4 + visible: sidebar.showScope + + RowLayout { + Layout.fillWidth: true + spacing: 4 + + ShellButton { + Layout.fillWidth: true + implicitHeight: 32 + subtle: true + iconName: "search" + iconSize: 16 + iconTint: Qt.alpha(sidebar.muted, 0.8) + tint: sidebar.foreground + text: qsTr("Search") + font.pixelSize: 14 + onClicked: Shell.dispatch("palette.open") + + background: Rectangle { + radius: 8 + color: parent.hovered || parent.down ? Theme.color("sidebarRowHover", "#1c1c21") : Theme.color("sidebarControlSurface", "#141416") + + Behavior on color { + ColorAnimation { + duration: 120 + } + } + } + } + + ShellButton { + subtle: true + implicitHeight: 32 + iconName: "square-pen" + iconSize: 16 + iconTint: sidebar.iconColor + Accessible.name: qsTr("New thread") + onClicked: Shell.dispatch("thread.new", sidebar.model && sidebar.model.scopeProjectKey !== null ? { + projectKey: sidebar.model.scopeProjectKey + } : {}) + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 4 + + ShellButton { + id: scopeButton + + Layout.fillWidth: true + implicitHeight: 32 + leftPadding: 9 + subtle: true + chevron: true + chevronSize: 16 + iconName: "folder" + iconSize: 16 + iconTint: Qt.alpha(sidebar.muted, 0.8) + tint: Qt.alpha(sidebar.muted, 0.8) + text: sidebar.scopeLabel + font.pixelSize: 14 + Accessible.name: qsTr("Project scope") + onClicked: scopeMenu.open() + + ShellMenu { + id: scopeMenu + + y: parent.height + 4 + width: Math.max(parent.width, 200) + + ShellMenuItem { + text: qsTr("All projects") + current: sidebar.model !== null && sidebar.model.scopeProjectKey === null + onTriggered: Shell.dispatch("sidebar.scope", { + projectKey: null + }) + } + + Instantiator { + model: sidebar.projects + + delegate: ShellMenuItem { + required property var modelData + + text: modelData.displayName + iconName: "folder" + current: sidebar.model !== null && sidebar.model.scopeProjectKey === modelData.key + onTriggered: Shell.dispatch("sidebar.scope", { + projectKey: modelData.key + }) + } + + onObjectAdded: (index, object) => scopeMenu.insertItem(index + 1, object) + onObjectRemoved: (index, object) => scopeMenu.removeItem(object) + } + } + } + + ShellButton { + subtle: true + implicitHeight: 32 + iconName: "folder-plus" + iconSize: 16 + iconTint: sidebar.iconColor + Accessible.name: qsTr("Add project") + onClicked: Shell.dispatch("project.add") + } + } + } + + ListView { + id: list + objectName: "list" + + // The keyboard cursor, by row key so it survives the list being + // rebuilt around it. Up/Down/Home/End move it over the rows that + // can be acted on, Enter opens (or folds) the row, Menu or + // Shift+F10 opens its context menu. + property string cursorKey: "" + readonly property int cursorIndex: sidebar.rows.findIndex(r => r.rowKey !== undefined && r.rowKey === list.cursorKey) + + function moveCursor(delta) { + let i = cursorIndex; + do { + i += delta; + if (i < 0 || i >= sidebar.rows.length) { + return; + } + } while (sidebar.rows[i].rowKey === undefined); + cursorKey = sidebar.rows[i].rowKey; + positionViewAtIndex(i, ListView.Contain); + } + + function moveCursorToEdge(delta) { + const rows = sidebar.rows; + for (let i = delta > 0 ? 0 : rows.length - 1; i >= 0 && i < rows.length; i += delta) { + if (rows[i].rowKey !== undefined) { + cursorKey = rows[i].rowKey; + positionViewAtIndex(i, ListView.Contain); + return; + } + } + } + + function settleCursor() { + if (cursorIndex >= 0) { + return; + } + const current = sidebar.rows.find(r => r.rowKey !== undefined && sidebar.model !== null && (r.kind === "draft" ? r.item.draftId === sidebar.model.activeDraftId : r.kind !== "header" && r.item.key === sidebar.model.activeThreadKey)); + const first = current ?? sidebar.rows.find(r => r.rowKey !== undefined); + cursorKey = first ? first.rowKey : ""; + } + + function activateCursor() { + const row = sidebar.rows[cursorIndex]; + if (!row) { + return; + } + switch (row.kind) { + case "header": + sidebar.toggleSection(row.key); + break; + case "draft": + Shell.dispatch("draft.open", { + draftId: row.item.draftId + }); + break; + default: + Shell.dispatch("thread.open", { + key: row.item.key + }); + } + } + + function menuAtCursor() { + const row = sidebar.rows[cursorIndex]; + const item = itemAtIndex(cursorIndex); + if (!row || !item || row.kind === "header" || row.kind === "draft") { + return; + } + const p = item.mapToItem(null, item.width / 2, item.height / 2); + Shell.dispatch("thread.menu", { + key: row.item.key, + x: p.x, + y: p.y + }); + } + + Layout.fillWidth: true + Layout.fillHeight: true + Layout.leftMargin: 9 + Layout.rightMargin: 8 + Layout.bottomMargin: 4 + clip: true + model: rowModel + reuseItems: true + spacing: 1 + boundsBehavior: Flickable.StopAtBounds + activeFocusOnTab: true + keyNavigationEnabled: false + Accessible.role: Accessible.List + Accessible.name: qsTr("Threads") + onActiveFocusChanged: { + if (activeFocus) { + settleCursor(); + } + } + Keys.onUpPressed: moveCursor(-1) + Keys.onDownPressed: moveCursor(1) + Keys.onPressed: event => { + switch (event.key) { + case Qt.Key_Home: + moveCursorToEdge(1); + break; + case Qt.Key_End: + moveCursorToEdge(-1); + break; + case Qt.Key_Return: + case Qt.Key_Enter: + case Qt.Key_Space: + activateCursor(); + break; + case Qt.Key_Menu: + menuAtCursor(); + break; + case Qt.Key_F10: + if (!(event.modifiers & Qt.ShiftModifier)) { + return; + } + menuAtCursor(); + break; + default: + return; + } + event.accepted = true; + } + + delegate: Item { + id: entry + + required property var rowData + readonly property var modelData: rowData + + readonly property string kind: modelData.kind + readonly property bool focused: list.activeFocus && modelData.rowKey !== undefined && modelData.rowKey === list.cursorKey + + width: ListView.view.width + implicitHeight: kind === "header" ? 36 : kind === "divider" ? 13 : kind === "note" ? 28 : kind === "slim" ? 36 : 82 + + // Collapsible section header with a hairline (settled) or tint (snoozed). + Item { + anchors.fill: parent + visible: entry.kind === "header" + + Rectangle { + anchors.fill: parent + radius: 8 + color: "transparent" + border.width: 1 + border.color: Theme.color("focus", "#3b82f6") + visible: entry.focused + } + + HoverHandler { + id: headerHover + } + + TapHandler { + onTapped: sidebar.toggleSection(entry.modelData.key) + } + + RowLayout { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: 10 + anchors.rightMargin: 10 + anchors.bottomMargin: 4 + height: 20 + spacing: 8 + + Text { + text: entry.kind === "header" ? entry.modelData.label : "" + color: entry.kind === "header" && entry.modelData.key === "snoozed" ? Theme.color("info", "#60a5fa") : Qt.alpha(sidebar.muted, headerHover.hovered ? 0.8 : 0.5) + font.pixelSize: 12 + font.weight: Font.Medium + font.family: Theme.fontUi.length > 0 ? Theme.fontUi : Qt.application.font.family + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 1 + color: Qt.alpha(sidebar.hairline, 0.6) + visible: entry.kind === "header" && entry.modelData.key === "settled" + } + + Item { + Layout.fillWidth: true + visible: !(entry.kind === "header" && entry.modelData.key === "settled") + } + + Text { + visible: entry.kind === "header" && !entry.modelData.open + text: entry.kind === "header" ? entry.modelData.count : "" + color: Qt.alpha(sidebar.muted, 0.5) + font.pixelSize: 12 + font.family: Theme.fontUi.length > 0 ? Theme.fontUi : Qt.application.font.family + } + + ShellIcon { + name: "chevron-down" + size: 12 + color: Qt.alpha(sidebar.muted, 0.5) + rotation: entry.kind === "header" && entry.modelData.open ? 0 : -90 + Layout.alignment: Qt.AlignVCenter + + Behavior on rotation { + NumberAnimation { + duration: 150 + easing.type: Easing.OutCubic + } + } + } + } + } + + Rectangle { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 10 + anchors.rightMargin: 10 + height: 1 + visible: entry.kind === "divider" + color: Qt.alpha(sidebar.hairline, 0.6) + } + + Text { + anchors.centerIn: parent + visible: entry.kind === "note" + text: entry.kind === "note" ? entry.modelData.label : "" + color: Qt.alpha(sidebar.muted, 0.6) + font.pixelSize: 12 + font.family: Theme.fontUi.length > 0 ? Theme.fontUi : Qt.application.font.family + } + + Loader { + anchors.fill: parent + anchors.topMargin: entry.kind === "thread" ? 2 : 0 + anchors.bottomMargin: entry.kind === "thread" ? 2 : 0 + active: entry.kind === "thread" || entry.kind === "slim" || entry.kind === "draft" + + sourceComponent: SidebarThreadRow { + objectName: "threadRow:" + entry.modelData.rowKey + slim: entry.kind !== "thread" + section: entry.modelData.section + focused: entry.focused + item: entry.kind === "draft" ? { + title: entry.modelData.item.label, + status: "ready", + statusLabel: null, + unread: false, + branch: null, + updatedAt: null + } : entry.modelData.item + projectName: sidebar.projectNames[entry.modelData.item.projectKey] ?? "" + active: sidebar.model !== null && (entry.kind === "draft" ? entry.modelData.item.draftId === sidebar.model.activeDraftId : entry.modelData.item.key === sidebar.model.activeThreadKey) + onActivated: { + list.cursorKey = entry.modelData.rowKey; + if (entry.kind === "draft") { + Shell.dispatch("draft.open", { + draftId: entry.modelData.item.draftId + }); + } else { + Shell.dispatch("thread.open", { + key: entry.modelData.item.key + }); + } + } + onMenuRequested: (windowX, windowY) => { + if (entry.kind !== "draft") { + Shell.dispatch("thread.menu", { + key: entry.modelData.item.key, + x: windowX, + y: windowY + }); + } + } + onSettleRequested: Shell.dispatch("thread.settle", { + key: entry.modelData.item.key + }) + onUnsettleRequested: Shell.dispatch("thread.unsettle", { + key: entry.modelData.item.key + }) + onUnsnoozeRequested: Shell.dispatch("thread.unsnooze", { + key: entry.modelData.item.key + }) + onSnoozeRequested: (windowX, windowY) => Shell.dispatch("thread.snoozeMenu", { + key: entry.modelData.item.key, + x: windowX, + y: windowY + }) + onWokeDismissed: Shell.dispatch("thread.wokeDismiss", { + key: entry.modelData.item.key + }) + } + } + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + y: 24 + visible: list.count === 0 + text: sidebar.model === null ? qsTr("Waiting for the app…") : sidebar.projects.length === 0 ? qsTr("No projects yet") : qsTr("No threads yet") + color: Qt.alpha(sidebar.muted, 0.6) + font.pixelSize: 12 + font.family: Theme.fontUi.length > 0 ? Theme.fontUi : Qt.application.font.family + } + } + + RowLayout { + Layout.fillWidth: true + Layout.margins: 8 + spacing: 4 + visible: sidebar.showFooter + + FooterButton { + iconName: "settings" + Accessible.name: qsTr("Settings") + onClicked: Shell.dispatch("settings.open") + } + + FooterButton { + iconName: "git-pull-request" + Accessible.name: qsTr("Pull requests") + onClicked: Shell.dispatch("pullRequests.open") + } + + FooterButton { + iconName: "chart-no-axes-column" + Accessible.name: qsTr("Usage") + onClicked: Shell.dispatch("usage.open") + } + + Item { + Layout.fillWidth: true + } + } + } + + component FooterButton: ShellButton { + subtle: true + implicitHeight: 32 + iconSize: 16 + iconTint: sidebar.iconColor + } +} diff --git a/apps/desktop-qt/qml/T3/Bricks/SidebarThreadRow.qml b/apps/desktop-qt/qml/T3/Bricks/SidebarThreadRow.qml new file mode 100644 index 000000000000..f676c3c3d284 --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/SidebarThreadRow.qml @@ -0,0 +1,391 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import T3.Shell + +// One thread (or draft) row of the Sidebar brick: a card for pinned and +// active threads (project, status or age, title, branch), a slim line for +// snoozed and settled ones. Hovering or focusing the row swaps the status +// slot for the section's actions (snooze and settle, wake, un-settle), the +// same ones the page's sidebar shows on hover. +Item { + id: row + + required property var item + required property bool active + property bool slim: false + property string projectName: "" + // Which sidebar group the row sits in: pinned, active, snoozed, settled + // or draft. Decides the hover actions. + property string section: "active" + // Keyboard cursor: draws the focus ring and shows the actions. + property bool focused: false + property double ageNow: Date.now() + + signal activated + signal menuRequested(real windowX, real windowY) + signal settleRequested + signal unsettleRequested + signal snoozeRequested(real windowX, real windowY) + signal unsnoozeRequested + signal wokeDismissed + + readonly property color textColor: Theme.color("sidebarForeground", "#e4e4e7") + readonly property color secondaryColor: Theme.color("secondaryLabel", "#8b8b93") + // Themes may colour the project and branch lines and mark the active + // row with a bar; without those roles the row stays monochrome. + readonly property color projectColor: Theme.color("projectForeground", secondaryColor) + readonly property color branchColor: Theme.color("branchForeground", secondaryColor) + readonly property color indicatorColor: Theme.color("sidebarActiveIndicator", "transparent") + readonly property color focusColor: Theme.color("focus", "#3b82f6") + readonly property bool draft: section === "draft" + readonly property bool woke: item.wokeAt !== null && item.wokeAt !== undefined + readonly property bool parked: section === "snoozed" || section === "settled" + readonly property bool canSettle: !draft && !parked && item.canSettle === true + readonly property bool canSnooze: !draft && !parked && item.canSnooze === true + readonly property bool hasActions: parked || canSettle || canSnooze + readonly property bool showActions: hasActions && (hover.hovered || focused) + // The status word the page's sidebar uses for each state; empty when the + // row is at rest, then the slot shows the age (or the wake time). + readonly property string statusWord: { + switch (item.status) { + case "working": + return qsTr("Working"); + case "monitoring": + return qsTr("Monitoring"); + case "approval": + return qsTr("Approval"); + case "input": + return qsTr("Input"); + case "failed": + return qsTr("Failed"); + } + if (row.woke) { + return qsTr("Woke"); + } + if (item.unread === true) { + return qsTr("Done"); + } + return ""; + } + readonly property string statusIcon: { + switch (item.status) { + case "working": + return "circle-dashed"; + case "failed": + return "circle-x"; + } + if (row.woke) { + return "alarm-clock"; + } + if (item.unread === true) { + return "circle-check"; + } + return ""; + } + readonly property color statusColor: { + switch (item.status) { + case "approval": + return Theme.color("warning", "#f59e0b"); + case "input": + return Theme.color("accent", "#818cf8"); + case "working": + case "monitoring": + return Theme.color("info", "#38bdf8"); + case "failed": + return Theme.color("error", "#f87171"); + } + if (row.woke) { + return Theme.color("warning", "#f59e0b"); + } + if (item.unread === true) { + return Theme.color("success", "#34d399"); + } + return row.secondaryColor; + } + readonly property bool showStatus: statusWord.length > 0 + // In-flight and read-ready rows recede: prominence is for rows that need + // a human (done, failed, woke) and the one that is open. + readonly property bool recedes: !active && !woke && item.unread !== true && item.status !== "failed" + readonly property string ageLabel: item.wakeLabel ? item.wakeLabel : relativeAge(item.updatedAt, ageNow) + + function relativeAge(iso, now) { + if (!iso) { + return ""; + } + const seconds = Math.max(0, (now - Date.parse(iso)) / 1000); + if (seconds < 60) { + return qsTr("now"); + } + if (seconds < 3600) { + return qsTr("%1m").arg(Math.floor(seconds / 60)); + } + if (seconds < 86400) { + return qsTr("%1h").arg(Math.floor(seconds / 3600)); + } + if (seconds < 86400 * 30) { + return qsTr("%1d").arg(Math.floor(seconds / 86400)); + } + return qsTr("%1mo").arg(Math.floor(seconds / (86400 * 30))); + } + + Timer { + id: ageRefreshTimer + objectName: "ageRefreshTimer" + + interval: 60000 + repeat: true + running: row.visible && !row.item.wakeLabel + onTriggered: row.ageNow = Date.now() + } + + onItemChanged: ageNow = Date.now() + + function requestSnooze(button) { + const p = button.mapToItem(null, 0, button.height); + row.snoozeRequested(p.x, p.y); + } + + implicitHeight: slim ? 36 : 78 + Accessible.role: Accessible.ListItem + Accessible.name: item.title + + Rectangle { + id: rowBackground + objectName: "rowBackground" + anchors.fill: parent + radius: 8 + // Fade alpha without interpolating through black on light themes. + readonly property color hoverColor: Theme.color("sidebarRowHover", "#1c1c21") + color: row.active ? Theme.color("sidebarRowActive", "#2a2a30") : Qt.alpha(hoverColor, hover.hovered ? hoverColor.a : 0) + border.width: row.focused ? 1 : 0 + border.color: row.focusColor + + Behavior on color { + ColorAnimation { + duration: 120 + } + } + } + + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.topMargin: 8 + anchors.bottomMargin: 8 + width: 3 + radius: 1.5 + color: row.indicatorColor + visible: row.active && row.indicatorColor.a > 0 + } + + HoverHandler { + id: hover + } + + TapHandler { + acceptedButtons: Qt.LeftButton + onTapped: row.activated() + } + + // The menu opens on press, anywhere on the row, like the page's. + TapHandler { + acceptedButtons: Qt.RightButton + gesturePolicy: TapHandler.WithinBounds + onPressedChanged: { + if (pressed) { + row.menuRequested(point.scenePosition.x, point.scenePosition.y); + } + } + } + + // Row actions never take focus: the keyboard cursor stays on the list + // and reaches them through the context menu. + component RowAction: ShellButton { + subtle: true + focusPolicy: Qt.NoFocus + implicitHeight: 22 + implicitWidth: 22 + iconSize: 14 + iconTint: row.secondaryColor + } + + // The right-hand slot: the section's actions while hovered or focused, + // the woke pill (click acknowledges the wake), else the status or age. + component StatusSlot: RowLayout { + spacing: 2 + + RowAction { + id: snoozeButton + + visible: row.showActions && row.canSnooze + iconName: "clock" + Accessible.name: qsTr("Snooze") + onClicked: row.requestSnooze(snoozeButton) + } + + RowAction { + visible: row.showActions && row.canSettle + iconName: "check" + Accessible.name: qsTr("Settle") + onClicked: row.settleRequested() + } + + RowAction { + visible: row.showActions && row.section === "snoozed" + iconName: "alarm-clock-off" + Accessible.name: qsTr("Wake") + onClicked: row.unsnoozeRequested() + } + + RowAction { + visible: row.showActions && row.section === "settled" + iconName: "undo-2" + Accessible.name: qsTr("Un-settle") + onClicked: row.unsettleRequested() + } + + ShellButton { + visible: row.woke && !row.showActions + subtle: true + focusPolicy: Qt.NoFocus + implicitHeight: 22 + leftPadding: 6 + rightPadding: 6 + iconName: "alarm-clock" + iconSize: 12 + font.pixelSize: 12 + text: qsTr("Woke") + tint: row.statusColor + iconTint: row.statusColor + Accessible.name: qsTr("Dismiss woke") + onClicked: row.wokeDismissed() + } + + ShellIcon { + visible: !row.showActions && !row.woke && row.showStatus && row.statusIcon.length > 0 + name: row.statusIcon + size: 14 + color: row.statusColor + Layout.alignment: Qt.AlignVCenter + } + + Text { + visible: !row.showActions && !row.woke + text: row.showStatus ? row.statusWord : row.ageLabel + color: row.showStatus ? row.statusColor : row.secondaryColor + font.pixelSize: 12 + font.weight: Font.Medium + font.family: Theme.fontUi.length > 0 ? Theme.fontUi : Qt.application.font.family + } + } + + // Slim line: dimmed folder, title, status or age. + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 8 + spacing: 10 + visible: row.slim + + ShellIcon { + name: "folder" + size: 16 + color: row.secondaryColor + opacity: hover.hovered || row.focused ? 1 : 0.4 + Layout.alignment: Qt.AlignVCenter + + Behavior on opacity { + NumberAnimation { + duration: 120 + } + } + } + + Text { + Layout.fillWidth: true + text: row.item.title + color: Qt.alpha(row.secondaryColor, 0.7) + font.pixelSize: 14 + font.family: Theme.fontUi.length > 0 ? Theme.fontUi : Qt.application.font.family + elide: Text.ElideRight + } + + StatusSlot { + Layout.alignment: Qt.AlignVCenter + } + } + + // Card: project line, title, branch. + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 8 + anchors.topMargin: 8 + anchors.bottomMargin: 8 + spacing: 0 + visible: !row.slim + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 22 + spacing: 6 + + ShellIcon { + name: "folder" + size: 16 + color: row.projectColor + Layout.alignment: Qt.AlignVCenter + } + + Text { + Layout.fillWidth: true + text: row.projectName + color: row.projectColor + font.pixelSize: 12 + font.weight: Font.Medium + font.family: Theme.fontUi.length > 0 ? Theme.fontUi : Qt.application.font.family + elide: Text.ElideRight + } + + StatusSlot { + Layout.alignment: Qt.AlignVCenter + } + } + + Text { + Layout.fillWidth: true + Layout.topMargin: 2 + text: row.item.title + color: row.recedes ? Qt.alpha(row.textColor, 0.72) : row.textColor + font.pixelSize: 14 + font.weight: Font.Medium + font.family: Theme.fontUi.length > 0 ? Theme.fontUi : Qt.application.font.family + elide: Text.ElideRight + } + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 2 + spacing: 6 + + ShellIcon { + visible: row.item.branch !== null && row.item.branch !== undefined + name: "git-branch" + size: 12 + color: row.branchColor + Layout.alignment: Qt.AlignVCenter + } + + Text { + Layout.fillWidth: true + text: row.item.branch ?? "" + color: row.branchColor + font.pixelSize: 12 + font.family: Theme.fontUi.length > 0 ? Theme.fontUi : Qt.application.font.family + elide: Text.ElideRight + } + } + } +} diff --git a/apps/desktop-qt/qml/T3/Bricks/T3Wordmark.qml b/apps/desktop-qt/qml/T3/Bricks/T3Wordmark.qml new file mode 100644 index 000000000000..466cfaf7dab8 --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/T3Wordmark.qml @@ -0,0 +1,37 @@ +import QtQuick +import QtQuick.Shapes + +// The "T3" mark from the web app's wordmark (apps/web/src/components/ +// T3Wordmark.tsx), the same path filled as a Shape so it scales and tints +// like the page's SVG. `size` is its height; the width follows the mark. +Item { + id: mark + + property real size: 10 + property color color: "#e4e4e7" + readonly property real scale: size / 56.96 + + implicitWidth: 94.3941 * scale + implicitHeight: size + Accessible.ignored: true + + Shape { + // The path keeps the web's viewBox coordinates; shift its origin out. + x: -15.5309 * mark.scale + y: -37 * mark.scale + preferredRendererType: Shape.CurveRenderer + transform: Scale { + xScale: mark.scale + yScale: mark.scale + } + + ShapePath { + strokeWidth: -1 + fillColor: mark.color + + PathSvg { + path: "M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z" + } + } + } +} diff --git a/apps/desktop-qt/qml/T3/Bricks/TerminalDrawer.qml b/apps/desktop-qt/qml/T3/Bricks/TerminalDrawer.qml new file mode 100644 index 000000000000..c1cb2d656e66 --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/TerminalDrawer.qml @@ -0,0 +1,131 @@ +import QtQuick +import T3.Shell + +// The thread's terminal drawer: the page's own drawer, loaded as the embed +// route (`?surface=terminal`) in a web surface the shell places, so it can +// sit under the Composer the way the page keeps it under its own composer. +// Open flag, height and route come from Shell.state.workspace; dragging the +// top edge hands the height back with terminal.resize, and the page persists +// it with the drawer's other state. Not animated: every frame of it would +// relayout the web surface above (see RightPanel). +Item { + id: drawer + + readonly property var model: Shell.state.workspace ?? null + readonly property bool available: model !== null && model.terminalAvailable === true + readonly property bool open: available && model.terminalOpen === true + readonly property int minimumHeight: 180 + readonly property int pageHeight: available && typeof model.terminalHeight === "number" ? model.terminalHeight : 280 + readonly property url embedUrl: { + if (!available) { + return ""; + } + const page = Shell.pageUrl.toString(); + const origin = page.match(/^(https?:\/\/[^/]+)/); + return origin ? origin[1] + model.terminalEmbedPath : ""; + } + + // The web surface's corner radius, for a rice that cards the drawer. + property real radius: 0 + + // The height while the edge is dragged and until the page publishes it + // back; -1 when the page's height is the one shown. + property int localHeight: -1 + + // The page clamps the same way: never shorter than a few rows, never + // more than three quarters of the window. + function clampHeight(height) { + const ceiling = Math.max(minimumHeight, Math.floor(drawer.Window.height * 0.75)); + return Math.min(Math.max(Math.round(height), minimumHeight), ceiling); + } + + implicitHeight: open ? clampHeight(localHeight >= 0 ? localHeight : pageHeight) : 0 + visible: open + + onModelChanged: { + if (!edgeDrag.active) { + localHeight = -1; + } + } + + // The hairline over the drawer; a carded drawer has the card's edge instead. + Rectangle { + id: edgeLine + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: drawer.radius > 0 ? 0 : 1 + visible: height > 0 + color: Theme.color("border", "#27272a") + } + + Loader { + id: body + + readonly property bool wanted: drawer.open && drawer.embedUrl.toString().length > 0 + + anchors.top: edgeLine.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + // Created on first open and kept: the document holds the terminals. + active: false + onWantedChanged: { + if (wanted) { + active = true; + } + } + Component.onCompleted: { + if (wanted) { + active = true; + } + } + + sourceComponent: WebSurface { + surfaceId: "terminal" + sleepsWhenHidden: true + radius: drawer.radius + Component.onCompleted: url = drawer.embedUrl + } + } + + // The drag edge sits over the top of the web surface, like the page's + // own handle does. + Item { + id: edge + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: 6 + + HoverHandler { + cursorShape: Qt.SplitVCursor + } + + DragHandler { + id: edgeDrag + + property int startHeight: 0 + + target: null + xAxis.enabled: false + onActiveChanged: { + if (active) { + startHeight = drawer.height; + drawer.localHeight = startHeight; + } else if (drawer.localHeight !== drawer.clampHeight(drawer.pageHeight)) { + Shell.dispatch("terminal.resize", { + height: drawer.localHeight + }); + } else { + drawer.localHeight = -1; + } + } + onTranslationChanged: { + if (active) { + drawer.localHeight = drawer.clampHeight(startHeight - translation.y); + } + } + } + } +} diff --git a/apps/desktop-qt/qml/T3/Bricks/TitleBar.qml b/apps/desktop-qt/qml/T3/Bricks/TitleBar.qml new file mode 100644 index 000000000000..987cc23cbef8 --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/TitleBar.qml @@ -0,0 +1,57 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import T3.Shell + +// Frameless window chrome for rices that want a separate title row: drag +// anywhere, double-click to maximize, window buttons on the platform's usual +// side. DefaultShell folds these into its header strip instead. +Rectangle { + id: bar + + required property Window window + + readonly property bool controlsOnLeft: Qt.platform.os === "osx" + readonly property color foreground: Theme.color("text", "#e4e4e7") + + implicitHeight: 38 + color: Theme.color("chrome", "#141416") + + DragHandler { + target: null + grabPermissions: PointerHandler.CanTakeOverFromAnything + onActiveChanged: if (active) + bar.window.startSystemMove() + } + + TapHandler { + onDoubleTapped: bar.window.visibility === Window.Maximized ? bar.window.showNormal() : bar.window.showMaximized() + } + + RowLayout { + anchors.fill: parent + spacing: 0 + + WindowControls { + visible: bar.controlsOnLeft + window: bar.window + buttonHeight: bar.implicitHeight + } + + Label { + Layout.fillWidth: true + Layout.leftMargin: 12 + Layout.rightMargin: 12 + text: bar.window.title + color: bar.foreground + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + } + + WindowControls { + visible: !bar.controlsOnLeft + window: bar.window + buttonHeight: bar.implicitHeight + } + } +} diff --git a/apps/desktop-qt/qml/T3/Bricks/WebSurface.qml b/apps/desktop-qt/qml/T3/Bricks/WebSurface.qml new file mode 100644 index 000000000000..ea534ba173de --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/WebSurface.qml @@ -0,0 +1,179 @@ +import QtQuick +import QtWebChannel +import QtWebEngine +import T3.Shell + +// The web app, as-is. Wires the WebChannel (`window.t3Shell` on the page side) +// and pushes theme.json into the page as CSS custom properties: as a user +// script at document creation, so the first paint is already themed, and +// into the live document when the theme changes. +WebEngineView { + id: view + + // Lets the window tell web focus from chrome focus (ShellWindow's + // keybindings step aside while a page owns the keyboard). + objectName: "T3WebSurface" + + // Names this surface for the page (window.t3Shell.surfaceId), so menus it + // opens come back to this view. + property string surfaceId: "primary" + + // Freeze the page (no timers, no rendering) while the surface is hidden. + // Its state stays in memory and it resumes where it was; a document that + // must keep running out of view (the primary page) leaves this off. + property bool sleepsWhenHidden: false + + // The source of the installed theme user script, to skip reinstalling on + // theme signals that leave the page side unchanged. + property string installedThemeScript: "" + + // Rounds the document's corners, for a rice that cards the surface: pass + // the card's inner radius. The page clips itself to the curve and drops + // its own backdrop (index.css, `data-shell-surface-radius`), and the + // view's backdrop goes transparent, so the card shows through the corners. + property real radius: 0 + + backgroundColor: Theme.windowTransparent || view.radius > 0 ? "transparent" : Theme.color("chrome", "#0b0b0d") + + profile: WebProfile + lifecycleState: sleepsWhenHidden && !visible && !loading ? WebEngineView.LifecycleState.Frozen : WebEngineView.LifecycleState.Active + settings.javascriptCanAccessClipboard: false + settings.javascriptCanPaste: false + + webChannel: WebChannel { + id: channel + } + + function syncThemeScript() { + const source = Theme.loaded ? Theme.injectionScript : ""; + if (source === view.installedThemeScript) { + return false; + } + view.installedThemeScript = source; + for (const stale of view.userScripts.find("t3-theme")) { + view.userScripts.remove(stale); + } + if (source.length === 0) { + return true; + } + const script = WebEngine.script(); + script.name = "t3-theme"; + script.sourceCode = source; + script.injectionPoint = WebEngineScript.DocumentCreation; + script.worldId = WebEngineScript.MainWorld; + view.userScripts.insert(script); + return true; + } + + function radiusScript() { + const pixels = Math.max(0, Math.round(view.radius)); + return `(() => { + const run = () => { + const root = document.documentElement; + if (${pixels} > 0) { + root.style.setProperty('--app-shell-surface-radius', '${pixels}px'); + root.dataset.shellSurfaceRadius = '${pixels}'; + } else { + root.style.removeProperty('--app-shell-surface-radius'); + delete root.dataset.shellSurfaceRadius; + } + }; + if (document.documentElement) run(); + else new MutationObserver((_, observer) => { + if (document.documentElement) { observer.disconnect(); run(); } + }).observe(document, { childList: true }); + })();`; + } + + // Installed at document creation so the first paint is already clipped, + // and run live when the radius changes under a loaded page. + function syncRadiusScript() { + for (const stale of view.userScripts.find("t3-surface-radius")) { + view.userScripts.remove(stale); + } + const source = view.radiusScript(); + if (view.radius > 0) { + const script = WebEngine.script(); + script.name = "t3-surface-radius"; + script.sourceCode = source; + script.injectionPoint = WebEngineScript.DocumentCreation; + script.worldId = WebEngineScript.MainWorld; + view.userScripts.insert(script); + } + if (!view.loading) { + view.runJavaScript(source); + } + } + + onRadiusChanged: view.syncRadiusScript() + + Component.onCompleted: { + channel.registerObject("shell", Shell.channel); + view.syncThemeScript(); + if (view.radius > 0) { + view.syncRadiusScript(); + } + + const tag = WebEngine.script(); + tag.name = "t3-surface-id"; + tag.sourceCode = "window.__t3ShellSurfaceId = " + JSON.stringify(view.surfaceId) + ";"; + tag.injectionPoint = WebEngineScript.DocumentCreation; + tag.worldId = WebEngineScript.MainWorld; + view.userScripts.insert(tag); + + const transport = WebEngine.script(); + transport.name = "t3-webchannel"; + transport.sourceUrl = Shell.webChannelScriptUrl; + transport.injectionPoint = WebEngineScript.DocumentCreation; + transport.worldId = WebEngineScript.MainWorld; + view.userScripts.insert(transport); + + const connector = WebEngine.script(); + connector.name = "t3-shell-connect"; + connector.sourceUrl = "qrc:/qt/qml/T3/Bricks/js/shell-connect.js"; + connector.injectionPoint = WebEngineScript.DocumentCreation; + connector.worldId = WebEngineScript.MainWorld; + view.userScripts.insert(connector); + } + + onLoadingChanged: function (info) { + if (info.status === WebEngineView.LoadStartedStatus) { + return; + } + const ok = info.status === WebEngineView.LoadSucceededStatus; + console.info("[web]", ok ? "loaded" : "load failed", info.url, ok ? "" : info.errorString); + if (ok) { + view.runJavaScript(Theme.injectionScript); + } + Shell.notifyPageLoaded(ok, info.url); + } + + onNewWindowRequested: function (request) { + Shell.openExternal(request.requestedUrl); + } + + // The app's copy buttons and paste handlers use the async clipboard API. + // Nothing else (notifications, media, location) is granted: the page has + // no presenter for them here and falls back to its in-app affordances. + onPermissionRequested: function (permission) { + if (permission.permissionType === WebEnginePermission.PermissionType.ClipboardReadWrite && Shell.isAppOrigin(permission.origin)) { + permission.grant(); + } else { + permission.deny(); + } + } + + Connections { + target: Theme + function onThemeChanged() { + const changed = view.syncThemeScript(); + if (changed && !view.loading) { + view.runJavaScript(Theme.injectionScript); + } + } + } + + ContextMenuHost { + surfaceId: view.surfaceId + } +} diff --git a/apps/desktop-qt/qml/T3/Bricks/WindowControls.qml b/apps/desktop-qt/qml/T3/Bricks/WindowControls.qml new file mode 100644 index 000000000000..34016a8459bc --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/WindowControls.qml @@ -0,0 +1,85 @@ +import QtQuick +import QtQuick.Controls.Basic +import T3.Shell + +// Minimize / maximize / close for a frameless window, in the order the +// platform expects (the caller picks the side). `trafficLights` swaps the +// glyph buttons for macOS-style dots that reveal their glyph on hover and go +// grey while the window is inactive. +Row { + id: controls + + property Window window: null + property var order: Qt.platform.os === "osx" ? ["close", "minimize", "maximize"] : ["minimize", "maximize", "close"] + property bool trafficLights: false + property real buttonWidth: trafficLights ? 12 : 40 + property real buttonHeight: trafficLights ? 12 : 36 + readonly property color foreground: Theme.color("text", "#e4e4e7") + readonly property var lightColors: ({ + "close": "#ff5f57", + "minimize": "#febc2e", + "maximize": "#28c840" + }) + + spacing: trafficLights ? 8 : 0 + + HoverHandler { + id: hover + + enabled: controls.trafficLights + } + + Repeater { + model: controls.order + + AbstractButton { + id: button + + required property string modelData + + readonly property bool isClose: modelData === "close" + readonly property color light: controls.window === null || controls.window.active ? controls.lightColors[modelData] : Theme.color("mutedForeground", "#5b5b60") + + width: controls.buttonWidth + height: controls.buttonHeight + hoverEnabled: true + text: isClose ? qsTr("Close") : modelData === "minimize" ? qsTr("Minimize") : qsTr("Maximize") + Accessible.role: Accessible.Button + Accessible.name: text + onClicked: { + if (isClose) { + controls.window.close(); + } else if (modelData === "minimize") { + controls.window.showMinimized(); + } else { + controls.window.visibility === Window.Maximized ? controls.window.showNormal() : controls.window.showMaximized(); + } + } + + background: Rectangle { + radius: controls.trafficLights ? height / 2 : 6 + readonly property color hoverColor: button.isClose ? Theme.color("error", "#ef4444") : Theme.color("accentSurface", "#27272a") + color: controls.trafficLights ? button.light : Qt.alpha(hoverColor, button.hovered ? hoverColor.a : 0) + border.width: controls.trafficLights ? 1 : 0 + border.color: Qt.darker(button.light, 1.25) + + Behavior on color { + ColorAnimation { + duration: 120 + } + } + } + + contentItem: Item { + ShellIcon { + anchors.centerIn: parent + name: button.isClose ? "x" : button.modelData === "minimize" ? "minus" : controls.trafficLights ? "plus" : controls.window !== null && controls.window.visibility === Window.Maximized ? "copy" : "square" + size: controls.trafficLights ? 8 : button.isClose ? 14 : 12 + strokeWidth: controls.trafficLights ? 2.5 : 2 + color: controls.trafficLights ? Qt.darker(button.light, 2.4) : button.isClose && button.hovered ? Theme.color("errorForeground", "#ffffff") : controls.foreground + visible: !controls.trafficLights || hover.hovered + } + } + } + } +} diff --git a/apps/desktop-qt/qml/T3/Bricks/Workspace.qml b/apps/desktop-qt/qml/T3/Bricks/Workspace.qml new file mode 100644 index 000000000000..463976cdb010 --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/Workspace.qml @@ -0,0 +1,357 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import T3.Shell + +// The header strip: project / thread breadcrumb on the left, the run, open +// and git pills on the right, from Shell.state.workspace. Checkout mode and +// branch live under the Composer, as in the page. +Rectangle { + id: strip + + readonly property var model: Shell.state.workspace ?? null + readonly property bool ready: model !== null + readonly property color foreground: Theme.color("text", "#e4e4e7") + readonly property color muted: Theme.color("textMuted", "#8b8b93") + readonly property color projectColor: Theme.color("projectForeground", muted) + readonly property color iconMuted: Theme.color("iconMuted", "#8b8b93") + readonly property var preferredScript: { + if (!ready || model.scripts.length === 0) { + return null; + } + return model.scripts.find(script => script.id === model.preferredScriptId) ?? model.scripts[0]; + } + + // Set by the layout: whether the sidebar is collapsed. Null hides the + // toggle; true shows it in the sidebar's place. + property var sidebarToggle: null + // Same for the right panel: null hides the toggle, otherwise whether the + // panel is open. The page's header keeps this button next to the pills. + property var panelToggle: null + // The window, for a frameless shell: the strip is its drag handle and + // carries the window buttons. + property Window window: null + readonly property bool framelessChrome: window !== null && Theme.frameless + // Narrow strips (a wide right panel) drop the pill labels. + readonly property bool compact: width < 720 + + implicitHeight: 52 + color: Theme.color("canvas", "#0f0f12") + + DragHandler { + enabled: strip.framelessChrome + target: null + grabPermissions: PointerHandler.CanTakeOverFromAnything + onActiveChanged: if (active) + strip.window.startSystemMove() + } + + TapHandler { + enabled: strip.framelessChrome + onDoubleTapped: strip.window.visibility === Window.Maximized ? strip.window.showNormal() : strip.window.showMaximized() + } + + ShellButton { + x: 12 + y: 12 + subtle: true + implicitHeight: 28 + visible: strip.sidebarToggle === true + iconName: "panel-left" + iconSize: 16 + iconTint: strip.iconMuted + Accessible.name: qsTr("Show sidebar") + onClicked: Shell.dispatch("sidebar.toggle") + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: strip.sidebarToggle === true ? 52 : 20 + anchors.rightMargin: 20 + spacing: 12 + + // Breadcrumb: project / title. + RowLayout { + Layout.fillWidth: true + Layout.minimumWidth: 0 + spacing: 6 + + ShellIcon { + visible: strip.ready && strip.model.projectTitle !== null + name: "folder" + size: 14 + color: strip.iconMuted + Layout.alignment: Qt.AlignVCenter + } + + Text { + id: projectLabel + objectName: "projectLabel" + + Layout.fillWidth: true + Layout.minimumWidth: 24 + Layout.maximumWidth: Math.min(Math.ceil(implicitWidth), 160) + visible: strip.ready && strip.model.projectTitle !== null + text: strip.ready ? (strip.model.projectTitle ?? "") : "" + color: projectHover.hovered ? strip.foreground : strip.projectColor + font.pixelSize: 14 + font.weight: Font.Medium + font.family: Theme.fontUi.length > 0 ? Theme.fontUi : Qt.application.font.family + elide: Text.ElideRight + Accessible.role: Accessible.Button + Accessible.name: qsTr("New thread in project") + + Behavior on color { + ColorAnimation { + duration: 120 + } + } + + HoverHandler { + id: projectHover + + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + onTapped: Shell.dispatch("workspace.newThread") + } + } + + Text { + visible: strip.ready && strip.model.projectTitle !== null + text: "/" + color: strip.iconMuted + font.pixelSize: 14 + font.family: Theme.fontUi.length > 0 ? Theme.fontUi : Qt.application.font.family + } + + Item { + id: titleSlot + objectName: "titleSlot" + + property bool editing: false + property int handledRenameRequest: 0 + + function startEditing() { + editing = true; + titleEditor.text = strip.model.threadTitle; + titleEditor.forceActiveFocus(); + titleEditor.selectAll(); + } + + Layout.fillWidth: true + Layout.minimumWidth: 24 + implicitHeight: 28 + implicitWidth: titleRow.implicitWidth + + Connections { + target: strip + + function onModelChanged() { + if (strip.ready && strip.model.renameRequestId !== titleSlot.handledRenameRequest) { + titleSlot.handledRenameRequest = strip.model.renameRequestId; + if (strip.model.renameRequestId > 0) { + titleSlot.startEditing(); + } + } + } + } + + HoverHandler { + id: titleHover + + enabled: !titleSlot.editing && strip.ready && !strip.model.isDraft + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + enabled: !titleSlot.editing + acceptedButtons: Qt.LeftButton | Qt.RightButton + onTapped: eventPoint => { + if (!strip.ready || strip.model.isDraft) { + return; + } + const p = titleSlot.mapToItem(null, 0, titleSlot.height + 4); + Shell.dispatch("workspace.titleMenu", { + x: p.x, + y: p.y + }); + } + onDoubleTapped: { + if (strip.ready && !strip.model.isDraft) { + titleSlot.startEditing(); + } + } + } + + RowLayout { + id: titleRow + + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + // Layouts use whole pixels; rounding down a fractional + // text width would elide a title that actually fits. + width: Math.min(Math.ceil(implicitWidth), parent.width) + visible: !titleSlot.editing + spacing: 4 + + Text { + id: threadLabel + objectName: "threadLabel" + Layout.fillWidth: true + Layout.maximumWidth: Math.ceil(implicitWidth) + text: strip.ready ? strip.model.threadTitle : qsTr("No thread") + color: strip.foreground + font.pixelSize: 14 + font.weight: Font.Medium + font.family: Theme.fontUi.length > 0 ? Theme.fontUi : Qt.application.font.family + elide: Text.ElideRight + } + + ShellIcon { + name: "chevron-down" + size: 14 + color: strip.muted + opacity: titleHover.hovered ? 1 : 0 + Layout.alignment: Qt.AlignVCenter + + Behavior on opacity { + NumberAnimation { + duration: 120 + } + } + } + } + + ShellTextField { + id: titleEditor + + anchors.fill: parent + visible: titleSlot.editing + font.pixelSize: 14 + font.weight: Font.Medium + onAccepted: { + titleSlot.editing = false; + Shell.dispatch("workspace.rename", { + title: text + }); + } + Keys.onEscapePressed: titleSlot.editing = false + onActiveFocusChanged: if (!activeFocus && titleSlot.editing) { + titleSlot.editing = false; + } + } + } + } + + ShellSplitButton { + id: scriptsPill + + visible: strip.ready && strip.model.scripts.length > 0 + compact: strip.compact + iconName: "play" + text: strip.preferredScript ? qsTr("Run %1").arg(strip.preferredScript.name) : "" + onClicked: Shell.dispatch("workspace.runScript", { + scriptId: strip.preferredScript.id + }) + onMenuRequested: scriptsMenu.open() + + ShellMenu { + id: scriptsMenu + + y: parent.height + 4 + x: parent.width - width + + Instantiator { + model: strip.ready ? strip.model.scripts : [] + + delegate: ShellMenuItem { + required property var modelData + + text: modelData.name + iconName: "play" + current: strip.preferredScript !== null && modelData.id === strip.preferredScript.id + onTriggered: Shell.dispatch("workspace.runScript", { + scriptId: modelData.id + }) + } + + onObjectAdded: (index, object) => scriptsMenu.insertItem(index, object) + onObjectRemoved: (index, object) => scriptsMenu.removeItem(object) + } + } + } + + ShellSplitButton { + visible: strip.ready && strip.model.editors.length > 0 + compact: strip.compact + iconName: "external-link" + text: qsTr("Open") + onClicked: Shell.dispatch("workspace.openInEditor", {}) + onMenuRequested: editorsMenu.open() + + ShellMenu { + id: editorsMenu + + y: parent.height + 4 + x: parent.width - width + + Instantiator { + model: strip.ready ? strip.model.editors : [] + + delegate: ShellMenuItem { + required property var modelData + + text: modelData.label + current: strip.ready && modelData.id === strip.model.preferredEditorId + onTriggered: Shell.dispatch("workspace.openInEditor", { + editorId: modelData.id + }) + } + + onObjectAdded: (index, object) => editorsMenu.insertItem(index, object) + onObjectRemoved: (index, object) => editorsMenu.removeItem(object) + } + } + } + + GitActions { + compact: strip.compact + } + + // The terminal drawer stays in the page; its toggle is the strip's, + // next to the panel's, as in the page's header. + ShellButton { + visible: strip.ready && strip.model.terminalAvailable + subtle: true + implicitHeight: 28 + iconName: "panel-bottom" + iconSize: 16 + iconTint: strip.ready && strip.model.terminalOpen ? strip.foreground : strip.iconMuted + Layout.leftMargin: 4 + Accessible.name: strip.ready && strip.model.terminalOpen ? qsTr("Hide terminal") : qsTr("Show terminal") + onClicked: Shell.dispatch("terminal.toggle") + } + + ShellButton { + visible: strip.panelToggle !== null + subtle: true + implicitHeight: 28 + iconName: strip.panelToggle === true ? "panel-right-close" : "panel-right" + iconSize: 16 + iconTint: strip.iconMuted + Layout.leftMargin: 4 + Accessible.name: strip.panelToggle === true ? qsTr("Close panel") : qsTr("Open panel") + onClicked: Shell.dispatch("rightPanel.toggle") + } + + WindowControls { + visible: strip.framelessChrome && Qt.platform.os !== "osx" + window: strip.window + buttonWidth: 32 + buttonHeight: 28 + Layout.leftMargin: 8 + } + } +} diff --git a/apps/desktop-qt/qml/T3/Bricks/js/lucide.js b/apps/desktop-qt/qml/T3/Bricks/js/lucide.js new file mode 100644 index 000000000000..e0fd180d4e49 --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/js/lucide.js @@ -0,0 +1,66 @@ +.pragma library + +// Stroke path data for the lucide icons the bricks draw (ISC, lucide.dev), +// flattened to one SVG path per icon on a 24-unit grid. Generated by +// apps/desktop-qt/scripts/gen-icons.mjs; edit the list there, not this file. +var paths = { + "alarm-clock": "M4 13a8 8 0 1 0 16 0a8 8 0 1 0 -16 0M12 9v4l2 2M5 3 2 6M22 6l-3-3M6.38 18.7 4 21M17.64 18.67 20 21", + "alarm-clock-off": "M6.87 6.87a8 8 0 1 0 11.26 11.26M19.9 14.25a8 8 0 0 0-9.15-9.15M22 6l-3-3M6.26 18.67 4 21M2 2l20 20M4 4 2 6", + "arrow-up": "M5 12l7-7 7 7M12 19V5", + "bot": "M12 8V4H8M6 8h12a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-8a2 2 0 0 1 2 -2ZM2 14h2M20 14h2M15 13v2M9 13v2", + "chart-no-axes-column": "M5 21v-6M12 21V3M19 21V9", + "check": "M20 6 9 17l-5-5", + "chevron-down": "M6 9l6 6 6-6", + "chevron-right": "M9 18l6-6-6-6", + "chevron-up": "M18 15l-6-6-6 6", + "circle-check": "M2 12a10 10 0 1 0 20 0a10 10 0 1 0 -20 0M9 12l2 2 4-4", + "circle-dashed": "M10.1 2.182a10 10 0 0 1 3.8 0M13.9 21.818a10 10 0 0 1-3.8 0M17.609 3.721a10 10 0 0 1 2.69 2.7M2.182 13.9a10 10 0 0 1 0-3.8M20.279 17.609a10 10 0 0 1-2.7 2.69M21.818 10.1a10 10 0 0 1 0 3.8M3.721 6.391a10 10 0 0 1 2.7-2.69M6.391 20.279a10 10 0 0 1-2.69-2.7", + "circle-x": "M2 12a10 10 0 1 0 20 0a10 10 0 1 0 -20 0M15 9l-6 6M9 9l6 6", + "clock": "M12 6v6l4 2M2 12a10 10 0 1 0 20 0a10 10 0 1 0 -20 0", + "cloud": "M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z", + "cloud-upload": "M12 13v8M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242M8 17l4-4 4 4", + "code": "M16 18l6-6-6-6M8 6l-6 6 6 6", + "command": "M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3", + "copy": "M10 8h10a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2ZM4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2", + "external-link": "M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6", + "file-diff": "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2zM9 10h6M12 13V7M9 17h6", + "files": "M15 2h-4a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8M16.706 2.706A2.4 2.4 0 0 0 15 2v5a1 1 0 0 0 1 1h5a2.4 2.4 0 0 0-.706-1.706zM5 7a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h8a2 2 0 0 0 1.732-1", + "folder": "M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z", + "folder-git": "M10 13a2 2 0 1 0 4 0a2 2 0 1 0 -4 0M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2ZM14 13h3M7 13h3", + "folder-git-2": "M18 19a5 5 0 0 1-5-5v8M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v5M11 12a2 2 0 1 0 4 0a2 2 0 1 0 -4 0M18 19a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", + "folder-plus": "M12 10v6M9 13h6M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z", + "git-branch": "M15 6a9 9 0 0 0-9 9V3M15 6a3 3 0 1 0 6 0a3 3 0 1 0 -6 0M3 18a3 3 0 1 0 6 0a3 3 0 1 0 -6 0", + "git-commit-horizontal": "M9 12a3 3 0 1 0 6 0a3 3 0 1 0 -6 0M3 12L9 12M15 12L21 12", + "git-merge": "M15 18a3 3 0 1 0 6 0a3 3 0 1 0 -6 0M3 6a3 3 0 1 0 6 0a3 3 0 1 0 -6 0M6 21V9a9 9 0 0 0 9 9", + "git-pull-request": "M15 18a3 3 0 1 0 6 0a3 3 0 1 0 -6 0M3 6a3 3 0 1 0 6 0a3 3 0 1 0 -6 0M13 6h3a2 2 0 0 1 2 2v7M6 9L6 21", + "image": "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2ZM7 9a2 2 0 1 0 4 0a2 2 0 1 0 -4 0M21 15l-3.086-3.086a2 2 0 0 0-2.828 0L6 21", + "layout-grid": "M4 3h5a1 1 0 0 1 1 1v5a1 1 0 0 1 -1 1h-5a1 1 0 0 1 -1 -1v-5a1 1 0 0 1 1 -1ZM15 3h5a1 1 0 0 1 1 1v5a1 1 0 0 1 -1 1h-5a1 1 0 0 1 -1 -1v-5a1 1 0 0 1 1 -1ZM15 14h5a1 1 0 0 1 1 1v5a1 1 0 0 1 -1 1h-5a1 1 0 0 1 -1 -1v-5a1 1 0 0 1 1 -1ZM4 14h5a1 1 0 0 1 1 1v5a1 1 0 0 1 -1 1h-5a1 1 0 0 1 -1 -1v-5a1 1 0 0 1 1 -1Z", + "lock": "M5 11h14a2 2 0 0 1 2 2v7a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-7a2 2 0 0 1 2 -2ZM7 11V7a5 5 0 0 1 10 0v4", + "lock-open": "M5 11h14a2 2 0 0 1 2 2v7a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-7a2 2 0 0 1 2 -2ZM7 11V7a5 5 0 0 1 9.9-1", + "message-square": "M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z", + "minus": "M5 12h14", + "monitor": "M4 3h16a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-16a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2ZM8 21L16 21M12 17L12 21", + "panel-bottom": "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2ZM3 15h18", + "panel-left": "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2ZM9 3v18", + "panel-left-close": "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2ZM9 3v18M16 15l-3-3 3-3", + "panel-right": "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2ZM15 3v18", + "panel-right-close": "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2ZM15 3v18M8 9l3 3-3 3", + "paperclip": "M16 6l-8.414 8.586a2 2 0 0 0 2.829 2.829l8.414-8.586a4 4 0 1 0-5.657-5.657l-8.379 8.551a6 6 0 1 0 8.485 8.485l8.379-8.551", + "pen-line": "M13 21h8M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z", + "pencil-ruler": "M13 7 8.7 2.7a2.41 2.41 0 0 0-3.4 0L2.7 5.3a2.41 2.41 0 0 0 0 3.4L7 13M8 6l2-2M18 16l2-2M17 11l4.3 4.3c.94.94.94 2.46 0 3.4l-2.6 2.6c-.94.94-2.46.94-3.4 0L11 17M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497zM15 5l4 4", + "play": "M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z", + "plus": "M5 12h14M12 5v14", + "search": "M21 21l-4.34-4.34M3 11a8 8 0 1 0 16 0a8 8 0 1 0 -16 0", + "settings": "M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915M9 12a3 3 0 1 0 6 0a3 3 0 1 0 -6 0", + "sparkles": "M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594zM20 2v4M22 4h-4M2 20a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", + "square": "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2Z", + "square-pen": "M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z", + "star": "M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z", + "terminal": "M12 19h8M4 17l6-6-6-6", + "undo-2": "M9 14 4 9l5-5M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11", + "x": "M18 6 6 18M6 6l12 12" +}; + +function path(name) { + return paths[name] ?? ""; +} diff --git a/apps/desktop-qt/qml/T3/Bricks/js/shell-connect.js b/apps/desktop-qt/qml/T3/Bricks/js/shell-connect.js new file mode 100644 index 000000000000..8ca0f92b2832 --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/js/shell-connect.js @@ -0,0 +1,81 @@ +// Injected at document creation into every page the shell hosts. Defines +// `window.t3Shell` synchronously so the web app can detect the shell at module +// load (like `desktopBridge`); the channel itself connects once qwebchannel.js +// (injected alongside, order not guaranteed) has defined QWebChannel. +(() => { + if (window.t3Shell !== undefined) { + return; + } + const transport = window.qt && window.qt.webChannelTransport; + if (!transport) { + return; + } + const ready = new Promise((resolve, reject) => { + let attempts = 0; + const connect = () => { + if (typeof window.QWebChannel !== "function") { + attempts += 1; + if (attempts > 200) { + reject(new Error("qwebchannel.js never loaded")); + return; + } + setTimeout(connect, 10); + return; + } + void new window.QWebChannel(transport, (channel) => { + const shell = channel.objects.shell; + if (!shell) { + reject(new Error("t3 shell object missing on the web channel")); + return; + } + resolve(shell); + }); + }; + connect(); + }); + // Everything any document published, keyed as published; secondary + // documents (embed routes) read the primary's view models from here. The + // map is pulled on first use: a page that never reads it (the primary) is + // never sent its own publishes back. + const state = {}; + const stateListeners = new Set(); + let stateReady = null; + const ensureState = () => { + stateReady ??= ready.then( + (shell) => + new Promise((resolve) => { + shell.stateEntryChanged.connect((key, value) => { + state[key] = value; + for (const listener of stateListeners) { + listener(state); + } + }); + shell.snapshot((snapshot) => { + Object.assign(state, snapshot); + resolve(state); + }); + }), + ); + return stateReady; + }; + window.t3Shell = Object.freeze({ + protocolVersion: 1, + surfaceId: + typeof window.__t3ShellSurfaceId === "string" ? window.__t3ShellSurfaceId : "primary", + ready, + publish: (key, value) => ready.then((shell) => shell.publish(key, value)), + onAction: (listener) => + ready.then((shell) => { + shell.actionRequested.connect(listener); + return () => shell.actionRequested.disconnect(listener); + }), + dispatch: (action, payload) => ready.then((shell) => shell.dispatch(action, payload ?? null)), + getState: ensureState, + onState: (listener) => + ensureState().then((current) => { + stateListeners.add(listener); + listener(current); + return () => stateListeners.delete(listener); + }), + }); +})(); diff --git a/apps/desktop-qt/qml/T3/Bricks/qmldir b/apps/desktop-qt/qml/T3/Bricks/qmldir new file mode 100644 index 000000000000..0fcd6bf70a65 --- /dev/null +++ b/apps/desktop-qt/qml/T3/Bricks/qmldir @@ -0,0 +1,27 @@ +module T3.Bricks +Composer 1.0 Composer.qml +ContextMenuHost 1.0 ContextMenuHost.qml +DefaultShell 1.0 DefaultShell.qml +GitActions 1.0 GitActions.qml +Notifications 1.0 Notifications.qml +RightPanel 1.0 RightPanel.qml +SettingsNav 1.0 SettingsNav.qml +ShellErrorOverlay 1.0 ShellErrorOverlay.qml +ShellTextField 1.0 ShellTextField.qml +ShellWindow 1.0 ShellWindow.qml +ShellMenuItem 1.0 ShellMenuItem.qml +ShellMenu 1.0 ShellMenu.qml +ShellComboBox 1.0 ShellComboBox.qml +ShellButton 1.0 ShellButton.qml +ShellCard 1.0 ShellCard.qml +ShellIcon 1.0 ShellIcon.qml +ShellSplitButton 1.0 ShellSplitButton.qml +ShellChevron 1.0 ShellChevron.qml +Sidebar 1.0 Sidebar.qml +SidebarThreadRow 1.0 SidebarThreadRow.qml +T3Wordmark 1.0 T3Wordmark.qml +TerminalDrawer 1.0 TerminalDrawer.qml +TitleBar 1.0 TitleBar.qml +WebSurface 1.0 WebSurface.qml +WindowControls 1.0 WindowControls.qml +Workspace 1.0 Workspace.qml diff --git a/apps/desktop-qt/scripts/dev-qt.mjs b/apps/desktop-qt/scripts/dev-qt.mjs new file mode 100644 index 000000000000..2acb62004692 --- /dev/null +++ b/apps/desktop-qt/scripts/dev-qt.mjs @@ -0,0 +1,244 @@ +/** + * Dev loop for the Qt shell: + * 1. configure + build apps/desktop-qt with CMake (incremental after the first run) + * 2. find the dev server started by `vp run dev` and mint a pairing URL for it + * 3. launch t3code-qt --url + * + * Flags the script consumes: + * --home-dir T3 Code data directory of the dev server to pair with (same + * as `vp run dev --home-dir`). Also becomes the shell's + * T3CODE_HOME so it rices from /shell. Defaults to the + * worktree's own .t3, then T3CODE_HOME, then ~/.t3 — the + * precedence `vp run dev` and `t3 pair` use. + * --url skip pairing and load this URL + * --release build with CMAKE_BUILD_TYPE=Release (no disk QML loading) + * --configure-only stop after the CMake build + * --help + * Every other argument is passed through to the t3code-qt binary, so the + * shell's own flags (--config-dir, --qml-dir, --screenshot, --action, ...) + * work from `vp run dev:qt` too. A bare `--` forwards the rest verbatim. + * Environment: + * QT_PREFIX / CMAKE_PREFIX_PATH where Qt 6 lives (defaults: qmake6 on PATH, then Homebrew) + */ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import { findPairingUrl } from "../host/pairingUrl.ts"; + +const appDir = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); +const serverBin = NodePath.resolve(appDir, "../server/src/bin.ts"); + +function fail(message) { + process.stderr.write(`[dev-qt] ${message}\n`); + process.exit(1); +} + +function usage() { + process.stdout.write( + [ + "Usage: vp run dev:qt [--home-dir ] [--url ] [--release] [--configure-only] [-- ]", + "", + " --home-dir data directory of the dev server to pair with (as `vp run dev --home-dir`)", + " --url skip pairing and load this URL", + " --release Release build (no disk QML loading)", + " --configure-only build, do not launch", + "", + "Anything else is forwarded to t3code-qt (--config-dir, --qml-dir, --screenshot, --action, ...).", + "", + ].join("\n"), + ); +} + +function parseArgs(argv) { + const options = { configureOnly: false, release: false, url: undefined, homeDir: undefined }; + const shellArgs = []; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const takeValue = () => { + const value = argv[index + 1]; + if (value === undefined) fail(`${arg} needs a value`); + index += 1; + return value; + }; + if (arg === "--") { + shellArgs.push(...argv.slice(index + 1)); + break; + } else if (arg === "--help" || arg === "-h") { + usage(); + process.exit(0); + } else if (arg === "--configure-only") { + options.configureOnly = true; + } else if (arg === "--release") { + options.release = true; + } else if (arg === "--url") { + options.url = takeValue(); + } else if (arg.startsWith("--url=")) { + options.url = arg.slice("--url=".length); + } else if (arg === "--home-dir") { + options.homeDir = takeValue(); + } else if (arg.startsWith("--home-dir=")) { + options.homeDir = arg.slice("--home-dir=".length); + } else { + shellArgs.push(arg); + } + } + return { options, shellArgs }; +} + +const { options, shellArgs } = parseArgs(process.argv.slice(2)); +const buildType = options.release ? "Release" : "Debug"; +const buildDir = NodePath.join(appDir, "build", buildType.toLowerCase()); + +function run(command, commandArgs) { + const result = NodeChildProcess.spawnSync(command, commandArgs, { + cwd: appDir, + stdio: "inherit", + }); + if (result.error) fail(`${command} failed: ${result.error.message}`); + if (result.status !== 0) + fail(`${command} ${commandArgs.join(" ")} exited with ${String(result.status)}`); +} + +function capture(command, commandArgs) { + const result = NodeChildProcess.spawnSync(command, commandArgs, { encoding: "utf8" }); + if (result.error || result.status !== 0) return undefined; + return result.stdout.trim(); +} + +function resolveQtPrefix() { + const fromEnv = process.env.QT_PREFIX ?? process.env.CMAKE_PREFIX_PATH; + if (fromEnv) return fromEnv; + const fromQmake = + capture("qmake6", ["-query", "QT_INSTALL_PREFIX"]) ?? + capture("qmake", ["-query", "QT_INSTALL_PREFIX"]); + if (fromQmake) return fromQmake; + const fromBrew = capture("brew", ["--prefix", "qt"]); + if (fromBrew) return fromBrew; + return fail( + "Qt 6 not found. Set QT_PREFIX to the Qt install (e.g. /opt/homebrew/opt/qt or ~/Qt/6.11.1/gcc_64).", + ); +} + +function expandHome(raw) { + const trimmed = raw.trim(); + if (trimmed === "~" || trimmed.startsWith("~/")) { + return NodePath.join(NodeOS.homedir(), trimmed.slice(1)); + } + return trimmed; +} + +/** + * The worktree-local `.t3` when this checkout is a linked git worktree, else + * undefined. Mirrors `resolveWorktreeT3Home` in packages/shared/devHome: + * git puts a linked worktree's git dir at `/worktrees/`. + */ +function resolveWorktreeHome() { + const gitDir = capture("git", ["-C", appDir, "rev-parse", "--absolute-git-dir"]); + const topLevel = capture("git", ["-C", appDir, "rev-parse", "--show-toplevel"]); + if (gitDir === undefined || topLevel === undefined) return undefined; + const segments = gitDir.split(/[/\\]/).filter((segment) => segment.length > 0); + const isLinkedWorktree = segments.length >= 3 && segments.at(-2) === "worktrees"; + return isLinkedWorktree ? NodePath.join(topLevel, ".t3") : undefined; +} + +/** `--home-dir` > worktree `.t3` > `T3CODE_HOME` > `~/.t3`, as `vp run dev` and `t3 pair` resolve it. */ +function resolveHomeDir() { + const explicit = options.homeDir ?? ""; + if (explicit.trim().length > 0) return NodePath.resolve(expandHome(explicit)); + const worktreeHome = resolveWorktreeHome(); + if (worktreeHome !== undefined) return worktreeHome; + const fromEnv = process.env.T3CODE_HOME ?? ""; + if (fromEnv.trim().length > 0) return NodePath.resolve(expandHome(fromEnv)); + const shared = NodePath.join(NodeOS.homedir(), ".t3"); + refuseLiveInstall(shared); + return shared; +} + +/** + * `pair --base-dir ~/.t3` probes `userdata` (the installed app's database) + * before `dev` (what a plain-checkout `vp run dev` serves). Pairing must never + * mint a token into the live install, so bail out while that app is running. + */ +function refuseLiveInstall(sharedHome) { + const userdata = NodePath.join(sharedHome, "userdata"); + let pid; + try { + pid = JSON.parse( + NodeFS.readFileSync(NodePath.join(userdata, "server-runtime.json"), "utf8"), + ).pid; + if (typeof pid !== "number") return; + process.kill(pid, 0); + } catch { + return; + } + fail( + `the installed T3 Code app is running against ${userdata}; pairing here would target it instead of your dev server. Pass --url , or run from a worktree / with --home-dir.`, + ); +} + +function build() { + if (!NodeFS.existsSync(NodePath.join(buildDir, "CMakeCache.txt"))) { + const qtPrefix = resolveQtPrefix(); + process.stderr.write(`[dev-qt] configuring (${buildType}) with Qt at ${qtPrefix}\n`); + run("cmake", [ + "-S", + appDir, + "-B", + buildDir, + "-G", + "Ninja", + `-DCMAKE_BUILD_TYPE=${buildType}`, + `-DCMAKE_PREFIX_PATH=${qtPrefix}`, + ]); + } + run("cmake", ["--build", buildDir]); +} + +function binaryPath() { + // macOS bundle, plain executable, Windows executable: whichever this build produced. + const candidates = [ + NodePath.join(buildDir, "t3code-qt.app/Contents/MacOS/t3code-qt"), + NodePath.join(buildDir, "t3code-qt"), + NodePath.join(buildDir, "t3code-qt.exe"), + ]; + const found = candidates.find((candidate) => NodeFS.existsSync(candidate)); + return found ?? fail(`built binary not found under ${buildDir}`); +} + +function pairWithDevServer(homeDir) { + const result = NodeChildProcess.spawnSync( + process.execPath, + [serverBin, "pair", "--base-dir", homeDir], + { cwd: appDir, encoding: "utf8" }, + ); + const url = findPairingUrl(`${result.stdout}\n${result.stderr}`); + if (url !== undefined) return url; + // `pair` logs its "No running T3 Code server" report (with the state paths it + // checked) through the Effect logger, i.e. on stdout. + process.stderr.write(`${result.stdout}${result.stderr}`.trim() + "\n"); + return fail( + `no running dev server under ${homeDir}. Start \`vp run dev\` in another terminal first (same --home-dir), or pass --url .`, + ); +} + +build(); +if (options.configureOnly) process.exit(0); + +const homeDir = resolveHomeDir(); +const url = options.url ?? pairWithDevServer(homeDir); +const binary = binaryPath(); +const binaryArgs = ["--url", url, ...shellArgs]; +process.stderr.write(`[dev-qt] home ${homeDir}\n`); +process.stderr.write(`[dev-qt] launching ${binary} ${binaryArgs.join(" ")}\n`); +const child = NodeChildProcess.spawn(binary, binaryArgs, { + stdio: "inherit", + cwd: appDir, + env: { ...process.env, T3CODE_HOME: homeDir }, +}); +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => child.kill(signal)); +} +child.on("exit", (code) => process.exit(code ?? 0)); diff --git a/apps/desktop-qt/scripts/gen-icons.mjs b/apps/desktop-qt/scripts/gen-icons.mjs new file mode 100644 index 000000000000..8a55ea1ab2d0 --- /dev/null +++ b/apps/desktop-qt/scripts/gen-icons.mjs @@ -0,0 +1,153 @@ +// Regenerates qml/T3/Bricks/js/lucide.js: the stroke paths of the lucide +// icons the bricks draw, flattened to one SVG path per icon on a 24-unit grid +// so ShellIcon needs a single PathSvg. Add a name to ICONS and run +// `node apps/desktop-qt/scripts/gen-icons.mjs` from the repo root; the icon +// data comes from the lucide-react package the web app depends on. +import * as NodeFS from "node:fs"; +import * as NodeModule from "node:module"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +const ICONS = [ + "alarm-clock", + "alarm-clock-off", + "arrow-up", + "bot", + "chart-no-axes-column", + "check", + "chevron-down", + "chevron-right", + "chevron-up", + "circle-check", + "circle-dashed", + "circle-x", + "clock", + "cloud", + "cloud-upload", + "code", + "command", + "copy", + "external-link", + "file-diff", + "files", + "folder", + "folder-git", + "folder-git-2", + "folder-plus", + "git-branch", + "git-commit-horizontal", + "git-merge", + "git-pull-request", + "image", + "layout-grid", + "lock", + "lock-open", + "message-square", + "minus", + "monitor", + "panel-bottom", + "panel-left", + "panel-left-close", + "panel-right", + "panel-right-close", + "paperclip", + "pen-line", + "pencil-ruler", + "play", + "plus", + "search", + "settings", + "sparkles", + "square", + "square-pen", + "star", + "terminal", + "undo-2", + "x", +]; + +const here = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const repoRoot = NodePath.join(here, "..", "..", ".."); +const require = NodeModule.createRequire(NodePath.join(repoRoot, "apps/web/package.json")); +const iconsDir = NodePath.dirname(require.resolve("lucide-react/dist/esm/icons/x.js")); +const output = NodePath.join(here, "..", "qml/T3/Bricks/js/lucide.js"); + +const num = (value) => Number(value); +const arc = (cx, cy, rx, ry) => + `M${cx - rx} ${cy}a${rx} ${ry} 0 1 0 ${2 * rx} 0a${rx} ${ry} 0 1 0 ${-2 * rx} 0`; +const points = (raw, close) => { + const p = raw.trim().split(/[\s,]+/); + let d = `M${p[0]} ${p[1]}`; + for (let i = 2; i < p.length; i += 2) d += `L${p[i]} ${p[i + 1]}`; + return close ? d + "Z" : d; +}; + +const toPath = ([tag, a]) => { + switch (tag) { + // Each starts at the origin, so a leading relative move is an + // absolute one; the implicit pairs after it stay relative lines. + case "path": + return a.d + .trim() + .replace( + /^m\s*(-?[\d.]+)[\s,]*(-?[\d.]+)\s*(.*)$/, + (_, x, y, rest) => `M${x} ${y}${/^[-\d.]/.test(rest) ? "l" : ""}${rest}`, + ); + case "line": + return `M${a.x1} ${a.y1}L${a.x2} ${a.y2}`; + case "polyline": + return points(a.points, false); + case "polygon": + return points(a.points, true); + case "circle": + return arc(num(a.cx), num(a.cy), num(a.r), num(a.r)); + case "ellipse": + return arc(num(a.cx), num(a.cy), num(a.rx), num(a.ry)); + case "rect": { + const x = num(a.x ?? 0); + const y = num(a.y ?? 0); + const w = num(a.width); + const h = num(a.height); + const r = num(a.rx ?? a.ry ?? 0); + if (!r) return `M${x} ${y}h${w}v${h}h${-w}Z`; + return ( + `M${x + r} ${y}h${w - 2 * r}a${r} ${r} 0 0 1 ${r} ${r}v${h - 2 * r}` + + `a${r} ${r} 0 0 1 ${-r} ${r}h${-(w - 2 * r)}a${r} ${r} 0 0 1 ${-r} ${-r}` + + `v${-(h - 2 * r)}a${r} ${r} 0 0 1 ${r} ${-r}Z` + ); + } + default: + throw new Error(`unhandled svg element ${tag}`); + } +}; + +const paths = {}; +for (const name of ICONS) { + const source = NodeFS.readFileSync(NodePath.join(iconsDir, `${name}.js`), "utf8"); + const match = source.match(/const __iconNode = (\[[\s\S]*?\]);\nconst /); + if (!match) throw new Error(`no icon node in ${name}`); + const nodes = new Function(`return ${match[1]}`)(); + paths[name] = nodes.map(toPath).join(""); +} + +const body = Object.entries(paths) + .map(([name, d]) => ` "${name}": "${d}"`) + .join(",\n"); + +NodeFS.writeFileSync( + output, + `.pragma library + +// Stroke path data for the lucide icons the bricks draw (ISC, lucide.dev), +// flattened to one SVG path per icon on a 24-unit grid. Generated by +// apps/desktop-qt/scripts/gen-icons.mjs; edit the list there, not this file. +var paths = { +${body} +}; + +function path(name) { + return paths[name] ?? ""; +} +`, +); +console.log(`${ICONS.length} icons -> ${output}`); diff --git a/apps/desktop-qt/scripts/package-linux.sh b/apps/desktop-qt/scripts/package-linux.sh new file mode 100755 index 000000000000..0207ce73d464 --- /dev/null +++ b/apps/desktop-qt/scripts/package-linux.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Builds an AppImage from a Release build directory with linuxdeploy and its +# Qt plugin (downloaded on first use). Usage: package-linux.sh +set -euo pipefail + +build_dir="${1:?build directory}" +app_dir="${build_dir}/AppDir" +tools_dir="${build_dir}/tools" +mkdir -p "${tools_dir}" + +fetch() { + local url="$1" out="$2" digest="$3" + if [ ! -f "${out}" ]; then + curl -fsSL "${url}" -o "${out}" + fi + printf '%s %s\n' "${digest}" "${out}" | sha256sum --check --status + chmod +x "${out}" +} +fetch "https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20251107-1/linuxdeploy-x86_64.AppImage" "${tools_dir}/linuxdeploy-1-alpha-20251107-1" "c20cd71e3a4e3b80c3483cef793cda3f4e990aca14014d23c544ca3ce1270b4d" +fetch "https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/1-alpha-20250213-1/linuxdeploy-plugin-qt-x86_64.AppImage" "${tools_dir}/linuxdeploy-plugin-qt" "15106be885c1c48a021198e7e1e9a48ce9d02a86dd0a1848f00bdbf3c1c92724" + +rm -rf "${app_dir}" +cmake --install "${build_dir}" --prefix "${app_dir}/usr" + +# Desktop entry + icon: the app id (t3code) must match what the shell sets so +# compositor rules can target the window. +mkdir -p "${app_dir}/usr/share/applications" "${app_dir}/usr/share/icons/hicolor/1024x1024/apps" +cat > "${app_dir}/usr/share/applications/t3code.desktop" <<'DESKTOP' +[Desktop Entry] +Type=Application +Name=T3 Code +Exec=t3code-qt +Icon=t3code +Categories=Development; +StartupWMClass=t3code +DESKTOP +icon_source="$(dirname "$0")/../../../assets/prod/black-universal-1024.png" +cp "${icon_source}" "${app_dir}/usr/share/icons/hicolor/1024x1024/apps/t3code.png" + +node "$(dirname "$0")/stage-runtime.mjs" "${app_dir}/usr/share/t3code" + +export QML_SOURCES_PATHS="$(cd "$(dirname "$0")/.." && pwd)/qml" +export OUTPUT="${build_dir}/t3code-qt-x86_64.AppImage" +"${tools_dir}/linuxdeploy-1-alpha-20251107-1" --appdir "${app_dir}" --plugin qt --output appimage +echo "AppImage at ${OUTPUT}" diff --git a/apps/desktop-qt/scripts/stage-runtime.mjs b/apps/desktop-qt/scripts/stage-runtime.mjs new file mode 100644 index 000000000000..82d692b079a2 --- /dev/null +++ b/apps/desktop-qt/scripts/stage-runtime.mjs @@ -0,0 +1,98 @@ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import { selectCliRuntimeExternalDependencies } from "../../../scripts/lib/cli-external-packages.ts"; + +const scriptDir = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const repoRoot = NodePath.resolve(scriptDir, "../../.."); +const destinationArg = process.argv[2]; +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone packaging script has no Effect runtime. +const hostPlatform = NodeOS.platform(); +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone packaging script has no Effect runtime. +const hostArchitecture = NodeOS.arch(); + +if (destinationArg === undefined) { + throw new Error("Usage: stage-runtime.mjs "); +} + +const destination = NodePath.resolve(destinationArg); +const serverDist = NodePath.join(repoRoot, "apps/server/dist"); +const nodePrefix = NodePath.resolve(NodePath.dirname(process.execPath), ".."); +const nodeLicense = NodePath.join(nodePrefix, "LICENSE"); +const nodeExecutableName = hostPlatform === "win32" ? "node.exe" : "node"; +const serverManifest = JSON.parse( + await NodeFSP.readFile(NodePath.join(repoRoot, "apps/server/package.json"), "utf8"), +); +const rootManifest = JSON.parse( + await NodeFSP.readFile(NodePath.join(repoRoot, "package.json"), "utf8"), +); + +await NodeFSP.access(NodePath.join(serverDist, "bin.mjs")); +await NodeFSP.access(NodePath.join(serverDist, "client/index.html")); +await NodeFSP.access(nodeLicense); +await NodeFSP.rm(destination, { recursive: true, force: true }); +await Promise.all([ + NodeFSP.mkdir(NodePath.join(destination, "host"), { recursive: true }), + NodeFSP.mkdir(NodePath.join(destination, "bin"), { recursive: true }), + NodeFSP.mkdir(NodePath.join(destination, "licenses/node"), { recursive: true }), +]); +await Promise.all([ + NodeFSP.copyFile( + NodePath.join(repoRoot, "apps/desktop-qt/host/main.ts"), + NodePath.join(destination, "host/main.ts"), + ), + NodeFSP.copyFile( + NodePath.join(repoRoot, "apps/desktop-qt/host/pairingUrl.ts"), + NodePath.join(destination, "host/pairingUrl.ts"), + ), + NodeFSP.cp(serverDist, NodePath.join(destination, "server"), { recursive: true }), + NodeFSP.copyFile(process.execPath, NodePath.join(destination, "bin", nodeExecutableName)), + NodeFSP.copyFile(nodeLicense, NodePath.join(destination, "licenses/node/LICENSE")), +]); +if (hostPlatform !== "win32") { + await NodeFSP.chmod(NodePath.join(destination, "bin", nodeExecutableName), 0o755); +} + +const dependencies = selectCliRuntimeExternalDependencies(serverManifest.dependencies); +await NodeFSP.writeFile( + NodePath.join(destination, "package.json"), + `${JSON.stringify( + { + name: "t3code-qt-runtime", + version: serverManifest.version, + private: true, + type: "module", + packageManager: rootManifest.packageManager, + dependencies, + }, + null, + 2, + )}\n`, +); + +const supportedArchitectures = [ + "supportedArchitectures:", + ` os: [${hostPlatform}]`, + ` cpu: [${hostArchitecture}]`, + ...(hostPlatform === "linux" ? [" libc: [glibc]"] : []), + "allowBuilds:", + " msgpackr-extract: true", + " node-pty: true", + "nodeLinker: hoisted", + "", +].join("\n"); +await NodeFSP.writeFile(NodePath.join(destination, "pnpm-workspace.yaml"), supportedArchitectures); + +const install = NodeChildProcess.spawnSync("vp", ["install", "--prod"], { + cwd: destination, + stdio: "inherit", +}); +if (install.error) { + throw install.error; +} +if (install.status !== 0) { + throw new Error(`vp install --prod exited with ${String(install.status)}`); +} diff --git a/apps/desktop-qt/scripts/test-qml.mjs b/apps/desktop-qt/scripts/test-qml.mjs new file mode 100644 index 000000000000..dfd220c146a9 --- /dev/null +++ b/apps/desktop-qt/scripts/test-qml.mjs @@ -0,0 +1,52 @@ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +const appDir = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone test launcher has no Effect runtime. +const hostPlatform = NodeOS.platform(); +const executableName = hostPlatform === "win32" ? "qmltestrunner.exe" : "qmltestrunner"; +const testPlatform = process.env.T3_QML_TEST_PLATFORM ?? "offscreen"; + +function capture(command, args) { + const result = NodeChildProcess.spawnSync(command, args, { encoding: "utf8" }); + return result.status === 0 ? result.stdout.trim() : undefined; +} + +function findRunner() { + const binDirectories = [ + process.env.QT_ROOT_DIR, + process.env.QT_PREFIX, + ...(process.env.CMAKE_PREFIX_PATH?.split(NodePath.delimiter) ?? []), + ] + .filter(Boolean) + .map((prefix) => NodePath.join(prefix, "bin")); + binDirectories.push( + capture("qtpaths6", ["--query", "QT_INSTALL_BINS"]), + capture("qmake6", ["-query", "QT_INSTALL_BINS"]), + ); + for (const directory of binDirectories) { + if (!directory) continue; + const candidate = NodePath.join(directory, executableName); + if (NodeFS.existsSync(candidate)) return candidate; + } + return executableName; +} + +const runner = NodeChildProcess.spawnSync( + findRunner(), + ["-input", "tests", "-import", "tests/imports"], + { + cwd: appDir, + env: { + ...process.env, + QT_QPA_PLATFORM: testPlatform, + ...(testPlatform === "offscreen" ? { QT_QPA_PLATFORMTHEME: "" } : {}), + }, + stdio: "inherit", + }, +); +if (runner.error) throw runner.error; +process.exit(runner.status ?? 1); diff --git a/apps/desktop-qt/scripts/theme-from-terminal.mjs b/apps/desktop-qt/scripts/theme-from-terminal.mjs new file mode 100755 index 000000000000..07ce4abdda21 --- /dev/null +++ b/apps/desktop-qt/scripts/theme-from-terminal.mjs @@ -0,0 +1,316 @@ +#!/usr/bin/env node +// Dress the shell in the palette of the terminal this runs in. Asks the +// terminal for its colours over the OSC escape sequences every modern +// emulator answers (10 foreground, 11 background, 12 cursor, 17 selection, +// 4;n the sixteen ANSI slots), maps them onto the theme roles and writes +// theme.json into the shell config dir: +// +// vp run theme:qt # $T3CODE_HOME/shell, i.e. ~/.t3/shell +// vp run theme:qt ~/.t3/shell # or any directory (or a .json path) +// +// Run it inside the terminal whose colours you want; there is nothing to +// query through a pipe or an editor's task runner. The ANSI slots also land +// in the theme as ansiBlack … ansiBrightWhite for a rice to show. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeTty from "node:tty"; +import * as NodeURL from "node:url"; + +const ANSI_NAMES = [ + "Black", + "Red", + "Green", + "Yellow", + "Blue", + "Magenta", + "Cyan", + "White", + "BrightBlack", + "BrightRed", + "BrightGreen", + "BrightYellow", + "BrightBlue", + "BrightMagenta", + "BrightCyan", + "BrightWhite", +]; + +export const queryTerminal = () => + new Promise((resolve, reject) => { + let fd; + try { + fd = NodeFS.openSync("/dev/tty", "r+"); + } catch { + reject(new Error("no controlling terminal: run this inside the terminal you want to copy")); + return; + } + const tty = new NodeTty.ReadStream(fd); + tty.setRawMode(true); + const found = { ansi: [] }; + let buffer = ""; + let timer; + const finish = (error) => { + clearTimeout(timer); + tty.setRawMode(false); + tty.destroy(); + try { + NodeFS.closeSync(fd); + } catch {} + if (error) reject(error); + else resolve(found); + }; + const wanted = 4 + 16; + const seen = () => + ["foreground", "background", "cursor", "selection"].filter((key) => found[key]).length + + found.ansi.filter(Boolean).length; + tty.on("data", (chunk) => { + buffer += chunk.toString("latin1"); + // OSC replies: ESC ] ; [ ;] rgb:// (BEL | ESC \) + const reply = + // eslint-disable-next-line no-control-regex -- the reply is an escape sequence + /\x1b\](\d+);(?:(\d+);)?rgba?:([0-9a-f]+)\/([0-9a-f]+)\/([0-9a-f]+)(?:\/[0-9a-f]+)?(?:\x07|\x1b\\)/gi; + let match; + let consumed = 0; + while ((match = reply.exec(buffer)) !== null) { + const [, code, slot, r, g, b] = match; + const color = `#${[r, g, b].map((c) => c.slice(0, 2).padEnd(2, c[0])).join("")}`; + if (code === "4") found.ansi[Number(slot)] = color; + else if (code === "10") found.foreground = color; + else if (code === "11") found.background = color; + else if (code === "12") found.cursor = color; + else if (code === "17") found.selection = color; + consumed = reply.lastIndex; + } + buffer = buffer.slice(consumed); + if (seen() >= wanted) finish(); + else { + // Wait a beat for the rest; ESC alone means the user hit Escape. + clearTimeout(timer); + timer = setTimeout(() => finish(), 250); + } + }); + const queries = ["10", "11", "12", "17"].map((code) => `\x1b]${code};?\x07`); + for (let slot = 0; slot < 16; slot += 1) queries.push(`\x1b]4;${slot};?\x07`); + NodeFS.writeSync(fd, queries.join("")); + timer = setTimeout(() => finish(), 1500); + }); + +const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); +const hex = ([r, g, b]) => + `#${[r, g, b] + .map((c) => + Math.round(Math.max(0, Math.min(255, c))) + .toString(16) + .padStart(2, "0"), + ) + .join("")}`; +const mix = (a, b, t) => hex(rgb(a).map((c, i) => c + (rgb(b)[i] - c) * t)); +const alpha = (color, a) => + `${color}${Math.round(a * 255) + .toString(16) + .padStart(2, "0")}`; +const luminance = (color) => { + const [r, g, b] = rgb(color).map((c) => { + const channel = c / 255; + return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +}; +const contrast = (a, b) => { + const [high, low] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (high + 0.05) / (low + 0.05); +}; + +// Every theme role from the terminal's background, foreground and ANSI +// slots, with the hierarchy an editor theme has: chrome (sidebar, status +// line) a step darker than the canvas, surfaces stepping from the canvas +// toward the palette's bright black (what the terminal draws comments and +// inactive borders in) so panels keep the palette's tint instead of greying +// out, and muted text drawn as the comment colour rather than a wash of the +// foreground. Blue is the accent; red / yellow / green / cyan carry the +// semantic roles; hovers, selection and focus lean on the accent. +export const buildTheme = ({ background, foreground, cursor, selection, ansi }) => { + const bg = background; + const fg = foreground; + const dark = luminance(bg) < 0.35; + const slot = (index, fallback) => ansi[index] ?? fallback; + const accent = slot(4, slot(12, fg)); + const red = slot(1, slot(9, "#f7768e")); + const yellow = slot(3, slot(11, "#e0af68")); + const green = slot(2, slot(10, "#9ece6a")); + const magenta = slot(5, slot(13, accent)); + // Terminals draw bold in the bright-white slot; when it outshines the + // foreground it is the ink for titles and body text, and the foreground + // becomes the label tier a step behind it. + const brightWhite = slot(15, null); + const ink = brightWhite && contrast(brightWhite, bg) > contrast(fg, bg) ? brightWhite : fg; + const cyan = slot(6, slot(14, accent)); + // A bright black too close to the background (default xterm) or to the + // foreground (a light palette with a grey slot) is no use as a tint. + const brightBlack = slot(8, null); + const tone = + brightBlack && contrast(brightBlack, bg) >= 1.3 && contrast(brightBlack, fg) >= 1.5 + ? brightBlack + : mix(bg, fg, 0.5); + const raise = (t) => mix(bg, tone, t); + const tint = (t) => mix(bg, accent, t); + const onColor = (color) => (contrast(color, bg) >= 3 ? bg : ink); + const dimmer = (color) => (dark ? mix(color, ink, 0.15) : mix(color, "#000000", 0.35)); + const chrome = dark ? mix(bg, "#000000", 0.22) : mix(bg, "#000000", 0.04); + // Labels: the foreground when the ink is the bright slot, else the + // palette's normal white (the colour a terminal already uses for text a + // step back from the foreground) when it is distinct and readable, else + // a mix. Muted is the comment grey lifted toward the ink until it reads + // at 12px, kept a step below the label. + const white = slot(7, null); + const label = + fg !== ink + ? fg + : white && white !== fg && contrast(white, chrome) >= 4.5 + ? white + : mix(ink, bg, 0.25); + // Muted never outshines the label tier, however bright the comment grey. + const lifted = tone === brightBlack ? mix(tone, ink, 0.4) : mix(bg, ink, 0.55); + const muted = contrast(lifted, bg) < contrast(label, bg) ? lifted : mix(tone, label, 0.5); + const border = raise(0.9); + // The page's actions, bubbles and code carry the palette too: primary + // actions in the accent, secondary controls, chips and the user's own + // messages on an accent tint, outlines visible. + const colors = { + canvas: bg, + chrome, + toolbar: chrome, + toolbarForeground: ink, + toolbarBorder: border, + toolbarControl: tint(0.3), + toolbarControlForeground: ink, + toolbarControlHover: tint(0.5), + surface: raise(0.25), + surfaceRaised: raise(0.4), + surfaceOverlay: raise(0.32), + text: ink, + textMuted: muted, + border, + input: raise(0.75), + focus: accent, + accent, + accentForeground: onColor(accent), + secondary: tint(0.3), + secondaryForeground: ink, + muted: tint(0.25), + mutedForeground: muted, + placeholder: muted, + secondaryLabel: label, + iconMuted: label, + info: cyan, + success: green, + error: red, + errorForeground: dimmer(slot(9, red)), + errorSurface: mix(bg, red, 0.22), + warning: yellow, + warningForeground: dimmer(slot(11, yellow)), + warningSurface: mix(bg, yellow, 0.22), + update: green, + updateForeground: dimmer(slot(10, green)), + updateSurface: mix(bg, green, 0.22), + accentSurface: tint(0.34), + accentSurfaceForeground: ink, + messageSurface: tint(0.28), + messageForeground: ink, + messageAction: accent, + messageActionForeground: onColor(accent), + messageActionHover: mix(accent, fg, 0.15), + codeBackground: chrome, + codeForeground: ink, + sidebar: chrome, + sidebarForeground: ink, + sidebarMutedForeground: label, + sidebarControlSurface: raise(0.4), + sidebarRowHover: raise(0.35), + sidebarRowActive: tint(0.32), + sidebarRowSelected: tint(0.26), + sidebarBorder: border, + // The palette's own colours where the sidebar carries meaning: the + // project folder, the git branch, and the bar marking the open thread. + projectForeground: accent, + branchForeground: magenta, + sidebarActiveIndicator: accent, + terminalBackground: bg, + terminalForeground: fg, + terminalCursor: cursor ?? accent, + terminalSelection: selection ?? alpha(accent, 0.3), + terminalScrollbar: raise(0.6), + terminalScrollbarHover: raise(0.9), + }; + ANSI_NAMES.forEach((name, index) => { + if (ansi[index]) colors[`ansi${name}`] = ansi[index]; + }); + return { + version: 1, + id: "terminal-palette", + name: "Terminal palette", + appearance: dark ? "dark" : "light", + colors, + window: { opacity: 1, transparent: false, blur: false, frameless: true }, + radius: "4px", + fonts: { + ui: "JetBrains Mono, Menlo, monospace", + mono: "JetBrains Mono, Menlo, monospace", + }, + }; +}; + +// The shell config dir the way the app resolves it: T3CODE_HOME, then ~/.t3. +// A directory argument gets theme.json inside it; a .json argument is the +// file itself. +export const resolveOutput = (argument, env = process.env) => { + if (argument && NodePath.extname(argument) === ".json") return NodePath.resolve(argument); + const shellDir = + argument ?? NodePath.join(env.T3CODE_HOME || NodePath.join(NodeOS.homedir(), ".t3"), "shell"); + return NodePath.join(NodePath.resolve(shellDir), "theme.json"); +}; + +const USAGE = `usage: vp run theme:qt [shell-dir | theme.json] + +Asks the terminal it runs in for its colours (OSC 10/11/12/17 and 4;n) and +writes theme.json for the Qt shell. Without an argument the file goes to +$T3CODE_HOME/shell (~/.t3/shell); a directory or a .json path picks the spot.`; + +const main = async () => { + const [argument, ...rest] = process.argv.slice(2); + if (argument === "--help" || argument === "-h") { + console.log(USAGE); + return; + } + if (rest.length > 0 || argument?.startsWith("-")) { + throw new Error(`unknown option ${argument?.startsWith("-") ? argument : rest[0]}\n${USAGE}`); + } + const output = resolveOutput(argument); + const found = await queryTerminal(); + if (!found.background || !found.foreground) { + throw new Error( + "the terminal did not answer the colour queries (OSC 10/11); try a different emulator or outside tmux", + ); + } + const theme = buildTheme(found); + NodeFS.mkdirSync(NodePath.resolve(output, ".."), { recursive: true }); + NodeFS.writeFileSync(output, `${JSON.stringify(theme, null, 2)}\n`); + const swatch = (color) => { + const [r, g, b] = rgb(color.slice(0, 7)); + return `\x1b[48;2;${r};${g};${b}m \x1b[0m`; + }; + console.log( + `${swatch(found.background)}${swatch(found.foreground)}${found.ansi.map(swatch).join("")} -> ${output}`, + ); +}; + +if ( + process.argv[1] && + NodePath.resolve(process.argv[1]) === NodeURL.fileURLToPath(import.meta.url) +) { + main().catch((error) => { + console.error(error.message); + process.exit(1); + }); +} diff --git a/apps/desktop-qt/scripts/tooling.test.ts b/apps/desktop-qt/scripts/tooling.test.ts new file mode 100644 index 000000000000..7a01f5b4ef4c --- /dev/null +++ b/apps/desktop-qt/scripts/tooling.test.ts @@ -0,0 +1,77 @@ +// @effect-diagnostics nodeBuiltinImport:off - Runs the standalone tools against disposable on-disk fixtures. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +const directories: string[] = []; +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone tooling tests run without an Effect runtime. +const platform = NodeOS.platform(); +function temporaryDirectory() { + const path = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-qt-tooling-")); + directories.push(path); + return path; +} +afterEach(() => { + for (const directory of directories.splice(0)) + NodeFS.rmSync(directory, { recursive: true, force: true }); +}); + +describe.skipIf(platform === "win32")("Qt tooling", () => { + it("finds the test runner in a later CMAKE_PREFIX_PATH entry", () => { + const directory = temporaryDirectory(); + const prefix = NodePath.join(directory, "qt"); + NodeFS.mkdirSync(NodePath.join(prefix, "bin"), { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(prefix, "bin/qmltestrunner"), + '#!/bin/sh\nprintf "%s\\n" "$@"\n', + { mode: 0o755 }, + ); + const result = NodeChildProcess.spawnSync( + process.execPath, + [NodeURL.fileURLToPath(new URL("./test-qml.mjs", import.meta.url))], + { + encoding: "utf8", + env: { + ...process.env, + QT_ROOT_DIR: "", + QT_PREFIX: "", + CMAKE_PREFIX_PATH: [NodePath.join(directory, "missing"), prefix].join(NodePath.delimiter), + }, + }, + ); + expect(result.status).toBe(0); + expect(result.stdout.trim().split("\n")).toEqual([ + "-input", + "tests", + "-import", + "tests/imports", + ]); + }); + + it.skipIf(platform !== "linux")( + "rejects tampered cached packaging tools before installing or executing them", + () => { + const directory = temporaryDirectory(); + NodeFS.mkdirSync(NodePath.join(directory, "tools")); + NodeFS.mkdirSync(NodePath.join(directory, "AppDir")); + NodeFS.writeFileSync(NodePath.join(directory, "AppDir/keep"), "existing staging directory"); + NodeFS.writeFileSync( + NodePath.join(directory, "tools/linuxdeploy-1-alpha-20251107-1"), + "tampered tool", + { mode: 0o755 }, + ); + const result = NodeChildProcess.spawnSync( + "bash", + [NodeURL.fileURLToPath(new URL("./package-linux.sh", import.meta.url)), directory], + { encoding: "utf8" }, + ); + expect(result.status).not.toBe(0); + expect(NodeFS.readFileSync(NodePath.join(directory, "AppDir/keep"), "utf8")).toBe( + "existing staging directory", + ); + }, + ); +}); diff --git a/apps/desktop-qt/src/BackendProcess.cpp b/apps/desktop-qt/src/BackendProcess.cpp new file mode 100644 index 000000000000..b6b5d925d43f --- /dev/null +++ b/apps/desktop-qt/src/BackendProcess.cpp @@ -0,0 +1,96 @@ +#include "BackendProcess.h" + +#include +#include +#include +#include +#include + +BackendProcess::BackendProcess(Options options, QObject* parent) + : QObject(parent), m_options(std::move(options)) { + m_process.setProcessChannelMode(QProcess::SeparateChannels); + m_process.setReadChannel(QProcess::StandardOutput); + connect(&m_process, &QProcess::readyReadStandardOutput, this, &BackendProcess::readStdout); + connect(&m_process, &QProcess::readyReadStandardError, this, [this] { + const auto text = QString::fromUtf8(m_process.readAllStandardError()).trimmed(); + if (!text.isEmpty()) { + qInfo().noquote() << "[host]" << text; + } + }); + connect(&m_process, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) { + if (error == QProcess::FailedToStart) { + emit failed(QStringLiteral("Could not start %1 %2: %3") + .arg(m_options.nodeExecutable, m_options.hostEntry, m_process.errorString())); + } + }); + connect(&m_process, &QProcess::finished, this, [this](int exitCode, QProcess::ExitStatus status) { + if (m_stopping) { + return; + } + const QString how = status == QProcess::CrashExit ? QStringLiteral("crashed") + : QStringLiteral("normal exit"); + // A host that dies after announcing leaves the web view on a dead origin; + // say so instead of letting the page spin on reconnects forever. + emit failed(m_announced + ? QStringLiteral("Desktop host exited (code %1, %2). Restart T3 Code to reconnect.") + .arg(exitCode) + .arg(how) + : QStringLiteral("Desktop host exited before it was ready (code %1, %2).") + .arg(exitCode) + .arg(how)); + }); +} + +void BackendProcess::start() { + QStringList arguments{m_options.hostEntry}; + arguments << m_options.hostArguments; + m_process.start(m_options.nodeExecutable, arguments); +} + +void BackendProcess::stop() { + m_stopping = true; + if (m_process.state() == QProcess::NotRunning) { + return; + } + // Closing stdin is the host's signal that its parent is going away; it + // tears the server down itself. Escalate only if it does not. + m_process.closeWriteChannel(); + m_process.terminate(); + if (!m_process.waitForFinished(2000)) { + m_process.kill(); + m_process.waitForFinished(1000); + } +} + +void BackendProcess::readStdout() { + m_stdoutBuffer.append(m_process.readAllStandardOutput()); + int newline = -1; + while ((newline = m_stdoutBuffer.indexOf('\n')) >= 0) { + const QByteArray line = m_stdoutBuffer.left(newline).trimmed(); + m_stdoutBuffer.remove(0, newline + 1); + if (!line.isEmpty()) { + handleLine(line); + } + } +} + +void BackendProcess::handleLine(const QByteArray& line) { + const QJsonDocument doc = QJsonDocument::fromJson(line); + if (!doc.isObject()) { + qInfo().noquote() << "[host]" << QString::fromUtf8(line); + return; + } + const QJsonObject message = doc.object(); + const QString type = message.value(QStringLiteral("type")).toString(); + if (type == QStringLiteral("ready") && !m_announced) { + const QUrl url(message.value(QStringLiteral("url")).toString()); + if (!url.isValid()) { + emit failed(QStringLiteral("Desktop host announced an invalid URL.")); + return; + } + m_announced = true; + emit ready(url); + } else if (type == QStringLiteral("error")) { + emit failed(message.value(QStringLiteral("message")).toString()); + } +} diff --git a/apps/desktop-qt/src/BackendProcess.h b/apps/desktop-qt/src/BackendProcess.h new file mode 100644 index 000000000000..826b30f57577 --- /dev/null +++ b/apps/desktop-qt/src/BackendProcess.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include +#include + +// Spawns the Node desktop host and waits for its `ready` line. The shell never +// speaks to the server itself; it only needs the URL to hand to the web view. +class BackendProcess : public QObject { + Q_OBJECT + +public: + struct Options { + QString nodeExecutable; + QString hostEntry; + QStringList hostArguments; + }; + + explicit BackendProcess(Options options, QObject* parent = nullptr); + + void start(); + void stop(); + +signals: + void ready(const QUrl& url); + void failed(const QString& message); + +private: + void readStdout(); + void handleLine(const QByteArray& line); + + Options m_options; + QProcess m_process; + QByteArray m_stdoutBuffer; + bool m_announced = false; + bool m_stopping = false; +}; diff --git a/apps/desktop-qt/src/PlatformWindow.cpp b/apps/desktop-qt/src/PlatformWindow.cpp new file mode 100644 index 000000000000..daaf1f094497 --- /dev/null +++ b/apps/desktop-qt/src/PlatformWindow.cpp @@ -0,0 +1,3 @@ +#include "PlatformWindow.h" + +void applyWindowBlur(QWindow*, bool, bool) {} diff --git a/apps/desktop-qt/src/PlatformWindow.h b/apps/desktop-qt/src/PlatformWindow.h new file mode 100644 index 000000000000..ae49c6fe2830 --- /dev/null +++ b/apps/desktop-qt/src/PlatformWindow.h @@ -0,0 +1,8 @@ +#pragma once + +class QWindow; + +// Frosted backdrop behind a transparent window where the platform can draw it +// itself (macOS). Elsewhere the compositor owns blur (see the Linux notes in +// docs/internals/desktop-qt.md), so this is a no-op. +void applyWindowBlur(QWindow* window, bool enabled, bool dark); diff --git a/apps/desktop-qt/src/PlatformWindow.mm b/apps/desktop-qt/src/PlatformWindow.mm new file mode 100644 index 000000000000..e809030ab5f5 --- /dev/null +++ b/apps/desktop-qt/src/PlatformWindow.mm @@ -0,0 +1,71 @@ +#include "PlatformWindow.h" + +#import +#include + +#include + +namespace { + +// WindowServer blur behind a window: pure blur with no tint, so the theme's +// own alpha decides how much of it shows. The same call terminals use for +// their "background blur" option; not public API, hence the dlsym. +bool applyWindowServerBlur(NSWindow* native, int radius) { + using ConnectionFn = int (*)(); + using BlurFn = int (*)(int, NSInteger, int); + static const auto connection = + reinterpret_cast(dlsym(RTLD_DEFAULT, "CGSDefaultConnectionForThread")); + static const auto setBlur = + reinterpret_cast(dlsym(RTLD_DEFAULT, "CGSSetWindowBackgroundBlurRadius")); + if (connection == nullptr || setBlur == nullptr) { + return false; + } + return setBlur(connection(), native.windowNumber, radius) == 0; +} + +void applyEffectView(NSWindow* native, bool enabled, bool dark) { + NSVisualEffectView* backdrop = nil; + if ([native.contentView isKindOfClass:[NSVisualEffectView class]]) { + backdrop = (NSVisualEffectView*)native.contentView; + } else if (enabled) { + // Qt's view becomes a child of the effect view: the blur paints below it and + // Qt keeps drawing on a clear surface on top. + NSView* content = native.contentView; + backdrop = [[NSVisualEffectView alloc] initWithFrame:content.frame]; + backdrop.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + backdrop.blendingMode = NSVisualEffectBlendingModeBehindWindow; + native.contentView = backdrop; + content.frame = backdrop.bounds; + content.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + [backdrop addSubview:content]; + } + if (backdrop == nil) { + return; + } + backdrop.material = dark ? NSVisualEffectMaterialHUDWindow : NSVisualEffectMaterialPopover; + backdrop.state = enabled ? NSVisualEffectStateActive : NSVisualEffectStateInactive; + backdrop.appearance = + [NSAppearance appearanceNamed:dark ? NSAppearanceNameDarkAqua : NSAppearanceNameAqua]; +} + +} // namespace + +void applyWindowBlur(QWindow* window, bool enabled, bool dark) { + if (window == nullptr) { + return; + } + auto* qtView = reinterpret_cast(window->winId()); + NSWindow* native = qtView.window; + if (native == nil) { + return; + } + if (enabled) { + native.opaque = NO; + native.backgroundColor = NSColor.clearColor; + } + if (!applyWindowServerBlur(native, enabled ? 32 : 0)) { + applyEffectView(native, enabled, dark); + } else if ([native.contentView isKindOfClass:[NSVisualEffectView class]]) { + applyEffectView(native, false, dark); + } +} diff --git a/apps/desktop-qt/src/ShellBridge.cpp b/apps/desktop-qt/src/ShellBridge.cpp new file mode 100644 index 000000000000..b0c6fa74f63c --- /dev/null +++ b/apps/desktop-qt/src/ShellBridge.cpp @@ -0,0 +1,155 @@ +#include "ShellBridge.h" + +#include +#include +#include +#include +#include + +namespace { + +// Everything the web app publishes (see apps/web/src/shell/*Bridge.tsx) plus +// the shell's own `backendError`. +constexpr const char* kStateKeys[] = { + "backendError", "composer", "contextMenu", "git", "keybindings", "layout", + "notifications", "rightPanel", "settings", "sidebar", "theme", "workspace", +}; + +// Qt 6.11 deprecates the public constructor in favour of create(); the +// minimum supported Qt (6.9) only has the constructor. +QQmlPropertyMap* createStateMap(QObject* parent) { +#if QT_VERSION >= QT_VERSION_CHECK(6, 11, 0) + return QQmlPropertyMap::create(parent); +#else + return new QQmlPropertyMap(parent); +#endif +} + +} // namespace + +ShellBridge::ShellBridge(QObject* parent) + : QObject(parent), m_state(createStateMap(this)), m_channel(new ShellChannel(this)) { + for (const char* key : kStateKeys) { + m_state->insert(QString::fromLatin1(key), QVariant()); + } +} + +QObject* ShellBridge::channel() const { + return m_channel; +} + +QVariantMap ShellBridge::snapshot() const { + QVariantMap result; + for (const QString& key : m_state->keys()) { + const QVariant value = m_state->value(key); + if (value.isValid()) { + result.insert(key, value); + } + } + return result; +} + +void ShellBridge::setPageUrl(const QUrl& url) { + if (m_pageUrl == url) { + return; + } + m_pageUrl = url; + emit pageUrlChanged(); +} + +bool ShellBridge::isAppOrigin(const QUrl& url) const { + const auto scheme = m_pageUrl.scheme(); + const int defaultPort = scheme == QStringLiteral("https") ? 443 : 80; + return url.isValid() && m_pageUrl.isValid() && !m_pageUrl.host().isEmpty() && + (scheme == QStringLiteral("http") || scheme == QStringLiteral("https")) && + url.scheme() == scheme && url.host() == m_pageUrl.host() && + url.port(defaultPort) == m_pageUrl.port(defaultPort); +} + +QUrl ShellBridge::webChannelScriptUrl() const { + return QUrl(QStringLiteral(T3_WEBCHANNEL_SCRIPT_URL)); +} + +void ShellBridge::publish(const QString& key, const QVariant& value) { + // An unchanged republish would re-evaluate every binding on the key for + // nothing, and echo it to every page following the state. + if (m_state->contains(key) && m_state->value(key) == value) { + return; + } + m_state->insert(key, value); + emit stateEntryChanged(key, value); +} + +void ShellBridge::openExternal(const QUrl& url) { + const auto scheme = url.scheme(); + if (scheme != QStringLiteral("http") && scheme != QStringLiteral("https") && + scheme != QStringLiteral("mailto")) { + return; + } + QDesktopServices::openUrl(url); +} + +void ShellBridge::setColorScheme(const QString& scheme) { + if (m_colorScheme == scheme) { + return; + } + m_colorScheme = scheme; + emit colorSchemeChanged(); +} + +void ShellBridge::windowCommand(const QString& command) { + emit windowCommandRequested(command); +} + +void ShellBridge::dispatch(const QString& action, const QVariant& payload) { + emit actionRequested(action, payload); +} + +void ShellBridge::notifyPageLoaded(bool ok, const QUrl& url) { + emit pageLoaded(ok, url); +} + +QVariantList ShellBridge::readImageFiles(const QList& urls) const { + constexpr qint64 kMaxBytes = 10 * 1024 * 1024; + QMimeDatabase mimeDatabase; + QVariantList result; + for (const QUrl& url : urls) { + if (!url.isLocalFile()) { + continue; + } + const QString path = url.toLocalFile(); + const QMimeType mime = mimeDatabase.mimeTypeForFile(path); + if (!mime.name().startsWith(QStringLiteral("image/"))) { + qInfo().noquote() << "[shell] skipping non-image attachment" << path; + continue; + } + QFile file(path); + if (file.size() > kMaxBytes || !file.open(QIODevice::ReadOnly)) { + qInfo().noquote() << "[shell] skipping attachment (too large or unreadable)" << path; + continue; + } + result.append(QVariantMap{ + {QStringLiteral("name"), QFileInfo(path).fileName()}, + {QStringLiteral("mimeType"), mime.name()}, + {QStringLiteral("base64"), QString::fromLatin1(file.readAll().toBase64())}, + }); + } + return result; +} + +ShellChannel::ShellChannel(ShellBridge* bridge) : QObject(bridge), m_bridge(bridge) { + connect(bridge, &ShellBridge::actionRequested, this, &ShellChannel::actionRequested); + connect(bridge, &ShellBridge::stateEntryChanged, this, &ShellChannel::stateEntryChanged); +} + +void ShellChannel::publish(const QString& key, const QVariant& value) { + m_bridge->publish(key, value); +} + +void ShellChannel::dispatch(const QString& action, const QVariant& payload) { + m_bridge->dispatch(action, payload); +} + +QVariantMap ShellChannel::snapshot() const { + return m_bridge->snapshot(); +} diff --git a/apps/desktop-qt/src/ShellBridge.h b/apps/desktop-qt/src/ShellBridge.h new file mode 100644 index 000000000000..88a9271c8887 --- /dev/null +++ b/apps/desktop-qt/src/ShellBridge.h @@ -0,0 +1,92 @@ +#pragma once + +#include +#include +#include +#include +#include + +class ShellChannel; + +// The `Shell` singleton QML bricks read from. State flows web -> QML through +// `publish`; actions flow QML -> web through `dispatch`/`actionRequested`. Qt +// holds no domain logic: it stores whatever the web app publishes and relays +// it. Pages reach it through `channel`, never through this object directly. +class ShellBridge : public QObject { + Q_OBJECT + Q_PROPERTY(int protocolVersion READ protocolVersion CONSTANT) + // One QML property per published key (Shell.state.composer, ...), so a + // publish only re-evaluates the bindings that read that key. Keys the page + // may publish are declared up front; a binding made before its key exists + // would never see the value. + Q_PROPERTY(QQmlPropertyMap* state READ state CONSTANT) + Q_PROPERTY(QObject* channel READ channel CONSTANT) + Q_PROPERTY(QUrl pageUrl READ pageUrl WRITE setPageUrl NOTIFY pageUrlChanged) + Q_PROPERTY(QUrl webChannelScriptUrl READ webChannelScriptUrl CONSTANT) + Q_PROPERTY(QString colorScheme READ colorScheme NOTIFY colorSchemeChanged) + +public: + explicit ShellBridge(QObject* parent = nullptr); + + int protocolVersion() const { return 1; } + QQmlPropertyMap* state() const { return m_state; } + QObject* channel() const; + QVariantMap snapshot() const; + QUrl pageUrl() const { return m_pageUrl; } + void setPageUrl(const QUrl& url); + QUrl webChannelScriptUrl() const; + QString colorScheme() const { return m_colorScheme; } + + // Called by the web app (via the channel) with its view models. + Q_INVOKABLE void publish(const QString& key, const QVariant& value); + Q_INVOKABLE void openExternal(const QUrl& url); + Q_INVOKABLE void setColorScheme(const QString& scheme); + Q_INVOKABLE void windowCommand(const QString& command); + + // Called by QML bricks; delivered to the web app as `actionRequested`. + Q_INVOKABLE void dispatch(const QString& action, const QVariant& payload = QVariant()); + // Reads image files for the composer: [{name, mimeType, base64}], skipping + // anything that is not an image or is over the page's size limit. + Q_INVOKABLE QVariantList readImageFiles(const QList& urls) const; + // Called by WebSurface when a top-level navigation finishes. + Q_INVOKABLE void notifyPageLoaded(bool ok, const QUrl& url); + // Permissions belong only to the configured app origin, including its port. + Q_INVOKABLE bool isAppOrigin(const QUrl& url) const; + +signals: + void stateEntryChanged(const QString& key, const QVariant& value); + void pageUrlChanged(); + void colorSchemeChanged(); + void actionRequested(const QString& action, const QVariant& payload); + void windowCommandRequested(const QString& command); + void pageLoaded(bool ok, const QUrl& url); + +private: + QQmlPropertyMap* m_state; + ShellChannel* m_channel; + QUrl m_pageUrl; + QString m_colorScheme = QStringLiteral("system"); +}; + +// The one object registered on every surface's WebChannel. It has no +// properties on purpose: QWebChannel re-serializes a changed property to +// every connected page, which for the state map meant every publish (each +// composer keystroke) echoed the whole map back to both surfaces. Pages that +// need the map pull `snapshot` once and follow `stateEntryChanged`. +class ShellChannel : public QObject { + Q_OBJECT + +public: + explicit ShellChannel(ShellBridge* bridge); + + Q_INVOKABLE void publish(const QString& key, const QVariant& value); + Q_INVOKABLE void dispatch(const QString& action, const QVariant& payload); + Q_INVOKABLE QVariantMap snapshot() const; + +signals: + void actionRequested(const QString& action, const QVariant& payload); + void stateEntryChanged(const QString& key, const QVariant& value); + +private: + ShellBridge* m_bridge; +}; diff --git a/apps/desktop-qt/src/ShellRuntime.cpp b/apps/desktop-qt/src/ShellRuntime.cpp new file mode 100644 index 000000000000..41ed81023487 --- /dev/null +++ b/apps/desktop-qt/src/ShellRuntime.cpp @@ -0,0 +1,297 @@ +#include "ShellRuntime.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "PlatformWindow.h" +#include "ShellBridge.h" +#include "ThemeStore.h" + +namespace { + +const QStringList kWatchedSuffixes{QStringLiteral("qml"), QStringLiteral("js"), + QStringLiteral("qmldir")}; + +bool isWatchedSource(const QFileInfo& info) { + return kWatchedSuffixes.contains(info.suffix()) || info.fileName() == QStringLiteral("qmldir"); +} + +} // namespace + +ShellRuntime::ShellRuntime(Options options, ShellBridge* bridge, ThemeStore* theme, QObject* parent) + : QObject(parent), m_options(std::move(options)), m_bridge(bridge), m_theme(theme) { + // These instances, including WebProfile registered by main, belong to one + // engine for its entire lifetime. Reload replaces only the root objects. + qmlRegisterSingletonInstance("T3.Shell", 1, 0, "Shell", m_bridge); + qmlRegisterSingletonInstance("T3.Shell", 1, 0, "Theme", m_theme); + qmlRegisterSingletonInstance("T3.Shell", 1, 0, "Runtime", this); + + m_engine = new QQmlApplicationEngine(this); + connect(m_engine, &QQmlEngine::warnings, this, [](const QList& warnings) { + for (const auto& warning : warnings) { + qWarning().noquote() << "[qml]" << warning.toString(); + } + }); + + m_debounce.setSingleShot(true); + m_debounce.setInterval(120); + connect(&m_debounce, &QTimer::timeout, this, [this] { + rebuildWatchList(); + const QString next = sourceFingerprint(); + if (next == m_fingerprint) { + return; + } + reload(); + }); + connect(&m_watcher, &QFileSystemWatcher::directoryChanged, &m_debounce, + qOverload<>(&QTimer::start)); + connect(&m_watcher, &QFileSystemWatcher::fileChanged, &m_debounce, qOverload<>(&QTimer::start)); + connect(m_theme, &ThemeStore::themeChanged, this, &ShellRuntime::applyWindowTheme); +} + +ShellRuntime::~ShellRuntime() { + delete m_engine; +} + +QString ShellRuntime::userShellPath() const { + return QDir(m_options.configDir).filePath(QStringLiteral("shell.qml")); +} + +QString ShellRuntime::appVersion() const { + return QStringLiteral(T3_APP_VERSION); +} + +QUrl ShellRuntime::defaultShellUrl() const { + if (!m_options.qmlSourceDir.isEmpty()) { + const QString onDisk = + QDir(m_options.qmlSourceDir).filePath(QStringLiteral("T3/Bricks/DefaultShell.qml")); + if (QFileInfo::exists(onDisk)) { + return QUrl::fromLocalFile(onDisk); + } + } + return QUrl(QStringLiteral("qrc:/qt/qml/T3/Bricks/DefaultShell.qml")); +} + +void ShellRuntime::start() { + rebuildWatchList(); + reload(); +} + +void ShellRuntime::reload() { + const auto previous = m_engine->rootObjects(); + const bool previousUsingUserShell = m_usingUserShell; + // Old roots keep their own QML types until the replacement loads. Only + // C++ singleton state crosses generations; QML-created objects never do. + m_engine->clearComponentCache(); + m_lastError.clear(); + m_usingUserShell = false; + + QString error; + const QString userShell = userShellPath(); + bool loaded = false; + if (QFileInfo::exists(userShell)) { + loaded = loadGeneration(QUrl::fromLocalFile(userShell), &error); + m_usingUserShell = loaded; + if (!loaded) { + m_lastError = error; + qWarning().noquote() << "[shell] user shell failed, falling back to default:\n" << error; + } + } + if (!loaded) { + QString defaultError; + loaded = loadGeneration(defaultShellUrl(), &defaultError); + if (!loaded) { + m_lastError = m_lastError.isEmpty() ? defaultError : m_lastError + QLatin1Char('\n') + defaultError; + qCritical().noquote() << "[shell] default shell failed:\n" << defaultError; + } + } + + if (!loaded) { + // Neither shell loaded: keep the previous generation on screen so the + // app never loses its window; the overlay shows lastError until the next + // source change retries. + m_usingUserShell = previousUsingUserShell; + m_fingerprint = sourceFingerprint(); + emit generationChanged(); + return; + } + // The new window exists before the old one goes, so the app never hits + // "last window closed" mid-reload. + for (QObject* root : previous) { + root->deleteLater(); + } + applyWindowTheme(); + m_fingerprint = sourceFingerprint(); + ++m_generation; + qInfo().noquote() << "[shell] generation" << m_generation << "loaded from" + << (m_usingUserShell ? userShellPath() : defaultShellUrl().toString()); + emit generationChanged(); +} + +bool ShellRuntime::loadGeneration(const QUrl& rootUrl, QString* errorOut) { + auto* engine = m_engine; + const auto previousRootCount = engine->rootObjects().size(); + if (!m_options.qmlSourceDir.isEmpty()) { + engine->addImportPath(m_options.qmlSourceDir); + } + const QString userImports = QDir(m_options.configDir).filePath(QStringLiteral("qml")); + if (QDir(userImports).exists()) { + engine->addImportPath(userImports); + } + + // Temporary diagnostic connections must not outlive the captured locals. + QStringList messages; + const auto warningsDuringLoad = + connect(engine, &QQmlEngine::warnings, this, [&messages](const QList& warnings) { + for (const auto& warning : warnings) { + messages << warning.toString(); + } + }); + bool failed = false; + const auto creationFailed = connect(engine, &QQmlApplicationEngine::objectCreationFailed, this, + [&failed](const QUrl&) { failed = true; }); + + engine->load(rootUrl); + disconnect(warningsDuringLoad); + disconnect(creationFailed); + const auto roots = engine->rootObjects(); + bool hasWindow = false; + for (auto index = previousRootCount; index < roots.size(); ++index) { + hasWindow |= qobject_cast(roots.at(index)) != nullptr; + } + if (failed || !hasWindow) { + for (auto index = previousRootCount; index < roots.size(); ++index) { + delete roots.at(index); + } + if (!failed && roots.size() > previousRootCount) { + messages << QStringLiteral("Shell root must be a QQuickWindow: %1").arg(rootUrl.toString()); + } + if (errorOut != nullptr) { + *errorOut = messages.isEmpty() + ? QStringLiteral("Failed to load %1").arg(rootUrl.toString()) + : messages.join(QLatin1Char('\n')); + } + return false; + } + return true; +} + +void ShellRuntime::rebuildWatchList() { + QStringList directories; + const auto collect = [&directories](const QString& root) { + if (root.isEmpty() || !QDir(root).exists()) { + return; + } + directories << root; + QDirIterator it(root, QDir::Dirs | QDir::NoDotAndDotDot, QDirIterator::Subdirectories); + while (it.hasNext()) { + directories << it.next(); + } + }; + collect(m_options.configDir); + collect(m_options.qmlSourceDir); + + const QStringList watched = m_watcher.directories(); + QStringList added; + for (const auto& dir : directories) { + if (!watched.contains(dir)) { + added << dir; + } + } + if (!added.isEmpty()) { + m_watcher.addPaths(added); + } + const QString userShell = userShellPath(); + if (QFileInfo::exists(userShell) && !m_watcher.files().contains(userShell)) { + m_watcher.addPath(userShell); + } +} + +QString ShellRuntime::sourceFingerprint() const { + QCryptographicHash hash(QCryptographicHash::Sha1); + for (const auto& dir : m_watcher.directories()) { + QDirIterator it(dir, QDir::Files); + while (it.hasNext()) { + const QFileInfo info(it.next()); + if (!isWatchedSource(info)) { + continue; + } + hash.addData(info.absoluteFilePath().toUtf8()); + hash.addData(QByteArray::number(info.lastModified().toMSecsSinceEpoch())); + hash.addData(QByteArray::number(info.size())); + } + } + return QString::fromLatin1(hash.result().toHex()); +} + +QQuickWindow* ShellRuntime::rootWindow() const { + if (m_engine == nullptr) { + return nullptr; + } + const auto roots = m_engine->rootObjects(); + for (auto root = roots.crbegin(); root != roots.crend(); ++root) { + if (auto* window = qobject_cast(*root)) { + return window; + } + } + return nullptr; +} + +void ShellRuntime::applyWindowTheme() { + if (auto* window = rootWindow()) { + applyWindowBlur(window, m_theme->windowTransparent() && m_theme->windowBlur(), + m_theme->appearance() != QStringLiteral("light")); + } +} + +bool ShellRuntime::captureWindow(const QString& path) { + QQuickWindow* window = rootWindow(); + if (window == nullptr) { + return false; + } + const QImage image = window->grabWindow(); + if (image.isNull() || !image.save(path)) { + qWarning().noquote() << "[shell] screenshot failed:" << path; + return false; + } + qInfo().noquote() << "[shell] screenshot written:" << path; + return true; +} + +bool ShellRuntime::pressKey(const QString& chord) { + const QKeySequence sequence(chord, QKeySequence::PortableText); + if (sequence.count() != 1) { + qWarning().noquote() << "[shell] not a single key chord:" << chord; + return false; + } + QQuickWindow* window = rootWindow(); + if (window == nullptr) { + return false; + } + const QKeyCombination combination = sequence[0]; + const Qt::KeyboardModifiers modifiers = combination.keyboardModifiers(); + const int key = combination.key(); + // Printable keys carry their text like a real press would; chords with + // Ctrl/Alt/Meta produce none. + QString text; + if ((modifiers & ~Qt::ShiftModifier) == Qt::NoModifier && key >= Qt::Key_Space && + key <= Qt::Key_ydiaeresis) { + const QChar character(key); + text = modifiers.testFlag(Qt::ShiftModifier) ? character.toUpper() : character.toLower(); + } + using Delivery = QWindowSystemInterface::SynchronousDelivery; + QWindowSystemInterface::handleKeyEvent(window, QEvent::KeyPress, key, modifiers, text); + QWindowSystemInterface::handleKeyEvent(window, QEvent::KeyRelease, key, modifiers, + text); + return true; +} diff --git a/apps/desktop-qt/src/ShellRuntime.h b/apps/desktop-qt/src/ShellRuntime.h new file mode 100644 index 000000000000..658066d6986d --- /dev/null +++ b/apps/desktop-qt/src/ShellRuntime.h @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +class ShellBridge; +class ThemeStore; + +// Owns one QML engine: resolves which shell.qml to load (user +// config dir first, built-in default otherwise), watches the QML sources it +// loaded from disk, and replaces its root objects when they change. A broken +// user shell falls back to the default with `lastError` set so a bad rice can +// never lock the app. +class ShellRuntime : public QObject { + Q_OBJECT + Q_PROPERTY(QString configDir READ configDir CONSTANT) + Q_PROPERTY(QString userShellPath READ userShellPath CONSTANT) + Q_PROPERTY(bool usingUserShell READ usingUserShell NOTIFY generationChanged) + Q_PROPERTY(QString lastError READ lastError NOTIFY generationChanged) + Q_PROPERTY(int generation READ generation NOTIFY generationChanged) + Q_PROPERTY(bool hotReloadEnabled READ hotReloadEnabled CONSTANT) + Q_PROPERTY(QString appVersion READ appVersion CONSTANT) + +public: + struct Options { + QString configDir; + QString qmlSourceDir; // empty = compiled-in bricks only + }; + + ShellRuntime(Options options, ShellBridge* bridge, ThemeStore* theme, QObject* parent = nullptr); + ~ShellRuntime() override; + + QString configDir() const { return m_options.configDir; } + QString userShellPath() const; + bool usingUserShell() const { return m_usingUserShell; } + QString lastError() const { return m_lastError; } + int generation() const { return m_generation; } + bool hotReloadEnabled() const { return true; } + QString appVersion() const; + + void start(); + Q_INVOKABLE void reload(); + // Grabs the current root window into a PNG; used by --screenshot and + // available to bricks for the same purpose. + Q_INVOKABLE bool captureWindow(const QString& path); + // Presses a chord ("Ctrl+1", "Shift+Tab") on the root window through the + // platform input path, so shortcuts and focus behave as for a real key. + // Used by --key. + Q_INVOKABLE bool pressKey(const QString& chord); + +signals: + void generationChanged(); + +private: + bool loadGeneration(const QUrl& rootUrl, QString* errorOut); + void rebuildWatchList(); + QString sourceFingerprint() const; + QUrl defaultShellUrl() const; + QQuickWindow* rootWindow() const; + void applyWindowTheme(); + + Options m_options; + ShellBridge* m_bridge; + ThemeStore* m_theme; + QQmlApplicationEngine* m_engine = nullptr; + QFileSystemWatcher m_watcher; + QTimer m_debounce; + QString m_fingerprint; + QString m_lastError; + bool m_usingUserShell = false; + int m_generation = 0; +}; diff --git a/apps/desktop-qt/src/ThemeStore.cpp b/apps/desktop-qt/src/ThemeStore.cpp new file mode 100644 index 000000000000..c79040bd43ad --- /dev/null +++ b/apps/desktop-qt/src/ThemeStore.cpp @@ -0,0 +1,372 @@ +#include "ThemeStore.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// Role names are camelCase in the file and become `--app-theme-` on +// the page, mirroring APP_THEME_VARIABLES in apps/web/src/themePalette.ts. +bool isRoleName(const QString& name) { + static const QRegularExpression pattern(QStringLiteral("^[a-z][a-zA-Z0-9]*$")); + return pattern.match(name).hasMatch(); +} + +QString cssVariableForRole(const QString& role) { + if (role == QStringLiteral("terminalSelection")) { + return QStringLiteral("--app-theme-terminal-selection-background"); + } + QString kebab; + for (const QChar ch : role) { + if (ch.isUpper()) { + kebab += QLatin1Char('-'); + kebab += ch.toLower(); + } else { + kebab += ch; + } + } + return QStringLiteral("--app-theme-") + kebab; +} + +// Values land in inline styles; keep them to what a colour token can be. +bool isSafeColorValue(const QString& value) { + static const QRegularExpression pattern(QStringLiteral("^[a-zA-Z0-9#(),.%/ -]+$")); + return !value.isEmpty() && value.size() < 128 && pattern.match(value).hasMatch(); +} + +QString jsLiteral(const QJsonValue& value) { + return QString::fromUtf8(QJsonDocument(QJsonArray{value}).toJson(QJsonDocument::Compact)) + .mid(1) + .chopped(1); +} + +void mergeColors(QVariantMap& into, const QJsonObject& colors) { + for (auto it = colors.begin(); it != colors.end(); ++it) { + if (isRoleName(it.key()) && it.value().isString() && isSafeColorValue(it.value().toString())) { + into.insert(it.key(), it.value().toString()); + } + } +} + +} // namespace + +ThemeStore::ThemeStore(const QString& configDir, QObject* parent) + : QObject(parent), + m_configDir(configDir), + m_path(QDir(configDir).filePath(QStringLiteral("theme.json"))) { + m_debounce.setSingleShot(true); + m_debounce.setInterval(80); + connect(&m_debounce, &QTimer::timeout, this, &ThemeStore::reload); + connect(&m_watcher, &QFileSystemWatcher::fileChanged, this, [this] { + watch(); + scheduleReload(); + }); + connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, [this] { + watch(); + scheduleReload(); + }); + applyDefaults(); + watch(); + reload(); +} + +void ThemeStore::watch() { + if (QDir(m_configDir).exists() && !m_watcher.directories().contains(m_configDir)) { + m_watcher.addPath(m_configDir); + } + if (QFileInfo::exists(m_path) && !m_watcher.files().contains(m_path)) { + m_watcher.addPath(m_path); + } +} + +void ThemeStore::scheduleReload() { + m_debounce.start(); +} + +void ThemeStore::applyDefaults() { + m_loaded = false; + m_id.clear(); + m_name.clear(); + m_appearance.clear(); + m_colors.clear(); + m_radius.clear(); + m_fontUi.clear(); + m_fontMono.clear(); + m_windowOpacity = 1.0; + m_windowTransparent = false; + m_windowBlur = false; + m_frameless = true; + m_lastError.clear(); +} + +void ThemeStore::reload() { + QFile file(m_path); + if (!file.exists()) { + if (m_loaded || !m_lastContent.isEmpty() || !m_lastError.isEmpty()) { + m_lastContent.clear(); + applyDefaults(); + emit themeChanged(); + } + return; + } + if (!file.open(QIODevice::ReadOnly)) { + m_lastError = QStringLiteral("Cannot read %1").arg(m_path); + emit themeChanged(); + return; + } + const QByteArray content = file.readAll(); + if (content == m_lastContent && m_loaded && m_lastError.isEmpty()) { + return; + } + m_lastContent = content; + + QJsonParseError parseError; + const QJsonDocument doc = QJsonDocument::fromJson(content, &parseError); + if (parseError.error != QJsonParseError::NoError || !doc.isObject()) { + // Keep the previous good theme; a half-written file must not flash defaults. + m_lastError = QStringLiteral("theme.json: %1").arg(parseError.errorString()); + emit themeChanged(); + return; + } + + applyDefaults(); + const QJsonObject root = doc.object(); + m_loaded = true; + m_id = root.value(QStringLiteral("id")).toString(QStringLiteral("shell")); + m_name = root.value(QStringLiteral("name")).toString(m_id); + m_appearance = root.value(QStringLiteral("appearance")).toString() == QStringLiteral("light") + ? QStringLiteral("light") + : QStringLiteral("dark"); + + mergeColors(m_colors, root.value(QStringLiteral("colors")).toObject()); + mergeColors(m_colors, root.value(QStringLiteral("variants")).toObject().value(m_appearance).toObject()); + // Shell-only extras; the page keeps its own font and radius preferences. + m_radius = root.value(QStringLiteral("radius")).toString(); + const QJsonObject fonts = root.value(QStringLiteral("fonts")).toObject(); + m_fontUi = fonts.value(QStringLiteral("ui")).toString(); + m_fontMono = fonts.value(QStringLiteral("mono")).toString(); + + const QJsonObject window = root.value(QStringLiteral("window")).toObject(); + m_windowOpacity = qBound(0.1, window.value(QStringLiteral("opacity")).toDouble(1.0), 1.0); + m_windowTransparent = window.value(QStringLiteral("transparent")).toBool(false); + m_windowBlur = window.value(QStringLiteral("blur")).toBool(false); + m_frameless = window.value(QStringLiteral("frameless")).toBool(true); + + emit themeChanged(); +} + +QString ThemeStore::injectionScript() const { + // Applies the theme the same way the web app applies its own + // (applyThemeColorPreview in themePalette.ts): `data-theme-id` on + // plus inline `--app-theme-*` variables. It runs as a user script at + // document creation so the first paint is already in the shell's colours, + // and again on theme changes. Once the page claims the bootstrap mailbox, + // subsequent injections deliver the override to its theme module. Older + // pages retain the observer fallback. An empty theme removes the override. + QJsonObject vars; + for (auto it = m_colors.cbegin(); it != m_colors.cend(); ++it) { + vars.insert(cssVariableForRole(it.key()), it.value().toString()); + } + // The boot splash in index.html paints `--boot-*` until React mounts. + const auto boot = [&](const char* variable, const char* role) { + const QString value = m_colors.value(QLatin1String(role)).toString(); + if (!value.isEmpty()) { + vars.insert(QLatin1String(variable), value); + } + }; + boot("--boot-background", "canvas"); + boot("--boot-foreground", "text"); + boot("--boot-accent", "accent"); + const QJsonObject theme{ + {QStringLiteral("id"), m_loaded ? m_id : QString()}, + {QStringLiteral("dark"), m_appearance != QStringLiteral("light")}, + {QStringLiteral("vars"), vars}, + }; + return QStringLiteral( + "(() => {" + " const theme = %1;" + " const run = () => {" + " const root = document.documentElement;" + " const state = (window.__t3ShellTheme ||= {});" + " if (state.observer) { state.observer.disconnect(); state.observer = null; }" + " state.override = theme;" + " if (state.applyOverride) { state.applyOverride(theme); return; }" + " const restoreBackground = () => {" + " if (state.original) root.style.setProperty('background-color', state.original.background, state.original.backgroundPriority);" + " state.chromeApplied = false;" + " };" + " const restoreVariable = name => {" + " const previous = state.originalVars?.[name];" + " if (previous) root.style.setProperty(name, previous.value, previous.priority);" + " else root.style.removeProperty(name);" + " if (state.originalVars) delete state.originalVars[name];" + " };" + " if (!theme.id) {" + " for (const name of state.applied || []) restoreVariable(name);" + " state.applied = [];" + " if (state.original) {" + " for (const [name, value] of Object.entries(state.original.attributes)) {" + " if (value === null) root.removeAttribute(name); else root.setAttribute(name, value);" + " }" + " root.classList.toggle('dark', state.original.dark);" + " restoreBackground();" + " delete state.original;" + " }" + " return;" + " }" + " state.original ||= {" + " attributes: {'data-theme-id': root.getAttribute('data-theme-id'), 'data-theme-selected': root.getAttribute('data-theme-selected')}," + " dark: root.classList.contains('dark')," + " background: root.style.getPropertyValue('background-color')," + " backgroundPriority: root.style.getPropertyPriority('background-color')" + " };" + " state.originalVars ||= {};" + " for (const name of Object.keys(theme.vars)) {" + " state.originalVars[name] ||= {value: root.style.getPropertyValue(name), priority: root.style.getPropertyPriority(name)};" + " }" + " const chrome = theme.vars['--app-theme-chrome'] || '';" + " const probe = document.createElement('div');" + " probe.style.backgroundColor = chrome;" + " const chromeCss = probe.style.backgroundColor;" + " if (!chromeCss && state.chromeApplied) restoreBackground();" + " const stale = () =>" + " root.dataset.themeId !== theme.id ||" + " root.classList.contains('dark') !== theme.dark ||" + " (chromeCss && root.style.backgroundColor !== chromeCss) ||" + " Object.entries(theme.vars).some(([name, value]) => root.style.getPropertyValue(name) !== value);" + " const apply = () => {" + " root.dataset.themeId = theme.id;" + " root.dataset.themeSelected = 'true';" + " root.classList.toggle('dark', theme.dark);" + " for (const [name, value] of Object.entries(theme.vars)) root.style.setProperty(name, value);" + " if (chromeCss) { root.style.backgroundColor = chromeCss; state.chromeApplied = true; }" + " };" + " for (const name of state.applied || []) if (!(name in theme.vars)) restoreVariable(name);" + " state.applied = Object.keys(theme.vars);" + " apply();" + " state.observer = new MutationObserver(() => {" + " if (stale()) apply();" + " });" + " state.observer.observe(root, { attributes: true, attributeFilter: ['data-theme-id', 'class', 'style'] });" + " };" + " if (document.documentElement) run();" + " else new MutationObserver((_, observer) => {" + " if (document.documentElement) { observer.disconnect(); run(); }" + " }).observe(document, { childList: true });" + "})();") + .arg(jsLiteral(theme)); +} + +namespace { + +// CSS hex colours put alpha last (#rrggbbaa); Qt puts it first (#aarrggbb). +QColor parseCssColor(const QString& value) { + const QString trimmed = value.trimmed(); + if (trimmed.startsWith(QLatin1Char('#'))) { + if (trimmed.size() == 9) { + return QColor(QStringLiteral("#") + trimmed.mid(7, 2) + trimmed.mid(1, 6)); + } + if (trimmed.size() == 5) { + const QString r = trimmed.mid(1, 1), g = trimmed.mid(2, 1), b = trimmed.mid(3, 1), + a = trimmed.mid(4, 1); + return QColor(QStringLiteral("#") + a + a + r + r + g + g + b + b); + } + } + return QColor(trimmed); +} + +} // namespace + +QColor ThemeStore::color(const QString& role, const QColor& fallback) const { + for (const QVariantMap* source : {&m_colors, &m_pageColors}) { + const auto value = source->value(role).toString(); + if (value.isEmpty()) { + continue; + } + const QColor parsed = parseCssColor(value); + if (parsed.isValid()) { + return parsed; + } + } + return fallback; +} + +namespace { + +// The page publishes CSS font-family lists; QML wants one family and falls +// back on its own, so take the first installed non-generic entry, or the +// first non-generic one when nothing in the list is installed. +QString firstFontFamily(const QString& list) { + static const QStringList generic{QStringLiteral("system-ui"), QStringLiteral("sans-serif"), + QStringLiteral("serif"), QStringLiteral("monospace"), + QStringLiteral("ui-sans-serif"), QStringLiteral("ui-monospace"), + QStringLiteral("-apple-system"), QStringLiteral("BlinkMacSystemFont")}; + QStringList candidates; + for (QString family : list.split(QLatin1Char(','))) { + family = family.trimmed(); + if (family.startsWith(QLatin1Char('"')) || family.startsWith(QLatin1Char('\''))) { + family = family.mid(1); + } + if (family.endsWith(QLatin1Char('"')) || family.endsWith(QLatin1Char('\''))) { + family.chop(1); + } + if (!family.isEmpty() && !generic.contains(family)) { + candidates.append(family); + } + } + for (const QString& family : candidates) { + if (QFontDatabase::hasFamily(family)) { + return family; + } + } + return candidates.value(0); +} + +} // namespace + +qreal ThemeStore::radius() const { + if (!m_radius.isEmpty()) { + // Accept "8", "8px" or "0.5rem" (16px root). + QString value = m_radius.trimmed(); + if (value.endsWith(QStringLiteral("rem"))) { + return value.chopped(3).toDouble() * 16.0; + } + if (value.endsWith(QStringLiteral("px"))) { + value.chop(2); + } + bool ok = false; + const qreal parsed = value.toDouble(&ok); + if (ok) { + return parsed; + } + } + return m_pageRadius; +} + +QString ThemeStore::fontUi() const { + return m_fontUi.isEmpty() ? firstFontFamily(m_pageFontUi) : firstFontFamily(m_fontUi); +} + +QString ThemeStore::fontMono() const { + return m_fontMono.isEmpty() ? firstFontFamily(m_pageFontMono) : firstFontFamily(m_fontMono); +} + +void ThemeStore::applyPageTheme(const QVariant& theme) { + const QVariantMap map = theme.toMap(); + if (map.isEmpty()) { + return; + } + m_pageColors = map.value(QStringLiteral("colors")).toMap(); + m_pageAppearance = map.value(QStringLiteral("appearance")).toString(); + m_pageRadius = map.value(QStringLiteral("radius"), 8).toDouble(); + m_pageFontUi = map.value(QStringLiteral("fontUi")).toString(); + m_pageFontMono = map.value(QStringLiteral("fontMono")).toString(); + emit themeChanged(); +} diff --git a/apps/desktop-qt/src/ThemeStore.h b/apps/desktop-qt/src/ThemeStore.h new file mode 100644 index 000000000000..5daaffd81cf5 --- /dev/null +++ b/apps/desktop-qt/src/ThemeStore.h @@ -0,0 +1,96 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +// Loads `/theme.json`, watches it, and exposes it to QML (the +// `Theme` singleton) and to the web view. The file is the web app's own +// ThemeFile format (`{version, id, name, appearance, colors, variants}`, role +// names such as `canvas`, `chrome`, `text`, `sidebar`) plus a shell-only +// `window` section, so one file themes both halves and the web's Theme Editor +// can author it. +class ThemeStore : public QObject { + Q_OBJECT + Q_PROPERTY(bool loaded READ loaded NOTIFY themeChanged) + Q_PROPERTY(QString path READ path CONSTANT) + Q_PROPERTY(QString id READ id NOTIFY themeChanged) + Q_PROPERTY(QString name READ name NOTIFY themeChanged) + Q_PROPERTY(QString appearance READ appearance NOTIFY themeChanged) + Q_PROPERTY(QVariantMap colors READ colors NOTIFY themeChanged) + Q_PROPERTY(qreal radius READ radius NOTIFY themeChanged) + Q_PROPERTY(QString fontUi READ fontUi NOTIFY themeChanged) + Q_PROPERTY(QString fontMono READ fontMono NOTIFY themeChanged) + Q_PROPERTY(bool pageThemeLoaded READ pageThemeLoaded NOTIFY themeChanged) + Q_PROPERTY(qreal windowOpacity READ windowOpacity NOTIFY themeChanged) + Q_PROPERTY(bool windowTransparent READ windowTransparent NOTIFY themeChanged) + Q_PROPERTY(bool windowBlur READ windowBlur NOTIFY themeChanged) + Q_PROPERTY(bool frameless READ frameless NOTIFY themeChanged) + Q_PROPERTY(QString injectionScript READ injectionScript NOTIFY themeChanged) + Q_PROPERTY(QString lastError READ lastError NOTIFY themeChanged) + +public: + explicit ThemeStore(const QString& configDir, QObject* parent = nullptr); + + bool loaded() const { return m_loaded; } + QString path() const { return m_path; } + QString id() const { return m_id; } + QString name() const { return m_name; } + QString appearance() const { return m_loaded ? m_appearance : m_pageAppearance; } + QVariantMap colors() const { return m_colors; } + qreal radius() const; + QString fontUi() const; + QString fontMono() const; + bool pageThemeLoaded() const { return !m_pageColors.isEmpty(); } + qreal windowOpacity() const { return m_windowOpacity; } + bool windowTransparent() const { return m_windowTransparent; } + bool windowBlur() const { return m_windowBlur; } + bool frameless() const { return m_frameless; } + QString injectionScript() const; + QString lastError() const { return m_lastError; } + + // Resolved colour for a role (`canvas`, `text`, ...): theme.json first, then + // the page's published theme, then `fallback`. + Q_INVOKABLE QColor color(const QString& role, const QColor& fallback) const; + Q_INVOKABLE void reload(); + +public slots: + // The page's resolved theme (ShellThemeState) published under `theme`. + void applyPageTheme(const QVariant& theme); + +signals: + void themeChanged(); + +private: + void watch(); + void scheduleReload(); + void applyDefaults(); + + QString m_configDir; + QString m_path; + QFileSystemWatcher m_watcher; + QTimer m_debounce; + QByteArray m_lastContent; + + bool m_loaded = false; + QString m_id; + QString m_name; + QString m_appearance; + QVariantMap m_colors; + QString m_radius; + QString m_fontUi; + QString m_fontMono; + QVariantMap m_pageColors; + QString m_pageAppearance; + qreal m_pageRadius = 8; + QString m_pageFontUi; + QString m_pageFontMono; + qreal m_windowOpacity = 1.0; + bool m_windowTransparent = false; + bool m_windowBlur = false; + bool m_frameless = true; + QString m_lastError; +}; diff --git a/apps/desktop-qt/src/WebProfile.cpp b/apps/desktop-qt/src/WebProfile.cpp new file mode 100644 index 000000000000..92712a4dd02b --- /dev/null +++ b/apps/desktop-qt/src/WebProfile.cpp @@ -0,0 +1,49 @@ +#include "WebProfile.h" + +#include +#include +#include +#include + +namespace { + +// The app bundle, fonts and icons add up to a few megabytes; this keeps the +// cache from growing with every dev rebuild while still holding a few of them. +constexpr int kHttpCacheBytes = 64 * 1024 * 1024; + +} // namespace + +WebProfile::WebProfile(const QString& dataDir) + : m_lock(QDir(dataDir).filePath(QStringLiteral("lock"))), + m_profile(QQuickWebEngineProfile::defaultProfile()) { + QDir().mkpath(dataDir); + // Chromium cannot share a profile directory between processes. A second + // shell on the same T3 home keeps Qt's off-the-record default instead of + // corrupting the first one's session; cookies and cache then live in memory + // for its run. + if (!m_lock.tryLock(0)) { + qWarning().noquote() << "[web] profile" << dataDir + << "is in use by another shell; this instance keeps no browser state"; + } else { + m_profile->setStorageName(QStringLiteral("t3code")); + m_profile->setOffTheRecord(false); + m_profile->setPersistentStoragePath(dataDir); + m_profile->setCachePath(QDir(dataDir).filePath(QStringLiteral("cache"))); + m_profile->setHttpCacheType(QQuickWebEngineProfile::DiskHttpCache); + m_profile->setHttpCacheMaximumSize(kHttpCacheBytes); + m_profile->setPersistentCookiesPolicy(QQuickWebEngineProfile::ForcePersistentCookies); + m_profile->setPersistentPermissionsPolicy( + QQuickWebEngineProfile::PersistentPermissionsPolicy::StoreOnDisk); + } + + // Without a handler every download is dropped on the floor. Saving into + // the user's download folder (Chromium's default target, deduplicated by + // name) matches what the app expects from a browser. + QObject::connect(m_profile, &QQuickWebEngineProfile::downloadRequested, m_profile, + [](QQuickWebEngineDownloadRequest* download) { + qInfo().noquote() << "[web] download" << download->url().toString() << "->" + << QDir(download->downloadDirectory()) + .filePath(download->downloadFileName()); + download->accept(); + }); +} diff --git a/apps/desktop-qt/src/WebProfile.h b/apps/desktop-qt/src/WebProfile.h new file mode 100644 index 000000000000..79a4711e4cde --- /dev/null +++ b/apps/desktop-qt/src/WebProfile.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include + +class QQuickWebEngineProfile; + +// Configures Qt WebEngine's default profile as the one browser profile every +// web surface shares. The primary page and the embed panels get the same +// cookies, storage and HTTP cache, and all of it comes back from disk on the +// next start. The profile is registered as the `WebProfile` singleton in +// T3.Shell; a view that sets no profile lands on it too. +class WebProfile { +public: + // `dataDir` is the profile's on-disk home (storage, cache, lock). + explicit WebProfile(const QString& dataDir); + + QQuickWebEngineProfile* profile() const { return m_profile; } + +private: + QLockFile m_lock; + QQuickWebEngineProfile* m_profile; +}; diff --git a/apps/desktop-qt/src/main.cpp b/apps/desktop-qt/src/main.cpp new file mode 100644 index 000000000000..ecfbfec9e1fe --- /dev/null +++ b/apps/desktop-qt/src/main.cpp @@ -0,0 +1,252 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "BackendProcess.h" +#include "ShellBridge.h" +#include "ShellRuntime.h" +#include "ThemeStore.h" +#include "WebProfile.h" + +namespace { + +// T3 home, resolved the way the dev runner resolves it: `--home-dir`, then +// T3CODE_HOME, then ~/.t3. The rice and browser profile live beside the +// server's state, so a sandboxed home carries the whole app. +QString resolveHomeDir(const QString& override) { + if (!override.trimmed().isEmpty()) { + return QDir(override).absolutePath(); + } + const QString fromEnv = + QProcessEnvironment::systemEnvironment().value(QStringLiteral("T3CODE_HOME")); + return fromEnv.isEmpty() ? QDir::home().filePath(QStringLiteral(".t3")) + : QDir(fromEnv).absolutePath(); +} + +QString resolveConfigDir(const QString& override, const QString& homeDir) { + if (!override.isEmpty()) { + return QDir(override).absolutePath(); + } + return QDir(homeDir).absoluteFilePath(QStringLiteral("shell")); +} + +QString resolveQmlSourceDir(const QString& override) { + if (!override.isNull()) { + return override; + } + const QString fromEnv = + QProcessEnvironment::systemEnvironment().value(QStringLiteral("T3CODE_QML_DIR")); + if (!fromEnv.isEmpty()) { + return fromEnv; + } + return QStringLiteral(T3_QML_SOURCE_DIR); +} + +QString resolveDefaultHostEntry() { + const QString configured = QStringLiteral(T3_HOST_ENTRY); + return QDir::isAbsolutePath(configured) + ? configured + : QDir(QCoreApplication::applicationDirPath()).absoluteFilePath(configured); +} + +QString resolveDefaultNodeExecutable() { + const QString fromEnv = + QProcessEnvironment::systemEnvironment().value(QStringLiteral("T3CODE_NODE")); + if (!fromEnv.isEmpty()) { + return fromEnv; + } + const QString configured = QStringLiteral(T3_NODE_ENTRY); + if (QDir::isAbsolutePath(configured)) { + return configured; + } + if (configured.contains(QLatin1Char('/')) || configured.contains(QLatin1Char('\\'))) { + return QDir(QCoreApplication::applicationDirPath()).absoluteFilePath(configured); + } + return configured; +} + +} // namespace + +int main(int argc, char* argv[]) { + QCoreApplication::setOrganizationName(QStringLiteral("T3 Tools")); + QCoreApplication::setOrganizationDomain(QStringLiteral("t3.codes")); + QCoreApplication::setApplicationName(QStringLiteral("t3code")); + QCoreApplication::setApplicationVersion(QStringLiteral(T3_APP_VERSION)); + // Stable app id so compositor rules (blur, opacity, workspace) can target it. + QGuiApplication::setDesktopFileName(QStringLiteral("t3code")); + + // Chromium's classic scrollbars paint a thumb in the page's scrollbar + // gutters; overlay scrollbars match what the app expects from browsers. + if (!qEnvironmentVariableIsSet("QTWEBENGINE_CHROMIUM_FLAGS")) { + qputenv("QTWEBENGINE_CHROMIUM_FLAGS", "--enable-features=OverlayScrollbar"); + } + QtWebEngineQuick::initialize(); + QGuiApplication app(argc, argv); + + QCommandLineParser parser; + parser.setApplicationDescription(QStringLiteral("T3 Code Qt shell")); + parser.addHelpOption(); + parser.addVersionOption(); + const QCommandLineOption urlOption( + QStringLiteral("url"), + QStringLiteral("Load this URL instead of spawning the desktop host (dev attach mode)."), + QStringLiteral("url")); + const QCommandLineOption configDirOption( + QStringLiteral("config-dir"), + QStringLiteral("Directory holding shell.qml, theme.json and qml/ (default $T3CODE_HOME/shell, i.e. ~/.t3/shell)."), + QStringLiteral("dir")); + const QCommandLineOption homeDirOption( + QStringLiteral("home-dir"), + QStringLiteral("T3 Code data directory for the shell profile, rice and server."), + QStringLiteral("dir")); + const QCommandLineOption qmlDirOption( + QStringLiteral("qml-dir"), + QStringLiteral("Load the built-in bricks from this directory instead of the binary."), + QStringLiteral("dir")); + const QCommandLineOption hostEntryOption( + QStringLiteral("host-entry"), QStringLiteral("Path to the Node desktop host entry."), + QStringLiteral("file"), resolveDefaultHostEntry()); + const QCommandLineOption nodeOption( + QStringLiteral("node"), QStringLiteral("Node executable used to run the desktop host."), + QStringLiteral("path"), resolveDefaultNodeExecutable()); + const QCommandLineOption screenshotOption( + QStringLiteral("screenshot"), + QStringLiteral("Write a PNG of the window once the page has loaded, then quit."), + QStringLiteral("file")); + const QCommandLineOption actionOption( + QStringLiteral("action"), + QStringLiteral("Dispatch a shell action after the page loads, e.g. rightPanel.toggle. " + "Repeatable; runs in order."), + QStringLiteral("name[=json]")); + const QCommandLineOption keyOption( + QStringLiteral("key"), + QStringLiteral("Press a key chord after the page loads, e.g. Ctrl+1 (portable QKeySequence " + "names). Repeatable; runs in command-line order together with --action."), + QStringLiteral("chord")); + parser.addOptions({urlOption, configDirOption, homeDirOption, qmlDirOption, hostEntryOption, + nodeOption, screenshotOption, actionOption, keyOption}); + parser.process(app); + + const QString homeDir = resolveHomeDir(parser.value(homeDirOption)); + const QString configDir = resolveConfigDir(parser.value(configDirOption), homeDir); + const QString qmlSourceDir = + resolveQmlSourceDir(parser.isSet(qmlDirOption) ? parser.value(qmlDirOption) : QString()); + qInfo().noquote() << "[shell] config dir:" << configDir; + if (!qmlSourceDir.isEmpty()) { + qInfo().noquote() << "[shell] bricks from disk:" << qmlSourceDir; + } + + // Configured before any engine exists so the first page already lands on it. + WebProfile webProfile(QDir(homeDir).filePath(QStringLiteral("userdata/shell-web"))); + qmlRegisterSingletonInstance("T3.Shell", 1, 0, "WebProfile", webProfile.profile()); + + ShellBridge bridge; + ThemeStore theme(configDir); + ShellRuntime runtime({configDir, qmlSourceDir}, &bridge, &theme); + // The page publishes its resolved theme; without a theme.json it is the + // shell's palette. + QObject::connect(&bridge, &ShellBridge::stateEntryChanged, &theme, + [&theme](const QString& key, const QVariant& value) { + if (key == QStringLiteral("theme")) { + theme.applyPageTheme(value); + } + }); + + BackendProcess::Options backendOptions; + backendOptions.nodeExecutable = parser.value(nodeOption); + backendOptions.hostEntry = parser.value(hostEntryOption); + backendOptions.hostArguments = parser.positionalArguments(); + if (parser.isSet(homeDirOption)) { + backendOptions.hostArguments.prepend(QStringLiteral("--base-dir=%1").arg(homeDir)); + } + BackendProcess backend(backendOptions); + QObject::connect(&backend, &BackendProcess::ready, &bridge, &ShellBridge::setPageUrl); + QObject::connect(&backend, &BackendProcess::failed, &bridge, [&bridge](const QString& message) { + qCritical().noquote() << "[shell]" << message; + bridge.publish(QStringLiteral("backendError"), message); + }); + QObject::connect(&app, &QCoreApplication::aboutToQuit, &backend, &BackendProcess::stop); + + if (parser.isSet(urlOption)) { + bridge.setPageUrl(QUrl::fromUserInput(parser.value(urlOption))); + } else { + backend.start(); + } + + // Scripted runs: replay --action and --key steps in command-line order once + // the page is up, then optionally grab the window and quit. Only the first + // load triggers this. + struct ScriptedStep { + bool isKey; + QString spec; + }; + QList scriptedSteps; + { + QStringList actions = parser.values(actionOption); + QStringList keys = parser.values(keyOption); + for (const QString& name : parser.optionNames()) { + if (name == QStringLiteral("action")) { + scriptedSteps.append({false, actions.takeFirst()}); + } else if (name == QStringLiteral("key")) { + scriptedSteps.append({true, keys.takeFirst()}); + } + } + } + const bool screenshotRequested = parser.isSet(screenshotOption); + if (!scriptedSteps.isEmpty() || screenshotRequested) { + const QString target = parser.value(screenshotOption); + QObject::connect(&bridge, &ShellBridge::pageLoaded, &runtime, + [&runtime, &bridge, &app, target, scriptedSteps, + screenshotRequested](bool ok) { + if (!ok) { + qWarning().noquote() << "[shell] page failed to load; scripted run aborted"; + if (screenshotRequested) { + app.exit(2); + } + return; + } + int delay = 1500; + for (const ScriptedStep& step : scriptedSteps) { + if (step.isKey) { + QTimer::singleShot(delay, &runtime, [&runtime, step] { + qInfo().noquote() << "[shell] scripted key" << step.spec; + runtime.pressKey(step.spec); + }); + delay += 1500; + continue; + } + const QString spec = step.spec; + QTimer::singleShot(delay, &bridge, [&bridge, spec] { + const int eq = spec.indexOf(QLatin1Char('=')); + const QString name = eq < 0 ? spec : spec.left(eq); + QVariant payload; + if (eq >= 0) { + payload = QJsonDocument::fromJson(spec.mid(eq + 1).toUtf8()) + .toVariant(); + } + qInfo().noquote() << "[shell] scripted action" << name; + bridge.dispatch(name, payload); + }); + delay += 1500; + } + if (screenshotRequested) { + QTimer::singleShot(delay + 1500, &runtime, [&runtime, &app, target] { + const bool ok = runtime.captureWindow(target); + app.exit(ok ? 0 : 2); + }); + } + }, + Qt::SingleShotConnection); + } + + runtime.start(); + return app.exec(); +} diff --git a/apps/desktop-qt/tests/imports/T3/Shell/Shell.qml b/apps/desktop-qt/tests/imports/T3/Shell/Shell.qml new file mode 100644 index 000000000000..d6b705c2d1b0 --- /dev/null +++ b/apps/desktop-qt/tests/imports/T3/Shell/Shell.qml @@ -0,0 +1,97 @@ +pragma Singleton +import QtQuick + +QtObject { + id: shell + + property var state: ({ + composer: defaultComposer(), + workspace: null + }) + property var dispatchedActions: [] + property int dispatchCount: 0 + property bool echoTextEdits: true + + signal actionRequested(string action, var payload) + + function defaultComposer() { + return { + target: "thread-a", + edit: null, + text: "", + cursor: 0, + suggestions: [], + triggerKind: null, + suggestionsEmptyText: null, + instances: [], + options: [], + attachments: [], + terminalContexts: [], + placeholder: qsTr("Send a message"), + editorDisabled: false, + canSend: true, + selectedInstanceId: null, + selectedModel: null, + runtimeMode: "approval-required", + runtimeModes: [], + showInteractionModeToggle: false, + interactionMode: "default", + pendingApprovalCount: 0, + showPlanFollowUpPrompt: false, + isRunning: false + }; + } + + function reset() { + echoTextEdits = true; + dispatchedActions = []; + dispatchCount = 0; + state = { + composer: defaultComposer(), + workspace: null + }; + } + + function publishComposerText(text, cursor, edit = state.composer.edit) { + state = { + composer: Object.assign({}, state.composer, { + text: text, + edit: edit, + cursor: cursor + }), + workspace: state.workspace + }; + } + + function publishComposerTarget(target, text, cursor) { + state = { + composer: Object.assign({}, state.composer, { + target: target, + edit: null, + text: text, + cursor: cursor + }), + workspace: state.workspace + }; + } + + function dispatch(action, payload) { + dispatchedActions = dispatchedActions.concat([ + { + action: action, + payload: payload + } + ]); + dispatchCount += 1; + if (echoTextEdits && action === "composer.text.set" && payload.target === state.composer.target) { + publishComposerText(payload.text, payload.cursor, payload.edit); + } else if (action === "composer.submit") { + publishComposerText(payload.text, state.composer.cursor, payload.edit); + } + actionRequested(action, payload); + } + + function readImageFiles(urls) { + return []; + } +} diff --git a/apps/desktop-qt/tests/imports/T3/Shell/Theme.qml b/apps/desktop-qt/tests/imports/T3/Shell/Theme.qml new file mode 100644 index 000000000000..d54eaa899037 --- /dev/null +++ b/apps/desktop-qt/tests/imports/T3/Shell/Theme.qml @@ -0,0 +1,12 @@ +pragma Singleton +import QtQuick + +QtObject { + property real radius: 8 + readonly property string fontUi: "" + property var colors: ({}) + + function color(role, fallback) { + return colors[role] ?? fallback; + } +} diff --git a/apps/desktop-qt/tests/imports/T3/Shell/qmldir b/apps/desktop-qt/tests/imports/T3/Shell/qmldir new file mode 100644 index 000000000000..386d020a58ec --- /dev/null +++ b/apps/desktop-qt/tests/imports/T3/Shell/qmldir @@ -0,0 +1,3 @@ +module T3.Shell +singleton Shell 1.0 Shell.qml +singleton Theme 1.0 Theme.qml diff --git a/apps/desktop-qt/tests/native/CMakeLists.txt b/apps/desktop-qt/tests/native/CMakeLists.txt new file mode 100644 index 000000000000..7f3666815f98 --- /dev/null +++ b/apps/desktop-qt/tests/native/CMakeLists.txt @@ -0,0 +1,39 @@ +cmake_minimum_required(VERSION 3.21) +project(t3code-qt-runtime-tests LANGUAGES CXX) +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_AUTOMOC ON) +find_package(Qt6 6.9 REQUIRED COMPONENTS Core Gui GuiPrivate Quick Test WebEngineQuick WebChannel) +enable_testing() +foreach(test_name IN ITEMS ShellRuntime ShellExamples) + add_executable(tst_${test_name} + tst_${test_name}.cpp + ../../src/ShellRuntime.cpp + ../../src/ShellBridge.cpp + ../../src/ThemeStore.cpp + ../../src/WebProfile.cpp + ) + target_include_directories(tst_${test_name} PRIVATE ../../src) + target_compile_definitions(tst_${test_name} PRIVATE + T3_APP_VERSION="test" + T3_WEBCHANNEL_SCRIPT_URL="qrc:///qtwebchannel/qwebchannel.js" + T3_TEST_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/../.." + ) + target_link_libraries(tst_${test_name} PRIVATE + Qt6::Core Qt6::Gui Qt6::GuiPrivate Qt6::Quick Qt6::Test Qt6::WebEngineQuick Qt6::WebChannel + ) + if(APPLE) + enable_language(OBJCXX) + target_sources(tst_${test_name} PRIVATE ../../src/PlatformWindow.mm) + target_link_libraries(tst_${test_name} PRIVATE "-framework AppKit") + else() + target_sources(tst_${test_name} PRIVATE ../../src/PlatformWindow.cpp) + endif() + add_test(NAME ${test_name} COMMAND tst_${test_name}) + set_tests_properties(${test_name} PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;QT_QPA_PLATFORMTHEME=;QT_QUICK_BACKEND=software") +endforeach() + +qt_add_resources(tst_ShellExamples "shell_connector" + PREFIX "/qt/qml/T3/Bricks" + BASE "../../qml/T3/Bricks" + FILES "../../qml/T3/Bricks/js/shell-connect.js" +) diff --git a/apps/desktop-qt/tests/native/tst_ShellExamples.cpp b/apps/desktop-qt/tests/native/tst_ShellExamples.cpp new file mode 100644 index 000000000000..2d900ecf0026 --- /dev/null +++ b/apps/desktop-qt/tests/native/tst_ShellExamples.cpp @@ -0,0 +1,194 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ShellBridge.h" +#include "ShellRuntime.h" +#include "ThemeStore.h" +#include "WebProfile.h" + +// List delegates belong to the visual tree, not necessarily the QObject tree. +static QQuickItem* findVisualItem(QQuickItem* parent, const QString& name) { + if (parent->objectName() == name) return parent; + for (auto* child : parent->childItems()) { + if (auto* found = findVisualItem(child, name)) return found; + } + return nullptr; +} + +class ShellExamplesTest : public QObject { + Q_OBJECT + + QTemporaryDir directory; + ShellBridge bridge; + std::unique_ptr theme; + std::unique_ptr profile; + std::unique_ptr runtime; + +private slots: + void initTestCase() { + QVERIFY(directory.isValid()); + theme = std::make_unique(directory.path()); + profile = std::make_unique(directory.filePath("web")); + qmlRegisterSingletonInstance("T3.Shell", 1, 0, "WebProfile", profile->profile()); + runtime = std::make_unique( + ShellRuntime::Options{directory.path(), QStringLiteral(T3_TEST_SOURCE_DIR "/qml")}, + &bridge, theme.get()); + bridge.setPageUrl(QUrl("about:blank")); + const auto state = QJsonDocument::fromJson(R"({ + "workspace": { + "projectTitle": "Example project", "threadTitle": "Fix TUI Readability Issue", + "isDraft": false, "renameRequestId": 0, "scripts": [], "editors": [], + "terminalAvailable": false, "branch": "feature/a-descriptive-branch-name-that-needs-to-fit", + "environments": [], "environmentChangeable": false, "activeEnvironmentId": null, + "envMode": "local", "envModeLabel": "Local checkout", "envModeChangeable": false, + "canOpenPullRequest": false, "branchChangeable": false, "branchSwitchPending": false, + "branches": [], "branchesLoading": false, "branchesTotal": 0, + "git": {"hasUpstream": false, "hasWorkingTreeChanges": false, "pullRequest": null} + }, + "sidebar": { + "projects": [], "scopeProjectKey": null, "activeThreadKey": null, + "activeDraftId": null, "drafts": [], "pinned": [], "active": [], + "snoozed": [], "settled": [], "settledTotal": 0 + }, + "layout": {"sidebarCollapsed": false}, + "notifications": {"items": []} + })").toVariant().toMap(); + for (auto it = state.cbegin(); it != state.cend(); ++it) bridge.publish(it.key(), it.value()); + } + + void layoutsFit_data() { + QTest::addColumn("example"); + QTest::addColumn("width"); + for (const auto& example : {"minimal", "glass", "terminal", "dashboard"}) { + for (const int width : {1400, 1000, 640}) { + QTest::newRow(qPrintable(QString("%1-%2").arg(example).arg(width))) << QString(example) << width; + } + } + } + + void layoutsFit() { + QFETCH(QString, example); + QFETCH(int, width); + const QDir source(QStringLiteral(T3_TEST_SOURCE_DIR "/examples/") + example); + for (const auto& file : source.entryList(QDir::Files)) { + const QString target = directory.filePath(file); + if (QFile::exists(target)) QVERIFY(QFile::remove(target)); + QVERIFY(QFile::copy(source.filePath(file), target)); + } + theme->reload(); + runtime->reload(); + QVERIFY2(runtime->usingUserShell(), qPrintable(runtime->lastError())); + QVERIFY2(runtime->lastError().isEmpty(), qPrintable(runtime->lastError())); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + auto* engine = runtime->findChild(); + QVERIFY(engine); + auto* window = qobject_cast(engine->rootObjects().last()); + QVERIFY(window); + window->resize(width, 880); + QVERIFY(QTest::qWaitForWindowExposed(window)); + auto* title = window->findChild("threadLabel"); + QVERIFY(title); + if (width == 1400) QTRY_VERIFY(!title->property("truncated").toBool()); + QTRY_VERIFY(title->mapToScene(QPointF(title->width(), 0)).x() <= window->width()); + + if (example != "dashboard") return; + QVERIFY(window->setProperty("drawerOpen", true)); + auto* drawer = window->findChild("drawer"); + QVERIFY(drawer); + QTRY_VERIFY(drawer->opacity() > 0.99); + for (const auto& name : {"workspaceCard", "calendarCard", "metersCard", "agentCard"}) { + auto* card = window->findChild(name); + QVERIFY2(card, name); + QTRY_VERIFY2(card->mapToItem(drawer, QPointF(card->width(), 0)).x() <= drawer->width() - 10, name); + QVERIFY(card->mapToItem(drawer, QPointF(0, 0)).x() >= 10); + } + auto* branch = window->findChild("branchChip"); + QVERIFY(branch); + QTRY_VERIFY(branch->width() <= branch->parentItem()->width()); + if (width == 1000) { + drawer->setHeight(200); + auto* actions = window->findChild("agentActions"); + QVERIFY(actions); + const auto area = actions->mapRectToScene(QRectF(0, 0, actions->width(), actions->height())).toAlignedRect(); + QVERIFY(area.top() > drawer->mapToScene(QPointF(0, drawer->height())).y()); + QVERIFY(area.bottom() < window->height()); + const auto clipped = window->grabWindow().copy(area); + QVERIFY(!clipped.isNull()); + actions->setVisible(false); + QTRY_COMPARE(window->grabWindow().copy(area), clipped); + actions->setVisible(true); + } + auto* scroll = window->findChild("drawerScroll"); + QVERIFY(scroll); + auto* content = scroll->property("contentItem").value(); + QVERIFY(content); + const qreal bottom = content->property("contentHeight").toReal() - content->height(); + if (bottom > 0) { + QVERIFY(content->setProperty("contentY", bottom)); + auto* agent = window->findChild("agentCard"); + QVERIFY(agent); + QTRY_VERIFY(agent->mapToItem(drawer, QPointF(0, agent->height())).y() <= drawer->height() - 10); + } + } + + void panelTabsSupportKeyboardActivationAndClose() { + QFile source(directory.filePath("shell.qml")); + QVERIFY(source.open(QIODevice::WriteOnly | QIODevice::Truncate)); + source.write("import QtQuick\nimport T3.Bricks\nShellWindow { width: 600; height: 400; RightPanel { anchors.fill: parent } }"); + source.close(); + bridge.publish("rightPanel", QJsonDocument::fromJson(R"({ + "isOpen": true, "activeSurfaceId": "diff", "embedPath": "/test", + "surfaces": [{"id": "diff", "title": "Diff"}, {"id": "files", "title": "Files"}], + "canAdd": {"diff": true, "files": true, "terminal": true, "pullRequest": false, "agents": true} + })").toVariant()); + runtime->reload(); + QVERIFY2(runtime->lastError().isEmpty(), qPrintable(runtime->lastError())); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + auto* engine = runtime->findChild(); + QVERIFY(engine); + auto* window = qobject_cast(engine->rootObjects().last()); + QVERIFY(window); + QVERIFY(QTest::qWaitForWindowExposed(window)); + QTRY_VERIFY(findVisualItem(window->contentItem(), "panelTab-files")); + auto* tab = findVisualItem(window->contentItem(), "panelTab-files"); + auto* close = findVisualItem(window->contentItem(), "panelClose-files"); + QVERIFY(tab); + QVERIFY(close); + QSignalSpy actions(&bridge, &ShellBridge::actionRequested); + tab->forceActiveFocus(Qt::TabFocusReason); + QTest::keyClick(window, Qt::Key_Return); + QCOMPARE(actions.size(), 1); + QCOMPARE(actions.last().at(0).toString(), QString("rightPanel.activate")); + QCOMPARE(actions.last().at(1).toMap().value("id").toString(), QString("files")); + close->forceActiveFocus(Qt::TabFocusReason); + QTest::keyClick(window, Qt::Key_Space); + QCOMPARE(actions.size(), 2); + QCOMPARE(actions.last().at(0).toString(), QString("rightPanel.close")); + QCOMPARE(actions.last().at(1).toMap().value("id").toString(), QString("files")); + bridge.publish("rightPanel", QVariant()); + } + + void cleanupTestCase() { + runtime.reset(); + profile.reset(); + theme.reset(); + } +}; + +int main(int argc, char** argv) { + QtWebEngineQuick::initialize(); + QGuiApplication app(argc, argv); + ShellExamplesTest test; + return QTest::qExec(&test, argc, argv); +} + +#include "tst_ShellExamples.moc" diff --git a/apps/desktop-qt/tests/native/tst_ShellRuntime.cpp b/apps/desktop-qt/tests/native/tst_ShellRuntime.cpp new file mode 100644 index 000000000000..339291f86e5b --- /dev/null +++ b/apps/desktop-qt/tests/native/tst_ShellRuntime.cpp @@ -0,0 +1,287 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ShellBridge.h" +#include "ShellRuntime.h" +#include "ThemeStore.h" +#include "WebProfile.h" + +class ShellRuntimeTest : public QObject { + Q_OBJECT + +signals: + void scriptFinished(const QVariant& result); + +private slots: + void appPermissionsRequireMatchingHttpOrigin() { + ShellBridge bridge; + bridge.setPageUrl(QUrl("https://EXAMPLE.com/thread?id=1")); + QVERIFY(bridge.isAppOrigin(QUrl("https://example.com:443/"))); + QVERIFY(!bridge.isAppOrigin(QUrl("http://example.com/"))); + QVERIFY(!bridge.isAppOrigin(QUrl("https://example.com:8443/"))); + QVERIFY(!bridge.isAppOrigin(QUrl("https://example.com.evil.test/"))); + QVERIFY(!bridge.isAppOrigin(QUrl("https://example.com@evil.test/"))); + bridge.setPageUrl(QUrl("http://127.0.0.1:6182/thread")); + QVERIFY(bridge.isAppOrigin(QUrl("http://127.0.0.1:6182/"))); + QVERIFY(!bridge.isAppOrigin(QUrl("http://127.0.0.1:6183/"))); + bridge.setPageUrl(QUrl("file:///tmp/shell.html")); + QVERIFY(!bridge.isAppOrigin(bridge.pageUrl())); + bridge.setPageUrl(QUrl()); + QVERIFY(!bridge.isAppOrigin(QUrl())); + } + + void themeRecoversAfterReadFailureWithoutAcceptingInvalidJson() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + QFile file(directory.filePath("theme.json")); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.write("{\"colors\":{\"canvas\":\"#123456\"}}"); + file.close(); + ThemeStore theme(directory.path()); + QVERIFY(theme.lastError().isEmpty()); + const auto permissions = file.permissions(); + QVERIFY(file.setPermissions(QFile::WriteOwner)); + theme.reload(); + QVERIFY(!theme.lastError().isEmpty()); + QVERIFY(file.setPermissions(permissions)); + theme.reload(); + QVERIFY(theme.lastError().isEmpty()); + QCOMPARE(theme.color("canvas", Qt::black), QColor("#123456")); + + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Truncate)); + file.write("invalid JSON"); + file.close(); + theme.reload(); + QVERIFY(!theme.lastError().isEmpty()); + theme.reload(); + QVERIFY(!theme.lastError().isEmpty()); + QCOMPARE(theme.color("canvas", Qt::black), QColor("#123456")); + } + + void legacyThemeRemovalRestoresDocumentState() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + QFile file(directory.filePath("theme.json")); + const QByteArray source = "{\"id\":\"shell-night\",\"appearance\":\"dark\",\"colors\":{\"canvas\":\"#123456\",\"chrome\":\"#234567\"}}"; + QVERIFY(file.open(QIODevice::WriteOnly)); + file.write(source); + file.close(); + ThemeStore theme(directory.path()); + QWebEngineProfile profile; + QWebEnginePage page(&profile); + QSignalSpy loaded(&page, &QWebEnginePage::loadFinished); + page.setHtml("Legacy theme test"); + QVERIFY(loaded.wait()); + const auto evaluate = [&](const QString& script) { + QSignalSpy completed(this, &ShellRuntimeTest::scriptFinished); + page.runJavaScript(script, [this](const QVariant& result) { emit scriptFinished(result); }); + if (completed.isEmpty() && !completed.wait()) return QVariant(); + return completed.first().first(); + }; + evaluate(R"( + const root = document.documentElement; + root.dataset.themeId = 'page'; + root.dataset.themeSelected = 'false'; + root.classList.add('unrelated'); + root.style.setProperty('background-color', 'rgb(12, 34, 56)', 'important'); + root.style.setProperty('--app-theme-canvas', '#654321', 'important'); + window.snapshot = () => JSON.stringify([ + root.getAttribute('data-theme-id'), root.getAttribute('data-theme-selected'), + root.className, root.style.getPropertyValue('background-color'), + root.style.getPropertyPriority('background-color'), + root.style.getPropertyValue('--app-theme-canvas'), + root.style.getPropertyPriority('--app-theme-canvas') + ]); + )"); + const auto original = evaluate("snapshot()"); + evaluate(theme.injectionScript()); + QVERIFY(evaluate("snapshot()") != original); + evaluate(theme.injectionScript()); + QVERIFY(file.remove()); + theme.reload(); + evaluate(theme.injectionScript()); + QCOMPARE(evaluate("snapshot()"), original); + QCOMPARE(evaluate("window.__t3ShellTheme.observer === null").toBool(), true); + QCOMPARE(evaluate("document.documentElement.style.getPropertyValue('--app-theme-chrome')").toString(), QString()); + + evaluate("document.documentElement.removeAttribute('data-theme-id'); document.documentElement.removeAttribute('data-theme-selected');"); + const auto withoutAttributes = evaluate("snapshot()"); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.write(source); + file.close(); + theme.reload(); + evaluate(theme.injectionScript()); + QVERIFY(file.remove()); + theme.reload(); + evaluate(theme.injectionScript()); + QCOMPARE(evaluate("snapshot()"), withoutAttributes); + } + + void themeBootstrapHandsOffWithoutRewritingThePage() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + QFile file(directory.filePath("theme.json")); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.write("{\"id\":\"shell-night\",\"appearance\":\"dark\",\"colors\":{\"canvas\":\"#123456\"}}"); + file.close(); + ThemeStore theme(directory.path()); + QWebEngineProfile profile; + QWebEnginePage page(&profile); + QSignalSpy loaded(&page, &QWebEnginePage::loadFinished); + page.setHtml("Theme test"); + QVERIFY(loaded.wait()); + QVERIFY(loaded.first().first().toBool()); + const auto evaluate = [&](const QString& source) { + QSignalSpy completed(this, &ShellRuntimeTest::scriptFinished); + page.runJavaScript(source, [this](const QVariant& result) { emit scriptFinished(result); }); + if (completed.isEmpty() && !completed.wait()) return QVariant(); + return completed.first().first(); + }; + evaluate(theme.injectionScript()); + QCOMPARE(evaluate("document.documentElement.dataset.themeId").toString(), QString("shell-night")); + // Unclaimed/older pages still recover when their own palette overwrites the bootstrap. + evaluate("document.documentElement.dataset.themeId = 'page';"); + QCOMPARE(evaluate("document.documentElement.dataset.themeId").toString(), QString("shell-night")); + evaluate(R"( + window.__t3ShellTheme.observer.disconnect(); + window.__t3ShellTheme.observer = null; + window.__t3ShellTheme.applyOverride = value => { window.deliveredTheme = value; }; + document.documentElement.dataset.themeId = 'page-owned'; + )"); + evaluate(theme.injectionScript()); + QCOMPARE(evaluate("window.deliveredTheme.id").toString(), QString("shell-night")); + QCOMPARE(evaluate("document.documentElement.dataset.themeId").toString(), QString("page-owned")); + QCOMPARE(evaluate("window.__t3ShellTheme.observer === null").toBool(), true); + const QString beforePublication = theme.injectionScript(); + theme.applyPageTheme(QVariantMap{{"appearance", "light"}, {"colors", QVariantMap{{"canvas", "#ffffff"}}}}); + QCOMPARE(theme.injectionScript(), beforePublication); + QVERIFY(file.remove()); + theme.reload(); + evaluate(theme.injectionScript()); + QCOMPARE(evaluate("window.deliveredTheme.id").toString(), QString()); + } + + void reloadKeepsSingletonsAndRecoversFromInvalidSource() { + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString config = directory.filePath("config"); + const QString sources = directory.filePath("qml"); + QVERIFY(QDir().mkpath(config)); + QVERIFY(QDir().mkpath(sources + "/T3/Bricks")); + const QString shellPath = config + "/shell.qml"; + const QString defaultPath = sources + "/T3/Bricks/DefaultShell.qml"; + const auto writeSource = [](const QString& path, const QByteArray& contents) { + QFile file(path); + return file.open(QIODevice::WriteOnly) && file.write(contents) == contents.size(); + }; + const auto source = [](int revision) { + return QByteArray(R"( +import QtQuick +import T3.Shell +Window { + objectName: "reload-probe" + property int revision: )") + QByteArray::number(revision) + QByteArray(revision, ' ') + R"( + property int protocol: Shell.protocolVersion + property string prompt: Shell.state.composer.text + property real radiusValue: Theme.radius + property string configValue: Runtime.configDir + property string profileName: WebProfile.storageName +} +)"; + }; + QVERIFY(writeSource(shellPath, source(1))); + QVERIFY(writeSource(defaultPath, source(0))); + ShellBridge bridge; + bridge.publish("composer", QVariantMap{{"text", "Retained draft"}}); + ThemeStore theme(config); + WebProfile webProfile(directory.filePath("web")); + qmlRegisterSingletonInstance("T3.Shell", 1, 0, "WebProfile", webProfile.profile()); + ShellRuntime runtime({config, sources}, &bridge, &theme); + const auto window = []() -> QQuickWindow* { + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + for (auto* candidate : QGuiApplication::allWindows()) { + if (candidate->objectName() == "reload-probe") return qobject_cast(candidate); + } + return nullptr; + }; + const auto verifySingletons = [&](QQuickWindow* root) { + QVERIFY(root); + QCOMPARE(root->property("protocol").toInt(), bridge.protocolVersion()); + QCOMPARE(root->property("prompt").toString(), QString("Retained draft")); + QCOMPARE(root->property("radiusValue").toReal(), theme.radius()); + QCOMPARE(root->property("configValue").toString(), config); + QCOMPARE(root->property("profileName").toString(), webProfile.profile()->storageName()); + }; + runtime.start(); + verifySingletons(window()); + for (int revision = 2; revision <= 3; ++revision) { + QPointer previous = window(); + QVERIFY(writeSource(shellPath, source(revision))); + runtime.reload(); + verifySingletons(window()); + QVERIFY(previous.isNull()); + QCOMPARE(window()->property("revision").toInt(), revision); + QCOMPARE(runtime.generation(), revision); + } + QPointer working = window(); + QVERIFY(writeSource(shellPath, "invalid QML")); + QVERIFY(writeSource(defaultPath, "invalid QML")); + runtime.reload(); + QCOMPARE(window(), working.data()); + QCOMPARE(runtime.generation(), 3); + QVERIFY(!runtime.lastError().isEmpty()); + verifySingletons(window()); + + QVERIFY(writeSource(defaultPath, source(4))); + runtime.reload(); + verifySingletons(window()); + QCOMPARE(window()->property("revision").toInt(), 4); + QVERIFY(!runtime.usingUserShell()); + QVERIFY(writeSource(shellPath, source(5))); + runtime.reload(); + verifySingletons(window()); + QCOMPARE(window()->property("revision").toInt(), 5); + QVERIFY(runtime.usingUserShell()); + QVERIFY(runtime.lastError().isEmpty()); + + QVERIFY(writeSource(shellPath, source(6))); + QTRY_COMPARE(runtime.generation(), 6); + verifySingletons(window()); + QCOMPARE(window()->property("revision").toInt(), 6); + + QVERIFY(writeSource(shellPath, "import QtQml\nQtObject {}")); + runtime.reload(); + QVERIFY(window()); + verifySingletons(window()); + QCOMPARE(window()->property("revision").toInt(), 4); + QVERIFY(!runtime.usingUserShell()); + QVERIFY(runtime.lastError().contains("Window")); + auto* engine = runtime.findChild(); + QVERIFY(engine); + QTRY_COMPARE(engine->rootObjects().size(), 1); + + working = window(); + const int generation = runtime.generation(); + QVERIFY(writeSource(defaultPath, "import QtQml\nQtObject {}")); + runtime.reload(); + QCOMPARE(window(), working.data()); + QCOMPARE(runtime.generation(), generation); + QCOMPARE(engine->rootObjects().size(), 1); + } +}; + +int main(int argc, char** argv) { + QtWebEngineQuick::initialize(); + QGuiApplication app(argc, argv); + ShellRuntimeTest test; + return QTest::qExec(&test, argc, argv); +} + +#include "tst_ShellRuntime.moc" diff --git a/apps/desktop-qt/tests/tst_Composer.qml b/apps/desktop-qt/tests/tst_Composer.qml new file mode 100644 index 000000000000..1457f25863d3 --- /dev/null +++ b/apps/desktop-qt/tests/tst_Composer.qml @@ -0,0 +1,228 @@ +import QtQuick +import QtTest +import "../qml/T3/Bricks" +import T3.Shell + +Item { + id: root + width: 900 + height: 700 + + Component { + id: composerComponent + + Composer { + width: 800 + height: 650 + } + } + + TestCase { + name: "ComposerTests" + when: windowShown + + function init() { + Shell.reset(); + } + + function test_inputAcceptsText() { + let composer = createTemporaryObject(composerComponent, root); + verify(!!composer, "Component exists"); + let input = findChild(composer, "input"); + verify(!!input, "Object exists"); + input.focus = true; + input.text = qsTr("Draft message"); + compare(input.text, qsTr("Draft message")); + input.text = qsTr("12345"); + compare(input.text, qsTr("12345")); + input.text = qsTr("Plan & review (v2)"); + compare(input.text, qsTr("Plan & review (v2)")); + } + + function test_submitKeepsDraftUntilPageClearsIt() { + let composer = createTemporaryObject(composerComponent, root); + verify(!!composer, "Component exists"); + let input = findChild(composer, "input"); + verify(!!input, "Object exists"); + const draft = qsTr("Keep 123 & retry"); + input.focus = true; + input.text = draft; + tryCompare(composer, "publishedText", draft); + + composer.submit("foreground"); + + compare(input.text, draft); + compare(Shell.dispatchedActions[Shell.dispatchedActions.length - 1].action, "composer.submit"); + + Shell.publishComposerText("", 0); + tryCompare(input, "text", ""); + } + + function test_textDispatchIncludesTarget() { + let composer = createTemporaryObject(composerComponent, root); + verify(!!composer, "Component exists"); + let input = findChild(composer, "input"); + verify(!!input, "Object exists"); + + input.text = qsTr("Scoped edit 123"); + + tryCompare(Shell, "dispatchCount", 1); + compare(Shell.dispatchedActions[0].action, "composer.text.set"); + compare(Shell.dispatchedActions[0].payload.target, "thread-a"); + } + + function test_targetSwitchDropsPendingDraftEdit() { + let composer = createTemporaryObject(composerComponent, root); + verify(!!composer, "Component exists"); + let input = findChild(composer, "input"); + verify(!!input, "Object exists"); + + input.text = qsTr("Belongs to thread A"); + Shell.publishComposerTarget("thread-b", "", 0); + + wait(180); + compare(Shell.dispatchCount, 0); + compare(input.text, ""); + } + + function test_echoPreservesEditStillWaitingForDebounce() { + Shell.echoTextEdits = false; + let composer = createTemporaryObject(composerComponent, root); + verify(!!composer, "Component exists"); + let input = findChild(composer, "input"); + verify(!!input, "Object exists"); + input.focus = true; + input.text = qsTr("Sent"); + composer.flushText(); + input.text = qsTr("Sent + local"); + input.cursorPosition = input.text.length; + + Shell.publishComposerText(qsTr("Sent"), 4, Shell.dispatchedActions[0].payload.edit); + compare(input.text, qsTr("Sent + local")); + compare(input.cursorPosition, 12); + composer.flushText(); + compare(Shell.dispatchedActions[1].payload.text, qsTr("Sent + local")); + } + + function test_coalescedEchoThenPageEdit() { + Shell.echoTextEdits = false; + let composer = createTemporaryObject(composerComponent, root); + verify(!!composer, "Component exists"); + let input = findChild(composer, "input"); + verify(!!input, "Object exists"); + input.text = qsTr("First"); + composer.flushText(); + input.text = qsTr("Second"); + composer.flushText(); + input.cursorPosition = 2; + + Shell.publishComposerText(qsTr("Second"), 6, Shell.dispatchedActions[1].payload.edit); + compare(input.text, qsTr("Second")); + compare(input.cursorPosition, 2); + // A later page edit may legitimately restore an earlier value. + Shell.publishComposerText(qsTr("First"), 3); + compare(input.text, qsTr("First")); + compare(input.cursorPosition, 3); + } + + function test_targetSwitchRetiresPreviousEditRevisions() { + Shell.echoTextEdits = false; + let composer = createTemporaryObject(composerComponent, root); + verify(!!composer, "Component exists"); + let input = findChild(composer, "input"); + verify(!!input, "Object exists"); + input.text = qsTr("Thread A edit"); + composer.flushText(); + Shell.publishComposerTarget("thread-b", qsTr("Thread B draft"), 4); + compare(input.text, qsTr("Thread B draft")); + compare(input.cursorPosition, 4); + // This helper publishes an external edit for the current target B. + // The old target's outstanding revision must not suppress it. + Shell.publishComposerText(qsTr("Thread B page edit"), 2); + compare(input.text, qsTr("Thread B page edit")); + compare(input.cursorPosition, 2); + } + + function test_repeatedTextDoesNotAcknowledgeAnOlderEdit() { + Shell.echoTextEdits = false; + let composer = createTemporaryObject(composerComponent, root); + verify(!!composer, "Component exists"); + let input = findChild(composer, "input"); + verify(!!input, "Object exists"); + for (const text of ["First", "Second", "First"]) { + input.text = text; + composer.flushText(); + } + input.cursorPosition = 2; + + Shell.publishComposerText("First", 5, Shell.dispatchedActions[0].payload.edit); + Shell.publishComposerText("Second", 6, Shell.dispatchedActions[1].payload.edit); + compare(input.text, "First"); + compare(input.cursorPosition, 2); + Shell.publishComposerText("First", 5, Shell.dispatchedActions[2].payload.edit); + Shell.publishComposerText("", 0); + compare(input.text, ""); + } + + function test_coalescedEchoReturningToInitialText() { + Shell.echoTextEdits = false; + let composer = createTemporaryObject(composerComponent, root); + verify(!!composer, "Component exists"); + let input = findChild(composer, "input"); + verify(!!input, "Object exists"); + input.text = "Temporary"; + composer.flushText(); + input.text = ""; + composer.flushText(); + + Shell.publishComposerText("", 0, Shell.dispatchedActions[1].payload.edit); + Shell.publishComposerText("Page replacement", 4); + compare(input.text, "Page replacement"); + compare(input.cursorPosition, 4); + } + + function test_legacyPageCanStillClearAfterSubmit() { + let composer = createTemporaryObject(composerComponent, root); + verify(!!composer, "Component exists"); + let input = findChild(composer, "input"); + verify(!!input, "Object exists"); + input.text = "Legacy draft"; + composer.submit("foreground"); + + // Remove the field entirely, as pages predating revisions do. + const state = JSON.parse(JSON.stringify(Shell.state)); + delete state.composer.edit; + state.composer.text = ""; + state.composer.cursor = 0; + Shell.state = state; + compare(input.text, ""); + } + + function test_delayedEchoPreservesNewerTextAndSubmit() { + Shell.echoTextEdits = false; + let composer = createTemporaryObject(composerComponent, root); + verify(!!composer, "Component exists"); + let input = findChild(composer, "input"); + verify(!!input, "Object exists"); + input.focus = true; + input.text = qsTr("First edit"); + input.cursorPosition = input.text.length; + composer.flushText(); + input.text = qsTr("First edit + 123"); + input.cursorPosition = input.text.length; + composer.flushText(); + compare(Shell.dispatchCount, 2); + + Shell.publishComposerText(qsTr("First edit"), 10, Shell.dispatchedActions[0].payload.edit); + compare(input.text, qsTr("First edit + 123")); + compare(input.cursorPosition, 16); + + composer.submit("foreground"); + compare(Shell.dispatchedActions[2].payload.text, qsTr("First edit + 123")); + Shell.publishComposerText(qsTr("First edit + 123"), 16, Shell.dispatchedActions[1].payload.edit); + compare(input.text, qsTr("First edit + 123")); + Shell.publishComposerText("", 0, Shell.dispatchedActions[2].payload.edit); + compare(input.text, ""); + } + } +} diff --git a/apps/desktop-qt/tests/tst_ComposerKeyboard.qml b/apps/desktop-qt/tests/tst_ComposerKeyboard.qml new file mode 100644 index 000000000000..8039319103eb --- /dev/null +++ b/apps/desktop-qt/tests/tst_ComposerKeyboard.qml @@ -0,0 +1,64 @@ +import QtQuick +import QtTest +import "../qml/T3/Bricks" +import T3.Shell + +Item { + id: root + width: 900 + height: 700 + Component { id: component; Composer { width: 800; height: 650 } } + TestCase { + name: "ComposerKeyboardTests" + when: windowShown + function init() { + Shell.reset(); + Shell.state = { composer: Object.assign({}, Shell.defaultComposer(), { + triggerKind: "mention", + suggestions: [{ id: "first", label: "First", description: "" }, { id: "second", label: "Second", description: "" }] + }), workspace: null }; + } + function cleanup() { Shell.reset(); } + function test_selectSuggestion_data() { + return [{ tag: "tab", key: Qt.Key_Tab }, { tag: "return", key: Qt.Key_Return }, { tag: "enter", key: Qt.Key_Enter }]; + } + function test_selectSuggestion(data) { + let composer = createTemporaryObject(component, root); + verify(!!composer, "Component exists"); + let input = findChild(composer, "input"); + verify(!!input, "Object exists"); + input.focus = true; + input.forceActiveFocus(); + keyClick(Qt.Key_Down); + keyClick(data.key); + compare(Shell.dispatchCount, 1); + compare(Shell.dispatchedActions[0].action, "composer.suggest.select"); + compare(Shell.dispatchedActions[0].payload.id, "second"); + } + function test_upWrapsAndEscapeDismissesWithoutSubmitting() { + let composer = createTemporaryObject(component, root); + verify(!!composer, "Component exists"); + let input = findChild(composer, "input"); + verify(!!input, "Object exists"); + input.focus = true; + input.forceActiveFocus(); + keyClick(Qt.Key_Up); + keyClick(Qt.Key_Tab); + compare(Shell.dispatchedActions[0].payload.id, "second"); + keyClick(Qt.Key_Escape); + compare(Shell.dispatchedActions[1].action, "composer.suggest.dismiss"); + compare(Shell.dispatchCount, 2); + } + function test_shiftEnterDoesNotChooseSuggestion() { + let composer = createTemporaryObject(component, root); + verify(!!composer, "Component exists"); + let input = findChild(composer, "input"); + verify(!!input, "Object exists"); + input.focus = true; + input.forceActiveFocus(); + keyClick(Qt.Key_Return, Qt.ShiftModifier); + compare(input.text, "\n"); + compare(Shell.dispatchCount, 0); + } + } +} diff --git a/apps/desktop-qt/tests/tst_GitActions.qml b/apps/desktop-qt/tests/tst_GitActions.qml new file mode 100644 index 000000000000..709e06a09aa1 --- /dev/null +++ b/apps/desktop-qt/tests/tst_GitActions.qml @@ -0,0 +1,48 @@ +import QtQuick +import QtTest +import "../qml/T3/Bricks" +import T3.Shell + +Item { + id: root + width: 700 + height: 600 + Component { id: component; GitActions { width: 300; height: 32 } } + TestCase { + name: "GitActionsTests" + when: windowShown + function init() { + Shell.reset(); + Shell.state = { git: { + available: true, isRepo: true, busy: false, isDefaultRef: false, + quickAction: { kind: "run_action", label: "Commit", disabledReason: null }, + files: [{ path: "a.txt", insertions: 1, deletions: 0 }], + menu: [], hints: [], canPublish: false, pendingDefaultBranch: null + } }; + } + function cleanup() { Shell.reset(); } + function test_emptySelectionDisablesBothCommitActions() { + let git = createTemporaryObject(component, root); + verify(!!git, "Component exists"); + let dialog = findChild(git, "commitDialog"); + verify(!!dialog, "Object exists"); + dialog.open(); + tryVerify(() => findChild(dialog.contentItem, "fileCheck-a.txt") !== null); + let checkbox = findChild(dialog.contentItem, "fileCheck-a.txt"); + verify(!!checkbox, "Object exists"); + let commit = findChild(dialog.contentItem, "commitSelected"); + verify(!!commit, "Object exists"); + let branch = findChild(dialog.contentItem, "commitNewBranch"); + verify(!!branch, "Object exists"); + mouseClick(checkbox); + tryCompare(commit, "enabled", false); + tryCompare(branch, "enabled", false); + mouseClick(checkbox); + tryCompare(commit, "enabled", true); + tryCompare(branch, "enabled", true); + mouseClick(commit); + tryCompare(Shell, "dispatchCount", 1); + tryCompare(Shell.dispatchedActions[0], "action", "git.commit"); + } + } +} diff --git a/apps/desktop-qt/tests/tst_Notifications.qml b/apps/desktop-qt/tests/tst_Notifications.qml new file mode 100644 index 000000000000..3760421a585e --- /dev/null +++ b/apps/desktop-qt/tests/tst_Notifications.qml @@ -0,0 +1,97 @@ +import QtQuick +import QtTest +import T3.Shell +import "../qml/T3/Bricks" + +Item { + id: root + width: 400 + height: 300 + + Component { + id: notificationsComponent + Notifications { + width: 340 + height: implicitHeight + } + } + + TestCase { + name: "NotificationsTests" + when: windowShown + + function cleanup() { + Shell.reset(); + Theme.colors = {}; + Theme.radius = 8; + } + + function test_accentClearsRoundedCorners_data() { + return [ + { + tag: "square", + radius: 0 + }, + { + tag: "terminal", + radius: 4 + }, + { + tag: "glass-minimal", + radius: 10 + }, + { + tag: "dashboard", + radius: 18 + } + ]; + } + + function isAccent(shot, x, y) { + return shot.red(x, y) > 200 && shot.green(x, y) < 60 && shot.blue(x, y) < 60; + } + + function test_accentClearsRoundedCorners(data) { + Theme.radius = data.radius; + Theme.colors = { + warning: "#ff0000", + surfaceOverlay: "#ffffff", + border: "#ffffff" + }; + Shell.state = { + notifications: { + items: [ + { + id: "update", + type: "warning", + title: qsTr("Update available"), + description: qsTr("Install the update now or review provider settings."), + actions: [] + } + ] + } + }; + let toast = createTemporaryObject(notificationsComponent, root); + verify(!!toast, "Component exists"); + tryVerify(() => { + const shot = grabImage(toast); + return isAccent(shot, 2, Math.floor(shot.height / 2)); + }); + const shot = grabImage(toast); + let top = shot.height; + let bottom = -1; + for (let y = 0; y < shot.height; ++y) { + for (let x = 0; x < 6; ++x) { + if (isAccent(shot, x, y)) { + top = Math.min(top, y); + bottom = Math.max(bottom, y); + } + } + } + verify(bottom > top); + verify(top >= Math.max(1, data.radius)); + verify(bottom < shot.height - Math.max(1, data.radius)); + compare(Math.abs(top - (shot.height - 1 - bottom)) <= 1, true); + } + } +} diff --git a/apps/desktop-qt/tests/tst_SettingsNav.qml b/apps/desktop-qt/tests/tst_SettingsNav.qml new file mode 100644 index 000000000000..b9a793bc75e8 --- /dev/null +++ b/apps/desktop-qt/tests/tst_SettingsNav.qml @@ -0,0 +1,66 @@ +import QtQuick +import QtTest +import "../qml/T3/Bricks" +import T3.Shell + +Item { + id: root + width: 400 + height: 600 + Component { id: component; SettingsNav { width: 300; height: 550 } } + TestCase { + name: "SettingsNavTests" + when: windowShown + function publish(query) { + Shell.state = { settings: { + active: true, activeSection: "/settings/general", searchQuery: query, + sections: [{ to: "/settings/general", label: "General" }, { to: "/settings/providers", label: "Providers" }], + searchResults: [{ to: "/settings/general", title: "Theme", sectionLabel: "General", targetId: "theme" }] + } }; + } + function init() { Shell.reset(); publish(""); } + function cleanup() { Shell.reset(); } + function test_keyboardNavigation() { + let nav = createTemporaryObject(component, root); + verify(!!nav, "Component exists"); + let row = findChild(nav, "settingsRow0"); + verify(!!row, "Object exists"); + row.focus = true; + row.forceActiveFocus(); + keyClick(Qt.Key_Down); + keyClick(Qt.Key_Return); + compare(Shell.dispatchedActions[0].action, "settings.navigate"); + compare(Shell.dispatchedActions[0].payload.to, "/settings/providers"); + keyClick(Qt.Key_Up); + keyClick(Qt.Key_Space); + compare(Shell.dispatchedActions[1].payload.to, "/settings/general"); + } + function test_escapeKeepsExternalSearchBinding() { + publish("theme"); + let nav = createTemporaryObject(component, root); + verify(!!nav, "Component exists"); + let search = findChild(nav, "search"); + verify(!!search, "Object exists"); + search.focus = true; + search.forceActiveFocus(); + keyClick(Qt.Key_Escape); + compare(Shell.dispatchedActions[0].payload.query, ""); + publish(""); + compare(search.text, ""); + publish("model 123 & provider"); + compare(search.text, "model 123 & provider"); + } + function test_keyboardSearchResult() { + publish("theme"); + let nav = createTemporaryObject(component, root); + verify(!!nav, "Component exists"); + let row = findChild(nav, "settingsRow0"); + verify(!!row, "Object exists"); + row.focus = true; + row.forceActiveFocus(); + keyClick(Qt.Key_Space); + compare(Shell.dispatchedActions[0].action, "settings.openResult"); + compare(Shell.dispatchedActions[0].payload.targetId, "theme"); + } + } +} diff --git a/apps/desktop-qt/tests/tst_ShellButton.qml b/apps/desktop-qt/tests/tst_ShellButton.qml new file mode 100644 index 000000000000..7f39a7dacd00 --- /dev/null +++ b/apps/desktop-qt/tests/tst_ShellButton.qml @@ -0,0 +1,164 @@ +import QtQuick +import QtTest +import T3.Shell +import "../qml/T3/Bricks" + +Item { + id: root + width: 400 + height: 200 + + Component { + id: buttonComponent + ShellButton { + width: 120 + height: 32 + tint: "#ff0000" + iconTint: tint + background: Rectangle { + color: "#000000" + } + } + } + + TestCase { + id: testCase + name: "ShellButtonTests" + when: windowShown + property var observedBackground: null + property real darkest: 1 + + Connections { + target: testCase.observedBackground + function onColorChanged() { + const c = testCase.observedBackground.color; + testCase.darkest = Math.min(testCase.darkest, c.r * c.a + (1 - c.a) * 0.98); + } + } + + Component { + id: themedButtonComponent + ShellButton { + width: 100 + height: 32 + subtle: true + text: qsTr("Settings") + } + } + + function cleanup() { + observedBackground = null; + Theme.colors = {}; + } + + function test_lightHoverNeverFlashesGray() { + Theme.colors = { + accentSurface: "#fff7f4" + }; + let button = createTemporaryObject(themedButtonComponent, root); + verify(!!button, "Component exists"); + observedBackground = button.background; + darkest = 0.98; + mouseMove(button, 50, 16); + tryCompare(button.background, "color", "#fff7f4"); + verify(darkest >= 0.98); + mouseMove(root, 380, 150); + tryCompare(button.background, "color", Qt.alpha("#fff7f4", 0)); + verify(darkest >= 0.98); + } + + function test_narrowContentsStayInsidePadding() { + let button = createTemporaryObject(buttonComponent, root, { + text: qsTr("A long action label"), + iconName: "plus", + chevron: true, + width: 90 + }); + verify(!!button, "Component exists"); + let contents = findChild(button, "contents"); + verify(!!contents, "Object exists"); + tryCompare(contents, "width", button.availableWidth); + compare(contents.x, 0); + button.width = 240; + tryVerify(() => contents.width < button.availableWidth); + compare(Math.round(contents.x + contents.width / 2), Math.round(button.availableWidth / 2)); + } + + function test_centeredContents_data() { + return [ + { + tag: "label", + text: qsTr("Update"), + iconName: "", + chevron: false, + width: 120, + height: 32 + }, + { + tag: "dismiss", + text: "✕", + iconName: "", + chevron: false, + width: 26, + height: 26 + }, + { + tag: "icon", + text: "", + iconName: "x", + chevron: false, + width: 40, + height: 28 + }, + { + tag: "icon-label", + text: qsTr("Update"), + iconName: "plus", + chevron: false, + width: 160, + height: 32 + }, + { + tag: "chevron", + text: "", + iconName: "", + chevron: true, + width: 40, + height: 24 + } + ]; + } + + function test_centeredContents(data) { + let button = createTemporaryObject(buttonComponent, root, { + text: data.text, + iconName: data.iconName, + chevron: data.chevron, + width: data.width, + height: data.height, + leftPadding: data.tag === "dismiss" ? 0 : 8, + rightPadding: data.tag === "dismiss" ? 0 : 8 + }); + verify(!!button, "Component exists"); + verify(waitForRendering(button)); + let shot = grabImage(button); + let left = shot.width; + let right = -1; + let top = shot.height; + let bottom = -1; + for (let y = 0; y < shot.height; ++y) { + for (let x = 0; x < shot.width; ++x) { + if (shot.red(x, y) > 80 && shot.green(x, y) < 40) { + left = Math.min(left, x); + right = Math.max(right, x); + top = Math.min(top, y); + bottom = Math.max(bottom, y); + } + } + } + verify(right >= left); + verify(Math.abs((left + right + 1) / 2 - button.width / 2) <= 2); + verify(Math.abs((top + bottom + 1) / 2 - button.height / 2) <= 2); + } + } +} diff --git a/apps/desktop-qt/tests/tst_Sidebar.qml b/apps/desktop-qt/tests/tst_Sidebar.qml new file mode 100644 index 000000000000..d2e564cb460d --- /dev/null +++ b/apps/desktop-qt/tests/tst_Sidebar.qml @@ -0,0 +1,133 @@ +import QtQuick +import QtTest +import T3.Shell +import "../qml/T3/Bricks" + +Item { + id: root + width: 400 + height: 500 + + Component { + id: sidebarComponent + Sidebar { + width: 272 + height: 450 + showScope: false + showFooter: false + } + } + + TestCase { + name: "SidebarTests" + when: windowShown + + function init() { + Shell.state = { + sidebar: { + projects: [ + { + key: "project", + displayName: qsTr("Project") + } + ], + scopeProjectKey: null, + activeThreadKey: null, + activeDraftId: null, + drafts: [], + pinned: [], + snoozed: [], + settled: [], + settledTotal: 0, + active: [ + { + key: "thread", + projectKey: "project", + title: qsTr("Review"), + status: "working", + canSettle: true, + canSnooze: true, + branch: "main", + updatedAt: "2026-09-07T12:00:00Z" + } + ] + } + }; + } + + function cleanup() { + Shell.reset(); + } + + function test_publicationKeepsHoveredRow() { + let sidebar = createTemporaryObject(sidebarComponent, root); + verify(!!sidebar, "Component exists"); + let list = findChild(sidebar, "list"); + verify(!!list, "Object exists"); + tryCompare(list, "count", 1); + let row = findChild(sidebar, "threadRow:thread"); + verify(!!row, "Object exists"); + mouseMove(row, 100, 40); + tryCompare(row, "showActions", true); + const next = JSON.parse(JSON.stringify(Shell.state.sidebar)); + next.active[0].title = qsTr("Review updated"); + Shell.state = { + sidebar: next + }; + tryVerify(() => findChild(sidebar, "threadRow:thread").item.title === qsTr("Review updated")); + compare(findChild(sidebar, "threadRow:thread"), row); + tryCompare(row, "showActions", true); + } + + function test_reorderParkFoldAndRemove() { + let sidebar = createTemporaryObject(sidebarComponent, root); + verify(!!sidebar, "Component exists"); + let list = findChild(sidebar, "list"); + verify(!!list, "Object exists"); + tryCompare(list, "count", 1); + let row = findChild(sidebar, "threadRow:thread"); + verify(!!row, "Object exists"); + let next = JSON.parse(JSON.stringify(Shell.state.sidebar)); + next.active.unshift(Object.assign({}, next.active[0], { + key: "second", + title: qsTr("Second") + })); + Shell.state = { + sidebar: next + }; + tryCompare(list, "count", 2); + compare(findChild(sidebar, "threadRow:thread"), row); + + next = JSON.parse(JSON.stringify(next)); + next.active.reverse(); + Shell.state = { + sidebar: next + }; + compare(findChild(sidebar, "threadRow:thread"), row); + mouseClick(row, 100, 40); + tryCompare(Shell, "dispatchCount", 1); + compare(Shell.dispatchedActions[0].payload.key, "thread"); + + next = JSON.parse(JSON.stringify(next)); + next.snoozed = [next.active.shift()]; + Shell.state = { + sidebar: next + }; + tryCompare(list, "count", 3); + tryCompare(row, "slim", true); + tryCompare(row, "section", "snoozed"); + sidebar.toggleSection("snoozed"); + tryCompare(list, "count", 2); + sidebar.toggleSection("snoozed"); + tryCompare(list, "count", 3); + + next = JSON.parse(JSON.stringify(next)); + next.active = []; + next.snoozed = []; + Shell.state = { + sidebar: next + }; + tryCompare(list, "count", 0); + } + } +} diff --git a/apps/desktop-qt/tests/tst_SidebarThreadRow.qml b/apps/desktop-qt/tests/tst_SidebarThreadRow.qml new file mode 100644 index 000000000000..02a16aaf5d18 --- /dev/null +++ b/apps/desktop-qt/tests/tst_SidebarThreadRow.qml @@ -0,0 +1,49 @@ +import QtQuick +import QtTest +import "../qml/T3/Bricks" + +Item { + id: root + width: 400 + height: 180 + + Component { + id: rowComponent + + SidebarThreadRow { + width: 360 + height: 78 + active: false + item: ({ + title: "Review", + status: "ready", + unread: false, + wokeAt: null, + wakeLabel: null, + canSettle: true, + canSnooze: true, + branch: "main", + updatedAt: new Date(Date.now() - 61000).toISOString() + }) + } + } + + TestCase { + name: "SidebarThreadRowTests" + when: windowShown + + function test_relativeAgeRefreshesWhileIdle() { + let row = createTemporaryObject(rowComponent, root); + verify(!!row, "Component exists"); + let timer = findChild(row, "ageRefreshTimer"); + verify(!!timer, "Object exists"); + row.ageNow = Date.parse(row.item.updatedAt) + 1000; + compare(row.ageLabel, qsTr("now")); + + timer.interval = 25; + timer.restart(); + + tryCompare(row, "ageLabel", qsTr("1m"), 2000); + } + } +} diff --git a/apps/desktop-qt/tests/tst_SidebarThreadRowHover.qml b/apps/desktop-qt/tests/tst_SidebarThreadRowHover.qml new file mode 100644 index 000000000000..2f19ecdb3783 --- /dev/null +++ b/apps/desktop-qt/tests/tst_SidebarThreadRowHover.qml @@ -0,0 +1,79 @@ +import QtQuick +import QtTest +import T3.Shell +import "../qml/T3/Bricks" + +Item { + id: root + width: 400 + height: 200 + + Component { + id: rowComponent + SidebarThreadRow { + width: 272 + height: 78 + active: false + item: ({ + title: qsTr("Review"), + status: "ready", + canSettle: true, + canSnooze: true, + branch: "main", + updatedAt: "2026-09-07T12:00:00Z" + }) + } + } + + TestCase { + id: testCase + name: "SidebarThreadRowHoverTests" + when: windowShown + property var observedBackground: null + property real darkest: 1 + + Connections { + target: testCase.observedBackground + function onColorChanged() { + const c = testCase.observedBackground.color; + testCase.darkest = Math.min(testCase.darkest, c.r * c.a + (1 - c.a) * 0.98); + } + } + + function cleanup() { + observedBackground = null; + Theme.colors = {}; + } + + function test_lightHoverNeverFlashesGray() { + Theme.colors = { + sidebarRowHover: "#fff7f4" + }; + let row = createTemporaryObject(rowComponent, root); + verify(!!row, "Component exists"); + let background = findChild(row, "rowBackground"); + verify(!!background, "Object exists"); + observedBackground = background; + darkest = 0.98; + mouseMove(row, 100, 40); + tryCompare(background, "color", "#fff7f4"); + verify(darkest >= 0.98); + mouseMove(root, 380, 150); + tryCompare(background, "color", Qt.alpha("#fff7f4", 0)); + verify(darkest >= 0.98); + } + + function test_hoverAcrossActions() { + let row = createTemporaryObject(rowComponent, root); + verify(!!row, "Component exists"); + mouseMove(row, 10, 12); + tryCompare(row, "showActions", true); + for (let x = 180; x < 264; ++x) { + mouseMove(row, x, 15, 20); + tryCompare(row, "showActions", true, 100); + } + mouseMove(root, 380, 150); + tryCompare(row, "showActions", false); + } + } +} diff --git a/apps/desktop-qt/tests/tst_Workspace.qml b/apps/desktop-qt/tests/tst_Workspace.qml new file mode 100644 index 000000000000..431e07d5d41e --- /dev/null +++ b/apps/desktop-qt/tests/tst_Workspace.qml @@ -0,0 +1,68 @@ +import QtQuick +import QtTest +import T3.Shell +import "../qml/T3/Bricks" + +Item { + id: root + width: 1200 + height: 100 + + Component { + id: workspaceComponent + Workspace { + width: 1100 + height: 52 + } + } + + TestCase { + name: "WorkspaceTests" + when: windowShown + + function cleanup() { + Shell.reset(); + } + + function test_titleUsesAvailableSpace_data() { + return [ + { + tag: "draft", + title: qsTr("New thread") + }, + { + tag: "thread", + title: qsTr("Fix TUI Readability Issue") + }, + { + tag: "long", + title: qsTr("Review the desktop shell and its shared widgets") + } + ]; + } + + function test_titleUsesAvailableSpace(data) { + Shell.state = { + workspace: { + projectTitle: qsTr("Project"), + threadTitle: data.title, + isDraft: false, + renameRequestId: 0, + scripts: [], + editors: [], + terminalAvailable: false + } + }; + let workspace = createTemporaryObject(workspaceComponent, root); + verify(!!workspace, "Component exists"); + let label = findChild(workspace, "threadLabel"); + verify(!!label, "Object exists"); + verify(waitForRendering(workspace)); + compare(label.truncated, false); + workspace.width = 160; + tryCompare(label, "truncated", true); + workspace.width = 1100; + tryCompare(label, "truncated", false); + } + } +} diff --git a/apps/desktop-qt/tsconfig.json b/apps/desktop-qt/tsconfig.json new file mode 100644 index 000000000000..cf660b219a3f --- /dev/null +++ b/apps/desktop-qt/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"], + "lib": ["ESNext"] + }, + "include": ["host", "scripts"] +} diff --git a/apps/web/index.html b/apps/web/index.html index c2d164742402..182dcafa7a46 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -437,6 +437,18 @@ color: var(--boot-foreground); } + /* A shell that cards the surface sets `data-shell-surface-radius` before + this document exists; the document stays transparent and the splash + clips to the curve, so the card shows through from the first paint. */ + html[data-shell-surface-radius], + html[data-shell-surface-radius] body { + background: transparent !important; + } + + html[data-shell-surface-radius] #boot-shell { + border-radius: var(--app-shell-surface-radius); + } + #boot-shell-card { position: relative; z-index: 1; diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 6769586f7fa8..c85ed022a8ac 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -9,9 +9,9 @@ import { } from "react"; import { useLocation, useNavigate } from "@tanstack/react-router"; -import { isElectron } from "../env"; +import { isElectron, isT3Shell } from "../env"; import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalStorage"; -import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; +import { shortcutLabelForCommand } from "../keybindings"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; @@ -21,6 +21,8 @@ import { usePanelNavigationSuppression, } from "../panelAnimations"; import LegacyThreadSidebar from "./LegacySidebar"; +import { useSidebarToggleKeybinding } from "../hooks/useSidebarToggleKeybinding"; +import { ShellLayoutBridge } from "../shell/lazy"; import ThreadSidebar from "./Sidebar"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; @@ -41,7 +43,6 @@ import { SidebarProvider, SidebarRail, SidebarTrigger, - useSidebar, useSidebarVisibility, } from "./ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; @@ -71,7 +72,6 @@ function readInitialThreadSidebarWidth(): number { function SidebarControl() { const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const { toggleSidebar } = useSidebar(); const isSidebarVisible = useSidebarVisibility(); const environmentIdentificationMode = useEnvironmentIdentificationMode(); const stageBackdropVariant = useSidebarStageBackdropVariant( @@ -79,26 +79,7 @@ function SidebarControl() { ); const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle"); - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented) return; - if ( - event.target instanceof HTMLElement && - event.target.closest("[data-keybinding-capture]") - ) { - return; - } - if (resolveShortcutCommand(event, keybindings) !== "sidebar.toggle") return; - - event.preventDefault(); - event.stopPropagation(); - toggleSidebar(); - }; - - // Capture before focused editors consume commands such as Mod+B for rich-text formatting. - window.addEventListener("keydown", onKeyDown, true); - return () => window.removeEventListener("keydown", onKeyDown, true); - }, [keybindings, toggleSidebar]); + useSidebarToggleKeybinding(); return ( // The right-side layout controls carry mr-px (border compensation inside @@ -227,35 +208,37 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { style={sidebarProviderStyle} > - - nextWidth <= currentWidth || - wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, - storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, - onResize: setSidebarWidth, - }} - > - {isOnSettings ? ( - <> - - - - ) : legacySidebarEnabled ? ( - - ) : ( - - )} - - + {isT3Shell ? null : ( + + nextWidth <= currentWidth || + wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, + storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, + onResize: setSidebarWidth, + }} + > + {isOnSettings ? ( + <> + + + + ) : legacySidebarEnabled ? ( + + ) : ( + + )} + + + )} {children} - + {isT3Shell ? : } ); diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 94697b9592fd..3e9ad804354d 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,49 +1,33 @@ import { RefreshIcon } from "~/components/ui/refresh-icon"; -import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { - isAtomCommandInterrupted, - squashAtomCommandFailure, -} from "@t3tools/client-runtime/state/runtime"; import type { ContextMenuItem, EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { ChevronDownIcon, GitBranchIcon, SearchIcon } from "lucide-react"; import { useCallback, - useDeferredValue, useEffect, useId, useLayoutEffect, useMemo, - useOptimistic, useRef, useState, - useTransition, type MouseEvent as ReactMouseEvent, } from "react"; -import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; +import { type DraftId } from "../composerDraftStore"; +import { useThreadBranchSelection } from "../hooks/useThreadBranchSelection"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { readLocalApi } from "../localApi"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { shouldLoadNextBranchPageAfterScroll } from "../state/paginatedBranches"; -import { usePaginatedBranches } from "../state/queries"; -import { useProject, useThread } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; -import { threadEnvironment } from "../state/threads"; -import { useAtomCommand } from "../state/use-atom-command"; import { vcsEnvironment } from "../state/vcs"; import { cn } from "../lib/utils"; import { parsePullRequestReference } from "../pullRequestReference"; import { getSourceControlPresentation } from "../sourceControlPresentation"; import { composerFloatingLayerProps } from "./chat/composerEventScope"; import { - deriveLocalBranchNameFromRemoteRef, resolveBranchTriggerLabel, resolveBranchToolbarPrBranch, - resolveBranchSelectionTarget, - resolveBranchToolbarValue, - resolveDraftEnvModeAfterBranchChange, - resolveEffectiveEnvMode, sanitizeNewRefName, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; @@ -98,176 +82,50 @@ export function BranchToolbarBranchSelector({ onComposerFocusRequest, }: BranchToolbarBranchSelectorProps) { const startFromOriginSwitchId = useId(); - const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); - const updateThreadMetadata = useAtomCommand( - threadEnvironment.updateMetadata, - "thread metadata update", - ); - const switchRef = useAtomCommand(vcsEnvironment.switchRef, { - reportFailure: false, - }); - const createRefMutation = useAtomCommand(vcsEnvironment.createRef, { - reportFailure: false, - }); - // --------------------------------------------------------------------------- - // Thread / project state (pushed down from parent to colocate with mutation) - // --------------------------------------------------------------------------- - const threadRef = useMemo( - () => scopeThreadRef(environmentId, threadId), - [environmentId, threadId], - ); - const draftThread = useComposerDraftStore((store) => - draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), - ); - const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); - const serverSession = serverThread?.session ?? null; - const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - - const activeProjectRef = serverThread - ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) - : draftThread - ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) - : null; - const activeProject = useProject(activeProjectRef); - - const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); - const activeThreadBranch = - activeThreadBranchOverride !== undefined - ? activeThreadBranchOverride - : (serverThread?.branch ?? draftThread?.branch ?? null); - const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; - const activeProjectCwd = activeProject?.workspaceRoot ?? null; - const branchCwd = activeWorktreePath ?? activeProjectCwd; - const hasServerThread = serverThread !== null; - const effectiveEnvMode = - effectiveEnvModeOverride ?? - resolveEffectiveEnvMode({ - activeWorktreePath, - hasServerThread, - draftThreadEnvMode: draftThread?.envMode, - }); - - // --------------------------------------------------------------------------- - // Thread branch mutation (colocated — only this component calls it) - // --------------------------------------------------------------------------- - const setThreadBranch = useCallback( - (branch: string | null, worktreePath: string | null, automatic = false) => { - if (!activeThreadId || !activeProject) return; - if (serverSession && worktreePath !== activeWorktreePath) { - void stopThreadSession({ - environmentId, - input: { threadId: activeThreadId }, - }); - } - if (hasServerThread) { - void updateThreadMetadata({ - environmentId, - input: { - threadId: activeThreadId, - branch, - worktreePath, - }, - }); - } - if (hasServerThread) { - onActiveThreadBranchOverrideChange?.(branch); - return; - } - const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({ - nextWorktreePath: worktreePath, - currentWorktreePath: activeWorktreePath, - effectiveEnvMode, - }); - setDraftThreadContext(draftId ?? threadRef, { - branch, - worktreePath, - envMode: nextDraftEnvMode, - environmentSelection: automatic ? (draftThread?.environmentSelection ?? "auto") : "manual", - projectRef: scopeProjectRef(environmentId, activeProject.id), - }); - }, - [ - activeThreadId, - activeProject, - serverSession, - activeWorktreePath, - hasServerThread, - onActiveThreadBranchOverrideChange, - setDraftThreadContext, - draftId, - threadRef, - environmentId, - effectiveEnvMode, - draftThread?.environmentSelection, - stopThreadSession, - updateThreadMetadata, - ], - ); - - // --------------------------------------------------------------------------- - // Git ref queries - // --------------------------------------------------------------------------- const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); const [branchQuery, setBranchQuery] = useState(""); - const deferredBranchQuery = useDeferredValue(branchQuery); - - const branchStatusQuery = useEnvironmentQuery( - branchCwd === null - ? null - : vcsEnvironment.status({ - environmentId, - input: { cwd: branchCwd }, - }), - ); - const trimmedBranchQuery = branchQuery.trim(); - const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); - // The server filters refs by substring, so it has to be given the sanitized - // name as well: querying the raw "new branch" drops an existing new-branch - // from the response entirely, which would defeat the collision check below. - // Ref names cannot contain an ASCII space, so sanitizing loses no matches. - const branchRefQuery = sanitizeNewRefName(deferredTrimmedBranchQuery); - const branchRefTarget = useMemo( - () => ({ - environmentId, - cwd: branchCwd, - query: branchRefQuery, - }), - [branchCwd, branchRefQuery, environmentId], - ); - const branchRefState = usePaginatedBranches(branchRefTarget); - const refs = branchRefState.refs; - const hasNextPage = - branchRefState.data?.nextCursor !== null && branchRefState.data?.nextCursor !== undefined; - const isFetchingNextPage = branchRefState.isFetchingNextPage; - const isInitialBranchesLoadPending = branchRefState.isPending && branchRefState.data === null; - const currentGitBranch = - branchStatusQuery.data?.refName ?? refs.find((refName) => refName.current)?.name ?? null; + const { + threadRef, + activeProjectCwd, + activeThreadBranch, + activeWorktreePath, + branchCwd, + effectiveEnvMode, + isSelectingWorktreeBase, + branchStatusQuery, + trimmedBranchQuery, + deferredTrimmedBranchQuery, + branchRefState, + refs, + hasNextPage, + isFetchingNextPage, + isInitialBranchesLoadPending, + resolvedActiveBranch, + branchByName, + branchNames, + isBranchActionPending, + selectBranch: selectBranchRef, + createRef: createRefNamed, + } = useThreadBranchSelection({ + environmentId, + threadId, + draftId, + envLocked, + effectiveEnvModeOverride, + activeThreadBranchOverride, + onActiveThreadBranchOverrideChange, + branchQuery, + }); const sourceControlPresentation = useMemo( () => getSourceControlPresentation(branchStatusQuery.data?.sourceControlProvider), [branchStatusQuery.data?.sourceControlProvider], ); const SourceControlIcon = sourceControlPresentation.Icon; - const canonicalActiveBranch = resolveBranchToolbarValue({ - envMode: effectiveEnvMode, - activeWorktreePath, - activeThreadBranch, - currentGitBranch, - }); - const branchNames = useMemo(() => refs.map((refName) => refName.name), [refs]); - const branchByName = useMemo( - () => new Map(refs.map((refName) => [refName.name, refName] as const)), - [refs], - ); const normalizedDeferredBranchQuery = deferredTrimmedBranchQuery.toLowerCase(); const prReference = parsePullRequestReference(trimmedBranchQuery); - const isSelectingWorktreeBase = - effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath; const checkoutPullRequestItemValue = prReference && onCheckoutPullRequestRequest ? `__checkout_pull_request__:${prReference}` : null; const canCreateBranch = !isSelectingWorktreeBase && trimmedBranchQuery.length > 0; - // The ref is created under its sanitized name, so the collision check has to - // use that name too. Matching on the raw query would offer to create a ref - // that already exists whenever sanitizing changes the name. const newRefName = sanitizeNewRefName(trimmedBranchQuery); const hasExactBranchMatch = branchByName.has(newRefName); const createBranchItemValue = canCreateBranch @@ -302,10 +160,6 @@ export function BranchToolbarBranchSelector({ normalizedDeferredBranchQuery, ], ); - const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic( - canonicalActiveBranch, - (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch, - ); const listedActiveBranch = resolvedActiveBranch === null ? null : (branchByName.get(resolvedActiveBranch) ?? null); const activeBranchRefQuery = useEnvironmentQuery( @@ -329,7 +183,6 @@ export function BranchToolbarBranchSelector({ : queriedActiveBranch ? queriedActiveBranch.isRemote === true : null; - const [isBranchActionPending, startBranchActionTransition] = useTransition(); const totalBranchCount = branchRefState.data?.totalCount ?? 0; const branchStatusText = isInitialBranchesLoadPending ? "Loading refs..." @@ -339,9 +192,6 @@ export function BranchToolbarBranchSelector({ ? `Showing ${refs.length} of ${totalBranchCount} refs` : null; - // --------------------------------------------------------------------------- - // Branch actions - // --------------------------------------------------------------------------- const copyBranchName = useCallback((branchName: string) => { void writeTextToClipboard(branchName, "branch name").then( (didCopy) => { @@ -381,142 +231,18 @@ export function BranchToolbarBranchSelector({ [copyBranchName], ); - const runBranchAction = (action: () => Promise) => { - startBranchActionTransition(async () => { - await action(); - branchRefState.refresh(); - branchStatusQuery.refresh(); - }); - }; - const selectBranch = (refName: VcsRef) => { - if (!branchCwd || !activeProjectCwd || isBranchActionPending) return; - - if (isSelectingWorktreeBase) { - setThreadBranch(refName.name, null); - setIsBranchMenuOpen(false); - onComposerFocusRequest?.(); - return; - } - - const selectionTarget = resolveBranchSelectionTarget({ - activeProjectCwd, - activeWorktreePath, - refName, - }); - - if (selectionTarget.reuseExistingWorktree) { - setThreadBranch(refName.name, selectionTarget.nextWorktreePath); - setIsBranchMenuOpen(false); - onComposerFocusRequest?.(); - return; - } - - const selectedBranchName = refName.isRemote - ? deriveLocalBranchNameFromRemoteRef(refName.name) - : refName.name; - + if (!selectBranchRef(refName)) return; setIsBranchMenuOpen(false); onComposerFocusRequest?.(); - - runBranchAction(async () => { - const previousBranch = resolvedActiveBranch; - setOptimisticBranch(selectedBranchName); - const checkoutResult = await switchRef({ - environmentId, - input: { - cwd: selectionTarget.checkoutCwd, - refName: refName.name, - }, - }); - if (checkoutResult._tag === "Success") { - const nextBranchName = refName.isRemote - ? (checkoutResult.value.refName ?? selectedBranchName) - : selectedBranchName; - setOptimisticBranch(nextBranchName); - setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); - return; - } - setOptimisticBranch(previousBranch); - if (!isAtomCommandInterrupted(checkoutResult)) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to switch ref.", - description: toBranchActionErrorMessage(squashAtomCommandFailure(checkoutResult)), - }), - ); - } - }); }; const createRef = (rawName: string) => { - const name = sanitizeNewRefName(rawName); - if (!branchCwd || !name || isBranchActionPending) return; - + if (!createRefNamed(rawName)) return; setIsBranchMenuOpen(false); onComposerFocusRequest?.(); - - runBranchAction(async () => { - const previousBranch = resolvedActiveBranch; - setOptimisticBranch(name); - const createBranchResult = await createRefMutation({ - environmentId, - input: { - cwd: branchCwd, - refName: name, - switchRef: true, - }, - }); - if (createBranchResult._tag === "Success") { - setOptimisticBranch(createBranchResult.value.refName); - setThreadBranch(createBranchResult.value.refName, activeWorktreePath); - return; - } - setOptimisticBranch(previousBranch); - if (!isAtomCommandInterrupted(createBranchResult)) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to create and switch ref.", - description: toBranchActionErrorMessage(squashAtomCommandFailure(createBranchResult)), - }), - ); - } - }); }; - // Default the worktree base to the repo default branch (origin/HEAD), only - // falling back to the checked-out branch when no default is known. - const defaultBranchName = useMemo( - () => refs.find((refName) => refName.isDefault)?.name ?? null, - [refs], - ); - const worktreeBaseBranchCandidate = isInitialBranchesLoadPending - ? null - : (defaultBranchName ?? currentGitBranch); - - useEffect(() => { - if ( - effectiveEnvMode !== "worktree" || - activeWorktreePath || - activeThreadBranch || - !worktreeBaseBranchCandidate - ) { - return; - } - setThreadBranch(worktreeBaseBranchCandidate, null, true); - }, [ - activeThreadBranch, - activeWorktreePath, - effectiveEnvMode, - setThreadBranch, - worktreeBaseBranchCandidate, - ]); - - // --------------------------------------------------------------------------- - // Combobox / list plumbing - // --------------------------------------------------------------------------- const branchListScrollElementRef = useRef(null); const previousBranchListScrollTopRef = useRef(null); const handleOpenChange = useCallback((open: boolean) => { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6d1906ff975c..173b2e02f27f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -22,7 +22,6 @@ import { type PreviewAnnotationPayload, ProviderInstanceId, type ServerProvider, - type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, type ThreadLinkedPullRequest, @@ -34,7 +33,6 @@ import { ProviderDriverKind, resolveEnvironmentMachineKind, RuntimeMode, - TerminalOpenInput, } from "@t3tools/contracts"; import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; @@ -46,7 +44,6 @@ import { type CodexFeedbackSubmission, } from "@t3tools/client-runtime/state/threads"; import { - parseScopedThreadKey, scopedThreadKey, scopeProjectRef, scopeThreadRef, @@ -62,17 +59,11 @@ import { resolveProjectScripts, } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; -import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; -import { - getTerminalLabel, - nextTerminalId, - resolveTerminalSessionLabel, -} from "@t3tools/shared/terminalLabels"; +import { getTerminalLabel, nextTerminalId } from "@t3tools/shared/terminalLabels"; import { Debouncer } from "@tanstack/react-pacer"; import { useAtomValue } from "@effect/atom-react"; import { lazy, - memo, Suspense, useCallback, useEffect, @@ -87,7 +78,6 @@ import { useLocation, useNavigate } from "@tanstack/react-router"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; -import { useShallow } from "zustand/react/shallow"; import { isAtomCommandInterrupted, mapAtomCommandResult, @@ -98,7 +88,7 @@ import { import * as Cause from "effect/Cause"; import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/unstable/reactivity"; -import { isElectron } from "../env"; +import { isElectron, isT3Shell } from "../env"; import { readLocalApi } from "../localApi"; import { useDiffPanelStore } from "../diffPanelStore"; import { @@ -146,7 +136,6 @@ import { import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, - DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, isBrowserPreviewAttachment, @@ -155,7 +144,6 @@ import { type Thread, } from "../types"; import { useTheme } from "../hooks/useTheme"; -import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; @@ -168,6 +156,7 @@ import { type RightPanelSurface, useRightPanelStore, } from "../rightPanelStore"; +import { ShellRightPanelBridge, ShellWorkspaceBridge } from "../shell/lazy"; import { isPreviewSupportedInRuntime, setActivePreviewTab, @@ -176,7 +165,6 @@ import { import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; import { BrowserSettingsReadError } from "../browser/openFileInPreview"; import { addBrowserSurface } from "./preview/addBrowserSurface"; -import { closePreviewSession } from "./preview/closePreviewSession"; import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; @@ -197,7 +185,14 @@ import { } from "@t3tools/client-runtime/state/subagentRuntime"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; -import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; +import { ThreadTerminals, PersistentThreadTerminalPanel } from "./ThreadTerminals"; +import { handleThreadTerminalShortcut } from "./threadTerminalShortcuts"; +import { useThreadPanelClosing } from "./useThreadPanelClosing"; +import { useThreadReferenceCopy } from "./useThreadReferenceCopy"; +import { + useThreadTerminalActions, + useThreadTerminalSessionState, +} from "./useThreadTerminalActions"; import { AlarmClockIcon, CheckCircle2Icon, @@ -235,7 +230,6 @@ import { import { useNowMinute } from "../hooks/useNowMinute"; import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; -import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; import { useThreadActions } from "../hooks/useThreadActions"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm"; @@ -279,7 +273,6 @@ import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation"; import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext"; import { environmentCatalog } from "../connection/catalog"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; -import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useEnvironmentQuery } from "../state/query"; import { environmentServerConfigsAtom, @@ -297,13 +290,7 @@ import { import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; -import { - useProject, - useProjects, - useThread, - useThreadRefs, - useThreadShell, -} from "../state/entities"; +import { useProject, useProjects, useThread, useThreadShell } from "../state/entities"; import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { createPageScrollController, type PageScrollKey } from "./chat/pageScrollController"; @@ -355,8 +342,6 @@ import { runMobileComposerTransition, } from "./chat/draftHeroTransition"; import { - MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, - agentControlledBrowserCloseConfirmation, branchMismatchKey, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, @@ -380,15 +365,12 @@ import { shouldOpenProactiveTurnDiff, shouldRenderPreviewMiniPlayer, getStartedThreadModelChangeBlockReason, - LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, - LastInvokedScriptByProjectSchema, type LocalDispatchSnapshot, PullRequestDialogState, cloneComposerImageForRetry, deriveLockedProvider, readFileAsDataUrl, resolveFileAttachmentUrl, - reconcileMountedTerminalThreadIds, resolveBackgroundDraftWorkspaceOptions, resolveComposerInteractionMode, resolveComposerProviderSelection, @@ -638,8 +620,6 @@ function formatOutgoingPrompt(params: { const promptEffort = resolvePromptInjectedEffort(caps, params.effort); return applyClaudePromptEffortPrefix(params.text, promptEffort); } -const SCRIPT_TERMINAL_COLS = 120; -const SCRIPT_TERMINAL_ROWS = 30; function isCompactCommandMessage(message: ChatMessage): boolean { const text = message.text.trim().toLowerCase(); @@ -656,6 +636,8 @@ type ChatViewProps = threadSyncPhase?: ThreadSyncPhase | null; routeKind: "server"; draftId?: never; + /** `rightPanel` renders only the thread's right panel for the shell's embed route. */ + presentation?: "full" | "rightPanel"; } | { environmentId: EnvironmentId; @@ -666,16 +648,9 @@ type ChatViewProps = threadSyncPhase?: never; routeKind: "draft"; draftId: DraftId; + presentation?: "full" | "rightPanel"; }; -interface TerminalLaunchContext { - threadId: ThreadId; - cwd: string; - worktreePath: string | null; -} - -type PersistentTerminalLaunchContext = Pick; - function useLocalDispatchState(input: { activeThread: Thread | undefined; activeLatestTurn: Thread["latestTurn"] | null; @@ -753,592 +728,6 @@ function useLocalDispatchState(input: { }; } -/** Same terminal ids (order ignored) — avoids reconcile when only server session ordering differs. */ -function terminalIdListsEqual(left: readonly string[], right: readonly string[]): boolean { - if (left.length !== right.length) { - return false; - } - if (left.length === 0) { - return true; - } - const sortedLeft = left.toSorted((a, b) => a.localeCompare(b)); - const sortedRight = right.toSorted((a, b) => a.localeCompare(b)); - for (let index = 0; index < sortedLeft.length; index += 1) { - if (sortedLeft[index] !== sortedRight[index]) { - return false; - } - } - return true; -} - -/** - * Server knows about fewer sessions than the client, but every server id still exists locally. - * Typical right after `terminal.open`: known-session list lags; reconciling would drop the new id - * and later re-add it as a separate group (no split layout). - */ -function serverTerminalIdsStrictSubsetOfClient( - serverIds: readonly string[], - clientIds: readonly string[], -): boolean { - if (serverIds.length >= clientIds.length || clientIds.length === 0) { - return false; - } - const clientSet = new Set(clientIds); - for (const id of serverIds) { - if (!clientSet.has(id)) { - return false; - } - } - return true; -} - -interface PersistentThreadTerminalDrawerProps { - threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; - threadId: ThreadId; - active: boolean; - launchContext: PersistentTerminalLaunchContext | null; - focusRequestId: number; - splitShortcutLabel: string | undefined; - splitVerticalShortcutLabel: string | undefined; - newShortcutLabel: string | undefined; - closeShortcutLabel: string | undefined; - keybindings: ResolvedKeybindingsConfig; - onAddTerminalContext: (selection: TerminalContextSelection) => void; -} - -const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({ - threadRef, - threadId, - active, - launchContext, - focusRequestId, - splitShortcutLabel, - splitVerticalShortcutLabel, - newShortcutLabel, - closeShortcutLabel, - keybindings, - onAddTerminalContext, -}: PersistentThreadTerminalDrawerProps) { - const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); - const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); - const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); - const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); - const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); - const projectRef = serverThread - ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) - : draftThread - ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) - : null; - const project = useProject(projectRef); - const terminalUiState = useTerminalUiStateStore((state) => - selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef), - ); - const visible = active && terminalUiState.terminalOpen; - const knownTerminalSessions = useKnownTerminalSessions({ - environmentId: threadRef.environmentId, - threadId, - }); - const panelSurfaces = useRightPanelStore( - (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces, - ); - const panelTerminalIds = useMemo( - () => - new Set( - panelSurfaces.flatMap((surface) => - surface.kind === "terminal" ? surface.terminalIds : [], - ), - ), - [panelSurfaces], - ); - const drawerTerminalSessions = useMemo( - () => - knownTerminalSessions.filter((session) => !panelTerminalIds.has(session.target.terminalId)), - [knownTerminalSessions, panelTerminalIds], - ); - const terminalLabelsById = useMemo(() => { - const next = new Map(); - for (const session of drawerTerminalSessions) { - next.set( - session.target.terminalId, - resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), - ); - } - return next; - }, [drawerTerminalSessions]); - const terminalLaunchLocationsById = useMemo(() => { - const next = new Map< - string, - { - readonly cwd: string; - readonly worktreePath: string | null; - readonly runtimeEnv: Record; - } - >(); - if (!project) { - return next; - } - - for (const session of drawerTerminalSessions) { - const summary = session.state.summary; - if (!summary) { - continue; - } - const worktreePathForLaunch = - launchContext !== null ? launchContext.worktreePath : summary.worktreePath; - next.set(session.target.terminalId, { - cwd: launchContext?.cwd ?? summary.cwd, - worktreePath: worktreePathForLaunch, - runtimeEnv: projectScriptRuntimeEnv({ - project: { cwd: project.workspaceRoot }, - worktreePath: worktreePathForLaunch, - }), - }); - } - - return next; - }, [drawerTerminalSessions, launchContext, project]); - const serverOrderedTerminalIds = useMemo( - () => drawerTerminalSessions.map((session) => session.target.terminalId), - [drawerTerminalSessions], - ); - // Every client-side id source participates in allocation: the server list - // lags fresh opens, and panel terminals are filtered out of the drawer's - // sessions — an id collision attaches two viewports to one PTY session. - const allocatableTerminalIds = useMemo( - () => [ - ...new Set([ - ...serverOrderedTerminalIds, - ...terminalUiState.terminalIds, - ...panelTerminalIds, - ]), - ], - [panelTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds], - ); - const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); - const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); - const storeSplitTerminalVertical = useTerminalUiStateStore( - (state) => state.splitTerminalVertical, - ); - const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal); - const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal); - const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal); - const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); - - useEffect(() => { - if (terminalIdListsEqual(serverOrderedTerminalIds, terminalUiState.terminalIds)) { - return; - } - if ( - serverTerminalIdsStrictSubsetOfClient(serverOrderedTerminalIds, terminalUiState.terminalIds) - ) { - return; - } - reconcileTerminalIds(threadRef, serverOrderedTerminalIds); - }, [reconcileTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds, threadRef]); - const [localFocusRequestId, setLocalFocusRequestId] = useState(0); - const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; - const effectiveWorktreePath = useMemo(() => { - if (launchContext !== null) { - return launchContext.worktreePath; - } - return worktreePath; - }, [launchContext, worktreePath]); - const cwd = useMemo( - () => - launchContext?.cwd ?? - (project - ? projectScriptCwd({ - project: { cwd: project.workspaceRoot }, - worktreePath: effectiveWorktreePath, - }) - : null), - [effectiveWorktreePath, launchContext?.cwd, project], - ); - const runtimeEnv = useMemo( - () => - project - ? projectScriptRuntimeEnv({ - project: { cwd: project.workspaceRoot }, - worktreePath: effectiveWorktreePath, - }) - : {}, - [effectiveWorktreePath, project], - ); - - const bumpFocusRequestId = useCallback(() => { - if (!visible) { - return; - } - setLocalFocusRequestId((value) => value + 1); - }, [visible]); - - const setTerminalHeight = useCallback( - (height: number) => { - storeSetTerminalHeight(threadRef, height); - }, - [storeSetTerminalHeight, threadRef], - ); - - const splitTerminal = useCallback(() => { - if (!cwd) { - return; - } - const terminalId = nextTerminalId(allocatableTerminalIds); - storeSplitTerminal(threadRef, terminalId); - bumpFocusRequestId(); - void openTerminal({ - environmentId: threadRef.environmentId, - input: { - threadId, - terminalId, - cwd, - ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), - env: runtimeEnv, - }, - }); - }, [ - allocatableTerminalIds, - bumpFocusRequestId, - cwd, - effectiveWorktreePath, - runtimeEnv, - storeSplitTerminal, - threadId, - threadRef, - openTerminal, - ]); - const splitTerminalVertical = useCallback(() => { - if (!cwd) { - return; - } - const terminalId = nextTerminalId(allocatableTerminalIds); - storeSplitTerminalVertical(threadRef, terminalId); - bumpFocusRequestId(); - void openTerminal({ - environmentId: threadRef.environmentId, - input: { - threadId, - terminalId, - cwd, - ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), - env: runtimeEnv, - }, - }); - }, [ - allocatableTerminalIds, - bumpFocusRequestId, - cwd, - effectiveWorktreePath, - openTerminal, - runtimeEnv, - storeSplitTerminalVertical, - threadId, - threadRef, - ]); - - const createNewTerminal = useCallback(() => { - if (!cwd) { - return; - } - const terminalId = nextTerminalId(allocatableTerminalIds); - storeNewTerminal(threadRef, terminalId); - bumpFocusRequestId(); - void openTerminal({ - environmentId: threadRef.environmentId, - input: { - threadId, - terminalId, - cwd, - ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), - env: runtimeEnv, - }, - }); - }, [ - bumpFocusRequestId, - cwd, - effectiveWorktreePath, - allocatableTerminalIds, - runtimeEnv, - storeNewTerminal, - threadId, - threadRef, - openTerminal, - ]); - - const activateTerminal = useCallback( - (terminalId: string) => { - storeSetActiveTerminal(threadRef, terminalId); - bumpFocusRequestId(); - }, - [bumpFocusRequestId, storeSetActiveTerminal, threadRef], - ); - - const closeTerminal = useCallback( - (terminalId: string) => { - const fallbackExitWrite = () => - writeTerminal({ - environmentId: threadRef.environmentId, - input: { threadId, terminalId, data: "exit\n" }, - }); - - void (async () => { - const closeResult = await closeTerminalMutation({ - environmentId: threadRef.environmentId, - input: { - threadId, - terminalId, - deleteHistory: true, - }, - }); - if (closeResult._tag === "Failure" && !isAtomCommandInterrupted(closeResult)) { - await fallbackExitWrite(); - } - })(); - - storeCloseTerminal(threadRef, terminalId); - bumpFocusRequestId(); - }, - [ - bumpFocusRequestId, - storeCloseTerminal, - threadId, - threadRef, - closeTerminalMutation, - writeTerminal, - ], - ); - - const handleAddTerminalContext = useCallback( - (selection: TerminalContextSelection) => { - if (!visible) { - return; - } - onAddTerminalContext(selection); - }, - [onAddTerminalContext, visible], - ); - - if (!project || (!terminalUiState.terminalOpen && !active) || !cwd) { - return null; - } - - return ( -
-
- -
-
- ); -}); - -interface PersistentThreadTerminalPanelProps { - visible: boolean; - threadRef: ScopedThreadRef; - surface: Extract; - launchContext: PersistentTerminalLaunchContext | null; - focusRequestId: number; - keybindings: ResolvedKeybindingsConfig; - onAddTerminalContext: (selection: TerminalContextSelection) => void; - onSplitTerminal: () => void; - onSplitTerminalVertical: () => void; - onNewTerminal: () => void; - onActiveTerminalChange: (terminalId: string) => void; - onCloseTerminal: (terminalId: string) => void; - splitShortcutLabel?: string | undefined; - splitVerticalShortcutLabel?: string | undefined; - newShortcutLabel?: string | undefined; - closeShortcutLabel?: string | undefined; -} - -const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPanel({ - visible, - threadRef, - surface, - launchContext, - focusRequestId, - keybindings, - onAddTerminalContext, - onSplitTerminal, - onSplitTerminalVertical, - onNewTerminal, - onActiveTerminalChange, - onCloseTerminal, - splitShortcutLabel, - splitVerticalShortcutLabel, - newShortcutLabel, - closeShortcutLabel, -}: PersistentThreadTerminalPanelProps) { - const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); - const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); - const projectRef = serverThread - ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) - : draftThread - ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) - : null; - const project = useProject(projectRef); - const knownTerminalSessions = useKnownTerminalSessions({ - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - }); - const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; - const activeSummary = - knownTerminalSessions.find((session) => session.target.terminalId === surface.activeTerminalId) - ?.state.summary ?? null; - const worktreePath = - launchContext?.worktreePath ?? activeSummary?.worktreePath ?? threadWorktreePath; - const cwd = useMemo( - () => - launchContext?.cwd ?? - activeSummary?.cwd ?? - (project - ? projectScriptCwd({ - project: { cwd: project.workspaceRoot }, - worktreePath, - }) - : null), - [activeSummary?.cwd, launchContext?.cwd, project, worktreePath], - ); - const runtimeEnv = useMemo( - () => - project - ? projectScriptRuntimeEnv({ - project: { cwd: project.workspaceRoot }, - worktreePath, - }) - : {}, - [project, worktreePath], - ); - const terminalLabelsById = useMemo(() => { - const labels = new Map(); - for (const terminalId of surface.terminalIds) { - const summary = - knownTerminalSessions.find((session) => session.target.terminalId === terminalId)?.state - .summary ?? null; - labels.set(terminalId, resolveTerminalSessionLabel(terminalId, summary)); - } - return labels; - }, [knownTerminalSessions, surface.terminalIds]); - const terminalLaunchLocationsById = useMemo(() => { - const locations = new Map< - string, - { - readonly cwd: string; - readonly worktreePath: string | null; - readonly runtimeEnv: Record; - } - >(); - for (const terminalId of surface.terminalIds) { - const summary = - knownTerminalSessions.find((session) => session.target.terminalId === terminalId)?.state - .summary ?? null; - const terminalWorktreePath = - launchContext?.worktreePath ?? summary?.worktreePath ?? threadWorktreePath; - const terminalCwd = - launchContext?.cwd ?? - summary?.cwd ?? - (project - ? projectScriptCwd({ - project: { cwd: project.workspaceRoot }, - worktreePath: terminalWorktreePath, - }) - : null); - if (!terminalCwd || !project) continue; - locations.set(terminalId, { - cwd: terminalCwd, - worktreePath: terminalWorktreePath, - runtimeEnv: projectScriptRuntimeEnv({ - project: { cwd: project.workspaceRoot }, - worktreePath: terminalWorktreePath, - }), - }); - } - return locations; - }, [ - knownTerminalSessions, - launchContext?.cwd, - launchContext?.worktreePath, - project, - surface.terminalIds, - threadWorktreePath, - ]); - - if (!project || !cwd) return null; - - return ( - undefined} - onAddTerminalContext={onAddTerminalContext} - terminalLabelsById={terminalLabelsById} - terminalLaunchLocationsById={terminalLaunchLocationsById} - keybindings={keybindings} - /> - ); -}); - // Errors surface through two maps (draft-keyed and thread-keyed) whose entries // can race around promotion, so each write carries its time to let the latest // one win when they collide. @@ -1368,6 +757,17 @@ function releaseChatTimelineAnchor(null); + const [shellRenameRequestId, setShellRenameRequestId] = useState(0); + const requestShellRename = useCallback(() => setShellRenameRequestId((id) => id + 1), []); const { environmentId, threadId, @@ -1393,7 +793,6 @@ export default function ChatView(props: ChatViewProps) { reportFailure: false, }); const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); - const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); const createThread = useAtomCommand(threadEnvironment.create, { reportFailure: false }); const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); @@ -1431,7 +830,6 @@ export default function ChatView(props: ChatViewProps) { reportFailure: false, }); const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); - const closePreview = useAtomCommand(previewEnvironment.close, "preview close"); const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false }); @@ -1627,11 +1025,8 @@ export default function ChatView(props: ChatViewProps) { useState>({}); const shouldUseRightPanelSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); const isMobileViewport = useMediaQuery("max-sm"); - const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); - const [terminalUiLaunchContext, setTerminalUiLaunchContext] = - useState(null); const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< Record >({}); @@ -1642,11 +1037,6 @@ export default function ChatView(props: ChatViewProps) { pendingServerThreadStartFromOriginByThreadId, setPendingServerThreadStartFromOriginByThreadId, ] = useState>({}); - const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( - LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, - {}, - LastInvokedScriptByProjectSchema, - ); const legendListRef = useRef(null); const getTimelineScrollableNode = useCallback( () => legendListRef.current?.getScrollableNode() ?? null, @@ -1675,45 +1065,11 @@ export default function ChatView(props: ChatViewProps) { const sendInFlightRef = useRef(false); const environmentUnavailableSendToastSlotRef = useRef(0); const feedbackUploadsInFlightRef = useRef(new Set()); - const terminalUiOpenByThreadRef = useRef>({}); const terminalUiState = useTerminalUiStateStore((state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), ); - const openTerminalThreadKeys = useTerminalUiStateStore( - useShallow((state) => - Object.entries(state.terminalUiStateByThreadKey).flatMap( - ([nextThreadKey, nextTerminalUiState]) => - nextTerminalUiState.terminalOpen ? [nextThreadKey] : [], - ), - ), - ); - const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen); - const storeEnsureTerminal = useTerminalUiStateStore((state) => state.ensureTerminal); - const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal); - const storeSplitTerminalVertical = useTerminalUiStateStore((s) => s.splitTerminalVertical); - const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal); - const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); - const serverThreadRefs = useThreadRefs(); - const serverThreadKeys = useMemo(() => serverThreadRefs.map(scopedThreadKey), [serverThreadRefs]); - const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); - const draftThreadKeys = useMemo( - () => - Object.values(draftThreadsByThreadKey).map((draftThread) => - scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)), - ), - [draftThreadsByThreadKey], - ); - const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState([]); - const mountedTerminalThreadRefs = useMemo( - () => - mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { - const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); - return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; - }), - [mountedTerminalThreadKeys], - ); const fallbackDraftProjectRef = draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) @@ -1807,40 +1163,6 @@ export default function ChatView(props: ChatViewProps) { const canCheckoutPullRequestIntoThread = isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; const activeThreadEnvironmentId = activeThread?.environmentId ?? null; - const runningTerminalIds = useThreadRunningTerminalIds({ - environmentId: activeThread?.environmentId ?? null, - threadId: activeThreadId, - }); - const activeThreadKnownSessionsRaw = useKnownTerminalSessions({ - environmentId: activeThread?.environmentId ?? null, - threadId: activeThreadId, - }); - const activeThreadKnownSessions = useMemo(() => { - if (activeThreadId === null) { - return []; - } - return activeThreadKnownSessionsRaw.filter( - (session) => session.target.threadId === activeThreadId, - ); - }, [activeThreadId, activeThreadKnownSessionsRaw]); - const activeServerOrderedTerminalIds = useMemo( - () => activeThreadKnownSessions.map((session) => session.target.terminalId), - [activeThreadKnownSessions], - ); - const activeKnownTerminalIds = useMemo( - () => [...new Set([...activeServerOrderedTerminalIds, ...terminalUiState.terminalIds])], - [activeServerOrderedTerminalIds, terminalUiState.terminalIds], - ); - const activeTerminalLabelsById = useMemo(() => { - const labels = new Map(); - for (const session of activeThreadKnownSessions) { - labels.set( - session.target.terminalId, - resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), - ); - } - return labels; - }, [activeThreadKnownSessions]); const activeThreadRef = useMemo( () => activeThreadEnvironmentId && activeThreadId @@ -1848,6 +1170,8 @@ export default function ChatView(props: ChatViewProps) { : null, [activeThreadEnvironmentId, activeThreadId], ); + const terminals = useThreadTerminalSessionState(routeThreadRef, activeThreadRef); + const { activeTerminalLabelsById, allocatableActiveTerminalIds } = terminals; const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); const [timelineAnchor, setTimelineAnchor] = useState<{ @@ -1880,30 +1204,10 @@ export default function ChatView(props: ChatViewProps) { const activePreviewMiniPlayer = usePreviewMiniPlayerStore((state) => selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef), ); - const panelTerminalIds = useMemo( - () => - new Set( - rightPanelState.surfaces.flatMap((surface) => - surface.kind === "terminal" ? surface.terminalIds : [], - ), - ), - [rightPanelState.surfaces], - ); - const allocatableActiveTerminalIds = useMemo( - () => [...new Set([...activeKnownTerminalIds, ...panelTerminalIds])], - [activeKnownTerminalIds, panelTerminalIds], - ); const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); const rightPanelOpen = rightPanelState.isOpen; const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); - const activeTerminalDrawerPresence = usePanelPresence( - Boolean(activeThreadKey && terminalUiState.terminalOpen), - true, - panelAnimationsActive, - activeThreadKey, - panelAnimationDurationMs, - ); const rightPanelPresenceValue = useMemo( () => ({ activeSurface: activeRightPanelSurface, @@ -1947,10 +1251,6 @@ export default function ChatView(props: ChatViewProps) { } }, [activePreviewMiniPlayer, activePreviewState.sessions, activeThreadRef]); - const existingOpenTerminalThreadKeys = useMemo(() => { - const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); - return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); - }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; const activeRunningTurnId = (activeThread?.session?.status === "running" ? activeThread.session.activeTurnId : null) ?? @@ -1973,21 +1273,6 @@ export default function ChatView(props: ChatViewProps) { serverThread?.id, serverThread?.latestTurn?.completedAt, ]); - useEffect(() => { - setMountedTerminalThreadKeys((currentThreadIds) => { - const nextThreadIds = reconcileMountedTerminalThreadIds({ - currentThreadIds, - openThreadIds: existingOpenTerminalThreadKeys, - activeThreadId: activeThreadKey, - activeThreadTerminalOpen: activeTerminalDrawerPresence.present, - maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, - }); - return currentThreadIds.length === nextThreadIds.length && - currentThreadIds.every((nextThreadId, index) => nextThreadId === nextThreadIds[index]) - ? currentThreadIds - : nextThreadIds; - }); - }, [activeTerminalDrawerPresence.present, activeThreadKey, existingOpenTerminalThreadKeys]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProjectRef = useMemo( () => @@ -3170,8 +2455,6 @@ export default function ChatView(props: ChatViewProps) { const activeProjectCwd = activeProject?.workspaceRoot ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; - const activeTerminalLaunchContext = - terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; // Default true while loading to avoid toolbar flicker. const isGitRepo = gitStatusQuery.data?.isRepo ?? true; // Keep a hidden, off-flow strip mounted for existing threads so the composer @@ -3363,16 +2646,6 @@ export default function ChatView(props: ChatViewProps) { [draftId, envLocked, logicalProjectEnvironments, setDraftThreadContext], ); - const activeTerminalGroup = - terminalUiState.terminalGroups.find( - (group) => group.id === terminalUiState.activeTerminalGroupId, - ) ?? - terminalUiState.terminalGroups.find((group) => - group.terminalIds.includes(terminalUiState.activeTerminalId), - ) ?? - null; - const hasReachedSplitLimit = - (activeTerminalGroup?.terminalIds.length ?? 0) >= MAX_TERMINALS_PER_GROUP; const setThreadError = useCallback( (targetThreadId: ThreadId | null, error: string | null) => { if (!targetThreadId) return; @@ -3425,305 +2698,50 @@ export default function ChatView(props: ChatViewProps) { const currentDraft = composer.getSendContext().prompt; const prompt = codexArtifactTemplatePromptToAppend(currentDraft, template); - if (prompt !== null && !composer.insertTextAtEnd(prompt, { ensureLeadingBoundary: true })) { - toastManager.add({ - type: "error", - title: "Unable to add to chat", - description: "The composer is busy; try again once it is ready.", - }); - return; - } - scheduleComposerFocus(); - }, - [composerRef, scheduleComposerFocus], - ); - const addTerminalContextToDraft = useCallback( - (selection: TerminalContextSelection) => { - composerRef.current?.addTerminalContext(selection); - }, - [composerRef], - ); - const setTerminalOpen = useCallback( - (open: boolean) => { - if (!activeThreadRef) return; - storeSetTerminalOpen(activeThreadRef, open); - }, - [activeThreadRef, storeSetTerminalOpen], - ); - const toggleTerminalVisibility = useCallback(() => { - if (!activeThreadRef) return; - const nextOpen = !terminalUiState.terminalOpen; - if (nextOpen && terminalUiState.terminalIds.length === 0) { - if (!activeThreadId || !activeProject) { - return; - } - const cwdForOpen = gitCwd ?? activeProject.workspaceRoot; - if (!cwdForOpen) { - return; - } - const terminalId = nextTerminalId(allocatableActiveTerminalIds); - storeEnsureTerminal(activeThreadRef, terminalId, { open: true }); - void openTerminal({ - environmentId, - input: { - threadId: activeThreadId, - terminalId, - cwd: cwdForOpen, - ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), - env: projectScriptRuntimeEnv({ - project: { cwd: activeProject.workspaceRoot }, - worktreePath: activeThreadWorktreePath, - }), - }, - }); - return; - } - setTerminalOpen(nextOpen); - }, [ - activeProject, - activeThreadId, - activeThreadRef, - activeThreadWorktreePath, - allocatableActiveTerminalIds, - environmentId, - gitCwd, - openTerminal, - setTerminalOpen, - storeEnsureTerminal, - terminalUiState.terminalIds.length, - terminalUiState.terminalOpen, - ]); - const splitTerminal = useCallback( - (direction: "horizontal" | "vertical" = "horizontal") => { - if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { - return; - } - const cwdForOpen = gitCwd ?? activeProject.workspaceRoot; - if (!cwdForOpen) { - return; - } - const terminalId = nextTerminalId(allocatableActiveTerminalIds); - if (direction === "vertical") { - storeSplitTerminalVertical(activeThreadRef, terminalId); - } else { - storeSplitTerminal(activeThreadRef, terminalId); - } - setTerminalFocusRequestId((value) => value + 1); - void openTerminal({ - environmentId, - input: { - threadId: activeThreadId, - terminalId, - cwd: cwdForOpen, - ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), - env: projectScriptRuntimeEnv({ - project: { cwd: activeProject.workspaceRoot }, - worktreePath: activeThreadWorktreePath, - }), - }, - }); - }, - [ - activeProject, - activeThreadId, - allocatableActiveTerminalIds, - activeThreadRef, - openTerminal, - activeThreadWorktreePath, - environmentId, - gitCwd, - hasReachedSplitLimit, - storeSplitTerminal, - storeSplitTerminalVertical, - ], - ); - const createNewTerminal = useCallback(() => { - if (!activeThreadRef || !activeThreadId || !activeProject) { - return; - } - const cwdForOpen = gitCwd ?? activeProject.workspaceRoot; - if (!cwdForOpen) { - return; - } - const terminalId = nextTerminalId(allocatableActiveTerminalIds); - storeNewTerminal(activeThreadRef, terminalId); - setTerminalFocusRequestId((value) => value + 1); - void openTerminal({ - environmentId, - input: { - threadId: activeThreadId, - terminalId, - cwd: cwdForOpen, - ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), - env: projectScriptRuntimeEnv({ - project: { cwd: activeProject.workspaceRoot }, - worktreePath: activeThreadWorktreePath, - }), - }, - }); - }, [ - activeProject, - activeThreadId, - allocatableActiveTerminalIds, - activeThreadRef, - openTerminal, - activeThreadWorktreePath, - environmentId, - gitCwd, - storeNewTerminal, - ]); - const closeTerminal = useCallback( - (terminalId: string) => { - if (!activeThreadId || !activeThreadRef) return; - const fallbackExitWrite = () => - writeTerminal({ - environmentId, - input: { threadId: activeThreadId, terminalId, data: "exit\n" }, - }); - void (async () => { - const closeResult = await closeTerminalMutation({ - environmentId, - input: { - threadId: activeThreadId, - terminalId, - deleteHistory: true, - }, - }); - if (closeResult._tag === "Failure" && !isAtomCommandInterrupted(closeResult)) { - await fallbackExitWrite(); - } - })(); - storeCloseTerminal(activeThreadRef, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }, - [ - activeThreadId, - activeThreadRef, - closeTerminalMutation, - environmentId, - storeCloseTerminal, - writeTerminal, - ], - ); - const runProjectScript = useCallback( - async ( - script: ProjectScript, - options?: { - cwd?: string; - env?: Record; - worktreePath?: string | null; - preferNewTerminal?: boolean; - rememberAsLastInvoked?: boolean; - }, - ) => { - if (!activeThreadId || !activeProject || !activeThread) return; - if (options?.rememberAsLastInvoked !== false) { - setLastInvokedScriptByProjectId((current) => { - if (current[activeProject.id] === script.id) return current; - return { ...current, [activeProject.id]: script.id }; + if (prompt !== null && !composer.insertTextAtEnd(prompt, { ensureLeadingBoundary: true })) { + toastManager.add({ + type: "error", + title: "Unable to add to chat", + description: "The composer is busy; try again once it is ready.", }); - } - const targetCwd = options?.cwd ?? gitCwd ?? activeProject.workspaceRoot; - const baseTerminalId = - terminalUiState.activeTerminalId || activeKnownTerminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; - const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId); - const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; - const shouldCreateNewTerminal = wantsNewTerminal; - const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; - - setTerminalUiLaunchContext({ - threadId: activeThreadId, - cwd: targetCwd, - worktreePath: targetWorktreePath, - }); - setTerminalOpen(true); - if (!activeThreadRef) { return; } - setTerminalFocusRequestId((value) => value + 1); - - const runtimeEnv = projectScriptRuntimeEnv({ - project: { - cwd: activeProject.workspaceRoot, - }, - worktreePath: targetWorktreePath, - ...(options?.env ? { extraEnv: options.env } : {}), - }); - const targetTerminalId = shouldCreateNewTerminal - ? nextTerminalId(allocatableActiveTerminalIds) - : baseTerminalId; - const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal - ? { - threadId: activeThreadId, - terminalId: targetTerminalId, - cwd: targetCwd, - ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), - env: runtimeEnv, - cols: SCRIPT_TERMINAL_COLS, - rows: SCRIPT_TERMINAL_ROWS, - } - : { - threadId: activeThreadId, - terminalId: targetTerminalId, - cwd: targetCwd, - ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), - env: runtimeEnv, - }; - - if (shouldCreateNewTerminal) { - storeNewTerminal(activeThreadRef, targetTerminalId); - } else { - storeSetActiveTerminal(activeThreadRef, targetTerminalId); - } - - const openResult = await openTerminal({ environmentId, input: openTerminalInput }); - if (openResult._tag === "Failure") { - if (!isAtomCommandInterrupted(openResult)) { - const error = squashAtomCommandFailure(openResult); - setThreadError( - activeThreadId, - error instanceof Error ? error.message : `Failed to run script "${script.name}".`, - ); - } + scheduleComposerFocus(); + }, + [composerRef, scheduleComposerFocus], + ); + const addTerminalContextToDraft = useCallback( + (selection: TerminalContextSelection) => { + // The embed documents have no composer; the primary's bridge adds it. + if (presentation === "rightPanel" && window.t3Shell) { + void window.t3Shell.dispatch("composer.terminalContext.add", selection); return; } - - const writeResult = await writeTerminal({ - environmentId, - input: { - threadId: activeThreadId, - terminalId: targetTerminalId, - data: `${script.command}\r`, - }, - }); - if (writeResult._tag === "Failure" && !isAtomCommandInterrupted(writeResult)) { - const error = squashAtomCommandFailure(writeResult); - setThreadError( - activeThreadId, - error instanceof Error ? error.message : `Failed to run script "${script.name}".`, - ); - } + composerRef.current?.addTerminalContext(selection); }, - [ - activeProject, - activeThread, - activeThreadId, - activeThreadRef, - gitCwd, - setTerminalOpen, - setThreadError, - storeNewTerminal, - storeSetActiveTerminal, - setLastInvokedScriptByProjectId, - environmentId, - openTerminal, - activeKnownTerminalIds, - allocatableActiveTerminalIds, - runningTerminalIds, - terminalUiState.activeTerminalId, - writeTerminal, - ], + [composerRef, presentation], ); + const { + setTerminalOpen, + resizeTerminal, + toggleTerminalVisibility, + splitTerminal, + createNewTerminal, + requestCloseTerminal, + runProjectScript, + activeTerminalLaunchContext, + terminalFocusRequestId, + setTerminalFocusRequestId, + lastInvokedScriptByProjectId, + } = useThreadTerminalActions({ + environmentId, + activeThreadRef, + activeThread, + activeProject, + terminals, + setThreadError, + focusComposer, + }); const persistProjectScripts = useCallback( async (input: { @@ -4230,15 +3248,6 @@ export default function ChatView(props: ChatViewProps) { }, [activeRightPanelSurface, activeThreadRef, closeTerminalMutation, storeCloseTerminal], ); - const requestCloseTerminal = useCallback( - (terminalId: string) => { - const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); - void confirmTerminalClose([label]).then((confirmed) => { - if (confirmed) closeTerminal(terminalId); - }); - }, - [activeTerminalLabelsById, closeTerminal], - ); const requestClosePanelTerminal = useCallback( (terminalId: string) => { const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); @@ -4278,151 +3287,12 @@ export default function ChatView(props: ChatViewProps) { threadKey === routeThreadKey ? null : routeThreadKey, ); }, [canMaximizeRightPanel, routeThreadKey]); - const cleanupRightPanelSurfaces = useCallback( - (surfaces: readonly RightPanelSurface[]) => { - if (!activeThreadRef) return; - for (const surface of surfaces) { - if (surface.kind === "preview" && surface.resourceId) { - void closePreviewSession({ - closePreview, - snapshot: activePreviewState.sessions[surface.resourceId] ?? null, - tabId: surface.resourceId, - threadRef: activeThreadRef, - }); - } - if (surface.kind === "terminal") { - for (const terminalId of surface.terminalIds) { - storeCloseTerminal(activeThreadRef, terminalId); - void closeTerminalMutation({ - environmentId: activeThreadRef.environmentId, - input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true }, - }); - } - } - } - }, - [ - activeThreadRef, - activePreviewState.sessions, - closePreview, - closeTerminalMutation, - storeCloseTerminal, - ], - ); - const closeAfterAgentBrowserConfirmation = useCallback( - (surfaces: readonly RightPanelSurface[], closeSurfaces: () => void) => { - const message = agentControlledBrowserCloseConfirmation( - surfaces, - activePreviewState.desktopByTabId, - ); - if (!message) { - closeSurfaces(); - return; - } - const localApi = readLocalApi(); - if (!localApi) return; - void localApi.dialogs.confirm(message, { variant: "destructive" }).then( - (confirmed) => { - if (confirmed) closeSurfaces(); - }, - () => undefined, - ); - }, - [activePreviewState.desktopByTabId], - ); - const syncActivePreviewSurface = useCallback(() => { - if (!activeThreadRef) return; - const nextActiveSurface = selectActiveRightPanelSurface( - useRightPanelStore.getState().byThreadKey, - activeThreadRef, - ); - if (nextActiveSurface?.kind === "preview" && nextActiveSurface.resourceId) { - setActivePreviewTab(activeThreadRef, nextActiveSurface.resourceId); - } - }, [activeThreadRef]); - const finishRightPanelSurfaceClose = useCallback( - (surfaces: readonly RightPanelSurface[]) => { - if (!activeThreadRef) return; - cleanupRightPanelSurfaces(surfaces); - const store = useRightPanelStore.getState(); - for (const surface of surfaces) { - store.closeSurface(activeThreadRef, surface.id); - } - syncActivePreviewSurface(); - }, - [activeThreadRef, cleanupRightPanelSurfaces, syncActivePreviewSurface], - ); - const closeRightPanelSurface = useCallback( - (surface: RightPanelSurface) => { - if (!activeThreadRef) return; - const finishClose = () => finishRightPanelSurfaceClose([surface]); - if (surface.kind === "preview") { - closeAfterAgentBrowserConfirmation([surface], finishClose); - return; - } - if (surface.kind !== "terminal") { - finishClose(); - return; - } - const activeLabel = - activeTerminalLabelsById.get(surface.activeTerminalId) ?? - getTerminalLabel(surface.activeTerminalId); - const otherLabels = surface.terminalIds - .filter((terminalId) => terminalId !== surface.activeTerminalId) - .map( - (terminalId) => activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId), - ); - void confirmTerminalClose([activeLabel, ...otherLabels]).then((confirmed) => { - if (confirmed) finishClose(); - }); - }, - [ - activeThreadRef, - activeTerminalLabelsById, - closeAfterAgentBrowserConfirmation, - finishRightPanelSurfaceClose, - ], - ); - const closeOtherRightPanelSurfaces = useCallback( - (surface: RightPanelSurface) => { - if (!activeThreadRef) return; - const surfaces = rightPanelState.surfaces.filter((entry) => entry.id !== surface.id); - const finishClose = () => finishRightPanelSurfaceClose(surfaces); - closeAfterAgentBrowserConfirmation(surfaces, finishClose); - }, - [ - activeThreadRef, - closeAfterAgentBrowserConfirmation, - finishRightPanelSurfaceClose, - rightPanelState.surfaces, - ], - ); - const closeRightPanelSurfacesToRight = useCallback( - (surface: RightPanelSurface) => { - if (!activeThreadRef) return; - const surfaceIndex = rightPanelState.surfaces.findIndex((entry) => entry.id === surface.id); - if (surfaceIndex < 0) return; - const surfaces = rightPanelState.surfaces.slice(surfaceIndex + 1); - const finishClose = () => finishRightPanelSurfaceClose(surfaces); - closeAfterAgentBrowserConfirmation(surfaces, finishClose); - }, - [ - activeThreadRef, - closeAfterAgentBrowserConfirmation, - finishRightPanelSurfaceClose, - rightPanelState.surfaces, - ], - ); - const closeAllRightPanelSurfaces = useCallback(() => { - if (!activeThreadRef) return; - const finishClose = () => finishRightPanelSurfaceClose(rightPanelState.surfaces); - closeAfterAgentBrowserConfirmation(rightPanelState.surfaces, finishClose); - }, [ - activeThreadRef, - closeAfterAgentBrowserConfirmation, - finishRightPanelSurfaceClose, - rightPanelState.surfaces, - ]); + const { + closeRightPanelSurface, + closeOtherRightPanelSurfaces, + closeRightPanelSurfacesToRight, + closeAllRightPanelSurfaces, + } = useThreadPanelClosing(activeThreadRef, activePreviewState, activeTerminalLabelsById); const copyRightPanelFilePath = useCallback((relativePath: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { toastManager.add( @@ -5175,42 +4045,11 @@ export default function ChatView(props: ChatViewProps) { resizeObserver.disconnect(); }; }, [composerOverlayElement, publishComposerOverlayHeight]); - const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef); - const activeThreadReferenceCopyTarget = useMemo( - () => - activeThreadId === null || !isServerThread - ? null - : resolveThreadReferenceCopyTarget({ - threadId: activeThreadId, - openPanelPullRequestUrl, - linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null, - }), - [activeThreadId, isServerThread, linkedThreadPullRequest?.url, openPanelPullRequestUrl], + const copyActiveThreadReference = useThreadReferenceCopy( + activeThreadRef, + isServerThread, + linkedThreadPullRequest?.url ?? null, ); - const copyActiveThreadReference = useCallback(() => { - const target = activeThreadReferenceCopyTarget; - if (target === null) return; - void writeTextToClipboard(target.value, target.clipboardTarget).then( - (didCopy) => { - if (!didCopy) return; - toastManager.add({ - type: "success", - title: target.successTitle, - description: target.value, - }); - }, - (error) => { - console.error(error); - toastManager.add( - stackedThreadToast({ - type: "error", - title: target.failureTitle, - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }, - ); - }, [activeThreadReferenceCopyTarget]); const addPullRequestSurface = useCallback(() => { if (!supportsPullRequests || activeThreadRef === null || linkedThreadPullRequest === null) return; @@ -5789,71 +4628,6 @@ export default function ChatView(props: ChatViewProps) { setPendingServerThreadBranch(undefined); }, [canOverrideServerThreadEnvMode]); - useEffect(() => { - if (!activeThreadId) { - setTerminalUiLaunchContext(null); - return; - } - setTerminalUiLaunchContext((current) => { - if (!current) return current; - if (current.threadId === activeThreadId) return current; - return null; - }); - }, [activeThreadId]); - - useEffect(() => { - if (!activeThreadId || !activeProjectCwd) { - return; - } - setTerminalUiLaunchContext((current) => { - if (!current || current.threadId !== activeThreadId) { - return current; - } - const settledCwd = projectScriptCwd({ - project: { cwd: activeProjectCwd }, - worktreePath: activeThreadWorktreePath, - }); - if ( - settledCwd === current.cwd && - (activeThreadWorktreePath ?? null) === current.worktreePath - ) { - return null; - } - return current; - }); - }, [activeProjectCwd, activeThreadId, activeThreadWorktreePath]); - - useEffect(() => { - if (terminalUiState.terminalOpen) { - return; - } - setTerminalUiLaunchContext((current) => - current?.threadId === activeThreadId ? null : current, - ); - }, [activeThreadId, terminalUiState.terminalOpen]); - - useEffect(() => { - if (!activeThreadKey) return; - const previous = terminalUiOpenByThreadRef.current[activeThreadKey] ?? false; - const current = Boolean(terminalUiState.terminalOpen); - - if (!previous && current) { - terminalUiOpenByThreadRef.current[activeThreadKey] = current; - setTerminalFocusRequestId((value) => value + 1); - return; - } else if (previous && !current) { - terminalUiOpenByThreadRef.current[activeThreadKey] = current; - const frame = window.requestAnimationFrame(() => { - focusComposer(); - }); - return () => { - window.cancelAnimationFrame(frame); - }; - } - - terminalUiOpenByThreadRef.current[activeThreadKey] = current; - }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]); - useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { if (preventRepeatedTerminalCloseShortcut(event, keybindings)) { @@ -5948,12 +4722,31 @@ export default function ChatView(props: ChatViewProps) { return; } - if (command === "terminal.toggle") { - event.preventDefault(); - event.stopPropagation(); - toggleTerminalVisibility(); + if ( + handleThreadTerminalShortcut( + command, + event, + terminalUiState, + { + toggleTerminalVisibility, + setTerminalOpen, + splitTerminal, + requestCloseTerminal, + createNewTerminal, + }, + terminalFocusOwner === "right-panel" + ? { + split: splitPanelTerminal, + newTerminal: addTerminalSurface, + close: + activeRightPanelSurface?.kind === "terminal" + ? () => requestClosePanelTerminal(activeRightPanelSurface.activeTerminalId) + : undefined, + } + : undefined, + ) + ) return; - } if (command === "rightPanel.toggle") { event.preventDefault(); @@ -5979,60 +4772,6 @@ export default function ChatView(props: ChatViewProps) { return; } - if (command === "terminal.split") { - event.preventDefault(); - event.stopPropagation(); - if (terminalFocusOwner === "right-panel") { - splitPanelTerminal(); - return; - } - if (!terminalUiState.terminalOpen) { - setTerminalOpen(true); - } - splitTerminal(); - return; - } - - if (command === "terminal.splitVertical") { - event.preventDefault(); - event.stopPropagation(); - if (terminalFocusOwner === "right-panel") { - splitPanelTerminal("vertical"); - return; - } - if (!terminalUiState.terminalOpen) { - setTerminalOpen(true); - } - splitTerminal("vertical"); - return; - } - - if (command === "terminal.close") { - event.preventDefault(); - event.stopPropagation(); - if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") { - requestClosePanelTerminal(activeRightPanelSurface.activeTerminalId); - return; - } - if (!terminalUiState.terminalOpen) return; - requestCloseTerminal(terminalUiState.activeTerminalId); - return; - } - - if (command === "terminal.new") { - event.preventDefault(); - event.stopPropagation(); - if (terminalFocusOwner === "right-panel") { - addTerminalSurface(); - return; - } - if (!terminalUiState.terminalOpen) { - setTerminalOpen(true); - } - createNewTerminal(); - return; - } - if (command === "diff.toggle") { event.preventDefault(); event.stopPropagation(); @@ -6043,7 +4782,10 @@ export default function ChatView(props: ChatViewProps) { if (command === "modelPicker.toggle") { event.preventDefault(); event.stopPropagation(); - composerRef.current?.toggleModelPicker(); + // The shell's native picker replaces the HTML one, so the toggle + // goes to it instead of to the composer. + if (shellHostsChrome) void window.t3Shell?.dispatch("composer.modelPicker.toggle"); + else composerRef.current?.toggleModelPicker(); return; } @@ -7829,9 +6571,112 @@ export default function ChatView(props: ChatViewProps) { addFiles: (files) => composerRef.current?.addDroppedFiles(files), }); + // The thread's terminal drawers, kept mounted across thread switches. When + // the shell hosts the chrome they live in their own document (the embed + // route with `surface=terminal`), which the shell places under its composer. + const terminalDrawers = ( + + ); + + if (presentation === "rightPanel") { + return ( +
+ {rightPanelContent} +
+ ); + } + return (
- {rightPanelControlsAtRoot ? panelLayoutControls : null} + {shellHostsChrome && activeThreadRef ? ( + + setShellTitleMenuRequest((prev) => ({ x, y, seq: (prev?.seq ?? 0) + 1 })) + } + onRenameRequested={requestShellRename} + threadTitleForRename={activeThread.title} + onNewThread={handleNewThreadInActiveProject} + onToggleTerminal={toggleTerminalVisibility} + onResizeTerminal={resizeTerminal} + onRunScript={runProjectScript} + onEnvModeChange={onEnvModeChange} + onStartFromOriginChange={onStartFromOriginChange} + onOpenPullRequest={ + supportsPullRequests && activeProjectRepository !== null + ? openProjectPullRequest + : undefined + } + onCheckoutPullRequestRequest={ + canCheckoutPullRequestIntoThread ? openPullRequestDialog : undefined + } + onEnvironmentChange={onEnvironmentChange} + /> + ) : null} + {shellHostsChrome && activeThreadRef ? ( + + ) : null} + {rightPanelControlsAtRoot && !shellHostsChrome ? panelLayoutControls : null}
{isElectron && rightPanelControlsAtRoot ? ( ) : null} - {!rightPanelControlsAtRoot && !rightPanelControlsInPanel ? panelLayoutControls : null} + {!shellHostsChrome && !rightPanelControlsAtRoot && !rightPanelControlsInPanel + ? panelLayoutControls + : null} - {mountComposerContextStrip && ( + {mountComposerContextStrip && !shellHostsChrome && (
{/* end horizontal flex container */} - {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( - - ))} + {shellHostsChrome ? null : terminalDrawers}
- {rightPanelPresent && !shouldUseRightPanelSheet && activeThreadRef ? ( + {!shellHostsChrome && rightPanelPresent && !shouldUseRightPanelSheet && activeThreadRef ? ( ) : null} - {rightPanelPresent && shouldUseRightPanelSheet && activeThreadRef ? ( + {!shellHostsChrome && rightPanelPresent && shouldUseRightPanelSheet && activeThreadRef ? ( void) | undefined; } -interface PendingDefaultBranchAction { - action: DefaultBranchConfirmableAction; - branchName: string; - includesCommit: boolean; - commitMessage?: string; - onConfirmed?: () => void; - filePaths?: string[]; -} - type PublishProviderKind = Extract< SourceControlProviderKind, "github" | "gitlab" | "bitbucket" | "azure-devops" >; -type GitActionToastId = ReturnType; - -interface ActiveGitActionProgress { - toastId: GitActionToastId; - toastData: ThreadToastData | undefined; - actionId: string; - title: string; - phaseStartedAtMs: number | null; - hookStartedAtMs: number | null; - hookName: string | null; - lastOutputLine: string | null; - currentPhaseLabel: string | null; -} - -interface RunGitActionWithToastInput { - action: GitStackedAction; - commitMessage?: string; - onConfirmed?: () => void; - skipDefaultBranchPrompt?: boolean; - statusOverride?: VcsStatusResult | null; - featureBranch?: boolean; - progressToastId?: GitActionToastId; - filePaths?: string[]; -} - -const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; - -type RefreshVcsStatus = (target: { - readonly environmentId: ScopedThreadRef["environmentId"]; - readonly input: { readonly cwd: string }; -}) => Promise; - -function requestVcsStatusRefresh( - refresh: RefreshVcsStatus, - environmentId: ScopedThreadRef["environmentId"] | null, - cwd: string | null, -): void { - if (environmentId === null || cwd === null) { - return; - } - void refresh({ environmentId, input: { cwd } }); -} -const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const; - const PUBLISH_PROVIDER_OPTIONS = [ { value: "github", @@ -252,95 +171,6 @@ function getPublishProviderReadiness(input: { return { ready: true, hint: null }; } -function formatElapsedDescription(startedAtMs: number | null): string | undefined { - if (startedAtMs === null) { - return undefined; - } - const elapsedSeconds = Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000)); - if (elapsedSeconds < 60) { - return `Running for ${elapsedSeconds}s`; - } - const minutes = Math.floor(elapsedSeconds / 60); - const seconds = elapsedSeconds % 60; - return `Running for ${minutes}m ${seconds}s`; -} - -function resolveProgressDescription(progress: ActiveGitActionProgress): string | undefined { - if (progress.lastOutputLine) { - return progress.lastOutputLine; - } - return formatElapsedDescription(progress.hookStartedAtMs ?? progress.phaseStartedAtMs); -} - -function getMenuActionDisabledReason({ - item, - gitStatus, - isBusy, - hasPrimaryRemote, -}: { - item: GitActionMenuItem; - gitStatus: VcsStatusResult | null; - isBusy: boolean; - hasPrimaryRemote: boolean; -}): string | null { - if (!item.disabled) return null; - if (isBusy) return "Git action in progress."; - if (!gitStatus) return "Git status is unavailable."; - - const hasBranch = gitStatus.refName !== null; - const hasChanges = gitStatus.hasWorkingTreeChanges; - const hasOpenPr = gitStatus.pr?.state === "open"; - const isAhead = gitStatus.aheadCount > 0; - const isBehind = gitStatus.behindCount > 0; - const terminology = getSourceControlPresentation(gitStatus.sourceControlProvider).terminology; - - if (item.id === "commit") { - if (!hasChanges) { - return "Worktree is clean. Make changes before committing."; - } - return "Commit is currently unavailable."; - } - - if (item.id === "push") { - if (!hasBranch) { - return "Detached HEAD: checkout a refName before pushing."; - } - if (hasChanges) { - return "Commit or stash local changes before pushing."; - } - if (isBehind) { - return "Branch is behind upstream. Pull/rebase before pushing."; - } - if (!gitStatus.hasUpstream && !hasPrimaryRemote) { - return 'Add an "origin" remote before pushing.'; - } - if (!isAhead) { - return "No local commits to push."; - } - return "Push is currently unavailable."; - } - - if (hasOpenPr) { - return `View ${terminology.singular} is currently unavailable.`; - } - if (!hasBranch) { - return `Detached HEAD: checkout a refName before creating a ${terminology.singular}.`; - } - if (hasChanges) { - return `Commit local changes before creating a ${terminology.singular}.`; - } - if (!gitStatus.hasUpstream && !hasPrimaryRemote) { - return `Add an "origin" remote before creating a ${terminology.singular}.`; - } - if (!isAhead) { - return `No local commits to include in a ${terminology.singular}.`; - } - if (isBehind) { - return `Branch is behind upstream. Pull/rebase before creating a ${terminology.singular}.`; - } - return `Create ${terminology.singular} is currently unavailable.`; -} - const COMMIT_DIALOG_TITLE = "Commit changes"; const COMMIT_DIALOG_DESCRIPTION = "Review and confirm your commit. Leave the message blank to auto-generate one."; @@ -981,545 +811,61 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { } export default function GitActionsControl({ + shellHosted = false, gitCwd, activeThreadRef, draftId, onOpenPullRequest, }: GitActionsControlProps) { - const updateThreadMetadata = useAtomCommand( - threadEnvironment.updateMetadata, - "thread branch metadata update", - ); - const activeEnvironmentId = activeThreadRef?.environmentId ?? null; - const serverConfig = useAtomValue(serverEnvironment.configValueAtom(activeEnvironmentId)); - const openInPreferredEditor = useOpenInPreferredEditor( - activeEnvironmentId, - serverConfig?.availableEditors ?? [], - ); - const threadToastData = useMemo( - () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined), - [activeThreadRef], - ); - const openPrLink = useOpenPrLink(activeThreadRef ?? undefined); - const openLink = useOpenLink(activeThreadRef); - const activeDraftThread = useComposerDraftStore((store) => - draftId - ? store.getDraftSession(draftId) - : activeThreadRef - ? store.getDraftThreadByRef(activeThreadRef) - : null, - ); - const activeServerThread = useThread(activeThreadRef, { - waitForShell: activeDraftThread !== null, - }); - const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); const [excludedFiles, setExcludedFiles] = useState>(new Set()); const [isEditingFiles, setIsEditingFiles] = useState(false); const [isPublishDialogOpen, setIsPublishDialogOpen] = useState(false); - const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = - useState(null); - const activeGitActionProgressRef = useRef(null); - const sourceControlScope = useMemo( - () => ({ environmentId: activeEnvironmentId, cwd: gitCwd }), - [activeEnvironmentId, gitCwd], - ); - let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise; - - const updateActiveProgressToast = useCallback(() => { - const progress = activeGitActionProgressRef.current; - if (!progress) { - return; - } - toastManager.update(progress.toastId, { - type: "loading", - title: progress.title, - description: resolveProgressDescription(progress), - timeout: 0, - data: progress.toastData, - }); - }, []); - - const persistThreadBranchSync = useCallback( - (branch: string | null, manualSelection = false) => { - if (!activeThreadRef) { - return; - } - - if (activeServerThread) { - if (activeServerThread.branch === branch) { - return; - } - - void updateThreadMetadata({ - environmentId: activeThreadRef.environmentId, - input: { - threadId: activeThreadRef.threadId, - ...resolveThreadBranchMetadataPatch(branch, activeServerThread.branch), - }, - }); - - return; - } - - if (!activeDraftThread || activeDraftThread.branch === branch) { - return; - } - - setDraftThreadContext(draftId ?? activeThreadRef, { - branch, - worktreePath: activeDraftThread.worktreePath, - environmentSelection: manualSelection - ? "manual" - : (activeDraftThread.environmentSelection ?? - (activeDraftThread.branch ? "manual" : "auto")), - }); - }, - [ - activeDraftThread, - activeServerThread, - activeThreadRef, - draftId, - setDraftThreadContext, - updateThreadMetadata, - ], - ); - - const syncThreadBranchAfterGitAction = useCallback( - (result: GitRunStackedActionResult) => { - const branchUpdate = resolveThreadBranchUpdate(result); - if (!branchUpdate) { - return; - } - - persistThreadBranchSync(branchUpdate.branch, true); - }, - [persistThreadBranchSync], - ); - - const gitStatusQuery = useEnvironmentQuery( - activeEnvironmentId !== null && gitCwd !== null - ? vcsEnvironment.status({ - environmentId: activeEnvironmentId, - input: { cwd: gitCwd }, - }) - : null, - ); - const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { - reportFailure: false, + const openPublishDialog = useCallback(() => setIsPublishDialogOpen(true), []); + const git = useGitActions({ + gitCwd, + activeThreadRef, + draftId, + onOpenPullRequest, + onOpenPublish: openPublishDialog, }); - const { data: gitStatus, error: gitStatusError } = gitStatusQuery; - const sourceControlPresentation = useMemo( - () => getSourceControlPresentation(gitStatus?.sourceControlProvider), - [gitStatus?.sourceControlProvider], - ); - const changeRequestTerminology = sourceControlPresentation.terminology; - const SourceControlIcon = sourceControlPresentation.Icon; - // Default to true while loading so we don't flash init controls. - const isRepo = gitStatus?.isRepo ?? true; - const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; - const gitStatusForActions = gitStatus; - - const allFiles = gitStatusForActions?.workingTree.files ?? []; + const { + activeEnvironmentId, + threadToastData, + openInPreferredEditor, + gitStatusForActions, + gitStatusError, + refreshVcsStatus, + SourceControlIcon, + isRepo, + hasPrimaryRemote, + isDefaultRef, + allFiles, + initAction, + initRepository, + isGitActionRunning, + gitActionMenuItems, + quickAction, + quickActionDisabledReason, + pendingDefaultBranchAction, + pendingDefaultBranchActionCopy, + dismissPendingDefaultBranchAction, + continuePendingDefaultBranchAction, + checkoutFeatureBranchAndContinuePendingAction, + runGitActionWithToast, + runQuickAction, + runMenuItemAction, + } = git; const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); const allSelected = excludedFiles.size === 0; const noneSelected = selectedFiles.length === 0; - const initAction = useVcsInitAction(sourceControlScope); - const runImmediateGitAction = useGitStackedAction(sourceControlScope); - const pullAction = useVcsPullAction(sourceControlScope); - const isGitActionRunning = useSourceControlActionRunning( - sourceControlScope, - RUNNING_SOURCE_CONTROL_ACTIONS, - ); - const isSelectingWorktreeBase = - !activeServerThread && - activeDraftThread?.envMode === "worktree" && - activeDraftThread.worktreePath === null; - - useEffect(() => { - if (isGitActionRunning || isSelectingWorktreeBase || activeServerThread) { - return; - } - - const branchUpdate = resolveLiveThreadBranchUpdate({ - threadBranch: activeDraftThread?.branch ?? null, - gitStatus: gitStatusForActions, - }); - if (!branchUpdate) { - return; - } - - persistThreadBranchSync(branchUpdate.branch); - }, [ - activeServerThread, - activeDraftThread?.branch, - gitStatusForActions, - isGitActionRunning, - isSelectingWorktreeBase, - persistThreadBranchSync, - ]); - - const isDefaultRef = useMemo(() => { - return gitStatusForActions?.isDefaultRef ?? false; - }, [gitStatusForActions?.isDefaultRef]); - - const gitActionMenuItems = useMemo( - () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasPrimaryRemote), - [gitStatusForActions, hasPrimaryRemote, isGitActionRunning], - ); - const quickAction = useMemo( - () => - resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultRef, hasPrimaryRemote), - [gitStatusForActions, hasPrimaryRemote, isDefaultRef, isGitActionRunning], - ); - const quickActionDisabledReason = quickAction.disabled - ? (quickAction.hint ?? "This action is currently unavailable.") - : null; - const pendingDefaultBranchActionCopy = pendingDefaultBranchAction - ? resolveDefaultBranchActionDialogCopy({ - action: pendingDefaultBranchAction.action, - branchName: pendingDefaultBranchAction.branchName, - includesCommit: pendingDefaultBranchAction.includesCommit, - terminology: changeRequestTerminology, - }) - : null; - - useEffect(() => { - const interval = window.setInterval(() => { - if (!activeGitActionProgressRef.current) { - return; - } - updateActiveProgressToast(); - }, 1000); - - return () => { - window.clearInterval(interval); - }; - }, [updateActiveProgressToast]); - - useEffect(() => { - if (gitCwd === null) { - return; - } - - let refreshTimeout: number | null = null; - const scheduleRefreshCurrentGitStatus = () => { - if (refreshTimeout !== null) { - window.clearTimeout(refreshTimeout); - } - refreshTimeout = window.setTimeout(() => { - refreshTimeout = null; - requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd); - }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS); - }; - const handleVisibilityChange = () => { - if (document.visibilityState === "visible") { - scheduleRefreshCurrentGitStatus(); - } - }; - - window.addEventListener("focus", scheduleRefreshCurrentGitStatus); - document.addEventListener("visibilitychange", handleVisibilityChange); - - return () => { - if (refreshTimeout !== null) { - window.clearTimeout(refreshTimeout); - } - window.removeEventListener("focus", scheduleRefreshCurrentGitStatus); - document.removeEventListener("visibilitychange", handleVisibilityChange); - }; - }, [activeEnvironmentId, gitCwd, refreshVcsStatus]); - - const openExistingPr = useCallback(async () => { - const openPr = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr : null; - // Beside the thread where it was made, the way the browser opens beside it. Checked before - // the shell, which opening in the app does not need. - if (openPr && onOpenPullRequest) { - onOpenPullRequest(openPr.number); - return; - } - const prUrl = openPr?.url ?? null; - if (!prUrl) { - toastManager.add({ - type: "error", - title: "No open pull request found.", - data: threadToastData, - }); - return; - } - void openLink(prUrl).catch((err: unknown) => { - console.error(err); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open pull request link", - description: err instanceof Error ? err.message : "An error occurred.", - ...(threadToastData !== undefined ? { data: threadToastData } : {}), - }), - ); - }); - }, [gitStatusForActions, onOpenPullRequest, openLink, threadToastData]); - - runGitActionWithToast = useEffectEvent( - async ({ - action, - commitMessage, - onConfirmed, - skipDefaultBranchPrompt = false, - statusOverride, - featureBranch = false, - progressToastId, - filePaths, - }: RunGitActionWithToastInput) => { - const actionStatus = statusOverride ?? gitStatusForActions; - const actionBranch = actionStatus?.refName ?? null; - const actionIsDefaultBranch = featureBranch ? false : isDefaultRef; - const actionCanCommit = - action === "commit" || action === "commit_push" || action === "commit_push_pr"; - const includesCommit = - actionCanCommit && - (action === "commit" || !!actionStatus?.hasWorkingTreeChanges || featureBranch); - if ( - !skipDefaultBranchPrompt && - requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) && - actionBranch - ) { - if ( - action !== "push" && - action !== "create_pr" && - action !== "commit_push" && - action !== "commit_push_pr" - ) { - return; - } - setPendingDefaultBranchAction({ - action, - branchName: actionBranch, - includesCommit, - ...(commitMessage ? { commitMessage } : {}), - ...(onConfirmed ? { onConfirmed } : {}), - ...(filePaths ? { filePaths } : {}), - }); - return; - } - onConfirmed?.(); - - const progressStages = buildGitActionProgressStages({ - action, - hasCustomCommitMessage: !!commitMessage?.trim(), - hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges, - featureBranch, - terminology: changeRequestTerminology, - shouldPushBeforePr: - action === "create_pr" && - (!actionStatus?.hasUpstream || (actionStatus?.aheadCount ?? 0) > 0), - }); - const scopedToastData = threadToastData ? { ...threadToastData } : undefined; - const actionId = randomUUID(); - const resolvedProgressToastId = - progressToastId ?? - toastManager.add({ - type: "loading", - title: progressStages[0] ?? "Running git action...", - description: "Waiting for Git...", - timeout: 0, - data: scopedToastData, - }); - - activeGitActionProgressRef.current = { - toastId: resolvedProgressToastId, - toastData: scopedToastData, - actionId, - title: progressStages[0] ?? "Running git action...", - phaseStartedAtMs: null, - hookStartedAtMs: null, - hookName: null, - lastOutputLine: null, - currentPhaseLabel: progressStages[0] ?? "Running git action...", - }; - - if (progressToastId) { - toastManager.update(progressToastId, { - type: "loading", - title: progressStages[0] ?? "Running git action...", - description: "Waiting for Git...", - timeout: 0, - data: scopedToastData, - }); - } - - const applyProgressEvent = (event: GitActionProgressEvent) => { - const progress = activeGitActionProgressRef.current; - if (!progress) { - return; - } - if (gitCwd && event.cwd !== gitCwd) { - return; - } - if (progress.actionId !== event.actionId) { - return; - } - - const now = Date.now(); - switch (event.kind) { - case "action_started": - progress.phaseStartedAtMs = now; - progress.hookStartedAtMs = null; - progress.hookName = null; - progress.lastOutputLine = null; - break; - case "phase_started": - progress.title = event.label; - progress.currentPhaseLabel = event.label; - progress.phaseStartedAtMs = now; - progress.hookStartedAtMs = null; - progress.hookName = null; - progress.lastOutputLine = null; - break; - case "hook_started": - progress.title = `Running ${event.hookName}...`; - progress.hookName = event.hookName; - progress.hookStartedAtMs = now; - progress.lastOutputLine = null; - break; - case "hook_output": - progress.lastOutputLine = event.text; - break; - case "hook_finished": - progress.title = progress.currentPhaseLabel ?? "Committing..."; - progress.hookName = null; - progress.hookStartedAtMs = null; - progress.lastOutputLine = null; - break; - case "action_finished": - // Let the resolved mutation update the toast so we keep the - // elapsed description visible until the final success state renders. - return; - case "action_failed": - // Let the settled mutation publish the error toast to avoid a - // transient intermediate state before the final failure message. - return; - } - - updateActiveProgressToast(); - }; - - const result = await runImmediateGitAction.run({ - actionId, - action, - ...(commitMessage ? { commitMessage } : {}), - ...(featureBranch ? { featureBranch } : {}), - ...(filePaths ? { filePaths } : {}), - onProgress: applyProgressEvent, - }); - - activeGitActionProgressRef.current = null; - if (result._tag === "Failure") { - if (isAtomCommandInterrupted(result)) { - toastManager.close(resolvedProgressToastId); - return; - } - - const error = squashAtomCommandFailure(result); - toastManager.update( - resolvedProgressToastId, - stackedThreadToast({ - type: "error", - title: "Action failed", - description: error instanceof Error ? error.message : "An error occurred.", - ...(scopedToastData !== undefined ? { data: scopedToastData } : {}), - }), - ); - return; - } - - const actionResult = result.value; - syncThreadBranchAfterGitAction(actionResult); - const closeResultToast = () => { - toastManager.close(resolvedProgressToastId); - }; - - const toastCta = actionResult.toast.cta; - let toastActionProps: { - children: string; - onClick: (event: MouseEvent) => void; - } | null = null; - if (toastCta.kind === "run_action") { - toastActionProps = { - children: toastCta.label, - onClick: () => { - closeResultToast(); - void runGitActionWithToast({ - action: toastCta.action.kind, - }); - }, - }; - } else if (toastCta.kind === "open_pr") { - toastActionProps = { - children: toastCta.label, - onClick: (event) => { - closeResultToast(); - openPrLink(event, toastCta.url); - }, - }; - } - - const successToastData = { - ...scopedToastData, - dismissAfterVisibleMs: 10_000, - }; - - if (toastActionProps) { - toastManager.update( - resolvedProgressToastId, - stackedThreadToast({ - type: "success", - title: actionResult.toast.title, - description: actionResult.toast.description, - timeout: 0, - actionProps: toastActionProps, - data: successToastData, - }), - ); - } else { - toastManager.update(resolvedProgressToastId, { - type: "success", - title: actionResult.toast.title, - description: actionResult.toast.description, - timeout: 0, - data: successToastData, - }); - } - }, - ); - - const continuePendingDefaultBranchAction = () => { - if (!pendingDefaultBranchAction) return; - const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction; - setPendingDefaultBranchAction(null); - void runGitActionWithToast({ - action, - ...(commitMessage ? { commitMessage } : {}), - ...(onConfirmed ? { onConfirmed } : {}), - ...(filePaths ? { filePaths } : {}), - skipDefaultBranchPrompt: true, - }); - }; - - const checkoutFeatureBranchAndContinuePendingAction = () => { - if (!pendingDefaultBranchAction) return; - const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction; - setPendingDefaultBranchAction(null); - void runGitActionWithToast({ - action, - ...(commitMessage ? { commitMessage } : {}), - ...(onConfirmed ? { onConfirmed } : {}), - ...(filePaths ? { filePaths } : {}), - featureBranch: true, - skipDefaultBranchPrompt: true, - }); + const openDialogForMenuItem = (item: GitActionMenuItem) => { + if (runMenuItemAction(item) !== "commit") return; + setExcludedFiles(new Set()); + setIsEditingFiles(false); + setIsCommitDialogOpen(true); }; const runDialogActionOnNewBranch = () => { @@ -1540,88 +886,6 @@ export default function GitActionsControl({ }); }; - const runQuickAction = () => { - if (quickAction.kind === "open_pr") { - void openExistingPr(); - return; - } - if (quickAction.kind === "open_publish") { - setIsPublishDialogOpen(true); - return; - } - if (quickAction.kind === "run_pull") { - const toastId = toastManager.add({ - type: "loading", - title: "Pulling...", - timeout: 0, - data: threadToastData, - }); - void (async () => { - const result = await pullAction.run(); - if (result._tag === "Failure") { - if (isAtomCommandInterrupted(result)) { - toastManager.close(toastId); - return; - } - const error = squashAtomCommandFailure(result); - toastManager.update( - toastId, - stackedThreadToast({ - type: "error", - title: "Pull failed", - description: error instanceof Error ? error.message : "An error occurred.", - ...(threadToastData !== undefined ? { data: threadToastData } : {}), - }), - ); - return; - } - - const pullResult = result.value; - toastManager.update(toastId, { - type: "success", - title: pullResult.status === "pulled" ? "Pulled" : "Already up to date", - description: - pullResult.status === "pulled" - ? `Updated ${pullResult.refName} from ${pullResult.upstreamRef ?? "upstream"}` - : `${pullResult.refName} is already synchronized.`, - data: threadToastData, - }); - })(); - return; - } - if (quickAction.kind === "show_hint") { - toastManager.add({ - type: "info", - title: quickAction.label, - description: quickAction.hint, - data: threadToastData, - }); - return; - } - if (quickAction.action) { - void runGitActionWithToast({ action: quickAction.action }); - } - }; - - const openDialogForMenuItem = (item: GitActionMenuItem) => { - if (item.disabled) return; - if (item.kind === "open_pr") { - void openExistingPr(); - return; - } - if (item.dialogAction === "push") { - void runGitActionWithToast({ action: "push" }); - return; - } - if (item.dialogAction === "create_pr") { - void runGitActionWithToast({ action: "create_pr" }); - return; - } - setExcludedFiles(new Set()); - setIsEditingFiles(false); - setIsCommitDialogOpen(true); - }; - const runDialogAction = () => { if (!isCommitDialogOpen) return; const commitMessage = dialogCommitMessage.trim(); @@ -1669,6 +933,20 @@ export default function GitActionsControl({ const canPublishRepository = isRepo && gitStatusForActions !== null && !hasPrimaryRemote; if (!gitCwd) return null; + if (shellHosted) { + return ( + <> + + + + ); + } return ( <> @@ -1677,23 +955,7 @@ export default function GitActionsControl({ variant="outline" size="xs" disabled={initAction.isPending} - onClick={() => { - void (async () => { - const result = await initAction.run(); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; - } - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Git initialization failed", - description: error instanceof Error ? error.message : "An error occurred.", - ...(threadToastData !== undefined ? { data: threadToastData } : {}), - }), - ); - })(); - }} + onClick={() => void initRepository()} > @@ -2011,7 +1273,7 @@ export default function GitActionsControl({ open={pendingDefaultBranchAction !== null} onOpenChange={(open) => { if (!open) { - setPendingDefaultBranchAction(null); + dismissPendingDefaultBranchAction(); } }} > @@ -2027,7 +1289,7 @@ export default function GitActionsControl({ className="w-full sm:mr-auto sm:w-auto" variant="outline" size="sm" - onClick={() => setPendingDefaultBranchAction(null)} + onClick={() => dismissPendingDefaultBranchAction()} > Abort diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index e9dab0c9d2b4..83ba140ccc65 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -581,7 +581,7 @@ function RightPanelEmptyState(props: { ); } -function surfaceTitle( +export function surfaceTitle( surface: RightPanelSurface, sessions: Readonly>, terminalLabelsById: ReadonlyMap, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 9b59675e2eb1..da6219c21ab1 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -16,6 +16,14 @@ import { type SettledThreadTimestampInput, type ThreadSortInput, } from "../lib/threadSort"; +import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { + sortActiveThreadsByOrderKey, + sortPinnedThreadsByOrderKey, +} from "@t3tools/client-runtime/state/thread-sort"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId } from "@t3tools/contracts"; import type { SidebarThreadSummary, Thread } from "../types"; import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; @@ -1231,3 +1239,91 @@ export function sortScopedProjectsForSidebar< left.id.localeCompare(right.id), ); } + +export interface SidebarThreadCapabilities { + readonly threadSettlement?: boolean | undefined; + readonly threadSnooze?: boolean | undefined; + readonly threadPinReorder?: boolean | undefined; +} + +export interface SidebarThreadPartitionInput { + readonly threads: ReadonlyArray; + /** `:` keys to keep, or null for every project. */ + readonly scopedProjectKeys: ReadonlySet | null; + readonly capabilitiesFor: (environmentId: EnvironmentId) => SidebarThreadCapabilities | undefined; + /** + * Real clock for snooze wake times, which are second-precise: a woken + * thread must not linger on the shelf for the rest of a quantized minute. + */ + readonly preciseNow: string; +} + +export interface SidebarThreadPartition { + readonly pinnedThreads: EnvironmentThreadShell[]; + readonly reorderablePinnedKeys: Set; + readonly activeThreads: EnvironmentThreadShell[]; + readonly snoozedThreads: EnvironmentThreadShell[]; + readonly settledThreads: EnvironmentThreadShell[]; + readonly snoozeNow: string; +} + +/** + * Buckets live thread shells into the sidebar's four sections. Settled threads + * stay in the live shell stream (settled ≠ archived), so this works directly + * off shells; archived threads are hidden. Shared by the HTML sidebar and the + * shell projection so both render the same rows. + */ +export function partitionSidebarThreads( + input: SidebarThreadPartitionInput, +): SidebarThreadPartition { + const visible = input.threads.filter( + (thread) => + thread.archivedAt === null && + (input.scopedProjectKeys === null || + input.scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), + ); + const pinned: EnvironmentThreadShell[] = []; + const active: EnvironmentThreadShell[] = []; + const snoozed: EnvironmentThreadShell[] = []; + const settled: EnvironmentThreadShell[] = []; + for (const thread of visible) { + // Threads on servers without the settlement capability (old server, or + // descriptor not loaded yet) never classify as settled: the user could + // neither un-settle nor pin them, so auto-settling them would strand rows + // in a tail with no working affordances. + const capabilities = input.capabilitiesFor(thread.environmentId); + const supportsSettlement = capabilities?.threadSettlement === true; + const supportsSnooze = capabilities?.threadSnooze === true; + // Snooze outranks settlement and pinning until the thread wakes. + if (supportsSnooze && effectiveSnoozed(thread, { now: input.preciseNow })) { + snoozed.push(thread); + } else if (supportsSettlement && thread.settledOverride === "settled") { + settled.push(thread); + } else if (thread.pinnedAt != null) { + pinned.push(thread); + } else { + active.push(thread); + } + } + // One shared rule on every platform (see sortPinnedThreadsByOrderKey): + // user-arranged keys first, keyless threads in creation order below. Server + // capability only gates DRAGGING — it must not influence the sort, or + // mixed-version fleets would render different pinned orders from the same data. + return { + pinnedThreads: sortPinnedThreadsByOrderKey(pinned), + reorderablePinnedKeys: new Set( + pinned + .filter((thread) => input.capabilitiesFor(thread.environmentId)?.threadPinReorder === true) + .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + ), + activeThreads: sortActiveThreadsByOrderKey(active), + // Soonest wake first: "what comes back next" is the shelf's question. + snoozedThreads: snoozed.toSorted( + (left, right) => + firstValidTimestampMs(left.snoozedUntil ?? null) - + firstValidTimestampMs(right.snoozedUntil ?? null), + ), + settledThreads: sortSettledThreadsForSidebar(settled), + snoozeNow: input.preciseNow, + }; +} diff --git a/apps/web/src/components/Sidebar.partition.test.ts b/apps/web/src/components/Sidebar.partition.test.ts new file mode 100644 index 000000000000..260670f29b14 --- /dev/null +++ b/apps/web/src/components/Sidebar.partition.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; + +import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "../types"; +import { partitionSidebarThreads, type SidebarThreadCapabilities } from "./Sidebar.logic"; + +const environmentId = EnvironmentId.make("environment-local"); +const projectId = ProjectId.make("project-1"); +const now = "2026-03-10T10:00:00.000Z"; + +function makeThread( + id: string, + overrides: Partial = {}, +): EnvironmentThreadShell { + return { + id: ThreadId.make(id), + environmentId, + projectId, + title: id, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +} + +const fullCapabilities: SidebarThreadCapabilities = { + threadSettlement: true, + threadSnooze: true, + threadPinReorder: true, +}; + +function partition( + threads: ReadonlyArray, + options: { + capabilities?: SidebarThreadCapabilities | undefined; + scopedProjectKeys?: ReadonlySet | null; + } = {}, +) { + return partitionSidebarThreads({ + threads, + scopedProjectKeys: options.scopedProjectKeys ?? null, + capabilitiesFor: () => ("capabilities" in options ? options.capabilities : fullCapabilities), + preciseNow: now, + }); +} + +const ids = (threads: ReadonlyArray) => threads.map((thread) => thread.id); + +describe("partitionSidebarThreads", () => { + it("hides archived threads and threads outside the scoped project", () => { + const result = partition( + [ + makeThread("kept"), + makeThread("archived", { archivedAt: now }), + makeThread("elsewhere", { projectId: ProjectId.make("project-2") }), + ], + { scopedProjectKeys: new Set([`${environmentId}:${projectId}`]) }, + ); + expect(ids(result.activeThreads)).toEqual(["kept"]); + expect(result.pinnedThreads).toEqual([]); + expect(result.snoozedThreads).toEqual([]); + expect(result.settledThreads).toEqual([]); + }); + + it("buckets snoozed over settled over pinned over active", () => { + const result = partition([ + makeThread("active"), + makeThread("pinned", { pinnedAt: now }), + makeThread("settled", { settledOverride: "settled", settledAt: now, pinnedAt: now }), + makeThread("snoozed", { + snoozedUntil: "2026-03-11T10:00:00.000Z", + settledOverride: "settled", + settledAt: now, + pinnedAt: now, + }), + ]); + expect(ids(result.activeThreads)).toEqual(["active"]); + expect(ids(result.pinnedThreads)).toEqual(["pinned"]); + expect(ids(result.settledThreads)).toEqual(["settled"]); + expect(ids(result.snoozedThreads)).toEqual(["snoozed"]); + expect(result.snoozeNow).toBe(now); + }); + + it("keeps a woken snooze in the active list", () => { + const result = partition([makeThread("woke", { snoozedUntil: "2026-03-10T09:00:00.000Z" })]); + expect(ids(result.activeThreads)).toEqual(["woke"]); + expect(result.settledThreads).toEqual([]); + expect(result.snoozedThreads).toEqual([]); + }); + + it("never settles or snoozes threads on servers without the capability", () => { + const result = partition( + [ + makeThread("settled", { settledOverride: "settled", settledAt: now }), + makeThread("snoozed", { snoozedUntil: "2026-03-11T10:00:00.000Z" }), + ], + { capabilities: undefined }, + ); + expect(ids(result.activeThreads).toSorted()).toEqual(["settled", "snoozed"]); + }); + + it("only marks pinned threads reorderable when the server supports it", () => { + const withReorder = partition([makeThread("pinned", { pinnedAt: now })]); + expect([...withReorder.reorderablePinnedKeys]).toEqual([`${environmentId}:pinned`]); + + const withoutReorder = partition([makeThread("pinned", { pinnedAt: now })], { + capabilities: { threadSettlement: true, threadSnooze: true, threadPinReorder: false }, + }); + expect(ids(withoutReorder.pinnedThreads)).toEqual(["pinned"]); + expect(withoutReorder.reorderablePinnedKeys.size).toBe(0); + }); +}); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5df4a9ea8b8b..b1b16c7812f0 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import { useThreadParking } from "../threadParking"; import * as Schema from "effect/Schema"; import { DndContext, @@ -2920,73 +2921,50 @@ export default function Sidebar() { [navigateToThread, rangeSelectTo, toggleThreadSelection], ); - // A settle per thread at a time: double clicks and repeated menu picks - // must not dispatch a second settle that fails and toasts a false error. - const settlingThreadKeysRef = useRef(new Set()); - // Parking the thread you're looking at (settle or snooze) moves you - // forward: the next remaining card (never a settled or snoozed row, never - // one leaving in the same batch), or a fresh draft in this project when it - // was the last active one. Callers snapshot the plan BEFORE the command - // mutates the partition; background parks never navigate (null plan). - const planForwardNavigation = useCallback( - (threadKey: string, coParkingKeys?: ReadonlySet): (() => void) | null => { - if (routeThreadKeyRef.current !== threadKey) return null; - const shell = threadByKeyRef.current.get(threadKey); - const orderedKeys = orderedThreadKeysRef.current; - const settledKeys = settledThreadKeysRef.current; - const snoozedKeys = snoozedThreadKeysRef.current; - const currentIndex = orderedKeys.indexOf(threadKey); - const nextCardKey = - currentIndex === -1 - ? null - : ([...orderedKeys.slice(currentIndex + 1), ...orderedKeys.slice(0, currentIndex)].find( - (key) => !settledKeys.has(key) && !snoozedKeys.has(key) && !coParkingKeys?.has(key), - ) ?? null); - const nextThread = nextCardKey ? threadByKeyRef.current.get(nextCardKey) : null; - return nextThread - ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id)) - : shell - ? () => - void handleNewThreadRef.current(scopeProjectRef(shell.environmentId, shell.projectId)) - : () => void router.navigate({ to: "/" }); + const readParkingContext = () => ({ + activeThreadKey: routeThreadKeyRef.current, + orderedThreadKeys: orderedThreadKeysRef.current, + isParked: (key: string) => + settledThreadKeysRef.current.has(key) || snoozedThreadKeysRef.current.has(key), + planNext: (key: string) => { + const thread = threadByKeyRef.current.get(key); + return thread + ? () => navigateToThread(scopeThreadRef(thread.environmentId, thread.id)) + : null; }, - [navigateToThread, router], - ); + planFallback: (key: string) => { + const thread = threadByKeyRef.current.get(key); + return thread + ? () => + void handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)) + : () => void router.navigate({ to: "/" }); + }, + }); + // The HTML sidebar has separate settle and snooze pending scopes. + const settleParking = useThreadParking(readParkingContext); + const snoozeParking = useThreadParking(readParkingContext); const attemptSettle = useCallback( (threadRef: ScopedThreadRef, opts: { coSettlingKeys?: ReadonlySet } = {}) => { void (async () => { - const threadKey = scopedThreadKey(threadRef); - if (settlingThreadKeysRef.current.has(threadKey)) return; - settlingThreadKeysRef.current.add(threadKey); - try { - const navigateAfterSettle = planForwardNavigation(threadKey, opts.coSettlingKeys); - const result = await settleThread(threadRef); - if (result._tag === "Failure") { - // Never navigate away from a thread that did not settle. - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to settle thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - return; - } - // Only move forward if the user is still on the settled thread — - // a navigation made during the await wins over ours. - if (routeThreadKeyRef.current === threadKey) { - navigateAfterSettle?.(); - } - } finally { - settlingThreadKeysRef.current.delete(threadKey); + const outcome = await settleParking.run( + scopedThreadKey(threadRef), + () => settleThread(threadRef), + opts.coSettlingKeys, + ); + if (outcome.status === "failure") { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to settle thread", + description: + outcome.error instanceof Error ? outcome.error.message : "An error occurred.", + }), + ); } })(); }, - [planForwardNavigation, settleThread], + [settleParking, settleThread], ); const attemptUnsettle = useCallback( (threadRef: ScopedThreadRef) => { @@ -3438,7 +3416,7 @@ export default function Sidebar() { activeReorderableKeys: activeReorderableThreadKeys, }); if (plan.kind === "none") return; - if (plan.kind === "settle" && settlingThreadKeysRef.current.has(activeKey)) return; + if (plan.kind === "settle" && settleParking.isPending(activeKey)) return; const assignments = plan.kind === "pin" ? [ @@ -3485,12 +3463,20 @@ export default function Sidebar() { }; switch (plan.kind) { case "settle": { - settlingThreadKeysRef.current.add(activeKey); - const navigateAfterSettle = planForwardNavigation(activeKey); - const settled = await run(settleThread(threadRef), "Failed to settle thread").finally( - () => settlingThreadKeysRef.current.delete(activeKey), - ); - if (settled && routeThreadKeyRef.current === activeKey) navigateAfterSettle?.(); + const outcome = await settleParking.run(activeKey, () => settleThread(threadRef)); + if (outcome.status === "failure" || outcome.status === "interrupted") { + setOptimisticDrop((current) => (current === drop ? null : current)); + if (outcome.status === "failure") { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to settle thread", + description: + outcome.error instanceof Error ? outcome.error.message : "An error occurred.", + }), + ); + } + } return; } case "move-active": @@ -3549,7 +3535,7 @@ export default function Sidebar() { draggableThreadKeys, pinThread, pinnedKeys, - planForwardNavigation, + settleParking, reorderPinnedThread, reorderActiveThread, sectionByThreadKey, @@ -3561,41 +3547,18 @@ export default function Sidebar() { unsnoozeThread, ], ); - // One snooze per thread at a time — same double-dispatch guard as settle. - const snoozingThreadKeysRef = useRef(new Set()); const performSnooze = useCallback( - async ( + ( threadRef: ScopedThreadRef, preset: SnoozePreset, opts: { coSnoozingKeys?: ReadonlySet } = {}, - ) => { - const threadKey = scopedThreadKey(threadRef); - if (snoozingThreadKeysRef.current.has(threadKey)) { - return { status: "skipped" } as const; - } - snoozingThreadKeysRef.current.add(threadKey); - try { - // Snoozing the open thread moves you forward, same as settle — - // both park the thread you're done with for now. - const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); - const result = await snoozeThread(threadRef, preset.snoozedUntil); - if (result._tag === "Failure") { - // Never navigate away from a thread that did not snooze. - return isAtomCommandInterrupted(result) - ? ({ status: "interrupted" } as const) - : ({ status: "failure", error: squashAtomCommandFailure(result) } as const); - } - // Only move forward if the user is still on the snoozed thread — - // a navigation made during the await wins over ours. - if (routeThreadKeyRef.current === threadKey) { - navigateAfterSnooze?.(); - } - return { status: "success" } as const; - } finally { - snoozingThreadKeysRef.current.delete(threadKey); - } - }, - [planForwardNavigation, snoozeThread], + ) => + snoozeParking.run( + scopedThreadKey(threadRef), + () => snoozeThread(threadRef, preset.snoozedUntil), + opts.coSnoozingKeys, + ), + [snoozeParking, snoozeThread], ); const attemptSnooze = useCallback( ( diff --git a/apps/web/src/components/ThreadTerminalDocument.test.tsx b/apps/web/src/components/ThreadTerminalDocument.test.tsx new file mode 100644 index 000000000000..9fa4aff0517e --- /dev/null +++ b/apps/web/src/components/ThreadTerminalDocument.test.tsx @@ -0,0 +1,607 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type KeybindingCommand, + type ResolvedKeybindingsConfig, + type ScopedThreadRef, + type TerminalSummary, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { act, useEffect, type ComponentProps } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import type ThreadTerminalDrawer from "./ThreadTerminalDrawer"; +import { ThreadTerminalDocument } from "./ThreadTerminalDocument"; +import { ThreadTerminals } from "./ThreadTerminals"; +import { + useThreadTerminalActions, + useThreadTerminalSessionState, +} from "./useThreadTerminalActions"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { useRightPanelStore } from "../rightPanelStore"; +import { useUiStateStore } from "../uiStateStore"; +import { commandForProjectScript } from "../projectScripts"; +import type { Project, Thread } from "../types"; + +type DrawerProps = ComponentProps; +const fixture = vi.hoisted(() => ({ + threads: new Map(), + refs: [] as ScopedThreadRef[], + summaries: [] as TerminalSummary[], + bindings: [] as ResolvedKeybindingsConfig, + project: null as Project | null, + drawers: new Map(), + mounts: new Map(), + paletteOpen: false, + supportsThreads: true, + open: vi.fn(), + write: vi.fn(), + close: vi.fn(), + confirm: vi.fn(), + settle: vi.fn(), + unsettle: vi.fn(), + pin: vi.fn(), + unpin: vi.fn(), + clipboard: vi.fn(), + dispatch: vi.fn(), +})); +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => fixture.bindings })); +vi.mock("../state/server", () => ({ primaryServerKeybindingsAtom: {} })); +vi.mock("../state/terminal", () => ({ + terminalEnvironment: { open: fixture.open, write: fixture.write, close: fixture.close }, +})); +vi.mock("../state/threads", () => ({ threadEnvironment: { unsettle: fixture.unsettle } })); +vi.mock("../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); +vi.mock("../state/entities", () => ({ + useThread: (ref: ScopedThreadRef | null) => + ref ? (fixture.threads.get(scopedThreadKey(ref)) ?? null) : null, + useThreadShell: (ref: ScopedThreadRef | null) => + ref ? (fixture.threads.get(scopedThreadKey(ref)) ?? null) : null, + useThreadRefs: () => fixture.refs, + useProject: () => fixture.project, +})); +vi.mock("../state/terminalSessions", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + useKnownTerminalSessions: ({ environmentId, threadId }: ScopedThreadRef) => + original.selectKnownTerminalSessions(fixture.summaries, environmentId, threadId), + useThreadRunningTerminalIds: () => + fixture.summaries.filter((s) => s.hasRunningSubprocess).map((s) => s.terminalId), + }; +}); +vi.mock("../hooks/useSettings", () => ({ + useEnvironmentSettings: () => ({ projectScriptOverrides: {}, defaultProjectScripts: [] }), +})); +vi.mock("../state/environments", () => ({ + useEnvironment: () => ({ + serverConfig: { + environment: { + capabilities: { + threadSettlement: fixture.supportsThreads, + threadPinning: fixture.supportsThreads, + }, + }, + }, + }), +})); +vi.mock("../hooks/useThreadActions", () => ({ + useThreadActions: () => ({ + settleThread: fixture.settle, + pinThread: fixture.pin, + confirmAndUnpinThread: fixture.unpin, + }), +})); +vi.mock("../hooks/useOpenPanelPullRequestUrl", () => ({ + useOpenPanelPullRequestUrl: () => undefined, +})); +vi.mock("../hooks/useCopyToClipboard", () => ({ writeTextToClipboard: fixture.clipboard })); +vi.mock("../commandPaletteBus", () => ({ isCommandPaletteOpen: () => fixture.paletteOpen })); +vi.mock("../lib/terminalFocus", () => ({ getTerminalFocusOwner: () => "drawer" })); +vi.mock("../localApi", () => ({ readLocalApi: () => ({ dialogs: { confirm: fixture.confirm } }) })); +vi.mock("../previewStateStore", () => ({ + useThreadPreviewState: () => ({ sessions: {}, desktopByTabId: {} }), + setActivePreviewTab: vi.fn(), +})); +vi.mock("../state/preview", () => ({ previewEnvironment: { close: vi.fn() } })); +vi.mock("../panelAnimations", () => ({ + usePanelAnimationSettings: () => ({ active: false, durationMs: 0 }), + usePanelPresence: (present: boolean) => ({ present }), +})); +vi.mock("./ThreadTerminalDrawer", () => ({ + default: function Drawer(props: DrawerProps) { + const key = scopedThreadKey(props.threadRef); + useEffect(() => { + fixture.drawers.set(key, props); + }, [key, props]); + useEffect(() => { + fixture.mounts.set(key, (fixture.mounts.get(key) ?? 0) + 1); + return () => { + fixture.drawers.delete(key); + }; + }, [key]); + return null; + }, +})); +// Importing the conversation implementation would make this isolated document fail immediately. +vi.mock("./ChatView", () => { + throw new Error("terminal document imported ChatView"); +}); + +const environmentId = EnvironmentId.make("env-a"); +const refA = scopeThreadRef(environmentId, ThreadId.make("thread-a")); +const refB = scopeThreadRef(environmentId, ThreadId.make("thread-b")); +const now = "2026-09-07T12:00:00.000Z"; +const project: Project = { + id: ProjectId.make("project"), + environmentId, + title: "Project", + workspaceRoot: "/repo", + repositoryIdentity: null, + defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + createdAt: now, + updatedAt: now, + scripts: [ + { id: "test", name: "Test", command: "vp test", icon: "test", runOnWorktreeCreate: false }, + ], +}; +function thread(ref: ScopedThreadRef): Thread { + return { + id: ref.threadId, + environmentId: ref.environmentId, + projectId: project.id, + title: ref.threadId, + modelSelection: project.defaultModelSelection!, + runtimeMode: "full-access", + interactionMode: "default", + session: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + latestTurn: null, + branch: null, + worktreePath: null, + }; +} +function summary(terminalId: string, changes: Partial = {}): TerminalSummary { + return { + threadId: refA.threadId, + terminalId, + cwd: "/repo", + worktreePath: null, + status: "running", + pid: 123, + exitCode: null, + exitSignal: null, + hasRunningSubprocess: false, + label: terminalId, + updatedAt: now, + ...changes, + }; +} +function binding(command: KeybindingCommand): ResolvedKeybindingsConfig { + return [ + { + command, + shortcut: { + key: "x", + ctrlKey: true, + metaKey: false, + altKey: false, + shiftKey: false, + modKey: false, + }, + whenAst: { type: "identifier", name: "terminalFocus" }, + }, + ]; +} +class KeyPress extends Event { + key = "x"; + ctrlKey = true; + metaKey = false; + altKey = false; + shiftKey = false; + constructor(readonly repeat = false) { + super("keydown", { cancelable: true, bubbles: true }); + } +} +let renderer: ReactTestRenderer | null = null; +async function mount(ref = refA) { + await act(() => { + renderer = create(); + }); +} +async function update(ref = refA) { + await act(() => { + renderer?.update(); + }); +} +async function press(command: KeybindingCommand, repeat = false) { + fixture.bindings = binding(command); + await update(); + const event = new KeyPress(repeat); + await act(() => { + window.dispatchEvent(event); + }); + return event; +} +const state = (ref = refA) => + selectThreadTerminalUiState(useTerminalUiStateStore.getState().terminalUiStateByThreadKey, ref); +const drawer = (ref = refA) => fixture.drawers.get(scopedThreadKey(ref))!; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +beforeEach(() => { + vi.clearAllMocks(); + fixture.project = project; + fixture.threads = new Map([ + [scopedThreadKey(refA), thread(refA)], + [scopedThreadKey(refB), thread(refB)], + ]); + fixture.refs = [refA, refB]; + fixture.summaries = []; + fixture.bindings = []; + fixture.drawers.clear(); + fixture.mounts.clear(); + fixture.paletteOpen = false; + fixture.supportsThreads = true; + for (const command of [ + fixture.open, + fixture.write, + fixture.close, + fixture.settle, + fixture.unsettle, + fixture.pin, + fixture.unpin, + ]) + command.mockResolvedValue(AsyncResult.success(undefined)); + fixture.confirm.mockResolvedValue(true); + fixture.clipboard.mockResolvedValue(true); + const storage = new Map(); + const listeners = new Map void>>(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("navigator", { platform: "Linux" }); + vi.stubGlobal("window", { + addEventListener: (type: string, listener: (event: Event) => void) => { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type)!.add(listener); + }, + removeEventListener: (type: string, listener: (event: Event) => void) => + listeners.get(type)?.delete(listener), + dispatchEvent: (event: Event) => { + // Listeners installed during dispatch do not receive the current event. + const dispatchedListeners = Array.from(listeners.get(event.type) ?? []); + for (const listener of dispatchedListeners) listener(event); + return !event.defaultPrevented; + }, + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }, + requestAnimationFrame: () => 1, + cancelAnimationFrame: () => {}, + t3Shell: { dispatch: fixture.dispatch }, + }); + useTerminalUiStateStore.setState({ terminalUiStateByThreadKey: {} }); + useRightPanelStore.setState({ byThreadKey: {} }); + useUiStateStore.setState({ threadLastVisitedAtById: {} }); +}); +afterEach(async () => { + await act(() => { + renderer?.unmount(); + }); + renderer = null; + vi.unstubAllGlobals(); +}); + +describe("dedicated thread terminals", () => { + it("keeps identical thread IDs isolated between environments", async () => { + const remote = scopeThreadRef(EnvironmentId.make("env-b"), refA.threadId); + fixture.refs = [refA, remote]; + fixture.threads.set(scopedThreadKey(remote), thread(remote)); + useTerminalUiStateStore.getState().setTerminalOpen(refA, true); + useTerminalUiStateStore.getState().setTerminalOpen(remote, true); + await mount(); + await update(remote); + await act(() => drawer(remote).onNewTerminal()); + expect(fixture.open).toHaveBeenLastCalledWith( + expect.objectContaining({ environmentId: remote.environmentId }), + ); + expect(state(refA).terminalIds).toEqual(["term-1"]); + expect(state(remote).terminalIds).toEqual(["term-1", "term-2"]); + expect(fixture.mounts.get(scopedThreadKey(refA))).toBe(1); + expect(fixture.mounts.get(scopedThreadKey(remote))).toBe(1); + }); + + it("does not forward selections from hidden retained drawers", async () => { + useTerminalUiStateStore.getState().setTerminalOpen(refA, true); + useTerminalUiStateStore.getState().setTerminalOpen(refB, true); + await mount(); + const selection = { + terminalId: "term-1", + terminalLabel: "Terminal", + lineStart: 1, + lineEnd: 1, + text: "output", + }; + await update(refB); + await act(() => drawer(refA).onAddTerminalContext(selection)); + expect(fixture.dispatch).not.toHaveBeenCalled(); + await act(() => drawer(refB).onAddTerminalContext(selection)); + expect(fixture.dispatch).toHaveBeenCalledWith("composer.terminalContext.add", selection); + expect(drawer(refB).focusRequestId).toBeGreaterThan(0); + }); + + it("does not write after an interrupted close or a failed script open", async () => { + useTerminalUiStateStore.getState().setTerminalOpen(refA, true); + await mount(); + fixture.close.mockResolvedValue(AsyncResult.failure(Cause.interrupt())); + await press("terminal.close"); + expect(fixture.write).not.toHaveBeenCalled(); + fixture.open.mockResolvedValue(AsyncResult.failure(Cause.fail(new Error("open unavailable")))); + await press(commandForProjectScript("test")!); + expect(fixture.write).not.toHaveBeenCalled(); + }); + + it("closes terminal panel sessions without using the drawer exit fallback", async () => { + useRightPanelStore.getState().openTerminal(refA, "term-2"); + fixture.close.mockResolvedValue( + AsyncResult.failure(Cause.fail(new Error("close unavailable"))), + ); + await mount(); + await press("rightPanel.close"); + expect(fixture.confirm).toHaveBeenCalledOnce(); + expect(fixture.close).toHaveBeenCalledWith({ + environmentId, + input: { threadId: refA.threadId, terminalId: "term-2", deleteHistory: true }, + }); + expect(fixture.write).not.toHaveBeenCalled(); + expect( + useRightPanelStore.getState().byThreadKey[scopedThreadKey(refA)]?.surfaces ?? [], + ).toEqual([]); + }); + + it("keeps stored theme changes subscribed after removing ChatView", async () => { + const classes = new Set(); + const variables = new Map(); + vi.stubGlobal("document", { + documentElement: { + dataset: {}, + classList: { + add: (name: string) => classes.add(name), + remove: (name: string) => classes.delete(name), + toggle: (name: string, enabled: boolean) => + enabled ? classes.add(name) : classes.delete(name), + }, + style: { + setProperty: (name: string, value: string) => variables.set(name, value), + removeProperty: (name: string) => variables.delete(name), + }, + }, + }); + vi.stubGlobal("requestAnimationFrame", () => 1); + await mount(); + for (const theme of ["dark", "light"]) { + window.localStorage.setItem("t3code:theme", theme); + await act(() => { + window.dispatchEvent(Object.assign(new Event("storage"), { key: "t3code:theme" })); + }); + expect(classes.has("dark")).toBe(theme === "dark"); + } + }); + + it("retains A across A → B → A and discards deleted threads", async () => { + useTerminalUiStateStore.getState().setTerminalOpen(refA, true); + useTerminalUiStateStore.getState().setTerminalOpen(refB, true); + await mount(); + expect(drawer().visible).toBe(true); + await update(refB); + expect(drawer().visible).toBe(false); + expect(drawer(refB).visible).toBe(true); + await update(); + expect(fixture.mounts.get(scopedThreadKey(refA))).toBe(1); + fixture.refs = [refA]; + fixture.threads.delete(scopedThreadKey(refB)); + await update(); + expect(fixture.drawers.has(scopedThreadKey(refB))).toBe(false); + }); + + it("allocates across optimistic, server, and panel IDs without destroying a new split", async () => { + useTerminalUiStateStore.getState().setTerminalOpen(refA, true); + useTerminalUiStateStore.getState().ensureTerminal(refA, "term-2"); + useRightPanelStore.getState().openTerminal(refA, "term-3"); + fixture.summaries = [summary("term-1"), summary("term-3")]; + await mount(); + await press("terminal.splitVertical"); + expect(fixture.open).toHaveBeenLastCalledWith( + expect.objectContaining({ input: expect.objectContaining({ terminalId: "term-4" }) }), + ); + expect( + state().terminalGroups.some( + (group) => group.terminalIds.includes("term-4") && group.splitDirection === "vertical", + ), + ).toBe(true); + await update(); + expect(state().terminalIds).toContain("term-2"); + expect(state().terminalIds).toContain("term-4"); + expect(drawer().terminalIds).not.toContain("term-3"); + }); + + it("attaches using known session locations from another document", async () => { + useTerminalUiStateStore.getState().setTerminalOpen(refA, true); + fixture.summaries = [ + summary("term-1", { cwd: "/worktrees/task", worktreePath: "/worktrees/task" }), + ]; + await mount(); + expect(drawer().terminalLaunchLocationsById?.get("term-1")).toMatchObject({ + cwd: "/worktrees/task", + worktreePath: "/worktrees/task", + runtimeEnv: { T3CODE_PROJECT_ROOT: "/repo", T3CODE_WORKTREE_PATH: "/worktrees/task" }, + }); + }); + + it("waits for close confirmation, suppresses repeats, and uses the drawer's exit fallback", async () => { + useTerminalUiStateStore.getState().setTerminalOpen(refA, true); + await mount(); + const confirmation = deferred(); + fixture.confirm.mockReturnValue(confirmation.promise); + fixture.close.mockResolvedValue( + AsyncResult.failure(Cause.fail(new Error("close unavailable"))), + ); + expect((await press("terminal.close")).defaultPrevented).toBe(true); + expect(fixture.close).not.toHaveBeenCalled(); + await press("terminal.close", true); + await press("terminal.close"); + expect(fixture.confirm).toHaveBeenCalledTimes(1); + await act(async () => { + confirmation.resolve(true); + await confirmation.promise; + }); + expect(fixture.close).toHaveBeenCalledWith({ + environmentId, + input: { threadId: refA.threadId, terminalId: "term-1", deleteHistory: true }, + }); + expect(fixture.write).toHaveBeenCalledWith({ + environmentId, + input: { threadId: refA.threadId, terminalId: "term-1", data: "exit\n" }, + }); + }); + + it("runs script shortcuts once and waits for open before writing", async () => { + await mount(); + const opened = deferred>>(); + fixture.open.mockReturnValue(opened.promise); + const command = commandForProjectScript("test")!; + expect((await press(command)).defaultPrevented).toBe(true); + expect(fixture.open).toHaveBeenCalledTimes(1); + expect(fixture.write).not.toHaveBeenCalled(); + await act(async () => { + opened.resolve(AsyncResult.success(undefined)); + await opened.promise; + }); + expect(fixture.write).toHaveBeenCalledWith({ + environmentId, + input: { threadId: refA.threadId, terminalId: "term-1", data: "vp test\r" }, + }); + }); + + it("keeps terminal-focus-only thread/panel commands local and stamps only the observed completion", async () => { + fixture.threads.set(scopedThreadKey(refA), { + ...thread(refA), + latestTurn: { + turnId: TurnId.make("turn"), + state: "completed", + requestedAt: now, + startedAt: now, + completedAt: now, + assistantMessageId: null, + }, + }); + await mount(); + expect(useUiStateStore.getState().threadLastVisitedAtById[scopedThreadKey(refA)]).toBe(now); + await press("thread.pin"); + await press("thread.settle"); + await press("thread.copyReference"); + expect(fixture.pin).toHaveBeenCalledWith(refA); + expect(fixture.settle).toHaveBeenCalledWith(refA); + expect(fixture.clipboard).toHaveBeenCalledTimes(1); + expect((await press("diff.toggle")).defaultPrevented).toBe(true); + expect(useRightPanelStore.getState().byThreadKey[scopedThreadKey(refA)]?.isOpen).toBe(true); + await press("rightPanel.toggle"); + expect(useRightPanelStore.getState().byThreadKey[scopedThreadKey(refA)]?.isOpen).toBe(false); + expect((await press("modelPicker.toggle")).defaultPrevented).toBe(true); + expect((await press("rightPanel.toggleMaximized")).defaultPrevented).toBe(true); + expect(fixture.dispatch).not.toHaveBeenCalled(); + }); + + it("consumes unsupported thread actions but leaves panel-close without a surface untouched", async () => { + fixture.supportsThreads = false; + await mount(); + expect((await press("thread.pin")).defaultPrevented).toBe(true); + expect((await press("thread.settle")).defaultPrevented).toBe(true); + expect(fixture.pin).not.toHaveBeenCalled(); + expect(fixture.settle).not.toHaveBeenCalled(); + expect((await press("rightPanel.close")).defaultPrevented).toBe(false); + fixture.paletteOpen = true; + await press("terminal.new"); + expect(fixture.open).not.toHaveBeenCalled(); + }); +}); + +describe("shared terminal launch state", () => { + it("keeps script overrides local and clears them on thread switch", async () => { + let actions!: ReturnType; + function Inline({ threadRef }: { threadRef: ScopedThreadRef }) { + const terminals = useThreadTerminalSessionState(threadRef); + const value = useThreadTerminalActions({ + environmentId: threadRef.environmentId, + activeThreadRef: threadRef, + activeThread: fixture.threads.get(scopedThreadKey(threadRef)), + activeProject: project, + terminals, + setThreadError: () => {}, + focusComposer: () => {}, + }); + useEffect(() => { + actions = value; + }, [value]); + return ( + {}} + /> + ); + } + await act(() => { + renderer = create(); + }); + await act(() => + actions.runProjectScript(project.scripts[0]!, { + cwd: "/worktrees/new", + worktreePath: "/worktrees/new", + }), + ); + await act(() => drawer().onNewTerminal()); + expect(fixture.open).toHaveBeenLastCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ cwd: "/worktrees/new", worktreePath: "/worktrees/new" }), + }), + ); + await act(() => actions.createNewTerminal()); + expect(fixture.open).toHaveBeenLastCalledWith( + expect.objectContaining({ input: expect.objectContaining({ cwd: "/repo" }) }), + ); + await act(() => { + renderer?.update(); + }); + expect(actions.activeTerminalLaunchContext).toBeNull(); + await act(() => { + renderer?.unmount(); + renderer = null; + }); + await mount(); + expect(drawer().cwd).toBe("/repo"); + }); +}); diff --git a/apps/web/src/components/ThreadTerminalDocument.tsx b/apps/web/src/components/ThreadTerminalDocument.tsx new file mode 100644 index 000000000000..e1cd0d0180e4 --- /dev/null +++ b/apps/web/src/components/ThreadTerminalDocument.tsx @@ -0,0 +1,278 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { resolveProjectScripts } from "@t3tools/shared/projectScripts"; +import { useEffect, useMemo } from "react"; + +import { isCommandPaletteOpen } from "../commandPaletteBus"; +import { useComposerDraftStore } from "../composerDraftStore"; +import { useEnvironmentSettings } from "../hooks/useSettings"; +import { useThreadActions } from "../hooks/useThreadActions"; +import { useTheme } from "../hooks/useTheme"; +import { resolveShortcutCommand } from "../keybindings"; +import { isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm"; +import { + preventRepeatedTerminalCloseShortcut, + preventTerminalCloseShortcut, +} from "../lib/terminalCloseShortcut"; +import type { TerminalContextSelection } from "../lib/terminalContext"; +import { getTerminalFocusOwner } from "../lib/terminalFocus"; +import { useThreadPreviewState } from "../previewStateStore"; +import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; +import { projectScriptIdFromCommand } from "../projectScripts"; +import { + selectActiveRightPanelSurface, + selectThreadRightPanelState, + useRightPanelStore, +} from "../rightPanelStore"; +import { useProject, useThread, useThreadShell } from "../state/entities"; +import { useEnvironment } from "../state/environments"; +import { primaryServerKeybindingsAtom } from "../state/server"; +import { threadEnvironment } from "../state/threads"; +import { useAtomCommand } from "../state/use-atom-command"; +import { useUiStateStore } from "../uiStateStore"; +import { buildLocalDraftThread } from "./ChatView.logic"; +import { ThreadTerminals } from "./ThreadTerminals"; +import { handleThreadTerminalShortcut } from "./threadTerminalShortcuts"; +import { + useThreadTerminalActions, + useThreadTerminalSessionState, +} from "./useThreadTerminalActions"; +import { useThreadPanelClosing } from "./useThreadPanelClosing"; +import { useThreadReferenceCopy } from "./useThreadReferenceCopy"; +import { stackedThreadToast, toastManager } from "./ui/toast"; + +// This document has neither a composer nor the conversation's private error banner. +const ignore = () => undefined; +const addTerminalContext = (selection: TerminalContextSelection) => { + void window.t3Shell?.dispatch("composer.terminalContext.add", selection); +}; +function reportThreadCommandFailure(result: AtomCommandResult, title: string) { + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); +} + +/** The dedicated terminal document owns commands and retained sessions, without conversation setup. */ +export function ThreadTerminalDocument({ threadRef }: { threadRef: ScopedThreadRef }) { + useTheme(); + const draft = useComposerDraftStore((store) => store.getDraftSessionByRef(threadRef)); + const serverThread = useThread(threadRef, { waitForShell: draft !== null }); + const settings = useEnvironmentSettings(threadRef.environmentId); + const project = useProject( + serverThread + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) + : draft + ? scopeProjectRef(draft.environmentId, draft.projectId) + : null, + ); + const localDraft = useMemo( + () => + draft + ? buildLocalDraftThread( + threadRef.threadId, + draft, + project?.defaultModelSelection ?? + settings.defaultModelSelection ?? + NO_PROVIDER_MODEL_SELECTION, + ) + : undefined, + [draft, project?.defaultModelSelection, settings.defaultModelSelection, threadRef.threadId], + ); + const thread = serverThread ?? localDraft; + const activeEnvironmentId = thread?.environmentId; + const activeThreadId = thread?.id; + const activeThreadRef = useMemo( + () => + activeEnvironmentId && activeThreadId + ? scopeThreadRef(activeEnvironmentId, activeThreadId) + : null, + [activeEnvironmentId, activeThreadId], + ); + const terminals = useThreadTerminalSessionState(threadRef, activeThreadRef); + const actions = useThreadTerminalActions({ + environmentId: threadRef.environmentId, + activeThreadRef, + activeThread: thread, + activeProject: project, + terminals, + setThreadError: ignore, + focusComposer: ignore, + }); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const scripts = useMemo( + () => (project ? resolveProjectScripts(settings, project) : []), + [project, settings], + ); + const threadShell = useThreadShell(serverThread ? activeThreadRef : null); + const environment = useEnvironment(thread?.environmentId ?? null); + const capabilities = environment?.serverConfig?.environment.capabilities; + const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions(); + const unsettleThread = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false }); + const panel = useRightPanelStore((state) => + selectThreadRightPanelState(state.byThreadKey, activeThreadRef), + ); + const activeSurface = useRightPanelStore((state) => + selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef), + ); + const preview = useThreadPreviewState(activeThreadRef); + const { closeRightPanelSurface } = useThreadPanelClosing( + activeThreadRef, + preview, + terminals.activeTerminalLabelsById, + ); + const metadata = threadShell ?? thread; + const copyReference = useThreadReferenceCopy( + activeThreadRef, + serverThread !== null, + (metadata?.linkedPullRequest ?? metadata?.branchPullRequest)?.url ?? null, + ); + const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); + useEffect(() => { + const completedAt = serverThread?.latestTurn?.completedAt; + if (!serverThread?.id || !completedAt) return; + markThreadVisited( + scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), + completedAt, + ); + }, [ + markThreadVisited, + serverThread?.environmentId, + serverThread?.id, + serverThread?.latestTurn?.completedAt, + ]); + + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if ( + preventRepeatedTerminalCloseShortcut(event, keybindings) || + (isTerminalCloseConfirmPending() && preventTerminalCloseShortcut(event, keybindings)) + ) { + event.stopPropagation(); + return; + } + if (!activeThreadRef || isCommandPaletteOpen()) return; + const focusOwner = getTerminalFocusOwner(); + if (event.defaultPrevented && focusOwner === null) return; + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: focusOwner !== null, + terminalOpen: Boolean(terminals.terminalUiState.terminalOpen), + modelPickerOpen: false, + }, + }); + if (!command) return; + if (handleThreadTerminalShortcut(command, event, terminals.terminalUiState, actions)) return; + const consume = () => { + event.preventDefault(); + event.stopPropagation(); + }; + switch (command) { + case "thread.copyReference": + consume(); + if (!event.repeat) copyReference(); + return; + case "thread.settle": + consume(); + if (!serverThread || capabilities?.threadSettlement !== true) return; + if (threadShell?.settledOverride === "settled") { + void unsettleThread({ + environmentId: activeThreadRef.environmentId, + input: { threadId: activeThreadRef.threadId, reason: "user" }, + }).then((result) => reportThreadCommandFailure(result, "Failed to un-settle thread")); + } else + void settleThread(activeThreadRef).then((result) => + reportThreadCommandFailure(result, "Failed to settle thread"), + ); + return; + case "thread.pin": { + consume(); + if (!serverThread || capabilities?.threadPinning !== true) return; + const pinned = threadShell?.pinnedAt != null; + void (pinned ? confirmAndUnpinThread(activeThreadRef) : pinThread(activeThreadRef)).then( + (result) => + reportThreadCommandFailure( + result, + pinned ? "Failed to unpin thread" : "Failed to pin thread", + ), + ); + return; + } + case "rightPanel.toggle": + consume(); + if (panel.isOpen) useRightPanelStore.getState().close(activeThreadRef); + else useRightPanelStore.getState().toggleVisibility(activeThreadRef); + return; + case "rightPanel.close": + if (!activeSurface) return; + consume(); + if (!event.repeat) closeRightPanelSurface(activeSurface); + return; + case "diff.toggle": + consume(); + if (serverThread) useRightPanelStore.getState().toggle(activeThreadRef, "diff"); + return; + case "modelPicker.toggle": + case "rightPanel.toggleMaximized": + consume(); + return; + default: { + const scriptId = projectScriptIdFromCommand(command); + const script = + scriptId && project ? scripts.find((entry) => entry.id === scriptId) : undefined; + if (!script) return; + consume(); + void actions.runProjectScript(script); + } + } + }; + window.addEventListener("keydown", handler, true); + return () => window.removeEventListener("keydown", handler, true); + }, [ + actions, + activeSurface, + activeThreadRef, + capabilities, + closeRightPanelSurface, + confirmAndUnpinThread, + copyReference, + keybindings, + panel.isOpen, + pinThread, + project, + scripts, + serverThread, + settleThread, + terminals.terminalUiState, + threadShell, + unsettleThread, + ]); + + return ( +
+ +
+ ); +} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index d9ddf9225bdf..12073084a101 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -987,7 +987,12 @@ export function TerminalViewport({ } interface ThreadTerminalDrawerProps { - mode?: "drawer" | "panel"; + /** + * `panel` sits inside the right panel; `fill` is the shell's own drawer + * surface. Both fill their host and leave the height to it, so neither + * draws the drawer's border or resize handle. + */ + mode?: "drawer" | "panel" | "fill"; threadRef: ScopedThreadRef; threadId: ThreadId; cwd: string; @@ -1077,6 +1082,7 @@ export default function ThreadTerminalDrawer({ terminalLaunchLocationsById, }: ThreadTerminalDrawerProps) { const isPanel = mode === "panel"; + const fillsHost = isPanel || mode === "fill"; const [advancedTypography] = useLocalStorage( TYPOGRAPHY_ADVANCED_STORAGE_KEY, false, @@ -1395,11 +1401,11 @@ export default function ThreadTerminalDrawer({ data-terminal-owner={isPanel ? "right-panel" : "drawer"} className={cn( "thread-terminal-drawer relative flex min-w-0 flex-col overflow-hidden bg-background", - isPanel ? "h-full flex-1" : "shrink-0 border-t border-border/80", + fillsHost ? "h-full flex-1" : "shrink-0 border-t border-border/80", )} - style={isPanel ? undefined : { height: `${drawerHeight}px` }} + style={fillsHost ? undefined : { height: `${drawerHeight}px` }} > - {!isPanel ? ( + {!fillsHost ? (
- {!isPanel ? ( + {!fillsHost ? (
; + +/** Same terminal ids (order ignored) — avoids reconcile when only server session ordering differs. */ +function terminalIdListsEqual(left: readonly string[], right: readonly string[]): boolean { + if (left.length !== right.length) { + return false; + } + if (left.length === 0) { + return true; + } + const sortedLeft = left.toSorted((a, b) => a.localeCompare(b)); + const sortedRight = right.toSorted((a, b) => a.localeCompare(b)); + for (let index = 0; index < sortedLeft.length; index += 1) { + if (sortedLeft[index] !== sortedRight[index]) { + return false; + } + } + return true; +} + +/** + * Server knows about fewer sessions than the client, but every server id still exists locally. + * Typical right after `terminal.open`: known-session list lags; reconciling would drop the new id + * and later re-add it as a separate group (no split layout). + */ +function serverTerminalIdsStrictSubsetOfClient( + serverIds: readonly string[], + clientIds: readonly string[], +): boolean { + if (serverIds.length >= clientIds.length || clientIds.length === 0) { + return false; + } + const clientSet = new Set(clientIds); + for (const id of serverIds) { + if (!clientSet.has(id)) { + return false; + } + } + return true; +} + +interface PersistentThreadTerminalDrawerProps { + threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; + threadId: ThreadId; + active: boolean; + fill: boolean; + launchContext: PersistentTerminalLaunchContext | null; + focusRequestId: number; + splitShortcutLabel: string | undefined; + splitVerticalShortcutLabel: string | undefined; + newShortcutLabel: string | undefined; + closeShortcutLabel: string | undefined; + keybindings: ResolvedKeybindingsConfig; + onAddTerminalContext: (selection: TerminalContextSelection) => void; +} + +const ignoreHeightChange = () => undefined; + +const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({ + threadRef, + threadId, + active, + fill, + launchContext, + focusRequestId, + splitShortcutLabel, + splitVerticalShortcutLabel, + newShortcutLabel, + closeShortcutLabel, + keybindings, + onAddTerminalContext, +}: PersistentThreadTerminalDrawerProps) { + const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); + const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); + const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); + const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); + const projectRef = serverThread + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) + : draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; + const project = useProject(projectRef); + const terminalUiState = useTerminalUiStateStore((state) => + selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef), + ); + const visible = active && terminalUiState.terminalOpen; + const knownTerminalSessions = useKnownTerminalSessions({ + environmentId: threadRef.environmentId, + threadId, + }); + const panelSurfaces = useRightPanelStore( + (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces, + ); + const panelTerminalIds = useMemo( + () => + new Set( + panelSurfaces.flatMap((surface) => + surface.kind === "terminal" ? surface.terminalIds : [], + ), + ), + [panelSurfaces], + ); + const drawerTerminalSessions = useMemo( + () => + knownTerminalSessions.filter((session) => !panelTerminalIds.has(session.target.terminalId)), + [knownTerminalSessions, panelTerminalIds], + ); + const terminalLabelsById = useMemo(() => { + const next = new Map(); + for (const session of drawerTerminalSessions) { + next.set( + session.target.terminalId, + resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), + ); + } + return next; + }, [drawerTerminalSessions]); + const terminalLaunchLocationsById = useMemo(() => { + const next = new Map< + string, + { + readonly cwd: string; + readonly worktreePath: string | null; + readonly runtimeEnv: Record; + } + >(); + if (!project) { + return next; + } + + for (const session of drawerTerminalSessions) { + const summary = session.state.summary; + if (!summary) { + continue; + } + const worktreePathForLaunch = + launchContext !== null ? launchContext.worktreePath : summary.worktreePath; + next.set(session.target.terminalId, { + cwd: launchContext?.cwd ?? summary.cwd, + worktreePath: worktreePathForLaunch, + runtimeEnv: projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath: worktreePathForLaunch, + }), + }); + } + + return next; + }, [drawerTerminalSessions, launchContext, project]); + const serverOrderedTerminalIds = useMemo( + () => drawerTerminalSessions.map((session) => session.target.terminalId), + [drawerTerminalSessions], + ); + // Every client-side id source participates in allocation: the server list + // lags fresh opens, and panel terminals are filtered out of the drawer's + // sessions — an id collision attaches two viewports to one PTY session. + const allocatableTerminalIds = useMemo( + () => [ + ...new Set([ + ...serverOrderedTerminalIds, + ...terminalUiState.terminalIds, + ...panelTerminalIds, + ]), + ], + [panelTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds], + ); + const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); + const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); + const storeSplitTerminalVertical = useTerminalUiStateStore( + (state) => state.splitTerminalVertical, + ); + const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal); + const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal); + const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal); + const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); + + useEffect(() => { + if (terminalIdListsEqual(serverOrderedTerminalIds, terminalUiState.terminalIds)) { + return; + } + if ( + serverTerminalIdsStrictSubsetOfClient(serverOrderedTerminalIds, terminalUiState.terminalIds) + ) { + return; + } + reconcileTerminalIds(threadRef, serverOrderedTerminalIds); + }, [reconcileTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds, threadRef]); + const [localFocusRequestId, setLocalFocusRequestId] = useState(0); + const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const effectiveWorktreePath = useMemo(() => { + if (launchContext !== null) { + return launchContext.worktreePath; + } + return worktreePath; + }, [launchContext, worktreePath]); + const cwd = useMemo( + () => + launchContext?.cwd ?? + (project + ? projectScriptCwd({ + project: { cwd: project.workspaceRoot }, + worktreePath: effectiveWorktreePath, + }) + : null), + [effectiveWorktreePath, launchContext?.cwd, project], + ); + const runtimeEnv = useMemo( + () => + project + ? projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath: effectiveWorktreePath, + }) + : {}, + [effectiveWorktreePath, project], + ); + + const bumpFocusRequestId = useCallback(() => { + if (!visible) { + return; + } + setLocalFocusRequestId((value) => value + 1); + }, [visible]); + + const setTerminalHeight = useCallback( + (height: number) => { + storeSetTerminalHeight(threadRef, height); + }, + [storeSetTerminalHeight, threadRef], + ); + + const splitTerminal = useCallback(() => { + if (!cwd) { + return; + } + const terminalId = nextTerminalId(allocatableTerminalIds); + storeSplitTerminal(threadRef, terminalId); + bumpFocusRequestId(); + void openTerminal({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }, + }); + }, [ + allocatableTerminalIds, + bumpFocusRequestId, + cwd, + effectiveWorktreePath, + runtimeEnv, + storeSplitTerminal, + threadId, + threadRef, + openTerminal, + ]); + const splitTerminalVertical = useCallback(() => { + if (!cwd) { + return; + } + const terminalId = nextTerminalId(allocatableTerminalIds); + storeSplitTerminalVertical(threadRef, terminalId); + bumpFocusRequestId(); + void openTerminal({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }, + }); + }, [ + allocatableTerminalIds, + bumpFocusRequestId, + cwd, + effectiveWorktreePath, + openTerminal, + runtimeEnv, + storeSplitTerminalVertical, + threadId, + threadRef, + ]); + + const createNewTerminal = useCallback(() => { + if (!cwd) { + return; + } + const terminalId = nextTerminalId(allocatableTerminalIds); + storeNewTerminal(threadRef, terminalId); + bumpFocusRequestId(); + void openTerminal({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }, + }); + }, [ + bumpFocusRequestId, + cwd, + effectiveWorktreePath, + allocatableTerminalIds, + runtimeEnv, + storeNewTerminal, + threadId, + threadRef, + openTerminal, + ]); + + const activateTerminal = useCallback( + (terminalId: string) => { + storeSetActiveTerminal(threadRef, terminalId); + bumpFocusRequestId(); + }, + [bumpFocusRequestId, storeSetActiveTerminal, threadRef], + ); + + const closeTerminal = useCallback( + (terminalId: string) => { + const fallbackExitWrite = () => + writeTerminal({ + environmentId: threadRef.environmentId, + input: { threadId, terminalId, data: "exit\n" }, + }); + + void (async () => { + const closeResult = await closeTerminalMutation({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + deleteHistory: true, + }, + }); + if (closeResult._tag === "Failure" && !isAtomCommandInterrupted(closeResult)) { + await fallbackExitWrite(); + } + })(); + + storeCloseTerminal(threadRef, terminalId); + bumpFocusRequestId(); + }, + [ + bumpFocusRequestId, + storeCloseTerminal, + threadId, + threadRef, + closeTerminalMutation, + writeTerminal, + ], + ); + + const handleAddTerminalContext = useCallback( + (selection: TerminalContextSelection) => { + if (!visible) { + return; + } + onAddTerminalContext(selection); + }, + [onAddTerminalContext, visible], + ); + + if (!project || (!terminalUiState.terminalOpen && !active) || !cwd) { + return null; + } + + return ( +
+
+ +
+
+ ); +}); + +interface PersistentThreadTerminalPanelProps { + visible: boolean; + threadRef: ScopedThreadRef; + surface: Extract; + launchContext: PersistentTerminalLaunchContext | null; + focusRequestId: number; + keybindings: ResolvedKeybindingsConfig; + onAddTerminalContext: (selection: TerminalContextSelection) => void; + onSplitTerminal: () => void; + onSplitTerminalVertical: () => void; + onNewTerminal: () => void; + onActiveTerminalChange: (terminalId: string) => void; + onCloseTerminal: (terminalId: string) => void; + splitShortcutLabel?: string | undefined; + splitVerticalShortcutLabel?: string | undefined; + newShortcutLabel?: string | undefined; + closeShortcutLabel?: string | undefined; +} + +export const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPanel({ + visible, + threadRef, + surface, + launchContext, + focusRequestId, + keybindings, + onAddTerminalContext, + onSplitTerminal, + onSplitTerminalVertical, + onNewTerminal, + onActiveTerminalChange, + onCloseTerminal, + splitShortcutLabel, + splitVerticalShortcutLabel, + newShortcutLabel, + closeShortcutLabel, +}: PersistentThreadTerminalPanelProps) { + const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); + const projectRef = serverThread + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) + : draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; + const project = useProject(projectRef); + const knownTerminalSessions = useKnownTerminalSessions({ + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + }); + const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const activeSummary = + knownTerminalSessions.find((session) => session.target.terminalId === surface.activeTerminalId) + ?.state.summary ?? null; + const worktreePath = + launchContext?.worktreePath ?? activeSummary?.worktreePath ?? threadWorktreePath; + const cwd = useMemo( + () => + launchContext?.cwd ?? + activeSummary?.cwd ?? + (project + ? projectScriptCwd({ + project: { cwd: project.workspaceRoot }, + worktreePath, + }) + : null), + [activeSummary?.cwd, launchContext?.cwd, project, worktreePath], + ); + const runtimeEnv = useMemo( + () => + project + ? projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath, + }) + : {}, + [project, worktreePath], + ); + const terminalLabelsById = useMemo(() => { + const labels = new Map(); + for (const terminalId of surface.terminalIds) { + const summary = + knownTerminalSessions.find((session) => session.target.terminalId === terminalId)?.state + .summary ?? null; + labels.set(terminalId, resolveTerminalSessionLabel(terminalId, summary)); + } + return labels; + }, [knownTerminalSessions, surface.terminalIds]); + const terminalLaunchLocationsById = useMemo(() => { + const locations = new Map< + string, + { + readonly cwd: string; + readonly worktreePath: string | null; + readonly runtimeEnv: Record; + } + >(); + for (const terminalId of surface.terminalIds) { + const summary = + knownTerminalSessions.find((session) => session.target.terminalId === terminalId)?.state + .summary ?? null; + const terminalWorktreePath = + launchContext?.worktreePath ?? summary?.worktreePath ?? threadWorktreePath; + const terminalCwd = + launchContext?.cwd ?? + summary?.cwd ?? + (project + ? projectScriptCwd({ + project: { cwd: project.workspaceRoot }, + worktreePath: terminalWorktreePath, + }) + : null); + if (!terminalCwd || !project) continue; + locations.set(terminalId, { + cwd: terminalCwd, + worktreePath: terminalWorktreePath, + runtimeEnv: projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath: terminalWorktreePath, + }), + }); + } + return locations; + }, [ + knownTerminalSessions, + launchContext?.cwd, + launchContext?.worktreePath, + project, + surface.terminalIds, + threadWorktreePath, + ]); + + if (!project || !cwd) return null; + + return ( + undefined} + onAddTerminalContext={onAddTerminalContext} + terminalLabelsById={terminalLabelsById} + terminalLaunchLocationsById={terminalLaunchLocationsById} + keybindings={keybindings} + /> + ); +}); + +export function ThreadTerminals({ + threadRef, + fill = false, + launchContext, + focusRequestId, + keybindings, + onAddTerminalContext, +}: { + threadRef: ScopedThreadRef | null; + fill?: boolean; + launchContext: PersistentTerminalLaunchContext | null; + focusRequestId: number; + keybindings: ResolvedKeybindingsConfig; + onAddTerminalContext: (selection: TerminalContextSelection) => void; +}) { + const activeThreadKey = threadRef ? scopedThreadKey(threadRef) : null; + const terminalUiState = useTerminalUiStateStore((state) => + selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef), + ); + const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = + usePanelAnimationSettings(); + const openTerminalThreadKeys = useTerminalUiStateStore( + useShallow((state) => + Object.entries(state.terminalUiStateByThreadKey).flatMap( + ([nextThreadKey, nextTerminalUiState]) => + nextTerminalUiState.terminalOpen ? [nextThreadKey] : [], + ), + ), + ); + const serverThreadRefs = useThreadRefs(); + const serverThreadKeys = useMemo(() => serverThreadRefs.map(scopedThreadKey), [serverThreadRefs]); + const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); + const draftThreadKeys = useMemo( + () => + Object.values(draftThreadsByThreadKey).map((draftThread) => + scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)), + ), + [draftThreadsByThreadKey], + ); + const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState([]); + const mountedTerminalThreadRefs = useMemo( + () => + mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { + const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); + return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; + }), + [mountedTerminalThreadKeys], + ); + + const activeTerminalDrawerPresence = usePanelPresence( + Boolean(activeThreadKey && terminalUiState.terminalOpen), + true, + panelAnimationsActive, + activeThreadKey, + panelAnimationDurationMs, + ); + const existingOpenTerminalThreadKeys = useMemo(() => { + const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); + return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); + }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); + useEffect(() => { + setMountedTerminalThreadKeys((currentThreadIds) => { + const nextThreadIds = reconcileMountedTerminalThreadIds({ + currentThreadIds, + openThreadIds: existingOpenTerminalThreadKeys, + activeThreadId: activeThreadKey, + activeThreadTerminalOpen: activeTerminalDrawerPresence.present, + maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, + }); + return currentThreadIds.length === nextThreadIds.length && + currentThreadIds.every((nextThreadId, index) => nextThreadId === nextThreadIds[index]) + ? currentThreadIds + : nextThreadIds; + }); + }, [activeTerminalDrawerPresence.present, activeThreadKey, existingOpenTerminalThreadKeys]); + const terminalShortcutLabelOptions = useMemo( + () => ({ + context: { + terminalFocus: true, + terminalOpen: Boolean(terminalUiState.terminalOpen), + }, + }), + [terminalUiState.terminalOpen], + ); + const splitTerminalShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions), + [keybindings, terminalShortcutLabelOptions], + ); + const splitTerminalVerticalShortcutLabel = useMemo( + () => + shortcutLabelForCommand(keybindings, "terminal.splitVertical", terminalShortcutLabelOptions), + [keybindings, terminalShortcutLabelOptions], + ); + const newTerminalShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "terminal.new", terminalShortcutLabelOptions), + [keybindings, terminalShortcutLabelOptions], + ); + const closeTerminalShortcutLabel = useMemo( + () => shortcutLabelForCommand(keybindings, "terminal.close", terminalShortcutLabelOptions), + [keybindings, terminalShortcutLabelOptions], + ); + const terminalDrawers = mountedTerminalThreadRefs.map( + ({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( + + ), + ); + + return <>{terminalDrawers}; +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index a8d810064428..56fbd361a319 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -796,6 +796,8 @@ import { } from "lucide-react"; import { proposedPlanTitle } from "../../proposedPlan"; import { hasProviderSetup } from "./ProviderStatusBanner"; +import { isT3Shell } from "../../env"; +import { ShellComposerBridge } from "../../shell/lazy"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -856,6 +858,11 @@ const runtimeModeConfig: Record< }; const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[]; +const shellRuntimeModes = runtimeModeOptions.map((mode) => ({ + value: mode, + label: runtimeModeConfig[mode].label, + description: runtimeModeConfig[mode].description, +})); const extendReplacementRangeForTrailingSpace = ( text: string, rangeEnd: number, @@ -4700,6 +4707,58 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Render // ------------------------------------------------------------------ + const composerPlaceholder = isComposerApprovalState + ? (activePendingApproval?.detail ?? "Resolve this approval request to continue") + : activePendingProgress + ? isChoiceOnlyPendingQuestion + ? "Choose an option above" + : "Type your own answer, or leave this blank to use the selected option" + : showPlanFollowUpPrompt && activeProposedPlan + ? "Add feedback to refine the plan, or leave this blank to implement it" + : projectSelectionRequired + ? "Choose a project above to start a thread" + : noProviderAvailable + ? "Enable a provider in Settings to send a message" + : phase === "disconnected" + ? DISCONNECTED_COMPOSER_PLACEHOLDER + : "Ask anything, @tag files/folders, $use skills, or / for commands"; + // Hosted by the Qt shell, the prompt editor and footer are native bricks + // (ShellComposerBridge feeds them). The editor stays for approval and + // user-input flows, which type their answers through it. + const shellHosted = isT3Shell; + const hideEditorForShell = + shellHosted && !isComposerApprovalState && pendingUserInputs.length === 0; + // The shell's editor works on the raw prompt (mentions written out), so its + // caret is an expanded cursor; ChatComposer tracks the collapsed one. + const onShellCursorChange = useCallback( + (expandedCursor: number) => { + const text = promptRef.current; + setComposerCursor(collapseExpandedComposerCursor(text, expandedCursor)); + setComposerTrigger(detectComposerTrigger(text, expandedCursor)); + }, + [promptRef], + ); + const dismissComposerTrigger = useCallback(() => { + setComposerTrigger(null); + }, []); + // With the editor and footer native, the frame only earns its space when a + // banner, attachment, context or validation message is visible. + const hasShoulderTab = showTasksTab || (!isComposerApprovalState && stashQueue.length > 0); + const shellFrameEmpty = + hideEditorForShell && + !hasShoulderTab && + composerImages.length === 0 && + composerFiles.length === 0 && + composerTerminalContexts.length === 0 && + composerElementContexts.length === 0 && + composerPreviewAnnotations.length === 0 && + composerReviewComments.length === 0 && + pendingApprovals.length === 0 && + !showPlanFollowUpPrompt && + activeTasksProgress === null && + providerInputSubmissionError === null && + composerSubmissionError === null; + return (
{composerControlsInStrip && restingControlsHost ? createPortal( @@ -5382,116 +5442,164 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
)} -
- - {isComposerResting ? collapsedComposerImagePreviews : null} - {showMobilePendingAnswerActions ? ( -
- -
- ) : null} -
+ > + + {isComposerResting ? collapsedComposerImagePreviews : null} + {showMobilePendingAnswerActions ? ( +
+ +
+ ) : null} +
+ )}
+ {shellHosted ? ( + { + void addComposerAttachments(files); + }} + onAddTerminalContext={(selection) => { + composerRef.current?.addTerminalContext(selection); + }} + attachments={standaloneComposerImages} + terminalContexts={composerTerminalContexts} + onRemoveAttachment={removeComposerImage} + onRemoveTerminalContext={removeComposerTerminalContextFromDraft} + placeholder={composerPlaceholder} + editorDisabled={ + isConnecting || + isComposerApprovalState || + projectSelectionRequired || + isChoiceOnlyPendingQuestion + } + hasSendableContent={composerSendState.hasSendableContent} + sendDisabledReason={sendDisabledReason} + phase={phase} + isSendBusy={isSendBusy} + isConnecting={isConnecting} + environmentUnavailable={environmentUnavailable !== null} + noProviderAvailable={noProviderAvailable} + projectSelectionRequired={projectSelectionRequired} + pendingApprovalCount={pendingApprovals.length} + pendingUserInputCount={pendingUserInputs.length} + showPlanFollowUpPrompt={showPlanFollowUpPrompt} + selectedInstanceId={selectedInstanceId} + selectedProvider={selectedProvider} + selectedModel={selectedModel} + selectedProviderModels={selectedProviderModels} + instanceEntries={providerInstanceEntries} + modelOptionsByInstance={modelOptionsByInstance} + modelOptions={composerModelOptions?.[selectedInstanceId]} + planModeEnabled={settings.planModeEnabled} + getModelDisabledReason={getModelDisabledReason} + onProviderModelSelect={onProviderModelSelect} + runtimeMode={runtimeMode} + runtimeModes={shellRuntimeModes} + interactionMode={interactionMode} + showInteractionModeToggle={planModeUiEnabled} + onRuntimeModeChange={handleRuntimeModeChange} + onInteractionModeChange={handleInteractionModeChange} + onSend={onSend} + onInterrupt={onInterrupt} + /> + ) : null} + {/* Bottom toolbar */} - {isComposerCollapsedMobile || isComposerApprovalState ? null : ( + {isComposerCollapsedMobile || isComposerApprovalState || shellHosted ? null : (
{ const primaryEnvironmentId = EnvironmentId.make("environment-primary"); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 98b661bf5ff6..e59ce0ffef54 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -6,10 +6,6 @@ import { type ThreadId, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { - isAtomCommandInterrupted, - squashAtomCommandFailure, -} from "@t3tools/client-runtime/state/runtime"; import { ChevronDownIcon } from "lucide-react"; import { memo, @@ -25,7 +21,6 @@ import GitActionsControl from "../GitActionsControl"; import { isTrailingDoubleClick } from "../Sidebar.logic"; import { type DraftId } from "~/composerDraftStore"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { toastManager } from "../ui/toast"; import ProjectScriptsControl, { type NewProjectScriptInput, type ProjectScriptActionResult, @@ -34,10 +29,9 @@ import { OpenInPicker } from "./OpenInPicker"; import { useRemoteOpenState, type RemoteOpenMode } from "../../remoteOpen"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts"; +import { useRenameThread } from "../../hooks/useRenameThread"; import { useThreadActionMenu } from "~/hooks/useThreadActionMenu"; import { readLocalApi } from "~/localApi"; -import { threadEnvironment } from "../../state/threads"; -import { useAtomCommand } from "../../state/use-atom-command"; import { observeResponsiveBreakpointFade, usePanelAnimationSettings } from "../../panelAnimations"; import { ProjectFavicon } from "../ProjectFavicon"; import { @@ -48,6 +42,11 @@ import { import { cn } from "~/lib/utils"; interface ChatHeaderProps { + /** Hosted by the Qt shell: breadcrumb, scripts and editor live in native chrome; only git stays. */ + shellHosted?: boolean; + /** Window-coordinate request from the shell to open the thread's action menu. */ + shellMenuRequest?: { x: number; y: number; seq: number } | null; + onShellRenameRequested?: () => void; activeThreadEnvironmentId: EnvironmentId; activeThreadId: ThreadId; draftId?: DraftId; @@ -77,20 +76,6 @@ interface ChatHeaderProps { onDeleteProjectScript: (scriptId: string) => Promise; } -/** - * Rename commit rule shared with the sidebar's inline rename: trim, reject - * empty (the caller toasts), and skip the mutation when nothing changed. - */ -export function resolveRenameCommit(input: { - readonly title: string; - readonly originalTitle: string; -}): { action: "commit"; title: string } | { action: "reject-empty" } | { action: "noop" } { - const trimmed = input.title.trim(); - if (trimmed.length === 0) return { action: "reject-empty" }; - if (trimmed === input.originalTitle) return { action: "noop" }; - return { action: "commit", title: trimmed }; -} - // How long a click on the thread title waits before opening the action menu, // so a double-click-to-rename can cancel it first. Only the native desktop // menu needs this: it swallows input while open, so the wait must cover the @@ -121,6 +106,9 @@ export function shouldShowOpenInPicker(input: { } export const ChatHeader = memo(function ChatHeader({ + shellHosted = false, + shellMenuRequest = null, + onShellRenameRequested, activeThreadEnvironmentId, activeThreadId, draftId, @@ -176,9 +164,6 @@ export const ChatHeader = memo(function ChatHeader({ () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId), [activeThreadEnvironmentId, activeThreadId], ); - const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { - reportFailure: false, - }); // Inline rename, keyed by thread: navigating away drops an in-progress // rename instead of committing stale text. Cleared on thread change (not // just hidden) so returning to the thread doesn't revive the old draft. @@ -189,33 +174,24 @@ export const ChatHeader = memo(function ChatHeader({ const renamingTitle = renaming?.threadId === activeThreadId ? renaming.title : null; const renameCommittedRef = useRef(false); const startRename = useCallback(() => { + if (shellHosted) { + onShellRenameRequested?.(); + return; + } renameCommittedRef.current = false; setRenaming({ threadId: activeThreadId, title: activeThreadTitle }); - }, [activeThreadId, activeThreadTitle]); + }, [activeThreadId, activeThreadTitle, onShellRenameRequested, shellHosted]); + const renameThread = useRenameThread({ + environmentId: activeThreadEnvironmentId, + threadId: activeThreadId, + currentTitle: activeThreadTitle, + }); const commitRename = useCallback( (title: string) => { setRenaming(null); - const resolution = resolveRenameCommit({ title, originalTitle: activeThreadTitle }); - if (resolution.action === "reject-empty") { - toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); - return; - } - if (resolution.action === "noop") return; - void updateThreadMetadata({ - environmentId: activeThreadEnvironmentId, - input: { threadId: activeThreadId, title: resolution.title }, - }).then((result) => { - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add({ - type: "error", - title: "Failed to rename thread", - description: error instanceof Error ? error.message : "An error occurred.", - }); - } - }); + renameThread(title); }, - [activeThreadEnvironmentId, activeThreadId, activeThreadTitle, updateThreadMetadata], + [renameThread], ); const { openMenu, closeMenu } = useThreadActionMenu({ threadRef: isServerThread ? activeThreadRef : null, @@ -276,6 +252,17 @@ export const ChatHeader = memo(function ChatHeader({ }, [cancelPendingTitleMenu, closeMenu, startRename], ); + // Native title clicks arrive as window coordinates; the shell renders the + // menu at the window level for the "shell" surface. Keyed on the request + // alone: openMenu changes identity on rename and PR polling, which must + // not re-open the menu. + const openMenuRef = useRef(openMenu); + openMenuRef.current = openMenu; + useEffect(() => { + if (!shellHosted || shellMenuRequest === null) return; + openMenuRef.current({ x: shellMenuRequest.x, y: shellMenuRequest.y, surface: "shell" }); + }, [shellHosted, shellMenuRequest]); + const handleHeaderContextMenu = useCallback( (event: ReactMouseEvent) => { if (renamingTitle !== null) return; @@ -322,7 +309,10 @@ export const ChatHeader = memo(function ChatHeader({ > {/* The project always leads the header: knowing which project a thread lives in is priority zero, and the thread title alone @@ -419,7 +409,7 @@ export const ChatHeader = memo(function ChatHeader({ "[[data-panel-animations=true]_&]:motion-safe:transition-[padding-right] [[data-panel-animations=true]_&]:motion-safe:[transition-duration:var(--panel-animation-duration)] [[data-panel-animations=true]_&]:motion-safe:ease-out", )} > - {activeProjectScripts && ( + {activeProjectScripts && !shellHosted && ( )} - {showOpenInPicker && ( + {showOpenInPicker && !shellHosted && ( , + | "toggleTerminalVisibility" + | "setTerminalOpen" + | "splitTerminal" + | "requestCloseTerminal" + | "createNewTerminal" +>; + +/** Called after keybinding resolution so inline and dedicated terminals consume the same commands. */ +export function handleThreadTerminalShortcut( + command: string, + event: { preventDefault: () => void; stopPropagation: () => void }, + terminalUiState: ReturnType["terminalUiState"], + actions: Actions, + panel?: { + split: (direction?: "horizontal" | "vertical") => void; + newTerminal: () => void; + close: (() => void) | undefined; + }, +): boolean { + if ( + ![ + "terminal.toggle", + "terminal.split", + "terminal.splitVertical", + "terminal.close", + "terminal.new", + ].includes(command) + ) + return false; + event.preventDefault(); + event.stopPropagation(); + if (command === "terminal.toggle") actions.toggleTerminalVisibility(); + else if (command === "terminal.close") { + if (panel?.close) panel.close(); + else if (terminalUiState.terminalOpen) + actions.requestCloseTerminal(terminalUiState.activeTerminalId); + } else if (command === "terminal.new") { + if (panel) panel.newTerminal(); + else { + if (!terminalUiState.terminalOpen) actions.setTerminalOpen(true); + actions.createNewTerminal(); + } + } else { + const direction = command === "terminal.splitVertical" ? "vertical" : "horizontal"; + if (panel) panel.split(direction); + else { + if (!terminalUiState.terminalOpen) actions.setTerminalOpen(true); + actions.splitTerminal(direction); + } + } + return true; +} diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 0f6483c2ae67..9c29a2d469d1 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -426,6 +426,33 @@ type ToastPosition = interface ToastProviderProps extends Toast.Provider.Props { position?: ToastPosition; + /** + * Hosted by a native shell: rendered inside the provider so it can read the + * live toast list; plain toasts then render natively and only rich ones + * (React-element bodies) stay in this viewport. + */ + shellMirror?: ReactNode; +} + +/** A toast the shell can render from strings alone. */ +export function isShellMirrorableToast(toast: { + title?: ReactNode; + description?: ReactNode; + positionerProps?: { anchor?: unknown } | undefined; + data?: ThreadToastData | undefined; +}): boolean { + if (toast.positionerProps?.anchor) return false; + if (toast.data?.expandableContent) return false; + if (typeof toast.title !== "string") return false; + return ( + toast.description === undefined || + toast.description === null || + typeof toast.description === "string" + ); +} + +export function dismissToast(toastId: ToastId, onClose: (() => void) | undefined): void { + handleToastDismissClick(toastManager, toastId, onClose); } function useActiveThreadRefFromRoute(): ScopedThreadRef | null { @@ -454,9 +481,12 @@ function useActiveThreadRefFromRoute(): ScopedThreadRef | null { function ThreadToastVisibleAutoDismiss({ toastId, dismissAfterVisibleMs, + ignoreFocus = false, }: { toastId: ToastId; dismissAfterVisibleMs: number | undefined; + /** Shell-mirrored toasts show in native chrome, so page focus is irrelevant. */ + ignoreFocus?: boolean; }) { useEffect(() => { if (!dismissAfterVisibleMs || dismissAfterVisibleMs <= 0) return; @@ -504,7 +534,8 @@ function ThreadToastVisibleAutoDismiss({ }; const syncTimer = () => { - const shouldRun = document.visibilityState === "visible" && document.hasFocus(); + const shouldRun = + document.visibilityState === "visible" && (ignoreFocus || document.hasFocus()); if (shouldRun) { start(); return; @@ -524,27 +555,39 @@ function ThreadToastVisibleAutoDismiss({ pause(); clearTimer(); }; - }, [dismissAfterVisibleMs, toastId]); + }, [dismissAfterVisibleMs, ignoreFocus, toastId]); return null; } -function ToastProvider({ children, position = "top-right", ...props }: ToastProviderProps) { +function ToastProvider({ + children, + position = "top-right", + shellMirror, + ...props +}: ToastProviderProps) { return ( {children} - + {shellMirror} + ); } -function Toasts({ position }: { position: ToastPosition }) { +function Toasts({ position, onlyRich = false }: { position: ToastPosition; onlyRich?: boolean }) { const { toasts } = Toast.useToastManager(); const activeThreadRef = useActiveThreadRefFromRoute(); const isTop = position.startsWith("top"); - const visibleToasts = toasts.filter((toast) => + const scopedToasts = toasts.filter((toast) => shouldRenderThreadScopedToast(toast.data, activeThreadRef), ); + const visibleToasts = onlyRich + ? scopedToasts.filter((toast) => !isShellMirrorableToast(toast)) + : scopedToasts; + // Base UI only drops a closed toast once its root finishes leaving, so the + // ones the shell renders still need a (hidden) root here or they never go. + const mirroredToasts = onlyRich ? scopedToasts.filter(isShellMirrorableToast) : []; const visibleToastLayout = buildVisibleToastLayout(visibleToasts); useEffect(() => { @@ -577,6 +620,15 @@ function Toasts({ position }: { position: ToastPosition }) { } as CSSProperties } > + {mirroredToasts.map((toast) => ( + + + + ))} {visibleToastLayout.items.map(({ toast, visibleIndex, offsetY }) => { const hideCollapsedContent = shouldHideCollapsedToastContent( visibleIndex, @@ -809,4 +861,5 @@ export { toastManager, AnchoredToastProvider, anchoredToastManager, + useActiveThreadRefFromRoute, }; diff --git a/apps/web/src/components/useThreadPanelClosing.ts b/apps/web/src/components/useThreadPanelClosing.ts new file mode 100644 index 000000000000..a5ab15ee77a0 --- /dev/null +++ b/apps/web/src/components/useThreadPanelClosing.ts @@ -0,0 +1,182 @@ +import { type ScopedThreadRef } from "@t3tools/contracts"; +import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; +import { useCallback } from "react"; +import { readLocalApi } from "../localApi"; +import { + selectActiveRightPanelSurface, + selectThreadRightPanelState, + type RightPanelSurface, + useRightPanelStore, +} from "../rightPanelStore"; +import { setActivePreviewTab, useThreadPreviewState } from "../previewStateStore"; +import { closePreviewSession } from "./preview/closePreviewSession"; +import { confirmTerminalClose } from "../lib/terminalCloseConfirm"; +import { useTerminalUiStateStore } from "../terminalUiStateStore"; +import { terminalEnvironment } from "../state/terminal"; +import { agentControlledBrowserCloseConfirmation } from "./ChatView.logic"; +import { previewEnvironment } from "../state/preview"; +import { useAtomCommand } from "../state/use-atom-command"; +export function useThreadPanelClosing( + activeThreadRef: ScopedThreadRef | null, + activePreviewState: ReturnType, + activeTerminalLabelsById: ReadonlyMap, +) { + const closePreview = useAtomCommand(previewEnvironment.close, "preview close"); + const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); + const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); + const rightPanelState = useRightPanelStore((s) => + selectThreadRightPanelState(s.byThreadKey, activeThreadRef), + ); + const cleanupRightPanelSurfaces = useCallback( + (surfaces: readonly RightPanelSurface[]) => { + if (!activeThreadRef) return; + for (const surface of surfaces) { + if (surface.kind === "preview" && surface.resourceId) { + void closePreviewSession({ + closePreview, + snapshot: activePreviewState.sessions[surface.resourceId] ?? null, + tabId: surface.resourceId, + threadRef: activeThreadRef, + }); + } + if (surface.kind === "terminal") { + for (const terminalId of surface.terminalIds) { + storeCloseTerminal(activeThreadRef, terminalId); + void closeTerminalMutation({ + environmentId: activeThreadRef.environmentId, + input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true }, + }); + } + } + } + }, + [ + activeThreadRef, + activePreviewState.sessions, + closePreview, + closeTerminalMutation, + storeCloseTerminal, + ], + ); + const closeAfterAgentBrowserConfirmation = useCallback( + (surfaces: readonly RightPanelSurface[], closeSurfaces: () => void) => { + const message = agentControlledBrowserCloseConfirmation( + surfaces, + activePreviewState.desktopByTabId, + ); + if (!message) { + closeSurfaces(); + return; + } + const localApi = readLocalApi(); + if (!localApi) return; + void localApi.dialogs.confirm(message, { variant: "destructive" }).then( + (confirmed) => { + if (confirmed) closeSurfaces(); + }, + () => undefined, + ); + }, + [activePreviewState.desktopByTabId], + ); + const syncActivePreviewSurface = useCallback(() => { + if (!activeThreadRef) return; + const nextActiveSurface = selectActiveRightPanelSurface( + useRightPanelStore.getState().byThreadKey, + activeThreadRef, + ); + if (nextActiveSurface?.kind === "preview" && nextActiveSurface.resourceId) { + setActivePreviewTab(activeThreadRef, nextActiveSurface.resourceId); + } + }, [activeThreadRef]); + const finishRightPanelSurfaceClose = useCallback( + (surfaces: readonly RightPanelSurface[]) => { + if (!activeThreadRef) return; + cleanupRightPanelSurfaces(surfaces); + const store = useRightPanelStore.getState(); + for (const surface of surfaces) { + store.closeSurface(activeThreadRef, surface.id); + } + syncActivePreviewSurface(); + }, + [activeThreadRef, cleanupRightPanelSurfaces, syncActivePreviewSurface], + ); + const closeRightPanelSurface = useCallback( + (surface: RightPanelSurface) => { + if (!activeThreadRef) return; + const finishClose = () => finishRightPanelSurfaceClose([surface]); + if (surface.kind === "preview") { + closeAfterAgentBrowserConfirmation([surface], finishClose); + return; + } + if (surface.kind !== "terminal") { + finishClose(); + return; + } + const activeLabel = + activeTerminalLabelsById.get(surface.activeTerminalId) ?? + getTerminalLabel(surface.activeTerminalId); + const otherLabels = surface.terminalIds + .filter((terminalId) => terminalId !== surface.activeTerminalId) + .map( + (terminalId) => activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId), + ); + void confirmTerminalClose([activeLabel, ...otherLabels]).then((confirmed) => { + if (confirmed) finishClose(); + }); + }, + [ + activeThreadRef, + activeTerminalLabelsById, + closeAfterAgentBrowserConfirmation, + finishRightPanelSurfaceClose, + ], + ); + const closeOtherRightPanelSurfaces = useCallback( + (surface: RightPanelSurface) => { + if (!activeThreadRef) return; + const surfaces = rightPanelState.surfaces.filter((entry) => entry.id !== surface.id); + const finishClose = () => finishRightPanelSurfaceClose(surfaces); + closeAfterAgentBrowserConfirmation(surfaces, finishClose); + }, + [ + activeThreadRef, + closeAfterAgentBrowserConfirmation, + finishRightPanelSurfaceClose, + rightPanelState.surfaces, + ], + ); + const closeRightPanelSurfacesToRight = useCallback( + (surface: RightPanelSurface) => { + if (!activeThreadRef) return; + const surfaceIndex = rightPanelState.surfaces.findIndex((entry) => entry.id === surface.id); + if (surfaceIndex < 0) return; + const surfaces = rightPanelState.surfaces.slice(surfaceIndex + 1); + const finishClose = () => finishRightPanelSurfaceClose(surfaces); + closeAfterAgentBrowserConfirmation(surfaces, finishClose); + }, + [ + activeThreadRef, + closeAfterAgentBrowserConfirmation, + finishRightPanelSurfaceClose, + rightPanelState.surfaces, + ], + ); + const closeAllRightPanelSurfaces = useCallback(() => { + if (!activeThreadRef) return; + const finishClose = () => finishRightPanelSurfaceClose(rightPanelState.surfaces); + closeAfterAgentBrowserConfirmation(rightPanelState.surfaces, finishClose); + }, [ + activeThreadRef, + closeAfterAgentBrowserConfirmation, + finishRightPanelSurfaceClose, + rightPanelState.surfaces, + ]); + + return { + closeRightPanelSurface, + closeOtherRightPanelSurfaces, + closeRightPanelSurfacesToRight, + closeAllRightPanelSurfaces, + }; +} diff --git a/apps/web/src/components/useThreadReferenceCopy.ts b/apps/web/src/components/useThreadReferenceCopy.ts new file mode 100644 index 000000000000..6896400f6da1 --- /dev/null +++ b/apps/web/src/components/useThreadReferenceCopy.ts @@ -0,0 +1,50 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; +import { useCallback, useMemo } from "react"; +import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; +import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; +import { stackedThreadToast, toastManager } from "./ui/toast"; +export function useThreadReferenceCopy( + activeThreadRef: ScopedThreadRef | null, + isServerThread: boolean, + linkedPullRequestUrl: string | null, +) { + const activeThreadId = activeThreadRef?.threadId ?? null; + const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef); + const activeThreadReferenceCopyTarget = useMemo( + () => + activeThreadId === null || !isServerThread + ? null + : resolveThreadReferenceCopyTarget({ + threadId: activeThreadId, + openPanelPullRequestUrl, + linkedPullRequestUrl: linkedPullRequestUrl, + }), + [activeThreadId, isServerThread, linkedPullRequestUrl, openPanelPullRequestUrl], + ); + const copyActiveThreadReference = useCallback(() => { + const target = activeThreadReferenceCopyTarget; + if (target === null) return; + void writeTextToClipboard(target.value, target.clipboardTarget).then( + (didCopy) => { + if (!didCopy) return; + toastManager.add({ + type: "success", + title: target.successTitle, + description: target.value, + }); + }, + (error) => { + console.error(error); + toastManager.add( + stackedThreadToast({ + type: "error", + title: target.failureTitle, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }, + ); + }, [activeThreadReferenceCopyTarget]); + return copyActiveThreadReference; +} diff --git a/apps/web/src/components/useThreadTerminalActions.test.tsx b/apps/web/src/components/useThreadTerminalActions.test.tsx new file mode 100644 index 000000000000..964bd20a1309 --- /dev/null +++ b/apps/web/src/components/useThreadTerminalActions.test.tsx @@ -0,0 +1,135 @@ +import { EnvironmentId, ProjectId, ThreadId, type ProjectScript } from "@t3tools/contracts"; +import { act, useLayoutEffect } from "react"; +import * as Cause from "effect/Cause"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { useThreadTerminalActions } from "./useThreadTerminalActions"; + +const commands = vi.hoisted(() => ({ open: vi.fn(), write: vi.fn(), close: vi.fn() })); +vi.mock("../state/terminal", () => ({ terminalEnvironment: {} })); +vi.mock("../state/use-atom-command", () => ({ + useAtomCommand: (_command: unknown, label: string) => + label === "terminal open" + ? commands.open + : label === "terminal write" + ? commands.write + : commands.close, +})); +vi.mock("~/hooks/useLocalStorage", () => ({ useLocalStorage: () => [{}, vi.fn()] })); + +const threadRef = { environmentId: EnvironmentId.make("test"), threadId: ThreadId.make("thread") }; +const project = { + id: ProjectId.make("project"), + workspaceRoot: "/tmp/project", +} as NonNullable[0]["activeProject"]>; +const script: ProjectScript = { + id: "build", + name: "Build", + command: "build", + icon: "play", + runOnWorktreeCreate: false, +}; +let actions: ReturnType; +let renderer: ReactTestRenderer; +const focusComposer = vi.fn(); +const setThreadError = vi.fn(); +const state = () => + selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + threadRef, + ); + +function Probe() { + const terminalUiState = useTerminalUiStateStore((store) => + selectThreadTerminalUiState(store.terminalUiStateByThreadKey, threadRef), + ); + const value = useThreadTerminalActions({ + environmentId: threadRef.environmentId, + activeThreadRef: threadRef, + activeThread: { + id: threadRef.threadId, + environmentId: threadRef.environmentId, + worktreePath: null, + }, + activeProject: project, + terminals: { + terminalUiState, + activeKnownTerminalIds: terminalUiState.terminalIds, + allocatableActiveTerminalIds: terminalUiState.terminalIds, + activeTerminalLabelsById: new Map(), + runningTerminalIds: [], + }, + setThreadError, + focusComposer, + }); + useLayoutEffect(() => { + actions = value; + }); + return null; +} + +beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { requestAnimationFrame: () => 1, cancelAnimationFrame: () => {} }); + useTerminalUiStateStore.setState({ + terminalUiStateByThreadKey: {}, + suppressedTerminalIdsByThreadKey: {}, + }); + commands.open.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); + commands.write.mockReset().mockResolvedValue({ _tag: "Success", value: undefined }); + await act(() => { + renderer = create(); + }); +}); + +afterEach(async () => { + await act(() => renderer.unmount()); + vi.unstubAllGlobals(); +}); + +describe("terminal allocation failures", () => { + const failOpen = () => + commands.open.mockResolvedValueOnce({ + _tag: "Failure", + cause: Cause.fail(new Error("Open failed")), + }); + + it("removes a failed first terminal and retries on the next toggle", async () => { + failOpen(); + await act(() => actions.toggleTerminalVisibility()); + expect(state().terminalIds).toEqual([]); + expect(state().terminalOpen).toBe(false); + await act(() => actions.toggleTerminalVisibility()); + expect(commands.open).toHaveBeenCalledTimes(2); + expect(state().terminalIds).toHaveLength(1); + }); + + it.each(["horizontal", "vertical", "new", "script"] as const)( + "preserves the existing terminal after a failed %s allocation", + async (kind) => { + await act(() => actions.toggleTerminalVisibility()); + const existing = state().terminalIds; + failOpen(); + await act(async () => { + if (kind === "new") actions.createNewTerminal(); + else if (kind === "script") + await actions.runProjectScript(script, { preferNewTerminal: true }); + else actions.splitTerminal(kind); + }); + expect(state().terminalIds).toEqual(existing); + expect(state().activeTerminalId).toBe(existing[0]); + expect(commands.write).not.toHaveBeenCalled(); + }, + ); + + it("does not remove an existing terminal when a script cannot reopen it", async () => { + await act(() => actions.toggleTerminalVisibility()); + const existing = state().terminalIds; + failOpen(); + await act(() => actions.runProjectScript(script)); + expect(state().terminalIds).toEqual(existing); + expect(commands.write).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/useThreadTerminalActions.ts b/apps/web/src/components/useThreadTerminalActions.ts new file mode 100644 index 000000000000..51b19d21f5c2 --- /dev/null +++ b/apps/web/src/components/useThreadTerminalActions.ts @@ -0,0 +1,552 @@ +import { + type EnvironmentId, + type ProjectScript, + type ScopedThreadRef, + type ThreadId, + TerminalOpenInput, +} from "@t3tools/contracts"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; +import { + getTerminalLabel, + nextTerminalId, + resolveTerminalSessionLabel, +} from "@t3tools/shared/terminalLabels"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type Thread } from "../types"; +import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore"; +import type { TerminalLaunchContext } from "./ThreadTerminals"; +import { confirmTerminalClose } from "../lib/terminalCloseConfirm"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; +import { terminalEnvironment } from "../state/terminal"; +import { useProject } from "../state/entities"; +import { + LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, + LastInvokedScriptByProjectSchema, +} from "./ChatView.logic"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { useAtomCommand } from "../state/use-atom-command"; +const SCRIPT_TERMINAL_COLS = 120; +const SCRIPT_TERMINAL_ROWS = 30; + +export function useThreadTerminalSessionState( + routeThreadRef: ScopedThreadRef, + activeThreadRef: ScopedThreadRef | null = routeThreadRef, +) { + const activeThreadId = activeThreadRef?.threadId ?? null; + const terminalUiState = useTerminalUiStateStore((state) => + selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), + ); + const rightPanelState = useRightPanelStore((state) => + selectThreadRightPanelState(state.byThreadKey, activeThreadRef), + ); + const runningTerminalIds = useThreadRunningTerminalIds({ + environmentId: activeThreadRef?.environmentId ?? null, + threadId: activeThreadId, + }); + const activeThreadKnownSessionsRaw = useKnownTerminalSessions({ + environmentId: activeThreadRef?.environmentId ?? null, + threadId: activeThreadId, + }); + const activeThreadKnownSessions = useMemo(() => { + if (activeThreadId === null) { + return []; + } + return activeThreadKnownSessionsRaw.filter( + (session) => session.target.threadId === activeThreadId, + ); + }, [activeThreadId, activeThreadKnownSessionsRaw]); + const activeServerOrderedTerminalIds = useMemo( + () => activeThreadKnownSessions.map((session) => session.target.terminalId), + [activeThreadKnownSessions], + ); + const activeKnownTerminalIds = useMemo( + () => [...new Set([...activeServerOrderedTerminalIds, ...terminalUiState.terminalIds])], + [activeServerOrderedTerminalIds, terminalUiState.terminalIds], + ); + const activeTerminalLabelsById = useMemo(() => { + const labels = new Map(); + for (const session of activeThreadKnownSessions) { + labels.set( + session.target.terminalId, + resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), + ); + } + return labels; + }, [activeThreadKnownSessions]); + const panelTerminalIds = useMemo( + () => + new Set( + rightPanelState.surfaces.flatMap((surface) => + surface.kind === "terminal" ? surface.terminalIds : [], + ), + ), + [rightPanelState.surfaces], + ); + const allocatableActiveTerminalIds = useMemo( + () => [...new Set([...activeKnownTerminalIds, ...panelTerminalIds])], + [activeKnownTerminalIds, panelTerminalIds], + ); + + return { + terminalUiState, + activeKnownTerminalIds, + activeTerminalLabelsById, + allocatableActiveTerminalIds, + runningTerminalIds, + }; +} + +/** Terminal commands share allocation and document-local script launch state in both hosts. */ +export function useThreadTerminalActions({ + environmentId, + activeThreadRef, + activeThread, + activeProject, + terminals, + setThreadError, + focusComposer, +}: { + environmentId: EnvironmentId; + activeThreadRef: ScopedThreadRef | null; + activeThread: Pick | null | undefined; + activeProject: ReturnType; + terminals: ReturnType; + setThreadError: (threadId: ThreadId | null, error: string | null) => void; + focusComposer: () => void; +}) { + const activeThreadId = activeThread?.id ?? null; + const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + const activeProjectCwd = activeProject?.workspaceRoot ?? null; + const activeThreadWorktreePath = activeThread?.worktreePath ?? null; + const gitCwd = activeProject + ? projectScriptCwd({ + project: { cwd: activeProject.workspaceRoot }, + worktreePath: activeThreadWorktreePath, + }) + : null; + const { + terminalUiState, + activeKnownTerminalIds, + activeTerminalLabelsById, + allocatableActiveTerminalIds, + runningTerminalIds, + } = terminals; + const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); + const [terminalUiLaunchContext, setTerminalUiLaunchContext] = + useState(null); + const terminalUiOpenByThreadRef = useRef>({}); + const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( + LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, + {}, + LastInvokedScriptByProjectSchema, + ); + const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen); + const storeSetTerminalHeight = useTerminalUiStateStore((s) => s.setTerminalHeight); + const storeEnsureTerminal = useTerminalUiStateStore((state) => state.ensureTerminal); + const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal); + const storeSplitTerminalVertical = useTerminalUiStateStore((s) => s.splitTerminalVertical); + const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal); + const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal); + const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); + + const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); + const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); + const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); + const openAllocatedTerminal = useCallback( + async (threadRef: ScopedThreadRef, input: TerminalOpenInput, newlyAllocated = true) => { + const result = await openTerminal({ environmentId: threadRef.environmentId, input }); + if (result._tag === "Failure" && newlyAllocated) { + storeCloseTerminal(threadRef, input.terminalId); + } + return result; + }, + [openTerminal, storeCloseTerminal], + ); + const activeTerminalGroup = + terminalUiState.terminalGroups.find( + (group) => group.id === terminalUiState.activeTerminalGroupId, + ) ?? + terminalUiState.terminalGroups.find((group) => + group.terminalIds.includes(terminalUiState.activeTerminalId), + ) ?? + null; + const hasReachedSplitLimit = + (activeTerminalGroup?.terminalIds.length ?? 0) >= MAX_TERMINALS_PER_GROUP; + const setTerminalOpen = useCallback( + (open: boolean) => { + if (!activeThreadRef) return; + storeSetTerminalOpen(activeThreadRef, open); + }, + [activeThreadRef, storeSetTerminalOpen], + ); + // The shell's terminal drawer hands its dragged height back; the drawer's + // own document only fills what the shell gives it. + const resizeTerminal = useCallback( + (height: number) => { + if (activeThreadRef) storeSetTerminalHeight(activeThreadRef, height); + }, + [activeThreadRef, storeSetTerminalHeight], + ); + const toggleTerminalVisibility = useCallback(() => { + if (!activeThreadRef) return; + const nextOpen = !terminalUiState.terminalOpen; + if (nextOpen && terminalUiState.terminalIds.length === 0) { + if (!activeThreadId || !activeProject) { + return; + } + const cwdForOpen = gitCwd ?? activeProject.workspaceRoot; + if (!cwdForOpen) { + return; + } + const terminalId = nextTerminalId(allocatableActiveTerminalIds); + storeEnsureTerminal(activeThreadRef, terminalId, { open: true }); + void openAllocatedTerminal(activeThreadRef, { + threadId: activeThreadId, + terminalId, + cwd: cwdForOpen, + ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), + env: projectScriptRuntimeEnv({ + project: { cwd: activeProject.workspaceRoot }, + worktreePath: activeThreadWorktreePath, + }), + }); + return; + } + setTerminalOpen(nextOpen); + }, [ + activeProject, + activeThreadId, + activeThreadRef, + activeThreadWorktreePath, + allocatableActiveTerminalIds, + gitCwd, + openAllocatedTerminal, + setTerminalOpen, + storeEnsureTerminal, + terminalUiState.terminalIds.length, + terminalUiState.terminalOpen, + ]); + const splitTerminal = useCallback( + (direction: "horizontal" | "vertical" = "horizontal") => { + if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { + return; + } + const cwdForOpen = gitCwd ?? activeProject.workspaceRoot; + if (!cwdForOpen) { + return; + } + const terminalId = nextTerminalId(allocatableActiveTerminalIds); + if (direction === "vertical") { + storeSplitTerminalVertical(activeThreadRef, terminalId); + } else { + storeSplitTerminal(activeThreadRef, terminalId); + } + setTerminalFocusRequestId((value) => value + 1); + void openAllocatedTerminal(activeThreadRef, { + threadId: activeThreadId, + terminalId, + cwd: cwdForOpen, + ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), + env: projectScriptRuntimeEnv({ + project: { cwd: activeProject.workspaceRoot }, + worktreePath: activeThreadWorktreePath, + }), + }); + }, + [ + activeProject, + activeThreadId, + allocatableActiveTerminalIds, + activeThreadRef, + openAllocatedTerminal, + activeThreadWorktreePath, + gitCwd, + hasReachedSplitLimit, + storeSplitTerminal, + storeSplitTerminalVertical, + ], + ); + const createNewTerminal = useCallback(() => { + if (!activeThreadRef || !activeThreadId || !activeProject) { + return; + } + const cwdForOpen = gitCwd ?? activeProject.workspaceRoot; + if (!cwdForOpen) { + return; + } + const terminalId = nextTerminalId(allocatableActiveTerminalIds); + storeNewTerminal(activeThreadRef, terminalId); + setTerminalFocusRequestId((value) => value + 1); + void openAllocatedTerminal(activeThreadRef, { + threadId: activeThreadId, + terminalId, + cwd: cwdForOpen, + ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), + env: projectScriptRuntimeEnv({ + project: { cwd: activeProject.workspaceRoot }, + worktreePath: activeThreadWorktreePath, + }), + }); + }, [ + activeProject, + activeThreadId, + allocatableActiveTerminalIds, + activeThreadRef, + openAllocatedTerminal, + activeThreadWorktreePath, + gitCwd, + storeNewTerminal, + ]); + const closeTerminal = useCallback( + (terminalId: string) => { + if (!activeThreadId || !activeThreadRef) return; + const fallbackExitWrite = () => + writeTerminal({ + environmentId, + input: { threadId: activeThreadId, terminalId, data: "exit\n" }, + }); + void (async () => { + const closeResult = await closeTerminalMutation({ + environmentId, + input: { + threadId: activeThreadId, + terminalId, + deleteHistory: true, + }, + }); + if (closeResult._tag === "Failure" && !isAtomCommandInterrupted(closeResult)) { + await fallbackExitWrite(); + } + })(); + storeCloseTerminal(activeThreadRef, terminalId); + setTerminalFocusRequestId((value) => value + 1); + }, + [ + activeThreadId, + activeThreadRef, + closeTerminalMutation, + environmentId, + storeCloseTerminal, + writeTerminal, + ], + ); + const runProjectScript = useCallback( + async ( + script: ProjectScript, + options?: { + cwd?: string; + env?: Record; + worktreePath?: string | null; + preferNewTerminal?: boolean; + rememberAsLastInvoked?: boolean; + }, + ) => { + if (!activeThreadId || !activeProject || !activeThread) return; + if (options?.rememberAsLastInvoked !== false) { + setLastInvokedScriptByProjectId((current) => { + if (current[activeProject.id] === script.id) return current; + return { ...current, [activeProject.id]: script.id }; + }); + } + const targetCwd = options?.cwd ?? gitCwd ?? activeProject.workspaceRoot; + const baseTerminalId = + terminalUiState.activeTerminalId || activeKnownTerminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; + const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId); + const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; + const shouldCreateNewTerminal = wantsNewTerminal; + const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; + + setTerminalUiLaunchContext({ + threadId: activeThreadId, + cwd: targetCwd, + worktreePath: targetWorktreePath, + }); + setTerminalOpen(true); + if (!activeThreadRef) { + return; + } + setTerminalFocusRequestId((value) => value + 1); + + const runtimeEnv = projectScriptRuntimeEnv({ + project: { + cwd: activeProject.workspaceRoot, + }, + worktreePath: targetWorktreePath, + ...(options?.env ? { extraEnv: options.env } : {}), + }); + const targetTerminalId = shouldCreateNewTerminal + ? nextTerminalId(allocatableActiveTerminalIds) + : baseTerminalId; + const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal + ? { + threadId: activeThreadId, + terminalId: targetTerminalId, + cwd: targetCwd, + ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), + env: runtimeEnv, + cols: SCRIPT_TERMINAL_COLS, + rows: SCRIPT_TERMINAL_ROWS, + } + : { + threadId: activeThreadId, + terminalId: targetTerminalId, + cwd: targetCwd, + ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), + env: runtimeEnv, + }; + + if (shouldCreateNewTerminal) { + storeNewTerminal(activeThreadRef, targetTerminalId); + } else { + storeSetActiveTerminal(activeThreadRef, targetTerminalId); + } + + const openResult = await openAllocatedTerminal( + activeThreadRef, + openTerminalInput, + !activeKnownTerminalIds.includes(targetTerminalId), + ); + if (openResult._tag === "Failure") { + if (!isAtomCommandInterrupted(openResult)) { + const error = squashAtomCommandFailure(openResult); + setThreadError( + activeThreadId, + error instanceof Error ? error.message : `Failed to run script "${script.name}".`, + ); + } + return; + } + + const writeResult = await writeTerminal({ + environmentId, + input: { + threadId: activeThreadId, + terminalId: targetTerminalId, + data: `${script.command}\r`, + }, + }); + if (writeResult._tag === "Failure" && !isAtomCommandInterrupted(writeResult)) { + const error = squashAtomCommandFailure(writeResult); + setThreadError( + activeThreadId, + error instanceof Error ? error.message : `Failed to run script "${script.name}".`, + ); + } + }, + [ + activeProject, + activeThread, + activeThreadId, + activeThreadRef, + gitCwd, + setTerminalOpen, + setThreadError, + storeNewTerminal, + storeSetActiveTerminal, + setLastInvokedScriptByProjectId, + environmentId, + openAllocatedTerminal, + activeKnownTerminalIds, + allocatableActiveTerminalIds, + runningTerminalIds, + terminalUiState.activeTerminalId, + writeTerminal, + ], + ); + + const requestCloseTerminal = useCallback( + (terminalId: string) => { + const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); + void confirmTerminalClose([label]).then((confirmed) => { + if (confirmed) closeTerminal(terminalId); + }); + }, + [activeTerminalLabelsById, closeTerminal], + ); + useEffect(() => { + if (!activeThreadId) { + setTerminalUiLaunchContext(null); + return; + } + setTerminalUiLaunchContext((current) => { + if (!current) return current; + if (current.threadId === activeThreadId) return current; + return null; + }); + }, [activeThreadId]); + + useEffect(() => { + if (!activeThreadId || !activeProjectCwd) { + return; + } + setTerminalUiLaunchContext((current) => { + if (!current || current.threadId !== activeThreadId) { + return current; + } + const settledCwd = projectScriptCwd({ + project: { cwd: activeProjectCwd }, + worktreePath: activeThreadWorktreePath, + }); + if ( + settledCwd === current.cwd && + (activeThreadWorktreePath ?? null) === current.worktreePath + ) { + return null; + } + return current; + }); + }, [activeProjectCwd, activeThreadId, activeThreadWorktreePath]); + + useEffect(() => { + if (terminalUiState.terminalOpen) { + return; + } + setTerminalUiLaunchContext((current) => + current?.threadId === activeThreadId ? null : current, + ); + }, [activeThreadId, terminalUiState.terminalOpen]); + + useEffect(() => { + if (!activeThreadKey) return; + const previous = terminalUiOpenByThreadRef.current[activeThreadKey] ?? false; + const current = Boolean(terminalUiState.terminalOpen); + + if (!previous && current) { + terminalUiOpenByThreadRef.current[activeThreadKey] = current; + setTerminalFocusRequestId((value) => value + 1); + return; + } else if (previous && !current) { + terminalUiOpenByThreadRef.current[activeThreadKey] = current; + const frame = window.requestAnimationFrame(() => { + focusComposer(); + }); + return () => { + window.cancelAnimationFrame(frame); + }; + } + + terminalUiOpenByThreadRef.current[activeThreadKey] = current; + }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]); + + const activeTerminalLaunchContext = + terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; + return { + setTerminalOpen, + resizeTerminal, + toggleTerminalVisibility, + splitTerminal, + createNewTerminal, + requestCloseTerminal, + runProjectScript, + activeTerminalLaunchContext, + terminalFocusRequestId, + setTerminalFocusRequestId, + lastInvokedScriptByProjectId, + }; +} diff --git a/apps/web/src/diffPanelStore.ts b/apps/web/src/diffPanelStore.ts index 56b5ad23fecc..07c0ca49ab2f 100644 --- a/apps/web/src/diffPanelStore.ts +++ b/apps/web/src/diffPanelStore.ts @@ -5,6 +5,8 @@ import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; +export const DIFF_PANEL_STORAGE_KEY = "t3code:diff-panel-state:v1"; + export type DiffPanelSelection = | { kind: "branch"; baseRef: string | null } | { kind: "unstaged" } @@ -118,7 +120,7 @@ export const useDiffPanelStore = create()( }), }), { - name: "t3code:diff-panel-state:v1", + name: DIFF_PANEL_STORAGE_KEY, version: 1, storage: createJSONStorage(() => resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), diff --git a/apps/web/src/documentThemeOverride.ts b/apps/web/src/documentThemeOverride.ts new file mode 100644 index 000000000000..57cc21dd9aa1 --- /dev/null +++ b/apps/web/src/documentThemeOverride.ts @@ -0,0 +1,48 @@ +/** A host's temporary document palette. Stored client preferences remain untouched. */ +export interface DocumentThemeOverride { + readonly id: string; + readonly dark: boolean; + readonly vars: Readonly>; +} + +let current: DocumentThemeOverride | null = null; +let currentJson = "null"; +const listeners = new Set<() => void>(); + +export function subscribeToDocumentThemeOverride(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** Replace only host-owned variables; removal leaves the next page application in control. */ +export function setDocumentThemeOverride(value: DocumentThemeOverride | null): void { + const next = value?.id ? value : null; + const json = JSON.stringify(next); + if (json === currentJson) return; + if (typeof document !== "undefined") { + for (const name of Object.keys(current?.vars ?? {})) { + if (!(name in (next?.vars ?? {}))) document.documentElement.style.removeProperty(name); + } + if (current?.vars["--app-theme-chrome"] && !next?.vars["--app-theme-chrome"]) { + document.documentElement.style.removeProperty("background-color"); + } + } + current = next; + currentJson = json; + applyDocumentThemeOverride(); + for (const listener of listeners) listener(); +} + +/** Last step of palette, preview, and appearance application; no competing DOM observer. */ +export function applyDocumentThemeOverride(): void { + if (!current || typeof document === "undefined") return; + const root = document.documentElement; + root.dataset.themeId = current.id; + root.dataset.themeSelected = "true"; + root.classList.toggle("dark", current.dark); + for (const [name, value] of Object.entries(current.vars)) root.style.setProperty(name, value); + const chrome = current.vars["--app-theme-chrome"]; + if (chrome) root.style.backgroundColor = chrome; +} diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index 2e08dd336984..002ea5aad49f 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -4,3 +4,17 @@ * code executes, so this is reliable at module load time. */ export const isElectron = typeof window !== "undefined" && window.desktopBridge !== undefined; + +/** + * True when hosted by the Qt shell (apps/desktop-qt), which injects + * window.t3Shell at document creation. The shell renders parts of the chrome + * itself; the app publishes what they need and hides its own copies. + */ +export const isT3Shell = typeof window !== "undefined" && window.t3Shell !== undefined; + +/** + * True in the shell's secondary (embed) document. It shares localStorage with + * the primary document and only mirrors its state, so it must never persist a + * copy of its own or it overwrites what the primary wrote. + */ +export const isT3ShellEmbed = isT3Shell && window.t3Shell?.surfaceId !== "primary"; diff --git a/apps/web/src/hooks/useGitActions.test.tsx b/apps/web/src/hooks/useGitActions.test.tsx new file mode 100644 index 000000000000..99e3a8ed2b81 --- /dev/null +++ b/apps/web/src/hooks/useGitActions.test.tsx @@ -0,0 +1,122 @@ +import { EnvironmentId, ThreadId, type VcsStatusResult } from "@t3tools/contracts"; +import { act, useLayoutEffect } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { useGitActions } from "./useGitActions"; + +const fixture = vi.hoisted(() => ({ + status: null as VcsStatusResult | null, + run: vi.fn(), + addToast: vi.fn(() => "toast"), + updateToast: vi.fn(), + closeToast: vi.fn(), + command: vi.fn(), +})); +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); +vi.mock("../state/query", () => ({ + useEnvironmentQuery: () => ({ data: fixture.status, error: null }), +})); +vi.mock("../state/entities", () => ({ useThread: () => ({ branch: fixture.status?.refName }) })); +vi.mock("../state/server", () => ({ serverEnvironment: { configValueAtom: () => null } })); +vi.mock("../state/threads", () => ({ threadEnvironment: {} })); +vi.mock("../state/vcs", () => ({ vcsEnvironment: { status: () => null } })); +vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => fixture.command })); +vi.mock("../lib/sourceControlActions", () => ({ + useGitStackedAction: () => ({ run: fixture.run }), + useSourceControlActionRunning: () => false, + useVcsInitAction: () => ({ run: fixture.command }), + useVcsPullAction: () => ({ run: fixture.command }), +})); +vi.mock("../components/ui/toast", () => ({ + toastManager: { add: fixture.addToast, update: fixture.updateToast, close: fixture.closeToast }, + stackedThreadToast: (value: unknown) => value, +})); +vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => fixture.command })); +vi.mock("../browser/useOpenLink", () => ({ useOpenLink: () => fixture.command })); +vi.mock("../lib/openPullRequestLink", () => ({ useOpenPrLink: () => fixture.command })); +vi.mock("../composerDraftStore", () => ({ + useComposerDraftStore: (select: (store: unknown) => unknown) => + select({ getDraftThreadByRef: () => null, setDraftThreadContext: fixture.command }), +})); + +let renderer: ReactTestRenderer; +let actions: ReturnType; +const threadRef = { environmentId: EnvironmentId.make("test"), threadId: ThreadId.make("thread") }; +function Probe() { + const value = useGitActions({ + gitCwd: "/tmp/project", + activeThreadRef: threadRef, + onOpenPublish: fixture.command, + }); + useLayoutEffect(() => { + actions = value; + }); + return null; +} + +beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { + setInterval: () => 1, + clearInterval: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + }); + vi.stubGlobal("document", { addEventListener: () => {}, removeEventListener: () => {} }); + fixture.status = { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/test", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 1, + behindCount: 0, + pr: null, + }; + fixture.run.mockReset().mockResolvedValue({ + _tag: "Success", + value: { + branch: { status: "skipped" }, + toast: { + title: "Committed", + description: "Ready to push", + cta: { kind: "run_action", label: "Push", action: { kind: "push" } }, + }, + }, + }); + fixture.updateToast.mockClear(); + await act(() => { + renderer = create(); + }); +}); +afterEach(async () => { + await act(() => renderer.unmount()); + vi.unstubAllGlobals(); +}); + +describe("git result toast actions", () => { + it("uses current branch state before retrying a retained toast action", async () => { + await act(() => actions.runGitActionWithToast({ action: "commit" })); + const retry = fixture.updateToast.mock.calls.at(-1)?.[1].actionProps.onClick; + expect(retry).toBeTypeOf("function"); + fixture.status = { ...fixture.status!, refName: "main", isDefaultRef: true }; + await act(() => renderer.update()); + await act(() => retry()); + expect(fixture.run).toHaveBeenCalledTimes(1); + expect(actions.pendingDefaultBranchAction).toMatchObject({ + action: "push", + branchName: "main", + }); + }); + + it("does not run a retained action after its owning hook unmounts", async () => { + await act(() => actions.runGitActionWithToast({ action: "commit" })); + const retry = fixture.updateToast.mock.calls.at(-1)?.[1].actionProps.onClick; + await act(() => renderer.unmount()); + await act(() => retry()); + expect(fixture.run).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/hooks/useGitActions.ts b/apps/web/src/hooks/useGitActions.ts new file mode 100644 index 000000000000..bb0ba9a83477 --- /dev/null +++ b/apps/web/src/hooks/useGitActions.ts @@ -0,0 +1,884 @@ +import { useAtomValue } from "@effect/atom-react"; +import { type ScopedThreadRef } from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { + GitActionProgressEvent, + GitRunStackedActionResult, + GitStackedAction, + VcsStatusResult, +} from "@t3tools/contracts"; +import { + type MouseEvent, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; + +import { + buildGitActionProgressStages, + buildMenuItems, + type GitActionMenuItem, + type DefaultBranchConfirmableAction, + requiresDefaultBranchConfirmation, + resolveDefaultBranchActionDialogCopy, + resolveLiveThreadBranchUpdate, + resolveThreadBranchMetadataPatch, + resolveQuickAction, + resolveThreadBranchUpdate, +} from "../components/GitActionsControl.logic"; +import { stackedThreadToast, toastManager, type ThreadToastData } from "../components/ui/toast"; +import { useOpenInPreferredEditor } from "../editorPreferences"; +import { + useGitStackedAction, + useSourceControlActionRunning, + useVcsInitAction, + useVcsPullAction, +} from "../lib/sourceControlActions"; +import { useThread } from "../state/entities"; +import { useEnvironmentQuery } from "../state/query"; +import { serverEnvironment } from "../state/server"; +import { threadEnvironment } from "../state/threads"; +import { useAtomCommand } from "../state/use-atom-command"; +import { vcsEnvironment } from "../state/vcs"; +import { randomUUID } from "../lib/utils"; +import { type DraftId, useComposerDraftStore } from "../composerDraftStore"; +import { useOpenLink } from "../browser/useOpenLink"; +import { getSourceControlPresentation } from "../sourceControlPresentation"; +import { useOpenPrLink } from "../lib/openPullRequestLink"; + +export interface PendingDefaultBranchAction { + action: DefaultBranchConfirmableAction; + branchName: string; + includesCommit: boolean; + commitMessage?: string; + onConfirmed?: () => void; + filePaths?: string[]; +} + +type GitActionToastId = ReturnType; + +interface ActiveGitActionProgress { + toastId: GitActionToastId; + toastData: ThreadToastData | undefined; + actionId: string; + title: string; + phaseStartedAtMs: number | null; + hookStartedAtMs: number | null; + hookName: string | null; + lastOutputLine: string | null; + currentPhaseLabel: string | null; +} + +export interface RunGitActionWithToastInput { + action: GitStackedAction; + commitMessage?: string; + onConfirmed?: () => void; + skipDefaultBranchPrompt?: boolean; + statusOverride?: VcsStatusResult | null; + featureBranch?: boolean; + progressToastId?: GitActionToastId; + filePaths?: string[]; +} + +const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; + +type RefreshVcsStatus = (target: { + readonly environmentId: ScopedThreadRef["environmentId"]; + readonly input: { readonly cwd: string }; +}) => Promise; + +export function requestVcsStatusRefresh( + refresh: RefreshVcsStatus, + environmentId: ScopedThreadRef["environmentId"] | null, + cwd: string | null, +): void { + if (environmentId === null || cwd === null) { + return; + } + void refresh({ environmentId, input: { cwd } }); +} +const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const; + +function formatElapsedDescription(startedAtMs: number | null): string | undefined { + if (startedAtMs === null) { + return undefined; + } + const elapsedSeconds = Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000)); + if (elapsedSeconds < 60) { + return `Running for ${elapsedSeconds}s`; + } + const minutes = Math.floor(elapsedSeconds / 60); + const seconds = elapsedSeconds % 60; + return `Running for ${minutes}m ${seconds}s`; +} + +function resolveProgressDescription(progress: ActiveGitActionProgress): string | undefined { + if (progress.lastOutputLine) { + return progress.lastOutputLine; + } + return formatElapsedDescription(progress.hookStartedAtMs ?? progress.phaseStartedAtMs); +} + +export function getMenuActionDisabledReason({ + item, + gitStatus, + isBusy, + hasPrimaryRemote, +}: { + item: GitActionMenuItem; + gitStatus: VcsStatusResult | null; + isBusy: boolean; + hasPrimaryRemote: boolean; +}): string | null { + if (!item.disabled) return null; + if (isBusy) return "Git action in progress."; + if (!gitStatus) return "Git status is unavailable."; + + const hasBranch = gitStatus.refName !== null; + const hasChanges = gitStatus.hasWorkingTreeChanges; + const hasOpenPr = gitStatus.pr?.state === "open"; + const isAhead = gitStatus.aheadCount > 0; + const isBehind = gitStatus.behindCount > 0; + const terminology = getSourceControlPresentation(gitStatus.sourceControlProvider).terminology; + + if (item.id === "commit") { + if (!hasChanges) { + return "Worktree is clean. Make changes before committing."; + } + return "Commit is currently unavailable."; + } + + if (item.id === "push") { + if (!hasBranch) { + return "Detached HEAD: checkout a refName before pushing."; + } + if (hasChanges) { + return "Commit or stash local changes before pushing."; + } + if (isBehind) { + return "Branch is behind upstream. Pull/rebase before pushing."; + } + if (!gitStatus.hasUpstream && !hasPrimaryRemote) { + return 'Add an "origin" remote before pushing.'; + } + if (!isAhead) { + return "No local commits to push."; + } + return "Push is currently unavailable."; + } + + if (hasOpenPr) { + return `View ${terminology.singular} is currently unavailable.`; + } + if (!hasBranch) { + return `Detached HEAD: checkout a refName before creating a ${terminology.singular}.`; + } + if (hasChanges) { + return `Commit local changes before creating a ${terminology.singular}.`; + } + if (!gitStatus.hasUpstream && !hasPrimaryRemote) { + return `Add an "origin" remote before creating a ${terminology.singular}.`; + } + if (!isAhead) { + return `No local commits to include in a ${terminology.singular}.`; + } + if (isBehind) { + return `Branch is behind upstream. Pull/rebase before creating a ${terminology.singular}.`; + } + return `Create ${terminology.singular} is currently unavailable.`; +} + +export interface GitActionsInput { + readonly gitCwd: string | null; + readonly activeThreadRef: ScopedThreadRef | null; + readonly draftId?: DraftId | undefined; + readonly onOpenPullRequest?: ((number: number) => void) | undefined; + /** The quick action wants the publish-repository flow (a dialog the caller owns). */ + readonly onOpenPublish: () => void; +} + +/** + * The git control's brain: status for the checkout, the stacked-action + * runner with its progress and result toasts, the default-branch gate, the + * thread↔branch sync, and the menu / quick-action model. Shared by the HTML + * GitActionsControl and the Qt shell's git bridge so both run git the same + * way; dialog form state stays with whichever view shows the dialog. + */ +export function useGitActions({ + gitCwd, + activeThreadRef, + draftId, + onOpenPullRequest, + onOpenPublish, +}: GitActionsInput) { + const updateThreadMetadata = useAtomCommand( + threadEnvironment.updateMetadata, + "thread branch metadata update", + ); + const activeEnvironmentId = activeThreadRef?.environmentId ?? null; + const serverConfig = useAtomValue(serverEnvironment.configValueAtom(activeEnvironmentId)); + const openInPreferredEditor = useOpenInPreferredEditor( + activeEnvironmentId, + serverConfig?.availableEditors ?? [], + ); + const threadToastData = useMemo( + () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined), + [activeThreadRef], + ); + const openPrLink = useOpenPrLink(activeThreadRef ?? undefined); + const openLink = useOpenLink(activeThreadRef); + const activeDraftThread = useComposerDraftStore((store) => + draftId + ? store.getDraftSession(draftId) + : activeThreadRef + ? store.getDraftThreadByRef(activeThreadRef) + : null, + ); + const activeServerThread = useThread(activeThreadRef, { + waitForShell: activeDraftThread !== null, + }); + const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); + const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = + useState(null); + const activeGitActionProgressRef = useRef(null); + const sourceControlScope = useMemo( + () => ({ environmentId: activeEnvironmentId, cwd: gitCwd }), + [activeEnvironmentId, gitCwd], + ); + const latestGitAction = useRef<((input: RunGitActionWithToastInput) => Promise) | null>( + null, + ); + + const updateActiveProgressToast = useCallback(() => { + const progress = activeGitActionProgressRef.current; + if (!progress) { + return; + } + toastManager.update(progress.toastId, { + type: "loading", + title: progress.title, + description: resolveProgressDescription(progress), + timeout: 0, + data: progress.toastData, + }); + }, []); + + const persistThreadBranchSync = useCallback( + (branch: string | null, manualSelection = false) => { + if (!activeThreadRef) { + return; + } + + if (activeServerThread) { + if (activeServerThread.branch === branch) { + return; + } + + void updateThreadMetadata({ + environmentId: activeThreadRef.environmentId, + input: { + threadId: activeThreadRef.threadId, + ...resolveThreadBranchMetadataPatch(branch, activeServerThread.branch), + }, + }); + + return; + } + + if (!activeDraftThread || activeDraftThread.branch === branch) { + return; + } + + setDraftThreadContext(draftId ?? activeThreadRef, { + branch, + worktreePath: activeDraftThread.worktreePath, + environmentSelection: manualSelection + ? "manual" + : (activeDraftThread.environmentSelection ?? + (activeDraftThread.branch ? "manual" : "auto")), + }); + }, + [ + activeDraftThread, + activeServerThread, + activeThreadRef, + draftId, + setDraftThreadContext, + updateThreadMetadata, + ], + ); + + const syncThreadBranchAfterGitAction = useCallback( + (result: GitRunStackedActionResult) => { + const branchUpdate = resolveThreadBranchUpdate(result); + if (!branchUpdate) { + return; + } + + persistThreadBranchSync(branchUpdate.branch, true); + }, + [persistThreadBranchSync], + ); + + const gitStatusQuery = useEnvironmentQuery( + activeEnvironmentId !== null && gitCwd !== null + ? vcsEnvironment.status({ + environmentId: activeEnvironmentId, + input: { cwd: gitCwd }, + }) + : null, + ); + const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { + reportFailure: false, + }); + const { data: gitStatus, error: gitStatusError } = gitStatusQuery; + const sourceControlPresentation = useMemo( + () => getSourceControlPresentation(gitStatus?.sourceControlProvider), + [gitStatus?.sourceControlProvider], + ); + const changeRequestTerminology = sourceControlPresentation.terminology; + const SourceControlIcon = sourceControlPresentation.Icon; + // Default to true while loading so we don't flash init controls. + const isRepo = gitStatus?.isRepo ?? true; + const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; + const gitStatusForActions = gitStatus; + + const allFiles = gitStatusForActions?.workingTree.files ?? []; + + const initAction = useVcsInitAction(sourceControlScope); + const initRepository = useCallback(async () => { + const result = await initAction.run(); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Git initialization failed", + description: error instanceof Error ? error.message : "An error occurred.", + ...(threadToastData !== undefined ? { data: threadToastData } : {}), + }), + ); + }, [initAction, threadToastData]); + const runImmediateGitAction = useGitStackedAction(sourceControlScope); + const pullAction = useVcsPullAction(sourceControlScope); + const isGitActionRunning = useSourceControlActionRunning( + sourceControlScope, + RUNNING_SOURCE_CONTROL_ACTIONS, + ); + const isSelectingWorktreeBase = + !activeServerThread && + activeDraftThread?.envMode === "worktree" && + activeDraftThread.worktreePath === null; + + useEffect(() => { + if (isGitActionRunning || isSelectingWorktreeBase || activeServerThread) { + return; + } + + const branchUpdate = resolveLiveThreadBranchUpdate({ + threadBranch: activeDraftThread?.branch ?? null, + gitStatus: gitStatusForActions, + }); + if (!branchUpdate) { + return; + } + + persistThreadBranchSync(branchUpdate.branch); + }, [ + activeServerThread, + activeDraftThread?.branch, + gitStatusForActions, + isGitActionRunning, + isSelectingWorktreeBase, + persistThreadBranchSync, + ]); + + const isDefaultRef = useMemo(() => { + return gitStatusForActions?.isDefaultRef ?? false; + }, [gitStatusForActions?.isDefaultRef]); + + const gitActionMenuItems = useMemo( + () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasPrimaryRemote), + [gitStatusForActions, hasPrimaryRemote, isGitActionRunning], + ); + const quickAction = useMemo( + () => + resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultRef, hasPrimaryRemote), + [gitStatusForActions, hasPrimaryRemote, isDefaultRef, isGitActionRunning], + ); + const quickActionDisabledReason = quickAction.disabled + ? (quickAction.hint ?? "This action is currently unavailable.") + : null; + const pendingDefaultBranchActionCopy = pendingDefaultBranchAction + ? resolveDefaultBranchActionDialogCopy({ + action: pendingDefaultBranchAction.action, + branchName: pendingDefaultBranchAction.branchName, + includesCommit: pendingDefaultBranchAction.includesCommit, + terminology: changeRequestTerminology, + }) + : null; + + useEffect(() => { + const interval = window.setInterval(() => { + if (!activeGitActionProgressRef.current) { + return; + } + updateActiveProgressToast(); + }, 1000); + + return () => { + window.clearInterval(interval); + }; + }, [updateActiveProgressToast]); + + useEffect(() => { + if (gitCwd === null) { + return; + } + + let refreshTimeout: number | null = null; + const scheduleRefreshCurrentGitStatus = () => { + if (refreshTimeout !== null) { + window.clearTimeout(refreshTimeout); + } + refreshTimeout = window.setTimeout(() => { + refreshTimeout = null; + requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd); + }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS); + }; + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + scheduleRefreshCurrentGitStatus(); + } + }; + + window.addEventListener("focus", scheduleRefreshCurrentGitStatus); + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + if (refreshTimeout !== null) { + window.clearTimeout(refreshTimeout); + } + window.removeEventListener("focus", scheduleRefreshCurrentGitStatus); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [activeEnvironmentId, gitCwd, refreshVcsStatus]); + + const openExistingPr = useCallback(async () => { + const openPr = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr : null; + // Beside the thread where it was made, the way the browser opens beside it. Checked before + // the shell, which opening in the app does not need. + if (openPr && onOpenPullRequest) { + onOpenPullRequest(openPr.number); + return; + } + const prUrl = openPr?.url ?? null; + if (!prUrl) { + toastManager.add({ + type: "error", + title: "No open pull request found.", + data: threadToastData, + }); + return; + } + void openLink(prUrl).catch((err: unknown) => { + console.error(err); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open pull request link", + description: err instanceof Error ? err.message : "An error occurred.", + ...(threadToastData !== undefined ? { data: threadToastData } : {}), + }), + ); + }); + }, [gitStatusForActions, onOpenPullRequest, openLink, threadToastData]); + + const runGitActionWithToast = async ({ + action, + commitMessage, + onConfirmed, + skipDefaultBranchPrompt = false, + statusOverride, + featureBranch = false, + progressToastId, + filePaths, + }: RunGitActionWithToastInput) => { + const actionStatus = statusOverride ?? gitStatusForActions; + const actionBranch = actionStatus?.refName ?? null; + const actionIsDefaultBranch = featureBranch ? false : isDefaultRef; + const actionCanCommit = + action === "commit" || action === "commit_push" || action === "commit_push_pr"; + const includesCommit = + actionCanCommit && + (action === "commit" || !!actionStatus?.hasWorkingTreeChanges || featureBranch); + if ( + !skipDefaultBranchPrompt && + requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) && + actionBranch + ) { + if ( + action !== "push" && + action !== "create_pr" && + action !== "commit_push" && + action !== "commit_push_pr" + ) { + return; + } + setPendingDefaultBranchAction({ + action, + branchName: actionBranch, + includesCommit, + ...(commitMessage ? { commitMessage } : {}), + ...(onConfirmed ? { onConfirmed } : {}), + ...(filePaths ? { filePaths } : {}), + }); + return; + } + onConfirmed?.(); + + const progressStages = buildGitActionProgressStages({ + action, + hasCustomCommitMessage: !!commitMessage?.trim(), + hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges, + featureBranch, + terminology: changeRequestTerminology, + shouldPushBeforePr: + action === "create_pr" && + (!actionStatus?.hasUpstream || (actionStatus?.aheadCount ?? 0) > 0), + }); + const scopedToastData = threadToastData ? { ...threadToastData } : undefined; + const actionId = randomUUID(); + const resolvedProgressToastId = + progressToastId ?? + toastManager.add({ + type: "loading", + title: progressStages[0] ?? "Running git action...", + description: "Waiting for Git...", + timeout: 0, + data: scopedToastData, + }); + + activeGitActionProgressRef.current = { + toastId: resolvedProgressToastId, + toastData: scopedToastData, + actionId, + title: progressStages[0] ?? "Running git action...", + phaseStartedAtMs: null, + hookStartedAtMs: null, + hookName: null, + lastOutputLine: null, + currentPhaseLabel: progressStages[0] ?? "Running git action...", + }; + + if (progressToastId) { + toastManager.update(progressToastId, { + type: "loading", + title: progressStages[0] ?? "Running git action...", + description: "Waiting for Git...", + timeout: 0, + data: scopedToastData, + }); + } + + const applyProgressEvent = (event: GitActionProgressEvent) => { + const progress = activeGitActionProgressRef.current; + if (!progress) { + return; + } + if (gitCwd && event.cwd !== gitCwd) { + return; + } + if (progress.actionId !== event.actionId) { + return; + } + + const now = Date.now(); + switch (event.kind) { + case "action_started": + progress.phaseStartedAtMs = now; + progress.hookStartedAtMs = null; + progress.hookName = null; + progress.lastOutputLine = null; + break; + case "phase_started": + progress.title = event.label; + progress.currentPhaseLabel = event.label; + progress.phaseStartedAtMs = now; + progress.hookStartedAtMs = null; + progress.hookName = null; + progress.lastOutputLine = null; + break; + case "hook_started": + progress.title = `Running ${event.hookName}...`; + progress.hookName = event.hookName; + progress.hookStartedAtMs = now; + progress.lastOutputLine = null; + break; + case "hook_output": + progress.lastOutputLine = event.text; + break; + case "hook_finished": + progress.title = progress.currentPhaseLabel ?? "Committing..."; + progress.hookName = null; + progress.hookStartedAtMs = null; + progress.lastOutputLine = null; + break; + case "action_finished": + // Let the resolved mutation update the toast so we keep the + // elapsed description visible until the final success state renders. + return; + case "action_failed": + // Let the settled mutation publish the error toast to avoid a + // transient intermediate state before the final failure message. + return; + } + + updateActiveProgressToast(); + }; + + const result = await runImmediateGitAction.run({ + actionId, + action, + ...(commitMessage ? { commitMessage } : {}), + ...(featureBranch ? { featureBranch } : {}), + ...(filePaths ? { filePaths } : {}), + onProgress: applyProgressEvent, + }); + + activeGitActionProgressRef.current = null; + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { + toastManager.close(resolvedProgressToastId); + return; + } + + const error = squashAtomCommandFailure(result); + toastManager.update( + resolvedProgressToastId, + stackedThreadToast({ + type: "error", + title: "Action failed", + description: error instanceof Error ? error.message : "An error occurred.", + ...(scopedToastData !== undefined ? { data: scopedToastData } : {}), + }), + ); + return; + } + + const actionResult = result.value; + syncThreadBranchAfterGitAction(actionResult); + const closeResultToast = () => { + toastManager.close(resolvedProgressToastId); + }; + + const toastCta = actionResult.toast.cta; + let toastActionProps: { + children: string; + onClick: (event: MouseEvent) => void; + } | null = null; + if (toastCta.kind === "run_action") { + toastActionProps = { + children: toastCta.label, + onClick: () => { + closeResultToast(); + void latestGitAction.current?.({ + action: toastCta.action.kind, + }); + }, + }; + } else if (toastCta.kind === "open_pr") { + toastActionProps = { + children: toastCta.label, + onClick: (event) => { + closeResultToast(); + openPrLink(event, toastCta.url); + }, + }; + } + + const successToastData = { + ...scopedToastData, + dismissAfterVisibleMs: 10_000, + }; + + if (toastActionProps) { + toastManager.update( + resolvedProgressToastId, + stackedThreadToast({ + type: "success", + title: actionResult.toast.title, + description: actionResult.toast.description, + timeout: 0, + actionProps: toastActionProps, + data: successToastData, + }), + ); + } else { + toastManager.update(resolvedProgressToastId, { + type: "success", + title: actionResult.toast.title, + description: actionResult.toast.description, + timeout: 0, + data: successToastData, + }); + } + }; + + useLayoutEffect(() => { + latestGitAction.current = runGitActionWithToast; + return () => { + latestGitAction.current = null; + }; + }); + + const continuePendingDefaultBranchAction = () => { + if (!pendingDefaultBranchAction) return; + const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction; + setPendingDefaultBranchAction(null); + void runGitActionWithToast({ + action, + ...(commitMessage ? { commitMessage } : {}), + ...(onConfirmed ? { onConfirmed } : {}), + ...(filePaths ? { filePaths } : {}), + skipDefaultBranchPrompt: true, + }); + }; + + const checkoutFeatureBranchAndContinuePendingAction = () => { + if (!pendingDefaultBranchAction) return; + const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction; + setPendingDefaultBranchAction(null); + void runGitActionWithToast({ + action, + ...(commitMessage ? { commitMessage } : {}), + ...(onConfirmed ? { onConfirmed } : {}), + ...(filePaths ? { filePaths } : {}), + featureBranch: true, + skipDefaultBranchPrompt: true, + }); + }; + + const runQuickAction = () => { + if (quickAction.kind === "open_pr") { + void openExistingPr(); + return; + } + if (quickAction.kind === "open_publish") { + onOpenPublish(); + return; + } + if (quickAction.kind === "run_pull") { + const toastId = toastManager.add({ + type: "loading", + title: "Pulling...", + timeout: 0, + data: threadToastData, + }); + void (async () => { + const result = await pullAction.run(); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { + toastManager.close(toastId); + return; + } + const error = squashAtomCommandFailure(result); + toastManager.update( + toastId, + stackedThreadToast({ + type: "error", + title: "Pull failed", + description: error instanceof Error ? error.message : "An error occurred.", + ...(threadToastData !== undefined ? { data: threadToastData } : {}), + }), + ); + return; + } + + const pullResult = result.value; + toastManager.update(toastId, { + type: "success", + title: pullResult.status === "pulled" ? "Pulled" : "Already up to date", + description: + pullResult.status === "pulled" + ? `Updated ${pullResult.refName} from ${pullResult.upstreamRef ?? "upstream"}` + : `${pullResult.refName} is already synchronized.`, + data: threadToastData, + }); + })(); + return; + } + if (quickAction.kind === "show_hint") { + toastManager.add({ + type: "info", + title: quickAction.label, + description: quickAction.hint, + data: threadToastData, + }); + return; + } + if (quickAction.action) { + void runGitActionWithToast({ action: quickAction.action }); + } + }; + + const runMenuItemAction = (item: GitActionMenuItem): "commit" | null => { + if (item.disabled) return null; + if (item.kind === "open_pr") { + void openExistingPr(); + return null; + } + if (item.dialogAction === "push") { + void runGitActionWithToast({ action: "push" }); + return null; + } + if (item.dialogAction === "create_pr") { + void runGitActionWithToast({ action: "create_pr" }); + return null; + } + return "commit"; + }; + + const dismissPendingDefaultBranchAction = useCallback(() => { + setPendingDefaultBranchAction(null); + }, []); + + return { + activeEnvironmentId, + threadToastData, + openInPreferredEditor, + gitStatusForActions, + gitStatusError, + refreshVcsStatus, + sourceControlPresentation, + changeRequestTerminology, + SourceControlIcon, + isRepo, + hasPrimaryRemote, + isDefaultRef, + allFiles, + initAction, + initRepository, + pullAction, + isGitActionRunning, + gitActionMenuItems, + quickAction, + quickActionDisabledReason, + pendingDefaultBranchAction, + pendingDefaultBranchActionCopy, + dismissPendingDefaultBranchAction, + continuePendingDefaultBranchAction, + checkoutFeatureBranchAndContinuePendingAction, + runGitActionWithToast, + openExistingPr, + runQuickAction, + /** Runs the item, or returns "commit" when it needs the caller's commit dialog. */ + runMenuItemAction, + }; +} diff --git a/apps/web/src/hooks/useRenameThread.ts b/apps/web/src/hooks/useRenameThread.ts new file mode 100644 index 000000000000..732ddd7b8047 --- /dev/null +++ b/apps/web/src/hooks/useRenameThread.ts @@ -0,0 +1,48 @@ +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { useCallback } from "react"; + +import { resolveRenameCommit } from "../components/chat/ChatHeader.logic"; +import { toastManager } from "../components/ui/toast"; +import { threadEnvironment } from "../state/threads"; +import { useAtomCommand } from "../state/use-atom-command"; + +/** Commits a new thread title with the header's validation and error toasts. */ +export function useRenameThread(input: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly currentTitle: string; +}) { + const { environmentId, threadId, currentTitle } = input; + // Failures toast below with the rename wording, so the generic report is off. + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + return useCallback( + (title: string) => { + const resolution = resolveRenameCommit({ title, originalTitle: currentTitle }); + if (resolution.action === "reject-empty") { + toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); + return; + } + if (resolution.action === "noop") return; + void updateThreadMetadata({ + environmentId, + input: { threadId, title: resolution.title }, + }).then((result) => { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Failed to rename thread", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + }); + }, + [currentTitle, environmentId, threadId, updateThreadMetadata], + ); +} diff --git a/apps/web/src/hooks/useSidebarProjectGroups.ts b/apps/web/src/hooks/useSidebarProjectGroups.ts new file mode 100644 index 000000000000..5fbf53eb1944 --- /dev/null +++ b/apps/web/src/hooks/useSidebarProjectGroups.ts @@ -0,0 +1,71 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { useMemo } from "react"; + +import { + orderItemsByPreferredIds, + sortLogicalProjectsForSidebar, +} from "../components/Sidebar.logic"; +import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; +import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; +import { useProjects } from "../state/entities"; +import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; +import { useClientSettings } from "./useSettings"; + +/** + * The sidebar's logical project groups (grouped across environments, ordered + * per the user's sort preference). Shared by the HTML sidebar and the shell + * projection so both agree on project keys and display names. + */ +export function useSidebarProjectGroups(threads: ReadonlyArray) { + const projects = useProjects(); + const projectOrder = useUiStateStore((store) => store.projectOrder); + const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + + const environmentLabelById = useMemo( + () => + new Map( + environments.map((environment) => [environment.environmentId, environment.label] as const), + ), + [environments], + ); + const orderedProjects = useMemo( + () => + orderItemsByPreferredIds({ + items: projects, + preferredIds: projectOrder, + getId: getProjectOrderKey, + getPreferenceIds: (project) => [ + getProjectOrderKey(project), + legacyProjectCwdPreferenceKey(project.workspaceRoot), + ], + }), + [projectOrder, projects], + ); + const unsortedProjectGroups = useMemo( + () => + buildSidebarProjectSnapshots({ + projects: sidebarProjectSortOrder === "manual" ? orderedProjects : projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, + }), + [ + environmentLabelById, + orderedProjects, + primaryEnvironmentId, + projectGroupingSettings, + projects, + sidebarProjectSortOrder, + ], + ); + const projectGroups = useMemo( + () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder), + [sidebarProjectSortOrder, threads, unsortedProjectGroups], + ); + + return { environmentLabelById, orderedProjects, unsortedProjectGroups, projectGroups }; +} diff --git a/apps/web/src/hooks/useSidebarToggleKeybinding.ts b/apps/web/src/hooks/useSidebarToggleKeybinding.ts new file mode 100644 index 000000000000..d5cf6d90d361 --- /dev/null +++ b/apps/web/src/hooks/useSidebarToggleKeybinding.ts @@ -0,0 +1,33 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useEffect } from "react"; + +import { useSidebar } from "../components/ui/sidebar"; +import { resolveShortcutCommand } from "../keybindings"; +import { primaryServerKeybindingsAtom } from "../state/server"; + +/** Toggles the main sidebar on the `sidebar.toggle` keybinding (Mod+B by default). */ +export function useSidebarToggleKeybinding(): void { + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const { toggleSidebar } = useSidebar(); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + if ( + event.target instanceof HTMLElement && + event.target.closest("[data-keybinding-capture]") + ) { + return; + } + if (resolveShortcutCommand(event, keybindings) !== "sidebar.toggle") return; + + event.preventDefault(); + event.stopPropagation(); + toggleSidebar(); + }; + + // Capture before focused editors consume commands such as Mod+B for rich-text formatting. + window.addEventListener("keydown", onKeyDown, true); + return () => window.removeEventListener("keydown", onKeyDown, true); + }, [keybindings, toggleSidebar]); +} diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 4eaa46286d8e..f2c354383e42 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -2,6 +2,10 @@ import type { DesktopBridge } from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import * as Schema from "effect/Schema"; import { useCallback, useEffect, useSyncExternalStore } from "react"; +import { + applyDocumentThemeOverride, + subscribeToDocumentThemeOverride, +} from "../documentThemeOverride"; import { applyThemePalette, CUSTOM_THEMES_STORAGE_KEY, @@ -139,6 +143,11 @@ let lastDesktopTheme: "light" | "dark" | "system" | null = null; let lastAppliedTheme: Omit | null = null; let themeStorageReadFailure: ThemeStorageError | null = null; +// A removed host override must not leave an unchanged stored selection cached out. +subscribeToDocumentThemeOverride(() => { + lastAppliedTheme = null; +}); + function emitChange() { snapshotStale = true; for (const listener of listeners) listener(); @@ -353,6 +362,7 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview ); applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance); document.documentElement.classList.toggle("dark", resolvedAppearance === "dark"); + applyDocumentThemeOverride(); lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves }; syncBrowserChromeTheme(); syncDesktopTheme(theme, followSystem, appearanceMode); diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index a66ea21b9891..b67c38cb901b 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -119,7 +119,7 @@ export function useThreadActionMenu(input: { }); const openMenu = useCallback( - (position: { x: number; y: number }) => { + (position: { x: number; y: number; surface?: "shell" }) => { if (threadRef === null) return; void (async () => { const api = readLocalApi(); diff --git a/apps/web/src/hooks/useThreadBranchSelection.ts b/apps/web/src/hooks/useThreadBranchSelection.ts new file mode 100644 index 000000000000..4a364064d6de --- /dev/null +++ b/apps/web/src/hooks/useThreadBranchSelection.ts @@ -0,0 +1,375 @@ +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId, ThreadId, VcsRef } from "@t3tools/contracts"; +import { + useCallback, + useDeferredValue, + useEffect, + useMemo, + useOptimistic, + useTransition, +} from "react"; + +import { + deriveLocalBranchNameFromRemoteRef, + resolveBranchSelectionTarget, + resolveBranchToolbarValue, + resolveDraftEnvModeAfterBranchChange, + resolveEffectiveEnvMode, + sanitizeNewRefName, +} from "../components/BranchToolbar.logic"; +import { stackedThreadToast, toastManager } from "../components/ui/toast"; +import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; +import { useProject, useThread } from "../state/entities"; +import { usePaginatedBranches } from "../state/queries"; +import { useEnvironmentQuery } from "../state/query"; +import { threadEnvironment } from "../state/threads"; +import { useAtomCommand } from "../state/use-atom-command"; +import { vcsEnvironment } from "../state/vcs"; + +export interface ThreadBranchSelectionInput { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly draftId?: DraftId | undefined; + readonly envLocked: boolean; + readonly effectiveEnvModeOverride?: "local" | "worktree" | undefined; + readonly activeThreadBranchOverride?: string | null | undefined; + readonly onActiveThreadBranchOverrideChange?: ((refName: string | null) => void) | undefined; + /** Search text for the ref list; the caller owns the input. */ + readonly branchQuery: string; +} + +function toBranchActionErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "An error occurred."; +} + +/** + * The branch selector's brain: which thread (server or draft) is active, its + * checkout, the paginated ref list for a query, the optimistic active + * branch, and the switch/create flows that stop the session and rewrite the + * thread's checkout. Shared by the HTML branch selector and the Qt shell's + * workspace bridge so both switch branches the same way. + */ +export function useThreadBranchSelection(input: ThreadBranchSelectionInput) { + const { + environmentId, + threadId, + draftId, + envLocked, + effectiveEnvModeOverride, + activeThreadBranchOverride, + onActiveThreadBranchOverrideChange, + branchQuery, + } = input; + const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); + const updateThreadMetadata = useAtomCommand( + threadEnvironment.updateMetadata, + "thread metadata update", + ); + const switchRef = useAtomCommand(vcsEnvironment.switchRef, { + reportFailure: false, + }); + const createRefMutation = useAtomCommand(vcsEnvironment.createRef, { + reportFailure: false, + }); + const threadRef = useMemo( + () => scopeThreadRef(environmentId, threadId), + [environmentId, threadId], + ); + const draftThread = useComposerDraftStore((store) => + draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), + ); + const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); + const serverSession = serverThread?.session ?? null; + const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); + + const activeProjectRef = serverThread + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) + : draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; + const activeProject = useProject(activeProjectRef); + + const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); + const activeThreadBranch = + activeThreadBranchOverride !== undefined + ? activeThreadBranchOverride + : (serverThread?.branch ?? draftThread?.branch ?? null); + const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const activeProjectCwd = activeProject?.workspaceRoot ?? null; + const branchCwd = activeWorktreePath ?? activeProjectCwd; + const hasServerThread = serverThread !== null; + const effectiveEnvMode = + effectiveEnvModeOverride ?? + resolveEffectiveEnvMode({ + activeWorktreePath, + hasServerThread, + draftThreadEnvMode: draftThread?.envMode, + }); + + const setThreadBranch = useCallback( + (branch: string | null, worktreePath: string | null, automatic = false) => { + if (!activeThreadId || !activeProject) return; + if (serverSession && worktreePath !== activeWorktreePath) { + void stopThreadSession({ + environmentId, + input: { threadId: activeThreadId }, + }); + } + if (hasServerThread) { + void updateThreadMetadata({ + environmentId, + input: { + threadId: activeThreadId, + branch, + worktreePath, + }, + }); + } + if (hasServerThread) { + onActiveThreadBranchOverrideChange?.(branch); + return; + } + const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({ + nextWorktreePath: worktreePath, + currentWorktreePath: activeWorktreePath, + effectiveEnvMode, + }); + setDraftThreadContext(draftId ?? threadRef, { + branch, + worktreePath, + envMode: nextDraftEnvMode, + environmentSelection: automatic ? (draftThread?.environmentSelection ?? "auto") : "manual", + projectRef: scopeProjectRef(environmentId, activeProject.id), + }); + }, + [ + activeThreadId, + activeProject, + serverSession, + activeWorktreePath, + hasServerThread, + onActiveThreadBranchOverrideChange, + setDraftThreadContext, + draftId, + threadRef, + environmentId, + effectiveEnvMode, + draftThread?.environmentSelection, + stopThreadSession, + updateThreadMetadata, + ], + ); + + const deferredBranchQuery = useDeferredValue(branchQuery); + const branchStatusQuery = useEnvironmentQuery( + branchCwd === null + ? null + : vcsEnvironment.status({ + environmentId, + input: { cwd: branchCwd }, + }), + ); + const trimmedBranchQuery = branchQuery.trim(); + const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); + const branchRefQuery = sanitizeNewRefName(deferredTrimmedBranchQuery); + const branchRefTarget = useMemo( + () => ({ + environmentId, + cwd: branchCwd, + query: branchRefQuery, + }), + [branchCwd, branchRefQuery, environmentId], + ); + const branchRefState = usePaginatedBranches(branchRefTarget); + const refs = branchRefState.refs; + const hasNextPage = + branchRefState.data?.nextCursor !== null && branchRefState.data?.nextCursor !== undefined; + const isFetchingNextPage = branchRefState.isFetchingNextPage; + const isInitialBranchesLoadPending = branchRefState.isPending && branchRefState.data === null; + const currentGitBranch = + branchStatusQuery.data?.refName ?? refs.find((refName) => refName.current)?.name ?? null; + const canonicalActiveBranch = resolveBranchToolbarValue({ + envMode: effectiveEnvMode, + activeWorktreePath, + activeThreadBranch, + currentGitBranch, + }); + const branchNames = useMemo(() => refs.map((refName) => refName.name), [refs]); + const branchByName = useMemo( + () => new Map(refs.map((refName) => [refName.name, refName] as const)), + [refs], + ); + const isSelectingWorktreeBase = + effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath; + const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic( + canonicalActiveBranch, + (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch, + ); + const [isBranchActionPending, startBranchActionTransition] = useTransition(); + + const runBranchAction = (action: () => Promise) => { + startBranchActionTransition(async () => { + await action(); + branchRefState.refresh(); + branchStatusQuery.refresh(); + }); + }; + + /** Returns false when the selection was ignored (nothing to check out, or busy). */ + const selectBranch = (refName: VcsRef): boolean => { + if (!branchCwd || !activeProjectCwd || isBranchActionPending) return false; + + if (isSelectingWorktreeBase) { + setThreadBranch(refName.name, null); + return true; + } + + const selectionTarget = resolveBranchSelectionTarget({ + activeProjectCwd, + activeWorktreePath, + refName, + }); + + if (selectionTarget.reuseExistingWorktree) { + setThreadBranch(refName.name, selectionTarget.nextWorktreePath); + return true; + } + + const selectedBranchName = refName.isRemote + ? deriveLocalBranchNameFromRemoteRef(refName.name) + : refName.name; + + runBranchAction(async () => { + const previousBranch = resolvedActiveBranch; + setOptimisticBranch(selectedBranchName); + const checkoutResult = await switchRef({ + environmentId, + input: { + cwd: selectionTarget.checkoutCwd, + refName: refName.name, + }, + }); + if (checkoutResult._tag === "Success") { + const nextBranchName = refName.isRemote + ? (checkoutResult.value.refName ?? selectedBranchName) + : selectedBranchName; + setOptimisticBranch(nextBranchName); + setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); + return; + } + setOptimisticBranch(previousBranch); + if (!isAtomCommandInterrupted(checkoutResult)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to switch ref.", + description: toBranchActionErrorMessage(squashAtomCommandFailure(checkoutResult)), + }), + ); + } + }); + return true; + }; + + /** Returns false when the name was empty/unsafe, or the selector is busy. */ + const createRef = (rawName: string): boolean => { + const name = sanitizeNewRefName(rawName); + if (!branchCwd || !name || isBranchActionPending) return false; + + runBranchAction(async () => { + const previousBranch = resolvedActiveBranch; + setOptimisticBranch(name); + const createBranchResult = await createRefMutation({ + environmentId, + input: { + cwd: branchCwd, + refName: name, + switchRef: true, + }, + }); + if (createBranchResult._tag === "Success") { + setOptimisticBranch(createBranchResult.value.refName); + setThreadBranch(createBranchResult.value.refName, activeWorktreePath); + return; + } + setOptimisticBranch(previousBranch); + if (!isAtomCommandInterrupted(createBranchResult)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to create and switch ref.", + description: toBranchActionErrorMessage(squashAtomCommandFailure(createBranchResult)), + }), + ); + } + }); + return true; + }; + + const defaultBranchName = useMemo( + () => refs.find((refName) => refName.isDefault)?.name ?? null, + [refs], + ); + const worktreeBaseBranchCandidate = isInitialBranchesLoadPending + ? null + : (defaultBranchName ?? currentGitBranch); + + // A worktree thread without a branch yet seeds from the default branch (or + // the current one) so the worktree has a base to be created from. + useEffect(() => { + if ( + effectiveEnvMode !== "worktree" || + activeWorktreePath || + activeThreadBranch || + !worktreeBaseBranchCandidate + ) { + return; + } + setThreadBranch(worktreeBaseBranchCandidate, null, true); + }, [ + activeThreadBranch, + activeWorktreePath, + effectiveEnvMode, + setThreadBranch, + worktreeBaseBranchCandidate, + ]); + + return { + threadRef, + draftThread, + serverThread, + serverSession, + activeProject, + activeProjectCwd, + activeThreadId, + activeThreadBranch, + activeWorktreePath, + branchCwd, + hasServerThread, + effectiveEnvMode, + isSelectingWorktreeBase, + branchStatusQuery, + trimmedBranchQuery, + deferredTrimmedBranchQuery, + branchRefState, + refs, + hasNextPage, + isFetchingNextPage, + isInitialBranchesLoadPending, + currentGitBranch, + canonicalActiveBranch, + resolvedActiveBranch, + setOptimisticBranch, + branchNames, + branchByName, + isBranchActionPending, + setThreadBranch, + selectBranch, + createRef, + defaultBranchName, + }; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index a0d7b2c1684a..b37d6268cd1a 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -813,6 +813,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil outline: none; } + /* Hosted by the Qt shell with nothing left to show, the composer frame + collapses so only the branch strip below it remains. */ + [data-chat-composer-shell-empty="true"] { + display: none; + } .settings-slider { --settings-slider-progress: 0%; --settings-slider-fill-offset: 0.5rem; @@ -1573,6 +1578,20 @@ body { background-size: var(--surface-grain-size); } +/* Hosted by the Qt shell inside a rounded card (WebSurface.radius): the shell + paints the card and passes its inner radius, the document stays transparent + (index.html), and the app clips itself to the curve. Backdrop and grain move + to #root, the box the clip covers. */ +html[data-shell-surface-radius] #root { + contain: paint; + overflow: hidden; + border-radius: var(--app-shell-surface-radius); + background-color: var(--app-chrome-background); + background-image: var(--surface-grain); + background-repeat: repeat; + background-size: var(--surface-grain-size); +} + pre, code { font-family: var(--font-mono); diff --git a/apps/web/src/lib/terminalContext.ts b/apps/web/src/lib/terminalContext.ts index 68d85f08d3b1..5d61dbd975fb 100644 --- a/apps/web/src/lib/terminalContext.ts +++ b/apps/web/src/lib/terminalContext.ts @@ -65,7 +65,7 @@ export function filterTerminalContextsWithText( return contexts.filter((context) => hasTerminalContextText(context)); } -function normalizeTerminalContextSelection( +export function normalizeTerminalContextSelection( selection: TerminalContextSelection, ): TerminalContextSelection | null { const text = normalizeTerminalContextText(selection.text); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index cafd04f9c048..90dfe0f63a25 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -2,6 +2,7 @@ import type { ConfirmDialogOptions, ContextMenuItem, LocalApi } from "@t3tools/c import { requestConfirmDialog } from "./confirmDialog"; import { dismissContextMenu, showContextMenuFallback } from "./contextMenuFallback"; +import { isT3Shell } from "./env"; import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientPersistenceStorage"; let cachedApi: LocalApi | undefined; @@ -44,17 +45,25 @@ function createBrowserLocalApi(): LocalApi { contextMenu: { show: async ( items: readonly ContextMenuItem[], - position?: { x: number; y: number }, + position?: { x: number; y: number; surface?: "shell" }, ): Promise => { if (window.desktopBridge) { return window.desktopBridge.showContextMenu(items, position) as Promise; } + if (isT3Shell) { + const { showShellContextMenu } = await import("./shell/bridges"); + return showShellContextMenu(items, position); + } return showContextMenuFallback(items, position); }, // A native desktop menu blocks keyboard input and closes on outside // interaction, so nothing to do there; the DOM fallback needs an explicit // dismiss when the state behind it goes away. close: async () => { + if (isT3Shell) { + const { closeShellContextMenu } = await import("./shell/bridges"); + closeShellContextMenu(); + } if (!window.desktopBridge) { dismissContextMenu(); } diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 8cafbd5009b4..97515c06a0a4 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -13,6 +13,8 @@ import { } from "./lib/windowControlsOverlay"; import { AppRoot } from "./AppRoot"; import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard"; +import { isT3Shell } from "./env"; +import { enableShellDocumentSync } from "./shell/shellDocumentSync"; // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); @@ -24,6 +26,10 @@ if (isElectron) { syncDocumentWindowControlsOverlayClass(); } +if (isT3Shell) { + enableShellDocumentSync(); +} + const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; // A failed split-chunk fetch usually means the hashed assets went stale under diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index acf673b042bd..10d425be97ec 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -68,7 +68,7 @@ export type RightPanelSurface = } | { id: "agents"; kind: "agents" }; -const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; +export const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; // v9 removed the "plan" surface kind (plans render inline in the transcript). // v10 keys pull-request surfaces by reference instead of a singleton tab. // v11 stops persisting the pull-request list's shared panel, so a restart opens the page fresh. diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index b1a9d0b9e04b..aed46502cac0 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -29,6 +29,7 @@ import { Route as SettingsAppearanceRouteImport } from './routes/settings.appear import { Route as ProjectsProjectKeyRouteImport } from './routes/projects.$projectKey' import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-requests' +import { Route as EmbedEnvironmentIdThreadIdRouteImport } from './routes/embed.$environmentId.$threadId' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' @@ -131,6 +132,12 @@ const ChatPullRequestsRoute = ChatPullRequestsRouteImport.update({ path: '/pull-requests', getParentRoute: () => ChatRoute, } as any) +const EmbedEnvironmentIdThreadIdRoute = + EmbedEnvironmentIdThreadIdRouteImport.update({ + id: '/embed/$environmentId/$threadId', + path: '/embed/$environmentId/$threadId', + getParentRoute: () => rootRouteImport, + } as any) const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ id: '/draft/$draftId', path: '/draft/$draftId', @@ -165,6 +172,7 @@ export interface FileRoutesByFullPath { '/settings/source-control': typeof SettingsSourceControlRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute + '/embed/$environmentId/$threadId': typeof EmbedEnvironmentIdThreadIdRoute } export interface FileRoutesByTo { '/connect': typeof ConnectRoute @@ -188,6 +196,7 @@ export interface FileRoutesByTo { '/': typeof ChatIndexRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute + '/embed/$environmentId/$threadId': typeof EmbedEnvironmentIdThreadIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -213,6 +222,7 @@ export interface FileRoutesById { '/_chat/': typeof ChatIndexRoute '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute + '/embed/$environmentId/$threadId': typeof EmbedEnvironmentIdThreadIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -238,6 +248,7 @@ export interface FileRouteTypes { | '/settings/source-control' | '/$environmentId/$threadId' | '/draft/$draftId' + | '/embed/$environmentId/$threadId' fileRoutesByTo: FileRoutesByTo to: | '/connect' @@ -261,6 +272,7 @@ export interface FileRouteTypes { | '/' | '/$environmentId/$threadId' | '/draft/$draftId' + | '/embed/$environmentId/$threadId' id: | '__root__' | '/_chat' @@ -285,6 +297,7 @@ export interface FileRouteTypes { | '/_chat/' | '/_chat/$environmentId/$threadId' | '/_chat/draft/$draftId' + | '/embed/$environmentId/$threadId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -296,6 +309,7 @@ export interface RootRouteChildren { WelcomeRoute: typeof WelcomeRoute ConnectCallbackRoute: typeof ConnectCallbackRoute ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute + EmbedEnvironmentIdThreadIdRoute: typeof EmbedEnvironmentIdThreadIdRoute } declare module '@tanstack/react-router' { @@ -440,6 +454,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatPullRequestsRouteImport parentRoute: typeof ChatRoute } + '/embed/$environmentId/$threadId': { + id: '/embed/$environmentId/$threadId' + path: '/embed/$environmentId/$threadId' + fullPath: '/embed/$environmentId/$threadId' + preLoaderRoute: typeof EmbedEnvironmentIdThreadIdRouteImport + parentRoute: typeof rootRouteImport + } '/_chat/draft/$draftId': { id: '/_chat/draft/$draftId' path: '/draft/$draftId' @@ -512,6 +533,7 @@ const rootRouteChildren: RootRouteChildren = { WelcomeRoute: WelcomeRoute, ConnectCallbackRoute: ConnectCallbackRoute, ProjectsProjectKeyRoute: ProjectsProjectKeyRoute, + EmbedEnvironmentIdThreadIdRoute: EmbedEnvironmentIdThreadIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index f42a49304fd7..cfae9915191e 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -35,10 +35,17 @@ import { toastManager, } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; +import { isT3Shell, isT3ShellEmbed } from "../env"; import { applyAppearanceFontVariables } from "~/appearanceFonts"; import { applyAppearanceContrast } from "~/appearanceContrast"; import { useClientSettings } from "../hooks/useSettings"; import { PlanAgentSelectionHeal } from "../planAgentSelectionHeal"; +import { + ShellSettingsBridge, + ShellThemeBridge, + ShellToastBridge, + T3ShellBridge, +} from "../shell/lazy"; import { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKeyFromPath, @@ -154,6 +161,25 @@ function RootRouteView() { ); } + // The shell's embed documents skip the app chrome but keep the providers + // the panel content relies on (toasts, confirms, appearance sync, events). + if (isT3ShellEmbed && pathname.startsWith("/embed/")) { + return ( + + + + + + + + + {primaryEnvironmentAuthenticated ? : null} + + + + ); + } + const appShell = ( @@ -167,7 +193,7 @@ function RootRouteView() { // decision is known, so a fresh install renders nothing (not the shell, // not a flash of threads) before landing on the welcome wizard. return ( - + : undefined}> @@ -190,6 +216,9 @@ function RootRouteView() { ) : null} {primaryEnvironmentAuthenticated ? : null} + {isT3Shell ? : null} + {isT3Shell ? : null} + {isT3Shell ? : null} {primaryEnvironmentAuthenticated ? : null} {appShell} {/* Above the router: a theme draft is judged by walking the app, so the diff --git a/apps/web/src/routes/embed.$environmentId.$threadId.tsx b/apps/web/src/routes/embed.$environmentId.$threadId.tsx new file mode 100644 index 000000000000..d349cfb4f27e --- /dev/null +++ b/apps/web/src/routes/embed.$environmentId.$threadId.tsx @@ -0,0 +1,76 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import ChatView from "../components/ChatView"; +import { ThreadTerminalDocument } from "../components/ThreadTerminalDocument"; +import { useComposerDraftStore } from "../composerDraftStore"; +import { ShellEmbedRouteBridge } from "../shell/lazy"; +import { useThreadDetail, useThreadShell, useThreadStatus } from "../state/entities"; +import { useEnvironmentQuery } from "../state/query"; +import { environmentShell } from "../state/shell"; +import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes"; + +/** Which part of the thread this document shows. */ +export type EmbedSurface = "panel" | "terminal"; + +/** + * One part of the thread on its own, for a web view the Qt shell places + * itself: the right panel's content, or with `?surface=terminal` the terminal + * drawer. Same session (cookies), its own WebSocket; the panel tabs and the + * drawer's state converge with the primary document through localStorage + * (see shell/shellDocumentSync.ts). + */ +function EmbedThreadPanelRouteView() { + const threadRef = Route.useParams({ + select: (params) => resolveThreadRouteRef(params), + }); + const { surface } = Route.useSearch(); + + const shell = useEnvironmentQuery( + threadRef === null ? null : environmentShell.stateAtom(threadRef.environmentId), + ); + const serverThreadShell = useThreadShell(threadRef); + const serverThreadDetail = useThreadDetail(threadRef); + const serverThreadStatus = useThreadStatus(threadRef); + // Drafts persist to localStorage, so a draft the primary document is + // showing is visible here too (its own store copy, same key). + const draftThreadExists = useComposerDraftStore((store) => + threadRef ? store.getDraftThreadByRef(threadRef) !== null : false, + ); + const bootstrapComplete = shell.data?.snapshot._tag === "Some"; + const renderState = resolveThreadRouteRenderState({ + bootstrapComplete, + serverThreadShellExists: serverThreadShell !== null, + serverThreadDetailExists: serverThreadDetail !== null, + serverThreadDetailDeleted: serverThreadStatus === "deleted", + draftThreadExists, + }); + + if (!threadRef) { + return null; + } + return ( +
+ {/* Follows the primary view's thread instead of the shell reloading this document. */} + + {renderState === "ready" || (renderState === "loading" && serverThreadShell !== null) ? ( + surface === "terminal" ? ( + + ) : ( + + ) + ) : null} +
+ ); +} + +export const Route = createFileRoute("/embed/$environmentId/$threadId")({ + validateSearch: (raw: Record): { surface: EmbedSurface } => ({ + surface: raw.surface === "terminal" ? "terminal" : "panel", + }), + component: EmbedThreadPanelRouteView, +}); diff --git a/apps/web/src/shell/ShellComposerBridge.test.tsx b/apps/web/src/shell/ShellComposerBridge.test.tsx new file mode 100644 index 000000000000..e398cac745b4 --- /dev/null +++ b/apps/web/src/shell/ShellComposerBridge.test.tsx @@ -0,0 +1,175 @@ +import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import { ShellComposerState, type T3Shell } from "@t3tools/contracts/shell"; +import * as Schema from "effect/Schema"; +import { act, useEffect, useRef, useState } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { DraftId } from "../composerDraftStore"; +import { ShellComposerBridge, type ShellComposerBridgeProps } from "./ShellComposerBridge"; + +const decodeComposer = Schema.decodeUnknownSync(ShellComposerState); + +const defaults = { + routeKind: "draft", + triggerKind: null, + suggestions: [], + suggestionsEmptyText: null, + onSelectSuggestion: () => {}, + onDismissSuggestions: () => {}, + onAttachFiles: () => {}, + onAddTerminalContext: () => {}, + attachments: [], + terminalContexts: [], + onRemoveAttachment: () => {}, + onRemoveTerminalContext: () => {}, + placeholder: "Ask anything", + editorDisabled: false, + hasSendableContent: true, + sendDisabledReason: null, + phase: "ready", + isSendBusy: false, + isConnecting: false, + environmentUnavailable: false, + noProviderAvailable: true, + projectSelectionRequired: false, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + showPlanFollowUpPrompt: false, + selectedInstanceId: ProviderInstanceId.make("codex"), + selectedProvider: ProviderDriverKind.make("codex"), + selectedModel: "test-model", + selectedProviderModels: [], + instanceEntries: [], + modelOptionsByInstance: new Map(), + modelOptions: null, + planModeEnabled: false, + getModelDisabledReason: () => null, + onProviderModelSelect: () => {}, + runtimeMode: "approval-required", + runtimeModes: [], + interactionMode: "default", + showInteractionModeToggle: false, + onRuntimeModeChange: () => {}, + onInteractionModeChange: () => {}, + onInterrupt: () => {}, +} satisfies Partial; + +let renderer: ReactTestRenderer | null = null; +let dispatch: Parameters[0]; +let published: ShellComposerState[]; +let replaceFromPage: (text: string) => void; +let submitted: string[]; + +function ComposerPage({ target = "draft-a" }: { target?: string }) { + const [prompt, setPrompt] = useState(""); + const [cursor, setCursor] = useState(0); + const promptRef = useRef(prompt); + useEffect(() => { + replaceFromPage = setPrompt; + }, []); + return ( + { + submitted.push(promptRef.current); + setPrompt(""); + setCursor(0); + }} + /> + ); +} + +beforeEach(async () => { + published = []; + submitted = []; + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { + t3Shell: { + publish: async (key, state) => { + if (key === "composer" && state !== null) { + published.push(decodeComposer(state)); + } + }, + onAction: async (listener) => { + dispatch = listener; + return () => {}; + }, + } satisfies Pick, + }); + await act(() => { + renderer = create(); + }); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = null; + vi.unstubAllGlobals(); +}); + +const edit = (revision: number) => ({ clientId: "qml-editor", revision }); + +describe("ShellComposerBridge edit acknowledgements", () => { + it("publishes the latest batched edit with its text, then retains it across page changes", async () => { + expect(published.at(-1)).toMatchObject({ text: "", edit: null }); + await act(() => { + dispatch("composer.text.set", { target: "draft-a", text: "First", cursor: 5, edit: edit(1) }); + dispatch("composer.text.set", { + target: "draft-a", + text: "Second", + cursor: 3, + edit: edit(2), + }); + }); + expect(published).toHaveLength(2); + expect(published.at(-1)).toMatchObject({ text: "Second", cursor: 3, edit: edit(2) }); + + await act(() => replaceFromPage("First")); + expect(published.at(-1)).toMatchObject({ text: "First", edit: edit(2) }); + await act(() => replaceFromPage("")); + expect(published.at(-1)).toMatchObject({ text: "", edit: edit(2) }); + }); + + it("acknowledges the submitted text even when sending clears it in the same render", async () => { + await act(() => { + dispatch("composer.text.set", { target: "draft-a", text: "Older", edit: edit(1) }); + }); + await act(() => { + dispatch("composer.submit", { text: "Newest", edit: edit(2) }); + }); + expect(submitted).toEqual(["Newest"]); + expect(published.at(-1)).toMatchObject({ text: "", edit: edit(2) }); + }); + + it("does not acknowledge edits for another target or carry acknowledgements into it", async () => { + await act(() => { + dispatch("composer.text.set", { target: "draft-a", text: "Draft A", edit: edit(1) }); + }); + await act(() => renderer?.update()); + expect(published.at(-1)).toMatchObject({ target: "draft-b", edit: null }); + const before = published.length; + await act(() => { + dispatch("composer.text.set", { target: "draft-a", text: "Stale", edit: edit(2) }); + }); + expect(published).toHaveLength(before); + await act(() => { + dispatch("composer.text.set", { target: "draft-b", text: "Draft B", edit: edit(3) }); + }); + expect(published.at(-1)).toMatchObject({ target: "draft-b", text: "Draft B", edit: edit(3) }); + }); + + it("still accepts edits from shells without revision support", async () => { + await act(() => dispatch("composer.text.set", { target: "draft-a", text: "Legacy" })); + expect(published.at(-1)).toMatchObject({ text: "Legacy", edit: null }); + await act(() => dispatch("composer.submit", { text: "Legacy send" })); + expect(submitted).toEqual(["Legacy send"]); + expect(published.at(-1)).toMatchObject({ text: "", edit: null }); + }); +}); diff --git a/apps/web/src/shell/ShellComposerBridge.tsx b/apps/web/src/shell/ShellComposerBridge.tsx new file mode 100644 index 000000000000..8971ffa4e179 --- /dev/null +++ b/apps/web/src/shell/ShellComposerBridge.tsx @@ -0,0 +1,277 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { + ModelSelection, + ProviderDriverKind, + ProviderInstanceId, + ProviderInteractionMode, + ProviderOptionSelection, + RuntimeMode, + ScopedThreadRef, + ServerProviderModel, +} from "@t3tools/contracts"; +import { useMemo, useState } from "react"; +import type { ShellComposerState } from "@t3tools/contracts/shell"; + +import { useShellActions } from "./useShellActions"; +import { useShellPublish } from "./useShellPublish"; + +import type { ComposerCommandItem } from "../components/chat/ComposerCommandMenu"; +import { + normalizeTerminalContextSelection, + type TerminalContextSelection, +} from "../lib/terminalContext"; +import { expandCollapsedComposerCursor } from "../composer-logic"; +import { type DraftId, useComposerDraftStore } from "../composerDraftStore"; +import type { AppModelOption } from "../modelSelection"; +import type { ProviderInstanceEntry } from "../providerInstances"; +import type { ComposerSubmissionIntent } from "../composer-logic"; +import type { SessionPhase } from "../types"; +import { + applyComposerOptionChange, + buildShellComposerState, + resolveComposerOptionDescriptors, +} from "./shellComposerState"; + +export interface ShellComposerBridgeProps { + readonly target: ScopedThreadRef | DraftId; + readonly routeKind: "server" | "draft"; + readonly prompt: string; + readonly promptRef: React.RefObject; + readonly setPrompt: (prompt: string) => void; + /** Collapsed caret ChatComposer tracks; published expanded for the raw-text editor. */ + readonly composerCursor: number; + readonly triggerKind: "path" | "slash-command" | "skill" | null; + readonly suggestions: ReadonlyArray; + readonly suggestionsEmptyText: string | null; + /** Expanded caret from the shell's editor; re-detects the trigger. */ + readonly onCursorChange: (expandedCursor: number) => void; + readonly onSelectSuggestion: (item: ComposerCommandItem) => void; + readonly onDismissSuggestions: () => void; + /** The drop pipeline (validation, limits, downscaling, uploads). */ + readonly onAttachFiles: (files: File[]) => void; + readonly onAddTerminalContext: (selection: TerminalContextSelection) => void; + readonly attachments: ReadonlyArray<{ id: string; name: string }>; + readonly terminalContexts: ReadonlyArray<{ + id: string; + terminalLabel: string; + lineStart: number; + lineEnd: number; + }>; + readonly onRemoveAttachment: (id: string) => void; + readonly onRemoveTerminalContext: (id: string) => void; + readonly placeholder: string; + readonly editorDisabled: boolean; + readonly hasSendableContent: boolean; + readonly sendDisabledReason: string | null; + readonly phase: SessionPhase; + readonly isSendBusy: boolean; + readonly isConnecting: boolean; + readonly environmentUnavailable: boolean; + readonly noProviderAvailable: boolean; + readonly projectSelectionRequired: boolean; + readonly pendingApprovalCount: number; + readonly pendingUserInputCount: number; + readonly showPlanFollowUpPrompt: boolean; + readonly selectedInstanceId: ProviderInstanceId; + readonly selectedProvider: ProviderDriverKind; + readonly selectedModel: string; + readonly selectedProviderModels: ReadonlyArray; + readonly instanceEntries: ReadonlyArray; + readonly modelOptionsByInstance: ReadonlyMap>; + readonly modelOptions: ReadonlyArray | null | undefined; + readonly planModeEnabled: boolean; + readonly getModelDisabledReason: (instanceId: ProviderInstanceId, model: string) => string | null; + readonly onProviderModelSelect: (instanceId: ProviderInstanceId, model: string) => void; + readonly runtimeMode: RuntimeMode; + readonly runtimeModes: ReadonlyArray<{ value: RuntimeMode; label: string; description: string }>; + readonly interactionMode: ProviderInteractionMode; + readonly showInteractionModeToggle: boolean; + readonly onRuntimeModeChange: (mode: RuntimeMode) => void; + readonly onInteractionModeChange: (mode: ProviderInteractionMode) => void; + readonly onSend: (e?: { preventDefault: () => void }, intent?: ComposerSubmissionIntent) => void; + readonly onInterrupt: () => void; +} + +/** + * Mounted inside ChatComposer when the Qt shell hosts the app. Publishes the + * composer view model under `composer` and turns `composer.*` actions into the + * same calls the HTML editor and footer make, so drafts, model selection, + * sending and interrupting keep one implementation. + */ +export function ShellComposerBridge(props: ShellComposerBridgeProps) { + const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); + const target = typeof props.target === "string" ? props.target : scopedThreadKey(props.target); + const [appliedEdit, setAppliedEdit] = useState<{ + target: string; + edit: ShellComposerState["edit"]; + } | null>(null); + + const optionDescriptors = useMemo( + () => + props.noProviderAvailable + ? [] + : resolveComposerOptionDescriptors({ + provider: props.selectedProvider, + model: props.selectedModel, + models: props.selectedProviderModels, + selections: props.modelOptions, + planModeEnabled: props.planModeEnabled, + }), + [ + props.modelOptions, + props.noProviderAvailable, + props.planModeEnabled, + props.selectedModel, + props.selectedProvider, + props.selectedProviderModels, + ], + ); + + const state = useMemo( + () => + buildShellComposerState({ + target: typeof props.target === "string" ? props.target : scopedThreadKey(props.target), + routeKind: props.routeKind, + text: props.prompt, + cursor: expandCollapsedComposerCursor(props.prompt, props.composerCursor), + triggerKind: props.triggerKind, + suggestions: props.suggestions.map((item) => ({ + id: item.id, + kind: item.type, + label: item.label, + description: item.description, + })), + suggestionsEmptyText: props.suggestionsEmptyText, + attachments: props.attachments.map((image) => ({ id: image.id, name: image.name })), + terminalContexts: props.terminalContexts.map((context) => ({ + id: context.id, + label: context.terminalLabel, + lineStart: context.lineStart, + lineEnd: context.lineEnd, + })), + placeholder: props.placeholder, + editorDisabled: props.editorDisabled, + hasSendableContent: props.hasSendableContent, + sendDisabledReason: props.sendDisabledReason, + isRunning: props.phase === "running", + isSendBusy: props.isSendBusy, + isConnecting: props.isConnecting, + environmentUnavailable: props.environmentUnavailable, + noProviderAvailable: props.noProviderAvailable, + projectSelectionRequired: props.projectSelectionRequired, + pendingApprovalCount: props.pendingApprovalCount, + pendingUserInputCount: props.pendingUserInputCount, + showPlanFollowUpPrompt: props.showPlanFollowUpPrompt, + selectedInstanceId: props.noProviderAvailable ? null : props.selectedInstanceId, + selectedModel: props.noProviderAvailable ? null : props.selectedModel, + instanceEntries: props.instanceEntries, + modelOptionsByInstance: props.modelOptionsByInstance, + getModelDisabledReason: props.getModelDisabledReason, + optionDescriptors, + runtimeMode: props.runtimeMode, + runtimeModes: props.runtimeModes, + interactionMode: props.interactionMode, + showInteractionModeToggle: props.showInteractionModeToggle, + }), + [optionDescriptors, props], + ); + + useShellPublish("composer", { + ...state, + edit: appliedEdit?.target === target ? (appliedEdit.edit ?? null) : null, + }); + + useShellActions((action) => { + switch (action.type) { + case "composer.text.set": + if (action.target !== state.target) return; + setAppliedEdit({ target, edit: action.edit }); + props.setPrompt(action.text); + props.promptRef.current = action.text; + if (action.cursor !== undefined) { + props.onCursorChange(action.cursor); + } + return; + case "composer.suggest.select": { + const item = props.suggestions.find((entry) => entry.id === action.id); + if (item) props.onSelectSuggestion(item); + return; + } + case "composer.suggest.dismiss": + props.onDismissSuggestions(); + return; + case "composer.attach": { + const files: File[] = []; + for (const entry of action.files) { + let bytes: Uint8Array; + try { + bytes = Uint8Array.from(atob(entry.base64), (char) => char.charCodeAt(0)); + } catch { + console.warn("[shell] dropped attachment with malformed base64:", entry.name); + continue; + } + files.push(new File([bytes], entry.name, { type: entry.mimeType })); + } + if (files.length > 0) props.onAttachFiles(files); + return; + } + case "composer.attachment.remove": + props.onRemoveAttachment(action.id); + return; + case "composer.terminalContext.remove": + props.onRemoveTerminalContext(action.id); + return; + case "composer.terminalContext.add": { + const selection = normalizeTerminalContextSelection({ + terminalId: action.terminalId, + terminalLabel: action.terminalLabel, + lineStart: action.lineStart, + lineEnd: action.lineEnd, + text: action.text, + }); + if (selection) props.onAddTerminalContext(selection); + return; + } + case "composer.submit": + if (action.text !== undefined) { + setAppliedEdit({ target, edit: action.edit }); + props.setPrompt(action.text); + props.promptRef.current = action.text; + } + props.onSend(undefined, action.intent ?? "foreground"); + return; + case "composer.interrupt": + props.onInterrupt(); + return; + case "composer.model.select": + props.onProviderModelSelect(action.instanceId as ProviderInstanceId, action.model); + return; + case "composer.option.set": { + if (props.noProviderAvailable) return; + const nextOptions: ModelSelection["options"] = applyComposerOptionChange( + optionDescriptors, + action.id, + action.value, + ); + setProviderModelOptions(props.target, props.selectedProvider, nextOptions, { + instanceId: props.selectedInstanceId, + model: props.selectedModel, + persistSticky: true, + }); + return; + } + case "composer.runtimeMode.set": + if (props.runtimeModes.some((mode) => mode.value === action.mode)) { + props.onRuntimeModeChange(action.mode as RuntimeMode); + } + return; + case "composer.interactionMode.set": + props.onInteractionModeChange(action.mode); + return; + default: + return; + } + }); + + return null; +} diff --git a/apps/web/src/shell/ShellEmbedRouteBridge.test.tsx b/apps/web/src/shell/ShellEmbedRouteBridge.test.tsx new file mode 100644 index 000000000000..ec5fd88d5757 --- /dev/null +++ b/apps/web/src/shell/ShellEmbedRouteBridge.test.tsx @@ -0,0 +1,66 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { ShellEmbedRouteBridge } from "./ShellEmbedRouteBridge"; + +const navigate = vi.hoisted(() => vi.fn()); +vi.mock("@tanstack/react-router", () => ({ useNavigate: () => navigate })); +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] })); +vi.mock("../state/server", () => ({ primaryServerKeybindingsAtom: {} })); +let renderer: ReactTestRenderer | null = null; + +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = null; + vi.unstubAllGlobals(); + navigate.mockClear(); +}); + +describe("retained shell embed navigation", () => { + it.each(["terminal", "panel"] as const)( + "follows %s thread publications without a document reload", + async (surface) => { + const first = { + environmentId: EnvironmentId.make("env-a"), + threadId: ThreadId.make("thread-a"), + }; + const second = { + environmentId: EnvironmentId.make("env-b"), + threadId: ThreadId.make("thread-b"), + }; + let publish!: (state: Record) => void; + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { + addEventListener: () => {}, + removeEventListener: () => {}, + t3Shell: { + onState: async (listener: typeof publish) => { + publish = listener; + return () => {}; + }, + }, + }); + await act(() => { + renderer = create(); + }); + const key = surface === "terminal" ? "workspace" : "rightPanel"; + await act(() => publish({ [key]: { threadKey: scopedThreadKey(first) } })); + expect(navigate).not.toHaveBeenCalled(); + await act(() => publish({ [key]: { threadKey: scopedThreadKey(second) } })); + expect(navigate).toHaveBeenCalledExactlyOnceWith({ + to: "/embed/$environmentId/$threadId", + params: { environmentId: "env-b", threadId: "thread-b" }, + search: { surface }, + replace: true, + }); + await act(() => + renderer?.update(), + ); + await act(() => publish({ [key]: { threadKey: scopedThreadKey(second) } })); + expect(navigate).toHaveBeenCalledTimes(1); + }, + ); +}); diff --git a/apps/web/src/shell/ShellEmbedRouteBridge.tsx b/apps/web/src/shell/ShellEmbedRouteBridge.tsx new file mode 100644 index 000000000000..0b339f0bc6d4 --- /dev/null +++ b/apps/web/src/shell/ShellEmbedRouteBridge.tsx @@ -0,0 +1,92 @@ +import { useAtomValue } from "@effect/atom-react"; +import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { useNavigate } from "@tanstack/react-router"; +import * as Schema from "effect/Schema"; +import { useEffect } from "react"; + +import { isPreviewFocused } from "../lib/previewFocus"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import type { EmbedSurface } from "../routes/embed.$environmentId.$threadId"; +import { primaryServerKeybindingsAtom } from "../state/server"; +import { buildThreadRouteParams } from "../threadRoutes"; +import { shellKeybindingPressToForward } from "./shellKeybindings"; + +// Only the thread key is read, so the whole struct is not validated on every +// publish (the workspace entry updates on each git poll). +const hasThreadKey = Schema.is(Schema.Struct({ threadKey: Schema.String })); + +/** + * Mounted by the embed route in one of the shell's secondary web views. + * Follows the thread the primary view publishes (`rightPanel` for the panel, + * `workspace` for the terminal drawer) so the document shows the thread the + * user is looking at, without the shell having to drive navigation. + * + * Also hands the primary document the page keybindings this one cannot act + * on itself (thread jumps, the sidebar toggle): while this view has focus the + * shell's own shortcuts are off, and the handlers for those live with the + * sidebar in the primary. + */ +export function ShellEmbedRouteBridge({ + threadRef, + surface, +}: { + readonly threadRef: ScopedThreadRef; + readonly surface: EmbedSurface; +}) { + const navigate = useNavigate(); + useEffect(() => { + const shell = window.t3Shell; + if (!shell) return; + let disposed = false; + let unsubscribe: (() => void) | null = null; + void shell + .onState((state) => { + const entry = surface === "terminal" ? state.workspace : state.rightPanel; + if (!hasThreadKey(entry)) return; + const target = parseScopedThreadKey(entry.threadKey); + if ( + target === null || + (target.environmentId === threadRef.environmentId && + target.threadId === threadRef.threadId) + ) { + return; + } + void navigate({ + to: "/embed/$environmentId/$threadId", + params: buildThreadRouteParams(target), + search: { surface }, + replace: true, + }); + }) + .then((dispose) => { + if (disposed) dispose(); + else unsubscribe = dispose; + }); + return () => { + disposed = true; + unsubscribe?.(); + }; + }, [navigate, surface, threadRef.environmentId, threadRef.threadId]); + + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + useEffect(() => { + const shell = window.t3Shell; + if (!shell) return; + // Bubble phase: this document's own handlers (ChatView's, the terminal's) + // have had the key by now and prevented what they consumed. + const onWindowKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.repeat) return; + const press = shellKeybindingPressToForward(event, keybindings, navigator.platform, { + terminalFocus: isTerminalFocused(), + previewFocus: isPreviewFocused(), + }); + if (press === null) return; + event.preventDefault(); + void shell.dispatch("keybinding.press", press); + }; + window.addEventListener("keydown", onWindowKeyDown); + return () => window.removeEventListener("keydown", onWindowKeyDown); + }, [keybindings]); + return null; +} diff --git a/apps/web/src/shell/ShellGitBridge.tsx b/apps/web/src/shell/ShellGitBridge.tsx new file mode 100644 index 000000000000..135af4a985f9 --- /dev/null +++ b/apps/web/src/shell/ShellGitBridge.tsx @@ -0,0 +1,121 @@ +import type { ShellGitState } from "@t3tools/contracts/shell"; +import { useMemo } from "react"; + +import { useShellActions } from "./useShellActions"; +import { useShellPublish } from "./useShellPublish"; + +import { + getMenuActionDisabledReason, + requestVcsStatusRefresh, + type useGitActions, +} from "../hooks/useGitActions"; + +export interface ShellGitBridgeProps { + readonly git: ReturnType; + readonly gitCwd: string | null; + readonly onOpenPublish: () => void; +} + +/** + * Mounted by GitActionsControl when the Qt shell hosts the app. Publishes the + * git model and runs the same hook methods the HTML buttons call; the commit + * and default-branch dialogs are rendered by the shell from the state. + */ +export function ShellGitBridge({ git, gitCwd, onOpenPublish }: ShellGitBridgeProps) { + const status = git.gitStatusForActions ?? null; + const state = useMemo((): ShellGitState => { + const hints: string[] = []; + if (status && status.isRepo && status.refName === null) { + hints.push("Detached HEAD: check out a branch to push or open a pull request."); + } + if (status && status.behindCount > 0) { + hints.push(`Behind upstream by ${status.behindCount} — pull or rebase before pushing.`); + } + if (git.gitStatusError) { + hints.push(String(git.gitStatusError)); + } + return { + available: gitCwd !== null, + isRepo: git.isRepo, + busy: git.isGitActionRunning, + initPending: git.initAction.isPending, + quickAction: { + label: git.quickAction.label, + disabledReason: git.quickActionDisabledReason, + kind: git.quickAction.kind, + }, + menu: git.gitActionMenuItems.map((item) => ({ + id: item.id, + label: item.label, + disabledReason: getMenuActionDisabledReason({ + item, + gitStatus: status, + isBusy: git.isGitActionRunning, + hasPrimaryRemote: git.hasPrimaryRemote, + }), + })), + canPublish: git.isRepo && status !== null && !git.hasPrimaryRemote, + hints, + branch: status?.refName ?? null, + isDefaultRef: git.isDefaultRef, + files: git.allFiles.map((file) => ({ + path: file.path, + insertions: file.insertions, + deletions: file.deletions, + })), + pendingDefaultBranch: + git.pendingDefaultBranchAction && git.pendingDefaultBranchActionCopy + ? { + title: git.pendingDefaultBranchActionCopy.title, + description: git.pendingDefaultBranchActionCopy.description, + continueLabel: git.pendingDefaultBranchActionCopy.continueLabel, + featureBranchLabel: "Checkout feature branch & continue", + } + : null, + }; + }, [git, gitCwd, status]); + + useShellPublish("git", state); + + useShellActions((action) => { + switch (action.type) { + case "git.quick": + git.runQuickAction(); + return; + case "git.menu": { + const item = git.gitActionMenuItems.find((entry) => entry.id === action.id); + if (!item) return; + // "commit" needs a dialog; the shell renders its own from the + // published files and comes back with `git.commit`. + git.runMenuItemAction(item); + return; + } + case "git.init": + void git.initRepository(); + return; + case "git.publish": + onOpenPublish(); + return; + case "git.refresh": + requestVcsStatusRefresh(git.refreshVcsStatus, git.activeEnvironmentId, gitCwd); + return; + case "git.commit": + void git.runGitActionWithToast({ + action: "commit", + ...(action.message.trim() ? { commitMessage: action.message.trim() } : {}), + ...(action.filePaths ? { filePaths: [...action.filePaths] } : {}), + ...(action.featureBranch ? { featureBranch: true, skipDefaultBranchPrompt: true } : {}), + }); + return; + case "git.defaultBranch": + if (action.choice === "abort") git.dismissPendingDefaultBranchAction(); + else if (action.choice === "continue") git.continuePendingDefaultBranchAction(); + else git.checkoutFeatureBranchAndContinuePendingAction(); + return; + default: + return; + } + }); + + return null; +} diff --git a/apps/web/src/shell/ShellLayoutBridge.tsx b/apps/web/src/shell/ShellLayoutBridge.tsx new file mode 100644 index 000000000000..308f987d431a --- /dev/null +++ b/apps/web/src/shell/ShellLayoutBridge.tsx @@ -0,0 +1,23 @@ +import type { ShellLayoutState } from "@t3tools/contracts/shell"; +import { useMemo } from "react"; + +import { useSidebar } from "../components/ui/sidebar"; +import { useSidebarToggleKeybinding } from "../hooks/useSidebarToggleKeybinding"; +import { useShellActions } from "./useShellActions"; +import { useShellPublish } from "./useShellPublish"; + +/** + * Mounted inside SidebarProvider when the Qt shell hosts the app. The page + * keeps owning the sidebar's open state (and its keybinding); this publishes + * it as `layout` and applies `sidebar.toggle` from native chrome. + */ +export function ShellLayoutBridge() { + const { open, toggleSidebar } = useSidebar(); + useSidebarToggleKeybinding(); + const state = useMemo((): ShellLayoutState => ({ sidebarCollapsed: !open }), [open]); + useShellPublish("layout", state); + useShellActions((action) => { + if (action.type === "sidebar.toggle") toggleSidebar(); + }); + return null; +} diff --git a/apps/web/src/shell/ShellRightPanelBridge.tsx b/apps/web/src/shell/ShellRightPanelBridge.tsx new file mode 100644 index 000000000000..fd325bf5900e --- /dev/null +++ b/apps/web/src/shell/ShellRightPanelBridge.tsx @@ -0,0 +1,101 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; +import { useMemo } from "react"; + +import { useShellActions } from "./useShellActions"; +import { useShellPublish } from "./useShellPublish"; + +import { surfaceTitle } from "../components/RightPanelTabs"; +import type { RightPanelSurface } from "../rightPanelStore"; +import { buildShellRightPanelState } from "./shellRightPanelState"; + +export interface ShellRightPanelBridgeProps { + readonly threadRef: ScopedThreadRef; + readonly isOpen: boolean; + readonly activeSurfaceId: string | null; + readonly surfaces: ReadonlyArray; + readonly terminalLabelsById: ReadonlyMap; + readonly previewSessions: Readonly>; + readonly canAdd: { + readonly diff: boolean; + readonly files: boolean; + readonly terminal: boolean; + readonly pullRequest: boolean; + readonly agents: boolean; + }; + readonly onToggle: () => void; + readonly onActivate: (surface: RightPanelSurface) => void; + readonly onClose: (surface: RightPanelSurface) => void; + readonly onAddDiff: () => void; + readonly onAddFiles: () => void; + readonly onAddTerminal: () => void; + readonly onAddPullRequest: () => void; + readonly onAddAgents: () => void; +} + +/** + * Mounted by ChatView when the Qt shell hosts the app. Publishes the right + * panel's tab model and routes tab actions to ChatView's handlers; the + * content itself is rendered by the embed route in the shell's second web + * view, which converges on the same tab model through localStorage. + */ +export function ShellRightPanelBridge(props: ShellRightPanelBridgeProps) { + const state = useMemo( + () => + buildShellRightPanelState({ + threadKey: scopedThreadKey(props.threadRef), + environmentId: props.threadRef.environmentId, + threadId: props.threadRef.threadId, + isOpen: props.isOpen, + activeSurfaceId: props.activeSurfaceId, + surfaces: props.surfaces, + titleFor: (surface) => + surfaceTitle(surface, props.previewSessions, props.terminalLabelsById), + canAdd: props.canAdd, + }), + [props], + ); + + useShellPublish("rightPanel", state); + + useShellActions((action) => { + switch (action.type) { + case "rightPanel.toggle": + props.onToggle(); + return; + case "rightPanel.activate": { + const surface = props.surfaces.find((item) => item.id === action.id); + if (surface) props.onActivate(surface); + return; + } + case "rightPanel.close": { + const surface = props.surfaces.find((item) => item.id === action.id); + if (surface) props.onClose(surface); + return; + } + case "rightPanel.add": + switch (action.kind) { + case "diff": + props.onAddDiff(); + return; + case "files": + props.onAddFiles(); + return; + case "terminal": + props.onAddTerminal(); + return; + case "pull-request": + props.onAddPullRequest(); + return; + case "agents": + props.onAddAgents(); + return; + } + return; + default: + return; + } + }); + + return null; +} diff --git a/apps/web/src/shell/ShellSettingsBridge.tsx b/apps/web/src/shell/ShellSettingsBridge.tsx new file mode 100644 index 000000000000..5ecbcba37a7a --- /dev/null +++ b/apps/web/src/shell/ShellSettingsBridge.tsx @@ -0,0 +1,72 @@ +import { useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router"; +import { useMemo, useState } from "react"; + +import { scrollToSettingsTarget } from "../components/settings/settingsLayout"; +import { buildShellSettingsState, isSettingsPath } from "./shellSettingsState"; +import { useShellActions } from "./useShellActions"; +import { useShellPublish } from "./useShellPublish"; + +/** + * Mounted from the root route when the Qt shell hosts the app. Publishes the + * settings navigation (sections, active one, search results) and turns + * settings.* actions into the same navigation SettingsSidebarNav performs. + * The settings pages themselves stay HTML in the primary web view. + */ +export function ShellSettingsBridge() { + const navigate = useNavigate(); + const canGoBack = useCanGoBack(); + const pathname = useLocation({ select: (location) => location.pathname }); + const hash = useLocation({ select: (location) => location.hash }); + const [searchQuery, setSearchQuery] = useState(""); + + const state = useMemo( + () => buildShellSettingsState({ pathname, searchQuery }), + [pathname, searchQuery], + ); + useShellPublish("settings", state); + + useShellActions((action) => { + switch (action.type) { + case "settings.navigate": + if (!isSettingsPath(action.to)) return; + setSearchQuery(""); + void navigate({ + to: action.to, + hash: "", + replace: true, + hashScrollIntoView: false, + }); + return; + case "settings.openResult": { + if (!isSettingsPath(action.to)) return; + setSearchQuery(""); + const targetId = action.targetId ?? ""; + if (targetId.length > 0 && pathname === action.to && hash.replace(/^#/, "") === targetId) { + scrollToSettingsTarget(targetId); + return; + } + void navigate({ + to: action.to, + hash: targetId, + replace: true, + hashScrollIntoView: false, + }); + return; + } + case "settings.search": + setSearchQuery(action.query); + return; + case "settings.back": + if (canGoBack) { + window.history.back(); + return; + } + void navigate({ to: "/" }); + return; + default: + return; + } + }); + + return null; +} diff --git a/apps/web/src/shell/ShellThemeBridge.tsx b/apps/web/src/shell/ShellThemeBridge.tsx new file mode 100644 index 000000000000..7b1b17f12240 --- /dev/null +++ b/apps/web/src/shell/ShellThemeBridge.tsx @@ -0,0 +1,51 @@ +import { useEffect } from "react"; + +import { readShellThemeState } from "./shellThemeState"; +import { claimShellThemeOverride } from "./shellThemeOverride"; + +/** + * Publishes the page's resolved theme to the Qt shell whenever it changes + * (theme preference, appearance, custom theme edits, shell theme.json + * injection), so native chrome matches the page by default. + */ +export function ShellThemeBridge({ publishToShell = true }: { publishToShell?: boolean }) { + useEffect(() => { + const shell = window.t3Shell; + if (!shell) return; + claimShellThemeOverride(); + // Embedded documents consume overrides; only the primary publishes native colors. + if (!publishToShell) return; + const root = document.documentElement; + let frame: number | null = null; + let lastJson = ""; + const publish = () => { + frame = null; + const state = readShellThemeState(root); + const json = JSON.stringify(state); + if (json === lastJson) return; + lastJson = json; + void shell.publish("theme", state); + }; + const schedule = () => { + if (frame !== null) return; + frame = window.requestAnimationFrame(publish); + }; + const observer = new MutationObserver(schedule); + observer.observe(root, { + attributes: true, + attributeFilter: ["class", "style", "data-theme-id"], + }); + const media = window.matchMedia("(prefers-color-scheme: dark)"); + media.addEventListener("change", schedule); + // Fonts and custom themes load after first paint; settle a little later too. + const settle = window.setTimeout(schedule, 1500); + schedule(); + return () => { + observer.disconnect(); + media.removeEventListener("change", schedule); + window.clearTimeout(settle); + if (frame !== null) window.cancelAnimationFrame(frame); + }; + }, [publishToShell]); + return null; +} diff --git a/apps/web/src/shell/ShellToastBridge.tsx b/apps/web/src/shell/ShellToastBridge.tsx new file mode 100644 index 000000000000..90cd13cb8383 --- /dev/null +++ b/apps/web/src/shell/ShellToastBridge.tsx @@ -0,0 +1,102 @@ +import { Toast } from "@base-ui/react/toast"; +import type { ShellNotification, ShellNotificationsState } from "@t3tools/contracts/shell"; +import { useMemo, useRef } from "react"; + +import { + dismissToast, + isShellMirrorableToast, + type ThreadToastData, + useActiveThreadRefFromRoute, +} from "../components/ui/toast"; +import { hasVisibleToastAction, shouldRenderThreadScopedToast } from "../components/ui/toast.logic"; +import { useShellActions } from "./useShellActions"; +import { useShellPublish } from "./useShellPublish"; + +type ToastHandlers = { + readonly onClose: (() => void) | undefined; + readonly actions: ReadonlyMap void>; +}; + +/** + * Mounted inside ToastProvider when the Qt shell hosts the app. Mirrors the + * stacked toast list (adds, updates, closes, expiry) as `notifications` and + * runs a toast's buttons or dismissal when the shell asks. Toasts whose body + * is a React element stay in the page's own viewport. + */ +export function ShellToastBridge() { + const { toasts } = Toast.useToastManager(); + const activeThreadRef = useActiveThreadRefFromRoute(); + + const handlersRef = useRef(new Map()); + const items = useMemo((): ReadonlyArray => { + const handlers = new Map(); + const result: ShellNotification[] = []; + for (const toast of toasts) { + if ( + !shouldRenderThreadScopedToast(toast.data, activeThreadRef) || + !isShellMirrorableToast(toast) + ) { + continue; + } + const actions = new Map void>(); + const actionList: Array = []; + const invoke = (onClick: ((event: never) => void) | undefined) => () => { + onClick?.({ preventDefault() {}, stopPropagation() {} } as never); + }; + for (const extra of toast.data?.additionalActions ?? []) { + if (typeof extra.props.children !== "string") continue; + actions.set(extra.id, invoke(extra.props.onClick as never)); + actionList.push({ id: extra.id, label: extra.props.children, primary: false }); + } + const secondary = toast.data?.secondaryActionProps; + if (secondary && typeof secondary.children === "string") { + actions.set("secondary", invoke(secondary.onClick as never)); + actionList.push({ id: "secondary", label: secondary.children, primary: false }); + } + const primary = toast.actionProps; + if (hasVisibleToastAction(primary) && typeof primary?.children === "string") { + actions.set("primary", invoke(primary.onClick as never)); + actionList.push({ id: "primary", label: primary.children, primary: true }); + } + handlers.set(toast.id, { onClose: toast.data?.onClose, actions }); + result.push({ + id: toast.id, + type: resolveType(toast.type), + title: typeof toast.title === "string" ? toast.title : "", + description: typeof toast.description === "string" ? toast.description : null, + updateKey: toast.updateKey ?? 0, + actions: actionList, + }); + } + handlersRef.current = handlers; + return result; + }, [activeThreadRef, toasts]); + + const state = useMemo((): ShellNotificationsState => ({ items }), [items]); + useShellPublish("notifications", state); + + useShellActions((action) => { + if (action.type === "notification.action") { + handlersRef.current.get(action.id)?.actions.get(action.actionId)?.(); + return; + } + if (action.type === "notification.dismiss") { + const handlers = handlersRef.current.get(action.id); + dismissToast(action.id, handlers?.onClose); + } + }); + + return null; +} + +function resolveType(type: string | undefined): ShellNotification["type"] { + switch (type) { + case "error": + case "loading": + case "success": + case "warning": + return type; + default: + return "info"; + } +} diff --git a/apps/web/src/shell/ShellWorkspaceBridge.tsx b/apps/web/src/shell/ShellWorkspaceBridge.tsx new file mode 100644 index 000000000000..530c16b643a4 --- /dev/null +++ b/apps/web/src/shell/ShellWorkspaceBridge.tsx @@ -0,0 +1,249 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { useAtomCommand } from "../state/use-atom-command"; +import { + EditorId, + type EnvironmentId, + type ProjectScript, + type ScopedThreadRef, + type VcsStatusResult, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { useEffect, useMemo, useRef, useState } from "react"; + +import { useShellActions } from "./useShellActions"; +import { useShellPublish } from "./useShellPublish"; + +import type { EnvMode } from "../components/BranchToolbar.logic"; +import { type DraftId } from "../composerDraftStore"; +import { resolveAndPersistPreferredEditor, usePreferredEditor } from "../editorPreferences"; +import { useRenameThread } from "../hooks/useRenameThread"; +import { useThreadBranchSelection } from "../hooks/useThreadBranchSelection"; +import { parsePullRequestReference } from "../pullRequestReference"; +import { subscribeShellRenameRequests } from "./shellRenameRequest"; +import { buildEmbedPath } from "./shellRightPanelState"; +import { shellEnvironment } from "../state/shell"; +import { buildShellWorkspaceState } from "./shellWorkspaceState"; + +const isEditorId = Schema.is(EditorId); + +export interface ShellWorkspaceBridgeProps { + readonly threadRef: ScopedThreadRef; + readonly draftId: DraftId | undefined; + readonly envLocked: boolean; + /** Set while a started server thread may still change its checkout (see ChatView). */ + readonly effectiveEnvModeOverride: EnvMode | undefined; + readonly activeThreadBranchOverride: string | null | undefined; + readonly onActiveThreadBranchOverrideChange: ((branch: string | null) => void) | undefined; + readonly projectTitle: string | null; + readonly projectRoot: string | null; + readonly openInCwd: string | null; + readonly threadTitle: string; + readonly isDraft: boolean; + readonly envMode: EnvMode; + readonly envModeChangeable: boolean; + readonly startFromOrigin: boolean; + readonly branch: string | null; + readonly worktreePath: string | null; + readonly gitStatus: VcsStatusResult | null; + readonly canOpenPullRequest: boolean; + readonly terminalAvailable: boolean; + readonly terminalOpen: boolean; + readonly terminalHeight: number; + readonly availableEditors: ReadonlyArray; + readonly scripts: ReadonlyArray; + readonly preferredScriptId: string | null; + readonly environments: ReadonlyArray<{ environmentId: EnvironmentId; label: string }>; + readonly environmentChangeable: boolean; + readonly onNewThread: () => void; + readonly onToggleTerminal: () => void; + readonly onResizeTerminal: (height: number) => void; + readonly onRunScript: (script: ProjectScript) => void; + readonly onEnvModeChange: (mode: EnvMode) => void; + readonly onStartFromOriginChange: (enabled: boolean) => void; + readonly onOpenPullRequest: ((number: number) => void) | undefined; + /** Set on draft threads: a PR number or URL in the branch picker checks it out. */ + readonly onCheckoutPullRequestRequest: ((reference: string) => void) | undefined; + readonly onEnvironmentChange: (environmentId: EnvironmentId) => void; + readonly renameRequestId: number; + readonly onTitleMenu: (x: number, y: number) => void; + readonly onRenameRequested: () => void; + readonly threadTitleForRename: string; +} + +/** + * Mounted by ChatView when the Qt shell hosts the app. Publishes the + * workspace strip (breadcrumb, checkout context, editors, scripts, the + * terminal drawer's state) and routes workspace.* actions and the drawer + * toggle to ChatView's handlers. Opening in an editor uses the + * same command and preference the HTML picker uses. + */ +export function ShellWorkspaceBridge(props: ShellWorkspaceBridgeProps) { + const [preferredEditorId, setPreferredEditor] = usePreferredEditor(props.availableEditors); + const openInEditor = useAtomCommand(shellEnvironment.openInEditor); + const [branchQuery, setBranchQuery] = useState(""); + const renameThread = useRenameThread({ + environmentId: props.threadRef.environmentId, + threadId: props.threadRef.threadId, + currentTitle: props.threadTitleForRename, + }); + // A sidebar row's "Rename" navigates here first, then asks for the editor. + const threadKey = scopedThreadKey(props.threadRef); + const onRenameRequestedRef = useRef(props.onRenameRequested); + onRenameRequestedRef.current = props.onRenameRequested; + useEffect( + () => subscribeShellRenameRequests(threadKey, () => onRenameRequestedRef.current()), + [threadKey], + ); + const branchSelection = useThreadBranchSelection({ + environmentId: props.threadRef.environmentId, + threadId: props.threadRef.threadId, + draftId: props.draftId, + envLocked: props.envLocked, + effectiveEnvModeOverride: props.effectiveEnvModeOverride, + activeThreadBranchOverride: props.activeThreadBranchOverride, + onActiveThreadBranchOverrideChange: props.onActiveThreadBranchOverrideChange, + branchQuery, + }); + const { + refs, + branchRefState, + isInitialBranchesLoadPending, + isBranchActionPending, + branchByName, + branchCwd, + } = branchSelection; + + const state = useMemo( + () => + buildShellWorkspaceState({ + threadKey: scopedThreadKey(props.threadRef), + projectTitle: props.projectTitle, + projectRoot: props.projectRoot, + threadTitle: props.threadTitle, + isDraft: props.isDraft, + envMode: props.envMode, + envModeChangeable: props.envModeChangeable, + startFromOrigin: props.startFromOrigin, + branch: props.branch, + worktreePath: props.worktreePath, + gitStatus: props.gitStatus, + canOpenPullRequest: props.onOpenPullRequest !== undefined && props.canOpenPullRequest, + terminalAvailable: props.terminalAvailable, + terminalOpen: props.terminalOpen, + terminalHeight: props.terminalHeight, + terminalEmbedPath: buildEmbedPath( + props.threadRef.environmentId, + props.threadRef.threadId, + "terminal", + ), + availableEditors: props.availableEditors, + preferredEditorId, + scripts: props.scripts, + preferredScriptId: props.preferredScriptId, + environments: props.environments, + activeEnvironmentId: props.threadRef.environmentId, + environmentChangeable: props.environmentChangeable, + renameRequestId: props.renameRequestId, + branchQuery, + branches: refs, + branchesTotal: branchRefState.data?.totalCount ?? refs.length, + branchesLoading: isInitialBranchesLoadPending, + branchSwitchPending: isBranchActionPending, + // Switching stops a live session first (see useThreadBranchSelection), + // so a started thread can still change its checkout. + branchChangeable: branchCwd !== null, + }), + [ + branchCwd, + branchQuery, + branchRefState.data?.totalCount, + isBranchActionPending, + isInitialBranchesLoadPending, + preferredEditorId, + props, + refs, + ], + ); + + useShellPublish("workspace", state); + + useShellActions((action) => { + switch (action.type) { + case "workspace.newThread": + props.onNewThread(); + return; + case "terminal.toggle": + if (props.terminalAvailable) props.onToggleTerminal(); + return; + case "terminal.resize": + if (props.terminalAvailable) props.onResizeTerminal(action.height); + return; + case "workspace.openInEditor": { + if (props.openInCwd === null) return; + const editor = + action.editorId !== undefined && isEditorId(action.editorId) + ? action.editorId + : resolveAndPersistPreferredEditor(props.availableEditors); + if (editor === null || !props.availableEditors.includes(editor)) return; + setPreferredEditor(editor); + void openInEditor({ + environmentId: props.threadRef.environmentId, + input: { cwd: props.openInCwd, editor }, + }); + return; + } + case "workspace.runScript": { + const script = props.scripts.find((item) => item.id === action.scriptId); + if (script) props.onRunScript(script); + return; + } + case "workspace.envMode.set": + if (props.envModeChangeable) props.onEnvModeChange(action.mode); + return; + case "workspace.startFromOrigin.set": + if (props.envModeChangeable) props.onStartFromOriginChange(action.enabled); + return; + case "workspace.openPullRequest": { + const number = props.gitStatus?.pr?.number; + if (number !== undefined) props.onOpenPullRequest?.(number); + return; + } + case "workspace.titleMenu": + props.onTitleMenu(action.x, action.y); + return; + case "workspace.rename": + renameThread(action.title); + return; + case "workspace.branch.search": + setBranchQuery(action.query); + return; + case "workspace.branch.select": { + const ref = branchByName.get(action.name); + if (ref) branchSelection.selectBranch(ref); + return; + } + case "workspace.branch.create": { + const prReference = parsePullRequestReference(action.name); + if (prReference !== null && props.onCheckoutPullRequestRequest) { + props.onCheckoutPullRequestRequest(prReference); + return; + } + branchSelection.createRef(action.name); + return; + } + case "workspace.environment.set": { + const target = props.environments.find( + (environment) => environment.environmentId === action.environmentId, + ); + if (target && props.environmentChangeable) { + props.onEnvironmentChange(target.environmentId); + } + return; + } + default: + return; + } + }); + + return null; +} diff --git a/apps/web/src/shell/T3ShellBridge.tsx b/apps/web/src/shell/T3ShellBridge.tsx new file mode 100644 index 000000000000..ee16180e655b --- /dev/null +++ b/apps/web/src/shell/T3ShellBridge.tsx @@ -0,0 +1,359 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + parseScopedThreadKey, + scopeProjectRef, + scopeThreadRef, + scopedProjectKey, + scopedThreadKey, +} from "@t3tools/client-runtime/environment"; +import type { EnvironmentId } from "@t3tools/contracts"; +import type { ShellSidebarDraft, ShellSidebarState } from "@t3tools/contracts/shell"; +import { useParams, useRouter } from "@tanstack/react-router"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { partitionSidebarThreads, resolveAdjacentThreadId } from "../components/Sidebar.logic"; +import { openCommandPalette } from "../commandPaletteBus"; +import { composerDraftHasUserContent, DraftId, useComposerDraftStore } from "../composerDraftStore"; +import { isT3ShellEmbed } from "../env"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { useNowMinute } from "../hooks/useNowMinute"; +import { useSidebarProjectGroups } from "../hooks/useSidebarProjectGroups"; +import { useThreadActionMenu } from "../hooks/useThreadActionMenu"; +import { + resolveShortcutCommand, + threadJumpIndexFromCommand, + threadTraversalDirectionFromCommand, +} from "../keybindings"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { requestShellRename } from "./shellRenameRequest"; +import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; +import { useThreadShells } from "../state/entities"; +import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; +import { useUiStateStore } from "../uiStateStore"; +import { buildShellKeybindings } from "./shellKeybindings"; +import { buildLogicalProjectKeyMap, buildShellSidebarState } from "./shellSidebarState"; +import { useShellActions } from "./useShellActions"; +import { useShellPublish } from "./useShellPublish"; +import { useShellThreadRowActions } from "./useShellThreadRowActions"; + +/** + * Feeds the native shell (window.t3Shell) the sidebar view model and turns + * its actions into navigation. Mounted only when hosted by the shell; the + * HTML sidebar hides itself in that case (AppSidebarLayout). Everything here + * is derived with the same logic the HTML sidebar uses, so the two never + * disagree about rows, order, or status. + */ +export function T3ShellBridge() { + const router = useRouter(); + const threads = useThreadShells(); + const { projectGroups } = useSidebarProjectGroups(threads); + const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const nowMinute = useNowMinute(); + const lastVisitedAtByKey = useUiStateStore((store) => store.threadLastVisitedAtById); + const handleNewThread = useNewThreadHandler(); + const [scopeProjectKey, setScopeProjectKey] = useState(null); + const routeTarget = useParams({ + strict: false, + select: (params) => resolveThreadRouteTarget(params), + }); + const activeThreadKey = + routeTarget?.kind === "server" ? scopedThreadKey(routeTarget.threadRef) : null; + const [menuTarget, setMenuTarget] = useState<{ + key: string; + x: number; + y: number; + seq: number; + } | null>(null); + const menuThreadRef = menuTarget ? parseScopedThreadKey(menuTarget.key) : null; + const menuThreadShell = useMemo( + () => + menuThreadRef + ? threads.find( + (thread) => + thread.environmentId === menuThreadRef.environmentId && + thread.id === menuThreadRef.threadId, + ) + : undefined, + [menuThreadRef, threads], + ); + const menuProjectCwd = useMemo(() => { + if (!menuThreadShell) return null; + for (const group of projectGroups) { + const member = group.memberProjects.find( + (project) => + project.environmentId === menuThreadShell.environmentId && + project.id === menuThreadShell.projectId, + ); + if (member) return member.workspaceRoot; + } + return null; + }, [menuThreadShell, projectGroups]); + const { openMenu: openThreadMenu } = useThreadActionMenu({ + threadRef: menuThreadRef, + projectCwd: menuProjectCwd, + onStartRename: () => { + if (!menuThreadRef) return; + void router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(menuThreadRef), + }); + // The workspace bridge for that thread claims the request once mounted. + requestShellRename(scopedThreadKey(menuThreadRef)); + }, + }); + useEffect(() => { + if (!menuTarget || !menuThreadRef) return; + void Promise.resolve().then(() => { + openThreadMenu({ x: menuTarget.x, y: menuTarget.y, surface: "shell" }); + }); + // Only re-open for a new request, not for hook identity churn. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [menuTarget?.seq]); + const draftSessions = useComposerDraftStore((store) => store.draftThreadsByThreadKey); + const draftContents = useComposerDraftStore((store) => store.draftsByThreadKey); + + const scopedGroup = useMemo( + () => + scopeProjectKey === null + ? null + : (projectGroups.find((group) => group.projectKey === scopeProjectKey) ?? null), + [projectGroups, scopeProjectKey], + ); + const scopedProjectKeys = useMemo( + () => + scopedGroup === null ? null : new Set(scopedGroup.memberProjectRefs.map(scopedProjectKey)), + [scopedGroup], + ); + useEffect(() => { + if (scopeProjectKey !== null && scopedGroup === null) { + setScopeProjectKey(null); + } + }, [scopeProjectKey, scopedGroup]); + + const capabilitiesFor = useCallback( + (environmentId: EnvironmentId) => serverConfigs.get(environmentId)?.environment.capabilities, + [serverConfigs], + ); + const partition = useMemo( + () => + partitionSidebarThreads({ + threads, + scopedProjectKeys, + capabilitiesFor, + preciseNow: new Date().toISOString(), + }), + // nowMinute re-runs the partition so snoozed threads wake on time. + [capabilitiesFor, nowMinute, scopedProjectKeys, threads], + ); + const rowActions = useShellThreadRowActions({ partition, activeThreadKey }); + + const threadCountByLogicalKey = useMemo(() => { + const logicalKeyByPhysicalKey = buildLogicalProjectKeyMap(projectGroups); + const counts = new Map(); + for (const thread of threads) { + if (thread.archivedAt !== null) continue; + const key = logicalKeyByPhysicalKey.get( + scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), + ); + if (key === undefined) continue; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return counts; + }, [projectGroups, threads]); + + const drafts = useMemo((): ReadonlyArray => { + const logicalKeyByPhysicalKey = buildLogicalProjectKeyMap(projectGroups); + const result: ShellSidebarDraft[] = []; + for (const [draftId, session] of Object.entries(draftSessions)) { + if (session.promotedTo != null) continue; + if (!composerDraftHasUserContent(draftContents[draftId])) continue; + const physicalKey = scopedProjectKey( + scopeProjectRef(session.environmentId, session.projectId), + ); + if (scopedProjectKeys !== null && !scopedProjectKeys.has(physicalKey)) continue; + result.push({ + draftId, + projectKey: logicalKeyByPhysicalKey.get(physicalKey) ?? physicalKey, + label: "Draft", + }); + } + return result; + }, [draftContents, draftSessions, projectGroups, scopedProjectKeys]); + + const state = useMemo( + (): ShellSidebarState => + buildShellSidebarState({ + projectGroups, + scopeProjectKey, + partition, + capabilitiesFor, + threadCountByLogicalKey, + lastVisitedAtByKey, + drafts, + activeThreadKey, + activeDraftId: routeTarget?.kind === "draft" ? routeTarget.draftId : null, + }), + [ + activeThreadKey, + capabilitiesFor, + drafts, + lastVisitedAtByKey, + partition, + projectGroups, + routeTarget, + scopeProjectKey, + threadCountByLogicalKey, + ], + ); + + useShellPublish("sidebar", state); + + const shellKeybindings = useMemo( + () => buildShellKeybindings(keybindings, navigator.platform), + [keybindings], + ); + useShellPublish("keybindings", shellKeybindings); + + // The HTML sidebar owns thread traversal and is not mounted under the + // shell, so the same keydown handling lives here, over the same order the + // native rows render in. + const orderedThreadKeys = useMemo( + () => + [ + ...partition.pinnedThreads, + ...partition.activeThreads, + ...partition.snoozedThreads, + ...partition.settledThreads, + ].map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + [partition], + ); + useEffect(() => { + const onWindowKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.repeat) return; + const command = resolveShortcutCommand(event, keybindings, { + platform: navigator.platform, + context: { terminalFocus: isTerminalFocused() }, + }); + const direction = threadTraversalDirectionFromCommand(command); + const jumpIndex = threadJumpIndexFromCommand(command ?? ""); + if (direction === null && jumpIndex === null) return; + const targetKey = + direction !== null + ? resolveAdjacentThreadId({ + threadIds: orderedThreadKeys, + currentThreadId: activeThreadKey, + direction, + }) + : (orderedThreadKeys[jumpIndex ?? -1] ?? null); + const threadRef = targetKey === null ? null : parseScopedThreadKey(targetKey); + if (threadRef === null) return; + event.preventDefault(); + event.stopPropagation(); + void router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }); + }; + window.addEventListener("keydown", onWindowKeyDown); + return () => window.removeEventListener("keydown", onWindowKeyDown); + }, [activeThreadKey, keybindings, orderedThreadKeys, router]); + + useShellActions((action) => { + switch (action.type) { + case "thread.open": { + const threadRef = parseScopedThreadKey(action.key); + if (threadRef === null) return; + void router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }); + return; + } + case "draft.open": + void router.navigate({ + to: "/draft/$draftId", + params: { draftId: DraftId.make(action.draftId) }, + }); + return; + case "thread.new": { + const group = + action.projectKey === undefined + ? projectGroups[0] + : projectGroups.find((item) => item.projectKey === action.projectKey); + if (group === undefined) { + // A stale key (project removed, environment gone) must not land + // the thread in whichever project sorts first. + if (action.projectKey === undefined) openCommandPalette({ open: "add-project" }); + return; + } + void handleNewThread(scopeProjectRef(group.environmentId, group.id)); + return; + } + case "sidebar.scope": + setScopeProjectKey(action.projectKey); + return; + case "thread.menu": + setMenuTarget((prev) => ({ + key: action.key, + x: action.x, + y: action.y, + seq: (prev?.seq ?? 0) + 1, + })); + return; + case "thread.settle": + rowActions.settle(action.key); + return; + case "thread.unsettle": + rowActions.unsettle(action.key); + return; + case "thread.unsnooze": + rowActions.unsnooze(action.key); + return; + case "thread.snoozeMenu": + rowActions.openSnoozeMenu(action.key, { x: action.x, y: action.y }); + return; + case "thread.wokeDismiss": + rowActions.dismissWoke(action.key); + return; + case "keybinding.press": { + // Native chrome had focus, so the page never saw the keydown. Replay + // it on the document: every shortcut handler listens on window, and + // a body target reads as "not typing" to all of them. + if (isT3ShellEmbed) return; + document.body.dispatchEvent( + new KeyboardEvent("keydown", { + key: action.key, + ctrlKey: action.ctrlKey, + metaKey: action.metaKey, + shiftKey: action.shiftKey, + altKey: action.altKey, + bubbles: true, + cancelable: true, + }), + ); + return; + } + case "project.add": + openCommandPalette({ open: "add-project" }); + return; + case "palette.open": + openCommandPalette({}); + return; + case "settings.open": + void router.navigate({ to: "/settings" }); + return; + case "pullRequests.open": + void router.navigate({ + to: "/pull-requests", + search: { involvement: "all", state: "open" }, + }); + return; + case "usage.open": + void router.navigate({ to: "/usage" }); + return; + } + }); + + return null; +} diff --git a/apps/web/src/shell/bridges.ts b/apps/web/src/shell/bridges.ts new file mode 100644 index 000000000000..759cb9314890 --- /dev/null +++ b/apps/web/src/shell/bridges.ts @@ -0,0 +1,14 @@ +// Everything the page only needs when a native shell hosts it. Imported +// through `./lazy` (components) or a dynamic import (context menu) so none of +// it, nor the shell schemas, lands in the browser bundle. +export { ShellComposerBridge } from "./ShellComposerBridge"; +export { ShellEmbedRouteBridge } from "./ShellEmbedRouteBridge"; +export { ShellGitBridge } from "./ShellGitBridge"; +export { ShellLayoutBridge } from "./ShellLayoutBridge"; +export { ShellRightPanelBridge } from "./ShellRightPanelBridge"; +export { ShellSettingsBridge } from "./ShellSettingsBridge"; +export { ShellThemeBridge } from "./ShellThemeBridge"; +export { ShellToastBridge } from "./ShellToastBridge"; +export { ShellWorkspaceBridge } from "./ShellWorkspaceBridge"; +export { T3ShellBridge } from "./T3ShellBridge"; +export { closeShellContextMenu, showShellContextMenu } from "./shellContextMenu"; diff --git a/apps/web/src/shell/crossDocumentStoreSync.ts b/apps/web/src/shell/crossDocumentStoreSync.ts new file mode 100644 index 000000000000..3f39c9821c8d --- /dev/null +++ b/apps/web/src/shell/crossDocumentStoreSync.ts @@ -0,0 +1,27 @@ +import type { StoreApi, UseBoundStore } from "zustand"; + +type PersistedStore = UseBoundStore> & { + persist: { rehydrate: () => void | Promise }; +}; + +/** + * Under the Qt shell the right panel's content renders in a second document + * (the embed route) that shares localStorage with the primary one. zustand's + * persist middleware writes through but never listens, so this rehydrates a + * store whenever another document wrote its key. Storage events only fire in + * documents that did not perform the write, which is exactly the set that + * needs to catch up. + */ +export function syncStoreAcrossDocuments(store: PersistedStore, storageKey: string): () => void { + if (typeof window === "undefined") { + return () => {}; + } + const listener = (event: StorageEvent) => { + if (event.storageArea !== window.localStorage || event.key !== storageKey) { + return; + } + void store.persist.rehydrate(); + }; + window.addEventListener("storage", listener); + return () => window.removeEventListener("storage", listener); +} diff --git a/apps/web/src/shell/lazy.tsx b/apps/web/src/shell/lazy.tsx new file mode 100644 index 000000000000..1e03f7d6f4e4 --- /dev/null +++ b/apps/web/src/shell/lazy.tsx @@ -0,0 +1,26 @@ +import { type ComponentType, createElement, lazy, Suspense } from "react"; + +type Bridges = typeof import("./bridges"); + +/** + * Wraps a bridge component so its module (and the shell contracts) loads only + * when it is first mounted, which only happens under a native shell. The + * bridges render nothing, so the empty Suspense fallback changes no layout. + */ +function lazyBridge

(pick: (bridges: Bridges) => ComponentType

) { + const Bridge = lazy(() => import("./bridges").then((bridges) => ({ default: pick(bridges) }))); + return function LazyShellBridge(props: P) { + return createElement(Suspense, { fallback: null }, createElement(Bridge, props)); + }; +} + +export const ShellComposerBridge = lazyBridge((b) => b.ShellComposerBridge); +export const ShellEmbedRouteBridge = lazyBridge((b) => b.ShellEmbedRouteBridge); +export const ShellGitBridge = lazyBridge((b) => b.ShellGitBridge); +export const ShellLayoutBridge = lazyBridge((b) => b.ShellLayoutBridge); +export const ShellRightPanelBridge = lazyBridge((b) => b.ShellRightPanelBridge); +export const ShellSettingsBridge = lazyBridge((b) => b.ShellSettingsBridge); +export const ShellThemeBridge = lazyBridge((b) => b.ShellThemeBridge); +export const ShellToastBridge = lazyBridge((b) => b.ShellToastBridge); +export const ShellWorkspaceBridge = lazyBridge((b) => b.ShellWorkspaceBridge); +export const T3ShellBridge = lazyBridge((b) => b.T3ShellBridge); diff --git a/apps/web/src/shell/shellComposerState.test.ts b/apps/web/src/shell/shellComposerState.test.ts new file mode 100644 index 000000000000..ca6757da48ad --- /dev/null +++ b/apps/web/src/shell/shellComposerState.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { ProviderInstanceId, ProviderOptionDescriptor, RuntimeMode } from "@t3tools/contracts"; + +import type { ProviderInstanceEntry } from "../providerInstances"; +import { applyComposerOptionChange, buildShellComposerState } from "./shellComposerState"; + +const codexInstanceId = "codex" as ProviderInstanceId; + +const effortDescriptor: ProviderOptionDescriptor = { + id: "effort", + label: "Effort", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + ], + currentValue: "low", +}; +const thinkingDescriptor: ProviderOptionDescriptor = { + id: "thinking", + label: "Thinking", + type: "boolean", + currentValue: false, +}; + +const codexEntry = { + instanceId: codexInstanceId, + driverKind: "codex", + displayName: "Codex", + enabled: true, + installed: true, + isDefault: true, + isAvailable: true, +} as ProviderInstanceEntry; + +const runtimeModes: ReadonlyArray<{ value: RuntimeMode; label: string; description: string }> = [ + { value: "approval-required", label: "Supervised", description: "Ask first." }, + { value: "full-access", label: "Full access", description: "Never ask." }, +]; + +function baseInput() { + return { + target: "env:thread-1", + routeKind: "server" as const, + text: "hello", + cursor: 5, + triggerKind: null, + suggestions: [], + suggestionsEmptyText: null, + attachments: [], + terminalContexts: [], + placeholder: "Ask anything", + editorDisabled: false, + hasSendableContent: true, + sendDisabledReason: null, + isRunning: false, + isSendBusy: false, + isConnecting: false, + environmentUnavailable: false, + noProviderAvailable: false, + projectSelectionRequired: false, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + showPlanFollowUpPrompt: false, + selectedInstanceId: codexInstanceId, + selectedModel: "gpt-5.4", + instanceEntries: [ + codexEntry, + { ...codexEntry, instanceId: "off" as ProviderInstanceId, enabled: false }, + ], + modelOptionsByInstance: new Map([ + [ + codexInstanceId, + [ + { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false }, + { slug: "gpt-5.4-mini", name: "GPT-5.4 mini", isCustom: false }, + ], + ], + ]), + getModelDisabledReason: (_instanceId: ProviderInstanceId, model: string) => + model === "gpt-5.4-mini" ? "Started with another model" : null, + optionDescriptors: [effortDescriptor, thinkingDescriptor], + runtimeMode: "approval-required" as RuntimeMode, + runtimeModes, + interactionMode: "default" as const, + showInteractionModeToggle: true, + }; +} + +describe("buildShellComposerState", () => { + it("projects enabled instances, their models, and option descriptors", () => { + const state = buildShellComposerState(baseInput()); + expect(state.canSend).toBe(true); + expect(state.instances).toEqual([ + { + instanceId: "codex", + driverKind: "codex", + displayName: "Codex", + isAvailable: true, + models: [ + { slug: "gpt-5.4", name: "GPT-5.4", disabledReason: null }, + { + slug: "gpt-5.4-mini", + name: "GPT-5.4 mini", + disabledReason: "Started with another model", + }, + ], + }, + ]); + expect(state.options).toEqual([ + { + id: "effort", + label: "Effort", + type: "select", + value: "low", + choices: [ + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + ], + }, + { id: "thinking", label: "Thinking", type: "boolean", value: false, choices: [] }, + ]); + expect(state.runtimeModes).toHaveLength(2); + }); + + it("blocks sending and explains why", () => { + expect(buildShellComposerState({ ...baseInput(), noProviderAvailable: true })).toMatchObject({ + canSend: false, + sendDisabledReason: "No provider available", + }); + expect(buildShellComposerState({ ...baseInput(), hasSendableContent: false })).toMatchObject({ + canSend: false, + sendDisabledReason: null, + }); + expect( + buildShellComposerState({ + ...baseInput(), + hasSendableContent: false, + showPlanFollowUpPrompt: true, + }).canSend, + ).toBe(true); + expect( + buildShellComposerState({ ...baseInput(), sendDisabledReason: "Messages loading" }) + .sendDisabledReason, + ).toBe("Messages loading"); + }); +}); + +describe("applyComposerOptionChange", () => { + it("updates a select option to a valid choice only", () => { + expect( + applyComposerOptionChange([effortDescriptor, thinkingDescriptor], "effort", "high"), + ).toEqual([ + { id: "effort", value: "high" }, + { id: "thinking", value: false }, + ]); + expect(applyComposerOptionChange([effortDescriptor], "effort", "extreme")).toEqual([ + { id: "effort", value: "low" }, + ]); + }); + + it("updates a boolean option only with a boolean", () => { + expect(applyComposerOptionChange([thinkingDescriptor], "thinking", true)).toEqual([ + { id: "thinking", value: true }, + ]); + expect(applyComposerOptionChange([thinkingDescriptor], "thinking", "yes")).toEqual([ + { id: "thinking", value: false }, + ]); + }); +}); diff --git a/apps/web/src/shell/shellComposerState.ts b/apps/web/src/shell/shellComposerState.ts new file mode 100644 index 000000000000..6461100a278c --- /dev/null +++ b/apps/web/src/shell/shellComposerState.ts @@ -0,0 +1,169 @@ +import type { + ModelSelection, + ProviderDriverKind, + ProviderInstanceId, + ProviderOptionDescriptor, + ProviderOptionSelection, + RuntimeMode, + ServerProviderModel, +} from "@t3tools/contracts"; +import type { + ShellComposerOption, + ShellComposerState, + ShellComposerSuggestion, +} from "@t3tools/contracts/shell"; +import { + buildProviderOptionSelectionsFromDescriptors, + getProviderOptionDescriptors, +} from "@t3tools/shared/model"; + +import type { AppModelOption } from "../modelSelection"; +import type { ProviderInstanceEntry } from "../providerInstances"; +import { getProviderModelCapabilities } from "../providerModels"; + +export interface ShellComposerStateInput { + readonly target: string | null; + readonly routeKind: "server" | "draft"; + readonly text: string; + readonly cursor: number; + readonly triggerKind: ShellComposerState["triggerKind"]; + readonly suggestions: ReadonlyArray; + readonly suggestionsEmptyText: string | null; + readonly attachments: ShellComposerState["attachments"]; + readonly terminalContexts: ShellComposerState["terminalContexts"]; + readonly placeholder: string; + readonly editorDisabled: boolean; + readonly hasSendableContent: boolean; + readonly sendDisabledReason: string | null; + readonly isRunning: boolean; + readonly isSendBusy: boolean; + readonly isConnecting: boolean; + readonly environmentUnavailable: boolean; + readonly noProviderAvailable: boolean; + readonly projectSelectionRequired: boolean; + readonly pendingApprovalCount: number; + readonly pendingUserInputCount: number; + readonly showPlanFollowUpPrompt: boolean; + readonly selectedInstanceId: ProviderInstanceId | null; + readonly selectedModel: string | null; + readonly instanceEntries: ReadonlyArray; + readonly modelOptionsByInstance: ReadonlyMap>; + readonly getModelDisabledReason: (instanceId: ProviderInstanceId, model: string) => string | null; + readonly optionDescriptors: ReadonlyArray; + readonly runtimeMode: RuntimeMode; + readonly runtimeModes: ReadonlyArray<{ value: RuntimeMode; label: string; description: string }>; + readonly interactionMode: "default" | "plan"; + readonly showInteractionModeToggle: boolean; +} + +/** The option descriptors for a model, with the draft's current selections applied. */ +export function resolveComposerOptionDescriptors(input: { + readonly provider: ProviderDriverKind; + readonly model: string; + readonly models: ReadonlyArray; + readonly selections: ReadonlyArray | null | undefined; + readonly planModeEnabled: boolean; +}): ReadonlyArray { + const caps = getProviderModelCapabilities( + input.models, + input.model, + input.provider, + input.planModeEnabled, + ); + return getProviderOptionDescriptors({ caps, selections: input.selections }); +} + +/** Applies one option change and returns the selections to persist on the draft. */ +export function applyComposerOptionChange( + descriptors: ReadonlyArray, + id: string, + value: string | boolean, +): ModelSelection["options"] { + const next = descriptors.map((descriptor) => { + if (descriptor.id !== id) return descriptor; + if (descriptor.type === "select") { + return typeof value === "string" && descriptor.options.some((choice) => choice.id === value) + ? { ...descriptor, currentValue: value } + : descriptor; + } + return typeof value === "boolean" ? { ...descriptor, currentValue: value } : descriptor; + }); + return buildProviderOptionSelectionsFromDescriptors(next); +} + +function toShellOption(descriptor: ProviderOptionDescriptor): ShellComposerOption { + return descriptor.type === "select" + ? { + id: descriptor.id, + label: descriptor.label, + type: "select", + value: descriptor.currentValue ?? null, + choices: descriptor.options.map((choice) => ({ id: choice.id, label: choice.label })), + } + : { + id: descriptor.id, + label: descriptor.label, + type: "boolean", + value: descriptor.currentValue ?? null, + choices: [], + }; +} + +export function buildShellComposerState(input: ShellComposerStateInput): ShellComposerState { + const blocked = + input.isSendBusy || + input.isConnecting || + input.environmentUnavailable || + input.noProviderAvailable || + input.projectSelectionRequired || + input.sendDisabledReason !== null; + return { + target: input.target, + routeKind: input.routeKind, + text: input.text, + cursor: input.cursor, + triggerKind: input.triggerKind, + suggestions: input.suggestions, + suggestionsEmptyText: input.suggestionsEmptyText, + attachments: input.attachments, + terminalContexts: input.terminalContexts, + placeholder: input.placeholder, + editorDisabled: input.editorDisabled, + canSend: !blocked && (input.hasSendableContent || input.showPlanFollowUpPrompt), + sendDisabledReason: + input.sendDisabledReason ?? + (input.environmentUnavailable + ? "Not connected" + : input.noProviderAvailable + ? "No provider available" + : input.projectSelectionRequired + ? "Choose a project first" + : null), + isRunning: input.isRunning, + isSendBusy: input.isSendBusy, + isConnecting: input.isConnecting, + pendingApprovalCount: input.pendingApprovalCount, + pendingUserInputCount: input.pendingUserInputCount, + showPlanFollowUpPrompt: input.showPlanFollowUpPrompt, + selectedInstanceId: input.selectedInstanceId, + selectedModel: input.selectedModel, + instances: input.instanceEntries + .filter((entry) => entry.enabled) + .map((entry) => ({ + instanceId: entry.instanceId, + driverKind: entry.driverKind, + displayName: entry.displayName, + isAvailable: entry.isAvailable, + models: (input.modelOptionsByInstance.get(entry.instanceId) ?? []).map((option) => ({ + slug: option.slug, + name: option.name, + disabledReason: input.getModelDisabledReason(entry.instanceId, option.slug), + })), + })), + options: input.optionDescriptors.map(toShellOption), + runtimeMode: input.runtimeMode, + runtimeModes: input.runtimeModes, + interactionMode: input.interactionMode, + showInteractionModeToggle: input.showInteractionModeToggle, + }; +} diff --git a/apps/web/src/shell/shellContextMenu.test.ts b/apps/web/src/shell/shellContextMenu.test.ts new file mode 100644 index 000000000000..b77ba747d9a9 --- /dev/null +++ b/apps/web/src/shell/shellContextMenu.test.ts @@ -0,0 +1,154 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import type { ContextMenuItem } from "@t3tools/contracts"; +import type { T3Shell } from "@t3tools/contracts/shell"; + +import { closeShellContextMenu, showShellContextMenu } from "./shellContextMenu"; + +type ActionListener = (type: string, payload: unknown) => void; + +// One shell for the file: the module subscribes to `onAction` once per document. +const listeners = new Set(); +const publish = vi.fn(async (_key: string, _value: unknown) => {}); +const shell = { + protocolVersion: 1, + surfaceId: "primary", + ready: Promise.resolve({}), + publish, + onAction: async (listener: ActionListener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + getState: async () => ({}), + onState: async () => () => {}, + dispatch: async () => {}, +} satisfies T3Shell; + +function select(requestId: string, id: string | null) { + for (const listener of listeners) listener("contextMenu.select", { requestId, id }); +} + +function lastPublished(): T { + return publish.mock.calls.at(-1)?.[1] as T; +} + +vi.stubGlobal("window", { t3Shell: shell }); + +const items: ContextMenuItem<"rename" | "delete">[] = [ + { id: "rename", label: "Rename" }, + { id: "delete", label: "Delete", destructive: true, separatorBefore: true }, +]; + +afterEach(() => { + closeShellContextMenu(); + publish.mockClear(); + vi.stubGlobal("window", { t3Shell: shell }); +}); + +describe("showShellContextMenu", () => { + it("keeps nested descendants selectable and preserves Qt section headers", async () => { + const result = showShellContextMenu([ + { id: "heading", label: "Project", header: true }, + { + id: "parent", + label: "Actions", + children: [ + { + id: "nested", + label: "More", + children: [{ id: "leaf", label: "Rename", separatorBefore: true }], + }, + ], + }, + { + id: "disabled", + label: "Unavailable", + disabled: true, + children: [{ id: "child", label: "Delete", destructive: true }], + }, + ]); + const published = lastPublished<{ requestId: string; items: unknown[] }>(); + expect(published.items).toEqual([ + { id: "heading", label: "Project", header: true }, + { id: "parent", label: "Actions", header: true }, + { id: "nested", label: "More", header: true }, + { id: "leaf", label: "Rename", separatorBefore: true }, + { id: "disabled", label: "Unavailable", header: true, disabled: true }, + { id: "child", label: "Delete", destructive: true, disabled: true }, + ]); + select(published.requestId, "leaf"); + await expect(result).resolves.toBe("leaf"); + }); + it("publishes the menu for the calling surface and resolves the selected id", async () => { + const result = showShellContextMenu(items, { x: 10, y: 20 }); + await Promise.resolve(); + const published = lastPublished<{ + requestId: string; + surfaceId: string; + x: number; + y: number; + items: unknown[]; + }>(); + expect(published).toMatchObject({ surfaceId: "primary", x: 10, y: 20 }); + expect(published.requestId).toMatch(/^primary:/); + expect(published.items).toEqual([ + { id: "rename", label: "Rename" }, + { id: "delete", label: "Delete", destructive: true, separatorBefore: true }, + ]); + select(published.requestId, "delete"); + await expect(result).resolves.toBe("delete"); + // The shell clears its menu once a choice is in. + expect(lastPublished()).toBeNull(); + }); + + it("targets the window when the caller passes the shell surface", async () => { + const result = showShellContextMenu(items, { x: 1, y: 2, surface: "shell" }); + await Promise.resolve(); + const published = lastPublished<{ requestId: string; surfaceId: string }>(); + expect(published.surfaceId).toBe("shell"); + select(published.requestId, null); + await expect(result).resolves.toBeNull(); + }); + + it("namespaces request ids by the calling document", async () => { + vi.stubGlobal("window", { + t3Shell: { + ...shell, + surfaceId: "rightPanel", + } satisfies T3Shell, + }); + + const result = showShellContextMenu(items); + await Promise.resolve(); + + const published = lastPublished<{ requestId: string }>(); + expect(published.requestId).toMatch(/^rightPanel:/); + closeShellContextMenu(); + await expect(result).resolves.toBeNull(); + }); + + it("ignores selections for another request and resolves null on close", async () => { + const result = showShellContextMenu(items); + await Promise.resolve(); + const published = lastPublished<{ requestId: string }>(); + select(`${published.requestId}:other`, "rename"); + closeShellContextMenu(); + await expect(result).resolves.toBeNull(); + }); + + it("dismisses the previous request when a new menu replaces it", async () => { + const firstResult = vi.fn(); + void showShellContextMenu(items).then(firstResult); + + const second = showShellContextMenu(items); + await Promise.resolve(); + + expect(firstResult).toHaveBeenCalledWith(null); + closeShellContextMenu(); + await expect(second).resolves.toBeNull(); + }); + + it("resolves null without a shell", async () => { + vi.stubGlobal("window", {}); + await expect(showShellContextMenu(items)).resolves.toBeNull(); + }); +}); diff --git a/apps/web/src/shell/shellContextMenu.ts b/apps/web/src/shell/shellContextMenu.ts new file mode 100644 index 000000000000..ee46e7fec734 --- /dev/null +++ b/apps/web/src/shell/shellContextMenu.ts @@ -0,0 +1,83 @@ +import type { ContextMenuItem } from "@t3tools/contracts"; +import type { ShellContextMenuItem } from "@t3tools/contracts/shell"; + +import { decodeShellAction } from "./useShellActions"; + +let nextRequestId = 1; +const pending = new Map void>(); +let listening = false; + +function ensureListener(): void { + if (listening || !window.t3Shell) return; + listening = true; + void window.t3Shell.onAction((type, payload) => { + const action = decodeShellAction(type, payload); + if (action === null || action.type !== "contextMenu.select") return; + const resolve = pending.get(action.requestId); + if (!resolve) return; + pending.delete(action.requestId); + resolve(action.id); + }); +} + +function toShellItems( + items: readonly ContextMenuItem[], + parentDisabled = false, +): ReadonlyArray { + return items.flatMap((item): ShellContextMenuItem[] => { + const entry = { + id: item.id, + label: item.label, + ...(item.destructive !== undefined ? { destructive: item.destructive } : {}), + ...(parentDisabled || item.disabled !== undefined + ? { disabled: parentDisabled || item.disabled } + : {}), + ...(item.header !== undefined ? { header: item.header } : {}), + ...(item.separatorBefore !== undefined ? { separatorBefore: item.separatorBefore } : {}), + }; + // Qt presents submenus as labelled sections. Flatten every depth here so + // the shell's bounded wire model never silently drops a descendant. + return item.children?.length + ? [ + { ...entry, header: true }, + ...toShellItems(item.children, parentDisabled || item.disabled === true), + ] + : [entry]; + }); +} + +/** + * The shell-hosted `localApi.contextMenu.show`: publishes the menu for the + * surface this document lives in (or window coordinates when `surface` is + * "shell") and resolves with the chosen id, or null when dismissed. + */ +export function showShellContextMenu( + items: readonly ContextMenuItem[], + position?: { x: number; y: number; surface?: string }, +): Promise { + const shell = window.t3Shell; + if (!shell) return Promise.resolve(null); + ensureListener(); + closeShellContextMenu(); + const requestId = `${shell.surfaceId}:${nextRequestId++}`; + return new Promise((resolve) => { + pending.set(requestId, (id) => { + void shell.publish("contextMenu", null); + resolve(id as T | null); + }); + void shell.publish("contextMenu", { + requestId, + surfaceId: position?.surface ?? shell.surfaceId, + x: position?.x ?? 0, + y: position?.y ?? 0, + items: toShellItems(items), + }); + }); +} + +export function closeShellContextMenu(): void { + for (const [requestId, resolve] of pending) { + pending.delete(requestId); + resolve(null); + } +} diff --git a/apps/web/src/shell/shellDocumentSync.ts b/apps/web/src/shell/shellDocumentSync.ts new file mode 100644 index 000000000000..15599e60fa6f --- /dev/null +++ b/apps/web/src/shell/shellDocumentSync.ts @@ -0,0 +1,11 @@ +import { DIFF_PANEL_STORAGE_KEY, useDiffPanelStore } from "../diffPanelStore"; +import { RIGHT_PANEL_STORAGE_KEY, useRightPanelStore } from "../rightPanelStore"; +import { TERMINAL_UI_STATE_STORAGE_KEY, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { syncStoreAcrossDocuments } from "./crossDocumentStoreSync"; + +/** Keeps the panel-related stores converged between the primary and embed documents. */ +export function enableShellDocumentSync(): void { + syncStoreAcrossDocuments(useRightPanelStore, RIGHT_PANEL_STORAGE_KEY); + syncStoreAcrossDocuments(useTerminalUiStateStore, TERMINAL_UI_STATE_STORAGE_KEY); + syncStoreAcrossDocuments(useDiffPanelStore, DIFF_PANEL_STORAGE_KEY); +} diff --git a/apps/web/src/shell/shellKeybindings.test.ts b/apps/web/src/shell/shellKeybindings.test.ts new file mode 100644 index 000000000000..59752c1e7859 --- /dev/null +++ b/apps/web/src/shell/shellKeybindings.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + compileResolvedKeybindingsConfig, + parseKeybindingShortcut, +} from "@t3tools/shared/keybindings"; + +import { + buildShellKeybindings, + shellKeybindingPressToForward, + toShellKeybinding, +} from "./shellKeybindings"; + +function shortcut(value: string) { + const parsed = parseKeybindingShortcut(value); + if (parsed === null) throw new Error(`unparseable shortcut ${value}`); + return parsed; +} + +describe("toShellKeybinding", () => { + it("maps mod to Ctrl on Linux and replays it as ctrlKey", () => { + expect(toShellKeybinding(shortcut("mod+shift+]"), "Linux x86_64")).toEqual({ + sequence: "Ctrl+Shift+]", + key: "]", + ctrlKey: true, + metaKey: false, + shiftKey: true, + altKey: false, + }); + }); + + it("maps mod to Command on macOS, which Qt spells Ctrl", () => { + expect(toShellKeybinding(shortcut("mod+b"), "MacIntel")).toMatchObject({ + sequence: "Ctrl+B", + metaKey: true, + ctrlKey: false, + }); + expect(toShellKeybinding(shortcut("ctrl+k"), "MacIntel")).toMatchObject({ + sequence: "Meta+K", + metaKey: false, + ctrlKey: true, + }); + }); + + it("names special keys the way QKeySequence expects", () => { + expect(toShellKeybinding(shortcut("mod+arrowdown"), "Linux")?.sequence).toBe("Ctrl+Down"); + expect(toShellKeybinding(shortcut("alt+space"), "Linux")?.sequence).toBe("Alt+Space"); + expect(toShellKeybinding(shortcut("mod+f5"), "Linux")?.sequence).toBe("Ctrl+F5"); + expect(toShellKeybinding(shortcut("mod++"), "Linux")?.sequence).toBe("Ctrl++"); + }); + + it("leaves unmodified and unnameable keys to the native control", () => { + expect(toShellKeybinding(shortcut("escape"), "Linux")).toBeNull(); + expect(toShellKeybinding(shortcut("shift+enter"), "Linux")).toBeNull(); + expect(toShellKeybinding(shortcut("mod+mediaplay"), "Linux")).toBeNull(); + }); +}); + +describe("buildShellKeybindings", () => { + it("publishes each sequence once regardless of how many rules share it", () => { + const config = compileResolvedKeybindingsConfig([ + { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, + { key: "mod+shift+m", command: "modelPicker.toggle" }, + { key: "escape", command: "commandPalette.toggle" }, + ]); + expect(buildShellKeybindings(config, "Linux").map((binding) => binding.sequence)).toEqual([ + "Ctrl+D", + "Ctrl+Shift+M", + ]); + }); +}); + +describe("shellKeybindingPressToForward", () => { + const config = compileResolvedKeybindingsConfig([ + { key: "mod+1", command: "thread.jump.1" }, + { key: "mod+b", command: "sidebar.toggle" }, + { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, + { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, + ]); + const ctrl = (key: string) => ({ + key, + ctrlKey: true, + metaKey: false, + shiftKey: false, + altKey: false, + }); + + it("forwards a chord the primary resolves to the same command", () => { + expect( + shellKeybindingPressToForward(ctrl("1"), config, "Linux", { terminalFocus: true }), + ).toEqual(ctrl("1")); + expect( + shellKeybindingPressToForward(ctrl("b"), config, "Linux", { terminalFocus: true }), + ).toEqual(ctrl("b")); + }); + + it("keeps a chord whose command depends on the embed's focus", () => { + expect( + shellKeybindingPressToForward(ctrl("d"), config, "Linux", { terminalFocus: true }), + ).toBeNull(); + expect( + shellKeybindingPressToForward(ctrl("w"), config, "Linux", { terminalFocus: true }), + ).toBeNull(); + }); + + it("normalizes shifted letter and named keys before crossing the shell contract", () => { + const shifted = compileResolvedKeybindingsConfig([ + { key: "mod+shift+b", command: "sidebar.toggle" }, + { key: "mod+arrowdown", command: "thread.jump.1" }, + ]); + expect( + shellKeybindingPressToForward({ ...ctrl("B"), shiftKey: true }, shifted, "Linux", {}), + ).toEqual({ ...ctrl("b"), shiftKey: true }); + expect(shellKeybindingPressToForward(ctrl("ArrowDown"), shifted, "Linux", {})).toEqual( + ctrl("arrowdown"), + ); + }); + + it("ignores keys bound to nothing", () => { + expect(shellKeybindingPressToForward(ctrl("z"), config, "Linux", {})).toBeNull(); + }); +}); diff --git a/apps/web/src/shell/shellKeybindings.ts b/apps/web/src/shell/shellKeybindings.ts new file mode 100644 index 000000000000..30d3b8683888 --- /dev/null +++ b/apps/web/src/shell/shellKeybindings.ts @@ -0,0 +1,117 @@ +import type { KeybindingShortcut, ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import type { ShellKeybinding, ShellKeybindingsState } from "@t3tools/contracts/shell"; + +import { isMacPlatform } from "../lib/utils"; +import { + resolveShortcutCommand, + type ShortcutEventLike, + type ShortcutMatchContext, +} from "../keybindings"; + +/** Web `event.key` names (lowercased by the parser) → portable QKeySequence names. */ +const QT_KEY_NAMES: Readonly> = { + " ": "Space", + arrowdown: "Down", + arrowleft: "Left", + arrowright: "Right", + arrowup: "Up", + backspace: "Backspace", + delete: "Del", + end: "End", + enter: "Return", + escape: "Esc", + home: "Home", + insert: "Ins", + pagedown: "PgDown", + pageup: "PgUp", + tab: "Tab", +}; + +function qtKeyName(key: string): string | null { + const named = QT_KEY_NAMES[key]; + if (named !== undefined) return named; + if (/^f([1-9]|1\d|2[0-4])$/.test(key)) return key.toUpperCase(); + if (key.length === 1) return key.toUpperCase(); + return null; +} + +/** + * One page shortcut as the shell should register it, or null when the shell + * must leave it alone: unmodified keys belong to whichever native control has + * focus, and a key Qt has no name for cannot be registered at all. + */ +export function toShellKeybinding( + shortcut: KeybindingShortcut, + platform: string, +): ShellKeybinding | null { + const mac = isMacPlatform(platform); + const metaKey = shortcut.metaKey || (shortcut.modKey && mac); + const ctrlKey = shortcut.ctrlKey || (shortcut.modKey && !mac); + if (!metaKey && !ctrlKey && !shortcut.altKey) return null; + const keyName = qtKeyName(shortcut.key); + if (keyName === null) return null; + // Qt's portable names are swapped on macOS: "Ctrl" is Command, "Meta" is Control. + const parts: string[] = []; + if (mac ? metaKey : ctrlKey) parts.push("Ctrl"); + if (mac ? ctrlKey : metaKey) parts.push("Meta"); + if (shortcut.altKey) parts.push("Alt"); + if (shortcut.shiftKey) parts.push("Shift"); + parts.push(keyName); + return { + sequence: parts.join("+"), + key: shortcut.key, + ctrlKey, + metaKey, + shiftKey: shortcut.shiftKey, + altKey: shortcut.altKey, + }; +} + +/** + * Every distinct sequence the page listens for. Rules that share a sequence + * (terminal vs. chat variants of the same chord) collapse to one entry: the + * page resolves the command, with its `when` clauses, once the key is + * replayed. + */ +export function buildShellKeybindings( + config: ResolvedKeybindingsConfig, + platform: string, +): ShellKeybindingsState { + const seen = new Set(); + const result: ShellKeybinding[] = []; + for (const rule of config) { + const binding = toShellKeybinding(rule.shortcut, platform); + if (binding === null || seen.has(binding.sequence)) continue; + seen.add(binding.sequence); + result.push(binding); + } + return result; +} + +export type ShellKeybindingPress = Omit; + +/** + * The `keybinding.press` a secondary document forwards for a keydown it + * received, or null when the key is not the primary's to act on. The primary + * replays a press on its body, that is with nothing focused, so only a chord + * that resolves to the same command with the embed's focus context and + * without one is forwarded: `mod+1` jumps threads either way, while `mod+d` + * splits the focused terminal here and would toggle the diff there. + */ +export function shellKeybindingPressToForward( + event: ShortcutEventLike, + config: ResolvedKeybindingsConfig, + platform: string, + context: Partial, +): ShellKeybindingPress | null { + const command = resolveShortcutCommand(event, config, { platform, context }); + if (command === null) return null; + if (resolveShortcutCommand(event, config, { platform }) !== command) return null; + return { + key: event.key.toLowerCase(), + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + }; +} diff --git a/apps/web/src/shell/shellRenameRequest.test.ts b/apps/web/src/shell/shellRenameRequest.test.ts new file mode 100644 index 000000000000..fabc9160c38a --- /dev/null +++ b/apps/web/src/shell/shellRenameRequest.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { requestShellRename, subscribeShellRenameRequests } from "./shellRenameRequest"; + +describe("shell rename requests", () => { + it("delivers to a mounted subscriber right away", () => { + const listener = vi.fn(); + const unsubscribe = subscribeShellRenameRequests("env:a", listener); + requestShellRename("env:a"); + expect(listener).toHaveBeenCalledTimes(1); + unsubscribe(); + requestShellRename("env:a"); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("holds an unclaimed request for the view that mounts next", () => { + requestShellRename("env:b"); + const listener = vi.fn(); + const unsubscribe = subscribeShellRenameRequests("env:b", listener); + expect(listener).toHaveBeenCalledTimes(1); + unsubscribe(); + + const again = vi.fn(); + subscribeShellRenameRequests("env:b", again)(); + expect(again).not.toHaveBeenCalled(); + }); + + it("drops a pending request when a view for another thread mounts", () => { + requestShellRename("env:c"); + const other = vi.fn(); + subscribeShellRenameRequests("env:other", other)(); + expect(other).not.toHaveBeenCalled(); + + const target = vi.fn(); + subscribeShellRenameRequests("env:c", target)(); + expect(target).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/shell/shellRenameRequest.ts b/apps/web/src/shell/shellRenameRequest.ts new file mode 100644 index 000000000000..40039223c333 --- /dev/null +++ b/apps/web/src/shell/shellRenameRequest.ts @@ -0,0 +1,36 @@ +type Listener = () => void; + +const listeners = new Map>(); +let pending: string | null = null; + +/** + * A sidebar "Rename" hands the request to the view showing that thread. That + * view usually mounts after the navigation that follows, so an unclaimed + * request waits for it; a view for any other thread mounting drops it. + */ +export function requestShellRename(threadKey: string): void { + const targets = listeners.get(threadKey); + if (targets !== undefined && targets.size > 0) { + for (const listener of targets) listener(); + return; + } + pending = threadKey; +} + +export function subscribeShellRenameRequests(threadKey: string, listener: Listener): () => void { + let targets = listeners.get(threadKey); + if (targets === undefined) { + targets = new Set(); + listeners.set(threadKey, targets); + } + targets.add(listener); + if (pending !== null) { + const claimed = pending === threadKey; + pending = null; + if (claimed) listener(); + } + return () => { + targets.delete(listener); + if (targets.size === 0) listeners.delete(threadKey); + }; +} diff --git a/apps/web/src/shell/shellRightPanelState.test.ts b/apps/web/src/shell/shellRightPanelState.test.ts new file mode 100644 index 000000000000..a6787dfe1fe2 --- /dev/null +++ b/apps/web/src/shell/shellRightPanelState.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { RightPanelSurface } from "../rightPanelStore"; +import { buildEmbedPath, buildShellRightPanelState } from "./shellRightPanelState"; + +describe("buildShellRightPanelState", () => { + it("projects surfaces with titles and the embed path", () => { + const surfaces: RightPanelSurface[] = [ + { id: "diff", kind: "diff" }, + { + id: "terminal:1", + kind: "terminal", + resourceId: "1", + terminalIds: ["t1"], + activeTerminalId: "t1", + }, + { + id: "file:src/a.ts", + kind: "file", + relativePath: "src/a.ts", + revealLine: null, + revealRequestId: 0, + }, + ]; + const state = buildShellRightPanelState({ + threadKey: "env:thread", + environmentId: "env", + threadId: "thread", + isOpen: true, + activeSurfaceId: "terminal:1", + surfaces, + titleFor: (surface) => (surface.kind === "file" ? surface.relativePath : surface.kind), + canAdd: { diff: true, files: true, terminal: true, pullRequest: false, agents: true }, + }); + expect(state.surfaces).toEqual([ + { id: "diff", kind: "diff", title: "diff" }, + { id: "terminal:1", kind: "terminal", title: "terminal" }, + { id: "file:src/a.ts", kind: "file", title: "src/a.ts" }, + ]); + expect(state.activeSurfaceId).toBe("terminal:1"); + expect(state.embedPath).toBe("/embed/env/thread"); + }); + + it("encodes path segments", () => { + expect(buildEmbedPath("env/1", "thread 2")).toBe("/embed/env%2F1/thread%202"); + }); +}); diff --git a/apps/web/src/shell/shellRightPanelState.ts b/apps/web/src/shell/shellRightPanelState.ts new file mode 100644 index 000000000000..1dedf6b20cc7 --- /dev/null +++ b/apps/web/src/shell/shellRightPanelState.ts @@ -0,0 +1,39 @@ +import type { ShellRightPanelState } from "@t3tools/contracts/shell"; + +import type { RightPanelSurface } from "../rightPanelStore"; + +export interface ShellRightPanelStateInput { + readonly threadKey: string; + readonly environmentId: string; + readonly threadId: string; + readonly isOpen: boolean; + readonly activeSurfaceId: string | null; + readonly surfaces: ReadonlyArray; + readonly titleFor: (surface: RightPanelSurface) => string; + readonly canAdd: ShellRightPanelState["canAdd"]; +} + +/** The thread's embed route: the right panel's content, or the terminal drawer's. */ +export function buildEmbedPath( + environmentId: string, + threadId: string, + surface: "panel" | "terminal" = "panel", +): string { + const path = `/embed/${encodeURIComponent(environmentId)}/${encodeURIComponent(threadId)}`; + return surface === "terminal" ? `${path}?surface=terminal` : path; +} + +export function buildShellRightPanelState(input: ShellRightPanelStateInput): ShellRightPanelState { + return { + threadKey: input.threadKey, + isOpen: input.isOpen, + activeSurfaceId: input.activeSurfaceId, + surfaces: input.surfaces.map((surface) => ({ + id: surface.id, + kind: surface.kind, + title: input.titleFor(surface), + })), + canAdd: input.canAdd, + embedPath: buildEmbedPath(input.environmentId, input.threadId), + }; +} diff --git a/apps/web/src/shell/shellSettingsState.test.ts b/apps/web/src/shell/shellSettingsState.test.ts new file mode 100644 index 000000000000..906986c3caa7 --- /dev/null +++ b/apps/web/src/shell/shellSettingsState.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildShellSettingsState, + isSettingsPath, + resolveActiveSettingsSection, +} from "./shellSettingsState"; + +describe("buildShellSettingsState", () => { + it("rejects inherited object properties as settings destinations", () => { + expect(isSettingsPath("/settings/general")).toBe(true); + for (const path of ["constructor", "toString", "__proto__", "/settings/missing"]) { + expect(isSettingsPath(path)).toBe(false); + } + }); + it("is inactive off the settings routes and lists every section", () => { + const state = buildShellSettingsState({ pathname: "/env/thread", searchQuery: "" }); + expect(state.active).toBe(false); + expect(state.activeSection).toBeNull(); + expect(state.sections.map((section) => section.to)).toContain("/settings/general"); + expect(state.searchResults).toEqual([]); + }); + + it("marks the active section and resolves search results with their section label", () => { + const state = buildShellSettingsState({ + pathname: "/settings/keybindings", + searchQuery: "theme", + }); + expect(state.active).toBe(true); + expect(state.activeSection).toBe("/settings/keybindings"); + expect(state.searchResults.length).toBeGreaterThan(0); + for (const result of state.searchResults) { + expect(result.sectionLabel.length).toBeGreaterThan(0); + expect(result.to.startsWith("/settings/")).toBe(true); + } + }); + + it("resolves nested settings paths to their section", () => { + expect(resolveActiveSettingsSection("/settings/providers/codex")).toBe("/settings/providers"); + expect(resolveActiveSettingsSection("/settings")).toBeNull(); + }); +}); diff --git a/apps/web/src/shell/shellSettingsState.ts b/apps/web/src/shell/shellSettingsState.ts new file mode 100644 index 000000000000..4131ffff9383 --- /dev/null +++ b/apps/web/src/shell/shellSettingsState.ts @@ -0,0 +1,42 @@ +import type { ShellSettingsState } from "@t3tools/contracts/shell"; + +import { + SETTINGS_SECTION_LABELS, + searchSettings, + type SettingsPath, +} from "../components/settings/settingsSearch"; + +export const SETTINGS_SECTIONS: ReadonlyArray<{ to: SettingsPath; label: string }> = ( + Object.keys(SETTINGS_SECTION_LABELS) as SettingsPath[] +).map((to) => ({ to, label: SETTINGS_SECTION_LABELS[to] })); + +export function isSettingsPath(value: string): value is SettingsPath { + return Object.hasOwn(SETTINGS_SECTION_LABELS, value); +} + +export function resolveActiveSettingsSection(pathname: string): SettingsPath | null { + return ( + SETTINGS_SECTIONS.find( + (section) => pathname === section.to || pathname.startsWith(`${section.to}/`), + )?.to ?? null + ); +} + +export function buildShellSettingsState(input: { + readonly pathname: string; + readonly searchQuery: string; +}): ShellSettingsState { + return { + active: input.pathname === "/settings" || input.pathname.startsWith("/settings/"), + sections: SETTINGS_SECTIONS, + activeSection: resolveActiveSettingsSection(input.pathname), + searchQuery: input.searchQuery, + searchResults: searchSettings(input.searchQuery).map((item) => ({ + id: item.id, + title: item.title, + to: item.to, + sectionLabel: SETTINGS_SECTION_LABELS[item.to], + targetId: item.targetId ?? null, + })), + }; +} diff --git a/apps/web/src/shell/shellSidebarState.test.ts b/apps/web/src/shell/shellSidebarState.test.ts new file mode 100644 index 000000000000..58e852f7b7c9 --- /dev/null +++ b/apps/web/src/shell/shellSidebarState.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; + +import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "../types"; +import type { SidebarProjectSnapshot } from "../sidebarProjectGrouping"; +import { + SHELL_SIDEBAR_SETTLED_LIMIT, + buildLogicalProjectKeyMap, + buildShellSidebarState, +} from "./shellSidebarState"; + +const environmentId = EnvironmentId.make("environment-local"); +const projectId = ProjectId.make("project-1"); + +function makeThread(overrides: Partial = {}): EnvironmentThreadShell { + return { + id: ThreadId.make("thread-1"), + environmentId, + projectId, + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +} + +function makeProjectGroup(): SidebarProjectSnapshot { + return { + id: projectId, + environmentId, + title: "T3 Code", + workspaceRoot: "/repo", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:00:00.000Z", + projectKey: "logical:t3code", + displayName: "T3 Code", + groupedProjectCount: 1, + environmentPresence: "local-only", + allRemoteMembersAreDesktopLocal: false, + allRemoteMembersAreWsl: false, + memberProjects: [], + memberProjectRefs: [{ environmentId, projectId }], + remoteEnvironmentLabels: [], + }; +} + +describe("buildLogicalProjectKeyMap", () => { + it("maps every member ref of a group to the group's logical key", () => { + const map = buildLogicalProjectKeyMap([makeProjectGroup()]); + expect(map.get(`${environmentId}:${projectId}`)).toBe("logical:t3code"); + }); +}); + +describe("buildShellSidebarState", () => { + const partition = { + pinnedThreads: [ + makeThread({ id: ThreadId.make("pinned"), pinnedAt: "2026-03-09T11:00:00.000Z" }), + ], + reorderablePinnedKeys: new Set(), + activeThreads: [ + makeThread({ + id: ThreadId.make("working"), + title: "Working thread", + branch: "feat/x", + session: { status: "running" } as never, + latestTurn: { completedAt: "2026-03-09T12:00:00.000Z" } as never, + }), + ], + snoozedThreads: [ + makeThread({ + id: ThreadId.make("napping"), + snoozedAt: "2026-03-09T11:30:00.000Z", + snoozedUntil: "2026-03-09T13:30:00.000Z", + }), + ], + settledThreads: Array.from({ length: SHELL_SIDEBAR_SETTLED_LIMIT + 5 }, (_, index) => + makeThread({ id: ThreadId.make(`settled-${index}`) }), + ), + snoozeNow: "2026-03-09T12:00:00.000Z", + }; + + it("projects threads onto shell rows with keys, status, and unread", () => { + const state = buildShellSidebarState({ + projectGroups: [makeProjectGroup()], + scopeProjectKey: null, + partition, + capabilitiesFor: () => ({ threadSettlement: true, threadSnooze: true }), + threadCountByLogicalKey: new Map([["logical:t3code", 3]]), + lastVisitedAtByKey: { [`${environmentId}:working`]: "2026-03-09T11:00:00.000Z" }, + drafts: [], + activeThreadKey: `${environmentId}:pinned`, + activeDraftId: null, + }); + + expect(state.projects).toEqual([ + { + key: "logical:t3code", + displayName: "T3 Code", + environmentId, + projectId, + workspaceRoot: "/repo", + threadCount: 3, + }, + ]); + expect(state.pinned[0]).toMatchObject({ + key: `${environmentId}:pinned`, + projectKey: "logical:t3code", + pinned: true, + status: "ready", + canSettle: true, + canSnooze: true, + wakeLabel: null, + wokeAt: null, + }); + expect(state.snoozed[0]).toMatchObject({ key: `${environmentId}:napping`, wakeLabel: "2h" }); + expect(state.active[0]).toMatchObject({ + key: `${environmentId}:working`, + title: "Working thread", + branch: "feat/x", + status: "working", + statusLabel: "Working", + unread: true, + }); + expect(state.activeThreadKey).toBe(`${environmentId}:pinned`); + }); + + it("caps settled rows and reports the full count", () => { + const state = buildShellSidebarState({ + projectGroups: [makeProjectGroup()], + scopeProjectKey: "logical:t3code", + partition, + capabilitiesFor: () => undefined, + threadCountByLogicalKey: new Map(), + lastVisitedAtByKey: {}, + drafts: [{ draftId: "draft-1", projectKey: "logical:t3code", label: "Draft" }], + activeThreadKey: null, + activeDraftId: "draft-1", + }); + expect(state.pinned[0]).toMatchObject({ canSettle: false, canSnooze: false }); + expect(state.settled).toHaveLength(SHELL_SIDEBAR_SETTLED_LIMIT); + expect(state.settledTotal).toBe(SHELL_SIDEBAR_SETTLED_LIMIT + 5); + expect(state.scopeProjectKey).toBe("logical:t3code"); + expect(state.drafts).toHaveLength(1); + expect(state.activeDraftId).toBe("draft-1"); + }); + + it("keeps the woke signal until the user visits after the wake", () => { + const woke = makeThread({ + id: ThreadId.make("woke"), + snoozedAt: "2026-03-09T09:00:00.000Z", + snoozedUntil: "2026-03-09T11:00:00.000Z", + }); + const build = (lastVisitedAt: string | undefined) => + buildShellSidebarState({ + projectGroups: [makeProjectGroup()], + scopeProjectKey: null, + partition: { ...partition, activeThreads: [woke] }, + capabilitiesFor: () => ({ threadSnooze: true }), + threadCountByLogicalKey: new Map(), + lastVisitedAtByKey: { [`${environmentId}:woke`]: lastVisitedAt }, + drafts: [], + activeThreadKey: null, + activeDraftId: null, + }); + expect(build(undefined).active[0]?.wokeAt).toBe("2026-03-09T11:00:00.000Z"); + expect(build("2026-03-09T10:00:00.000Z").active[0]?.wokeAt).toBe("2026-03-09T11:00:00.000Z"); + expect(build("2026-03-09T11:30:00.000Z").active[0]?.wokeAt).toBeNull(); + expect(build(undefined).active[0]).toMatchObject({ canSettle: false, canSnooze: true }); + }); +}); diff --git a/apps/web/src/shell/shellSidebarState.ts b/apps/web/src/shell/shellSidebarState.ts new file mode 100644 index 000000000000..825e8ee5b5c3 --- /dev/null +++ b/apps/web/src/shell/shellSidebarState.ts @@ -0,0 +1,151 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { + scopeProjectRef, + scopeThreadRef, + scopedProjectKey, + scopedThreadKey, +} from "@t3tools/client-runtime/environment"; +import { + canSnooze, + snoozeWakeLabel, + threadWokeAt, +} from "@t3tools/client-runtime/state/thread-settled"; +import type { + ShellSidebarDraft, + ShellSidebarState, + ShellSidebarThread, +} from "@t3tools/contracts/shell"; + +import { + hasUnseenCompletion, + resolveSidebarThreadStatus, + resolveThreadStatusPill, + type SidebarThreadCapabilities, + type SidebarThreadPartition, +} from "../components/Sidebar.logic"; +import type { SidebarProjectSnapshot } from "../sidebarProjectGrouping"; + +/** Settled rows beyond this are summarised by `settledTotal`. */ +export const SHELL_SIDEBAR_SETTLED_LIMIT = 50; + +export interface ShellSidebarStateInput { + readonly projectGroups: ReadonlyArray; + readonly scopeProjectKey: string | null; + readonly partition: SidebarThreadPartition; + readonly capabilitiesFor: (environmentId: EnvironmentId) => SidebarThreadCapabilities | undefined; + readonly threadCountByLogicalKey: ReadonlyMap; + readonly lastVisitedAtByKey: Readonly>; + readonly drafts: ReadonlyArray; + readonly activeThreadKey: string | null; + readonly activeDraftId: string | null; +} + +/** `:` → logical project key, from the grouped snapshots. */ +export function buildLogicalProjectKeyMap( + projectGroups: ReadonlyArray, +): Map { + const map = new Map(); + for (const group of projectGroups) { + for (const ref of group.memberProjectRefs) { + map.set(scopedProjectKey(ref), group.projectKey); + } + } + return map; +} + +interface ToShellThreadOptions { + readonly logicalKeyByPhysicalKey: ReadonlyMap; + readonly lastVisitedAtByKey: Readonly>; + readonly capabilitiesFor: (environmentId: EnvironmentId) => SidebarThreadCapabilities | undefined; + /** The partition's clock, so wake times agree with which shelf the row landed on. */ + readonly now: string; + /** Rows on the snoozed shelf carry their wake label; woken rows elsewhere do not. */ + readonly snoozed: boolean; +} + +/** + * The woke indicator survives until the user re-engages after the wake, the + * same rule the HTML row applies: an unparseable visit counts as never + * visited, and a thread settled by hand has nothing left to wake for. + */ +function visibleWokeAt( + thread: EnvironmentThreadShell, + lastVisitedAt: string | undefined, + now: string, +): string | null { + if (thread.settledOverride === "settled") return null; + const wokeAt = threadWokeAt(thread, { now }); + if (wokeAt === null) return null; + if (lastVisitedAt === undefined) return wokeAt; + const visitedMs = Date.parse(lastVisitedAt); + if (Number.isNaN(visitedMs) || visitedMs < Date.parse(wokeAt)) return wokeAt; + return null; +} + +function toShellThread( + thread: EnvironmentThreadShell, + options: ToShellThreadOptions, +): ShellSidebarThread { + const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const lastVisitedAt = options.lastVisitedAtByKey[key]; + const statusInput = { ...thread, lastVisitedAt }; + const capabilities = options.capabilitiesFor(thread.environmentId); + const physicalProjectKey = scopedProjectKey( + scopeProjectRef(thread.environmentId, thread.projectId), + ); + return { + key, + threadId: thread.id, + environmentId: thread.environmentId, + projectKey: options.logicalKeyByPhysicalKey.get(physicalProjectKey) ?? physicalProjectKey, + title: thread.title, + status: resolveSidebarThreadStatus(thread), + statusLabel: resolveThreadStatusPill({ thread: statusInput })?.label ?? null, + unread: hasUnseenCompletion(statusInput), + branch: thread.branch, + updatedAt: thread.updatedAt, + pinned: thread.pinnedAt != null, + snoozedUntil: thread.snoozedUntil ?? null, + wakeLabel: + options.snoozed && thread.snoozedUntil != null + ? snoozeWakeLabel(thread.snoozedUntil, { now: options.now }) + : null, + wokeAt: visibleWokeAt(thread, lastVisitedAt, options.now), + canSettle: capabilities?.threadSettlement === true, + canSnooze: capabilities?.threadSnooze === true && canSnooze(thread, { now: options.now }), + }; +} + +export function buildShellSidebarState(input: ShellSidebarStateInput): ShellSidebarState { + const logicalKeyByPhysicalKey = buildLogicalProjectKeyMap(input.projectGroups); + const convert = (threads: ReadonlyArray, snoozed = false) => + threads.map((thread) => + toShellThread(thread, { + logicalKeyByPhysicalKey, + lastVisitedAtByKey: input.lastVisitedAtByKey, + capabilitiesFor: input.capabilitiesFor, + now: input.partition.snoozeNow, + snoozed, + }), + ); + return { + projects: input.projectGroups.map((group) => ({ + key: group.projectKey, + displayName: group.displayName, + environmentId: group.environmentId, + projectId: group.id, + workspaceRoot: group.workspaceRoot, + threadCount: input.threadCountByLogicalKey.get(group.projectKey) ?? 0, + })), + scopeProjectKey: input.scopeProjectKey, + pinned: convert(input.partition.pinnedThreads), + active: convert(input.partition.activeThreads), + snoozed: convert(input.partition.snoozedThreads, true), + settled: convert(input.partition.settledThreads.slice(0, SHELL_SIDEBAR_SETTLED_LIMIT)), + settledTotal: input.partition.settledThreads.length, + drafts: input.drafts, + activeThreadKey: input.activeThreadKey, + activeDraftId: input.activeDraftId, + }; +} diff --git a/apps/web/src/shell/shellThemeOverride.test.ts b/apps/web/src/shell/shellThemeOverride.test.ts new file mode 100644 index 000000000000..5a334cf4523c --- /dev/null +++ b/apps/web/src/shell/shellThemeOverride.test.ts @@ -0,0 +1,174 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { act, createElement, useEffect } from "react"; +import { create } from "react-test-renderer"; + +import type { ShellThemeBootstrap } from "./shellThemeOverride"; + +function documentFixture(bootstrap: ShellThemeBootstrap = {}) { + const variables = new Map(); + const classes = new Set(); + const storage = new Map([["t3code:theme", "light"]]); + const root = { + dataset: {} as Record, + classList: { + add: (name: string) => classes.add(name), + remove: (name: string) => classes.delete(name), + toggle: (name: string, enabled: boolean) => + enabled ? classes.add(name) : classes.delete(name), + }, + style: { + backgroundColor: "", + setProperty: (name: string, value: string) => variables.set(name, value), + removeProperty: (name: string) => { + if (name === "background-color") root.style.backgroundColor = ""; + return variables.delete(name); + }, + }, + }; + vi.stubGlobal("document", { documentElement: root }); + vi.stubGlobal("window", { + __t3ShellTheme: bootstrap, + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + matchMedia: () => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }), + }); + vi.stubGlobal("requestAnimationFrame", () => 1); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + return { root, variables, classes, storage, bootstrap }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); +}); + +const shell = { + id: "shell-night", + dark: true, + vars: { "--app-theme-canvas": "#123456", "--boot-background": "#123456" }, +}; + +describe("shell theme ownership", () => { + it("releases the inline chrome background when replaced or removed", async () => { + const { root } = documentFixture(); + const { setDocumentThemeOverride } = await import("../documentThemeOverride"); + const withChrome = { ...shell, vars: { "--app-theme-chrome": "#123456" } }; + setDocumentThemeOverride(withChrome); + expect(root.style.backgroundColor).toBe("#123456"); + setDocumentThemeOverride(shell); + expect(root.style.backgroundColor).toBe(""); + setDocumentThemeOverride(withChrome); + setDocumentThemeOverride(null); + expect(root.style.backgroundColor).toBe(""); + }); + it("lets embedded documents claim the override without publishing native colors", async () => { + const disconnect = vi.fn(); + const fixture = documentFixture({ observer: { disconnect }, override: shell }); + const publish = vi.fn(); + vi.stubGlobal("window", { ...window, t3Shell: { publish } }); + const { ShellThemeBridge } = await import("./ShellThemeBridge"); + const renderer = await act(() => + create(createElement(ShellThemeBridge, { publishToShell: false })), + ); + expect(fixture.root.dataset.themeId).toBe(shell.id); + expect(disconnect).toHaveBeenCalledOnce(); + expect(publish).not.toHaveBeenCalled(); + await act(() => renderer.unmount()); + }); + + it("hands bootstrap ownership to the page and keeps the override over palettes and previews", async () => { + const disconnect = vi.fn(); + const fixture = documentFixture({ observer: { disconnect }, override: shell }); + const { claimShellThemeOverride } = await import("./shellThemeOverride"); + const { applyThemePalette, applyThemeColorPreview, T3_CHAT_THEME } = + await import("../themePalette"); + claimShellThemeOverride(); + claimShellThemeOverride(); + expect(disconnect).toHaveBeenCalledTimes(1); + expect(fixture.bootstrap.observer).toBeNull(); + for (const apply of [ + () => applyThemePalette("t3-chat", "light"), + () => applyThemeColorPreview(T3_CHAT_THEME.colors, "light"), + () => applyThemePalette("system"), + ]) { + apply(); + expect(fixture.root.dataset.themeId).toBe(shell.id); + expect(fixture.classes.has("dark")).toBe(true); + expect(fixture.variables.get("--app-theme-canvas")).toBe("#123456"); + } + expect(fixture.storage.get("t3code:theme")).toBe("light"); + }); + + it("accepts live replacement, removes stale variables, and releases unchanged stored preferences", async () => { + const fixture = documentFixture({ override: shell }); + const { claimShellThemeOverride } = await import("./shellThemeOverride"); + const { useTheme } = await import("../hooks/useTheme"); + let theme!: ReturnType; + function Probe() { + const value = useTheme(); + useEffect(() => { + theme = value; + }, [value]); + return null; + } + const renderer = await act(() => create(createElement(Probe))); + claimShellThemeOverride(); + await act(() => { + theme.setTheme("light"); + }); + fixture.bootstrap.applyOverride?.({ + id: "shell-day", + dark: false, + vars: { "--app-theme-text": "#222222" }, + }); + expect(fixture.root.dataset.themeId).toBe("shell-day"); + expect(fixture.classes.has("dark")).toBe(false); + expect(fixture.variables.has("--boot-background")).toBe(false); + expect(fixture.variables.get("--app-theme-text")).toBe("#222222"); + fixture.bootstrap.applyOverride?.({ id: "", dark: false, vars: {} }); + await act(() => { + theme.setTheme("light"); + }); + expect(fixture.root.dataset.themeId).toBeUndefined(); + expect(fixture.classes.has("dark")).toBe(false); + expect(fixture.variables.has("--app-theme-text")).toBe(false); + expect(fixture.storage.get("t3code:theme")).toBe("light"); + await act(() => renderer.unmount()); + }); + + it("ignores identical publications without rewriting the document", async () => { + const fixture = documentFixture({ override: shell }); + const { claimShellThemeOverride } = await import("./shellThemeOverride"); + const { subscribeToDocumentThemeOverride } = await import("../documentThemeOverride"); + const changed = vi.fn(); + const unsubscribe = subscribeToDocumentThemeOverride(changed); + claimShellThemeOverride(); + const write = vi.spyOn(fixture.root.style, "setProperty"); + fixture.bootstrap.applyOverride?.(structuredClone(shell)); + expect(write).not.toHaveBeenCalled(); + expect(changed).toHaveBeenCalledTimes(1); + unsubscribe(); + }); + + it("keeps older shells' observer when no override payload is available", async () => { + const disconnect = vi.fn(); + const fixture = documentFixture({ observer: { disconnect } }); + const { claimShellThemeOverride } = await import("./shellThemeOverride"); + claimShellThemeOverride(); + expect(disconnect).not.toHaveBeenCalled(); + expect(fixture.bootstrap.applyOverride).toBeUndefined(); + }); + + it("can claim before the native on-load injection", async () => { + const fixture = documentFixture(); + const { claimShellThemeOverride } = await import("./shellThemeOverride"); + claimShellThemeOverride(); + fixture.bootstrap.applyOverride?.(shell); + expect(fixture.root.dataset.themeId).toBe(shell.id); + }); +}); diff --git a/apps/web/src/shell/shellThemeOverride.ts b/apps/web/src/shell/shellThemeOverride.ts new file mode 100644 index 000000000000..cf377c6a7b87 --- /dev/null +++ b/apps/web/src/shell/shellThemeOverride.ts @@ -0,0 +1,20 @@ +import { setDocumentThemeOverride, type DocumentThemeOverride } from "../documentThemeOverride"; + +/** Shared only with the native document-creation script, not the shell action protocol. */ +export interface ShellThemeBootstrap { + observer?: { disconnect: () => void } | null; + override?: DocumentThemeOverride; + applyOverride?: (value: DocumentThemeOverride) => void; +} + +/** The page takes ownership once; native reinjections subsequently deliver input, not DOM writes. */ +export function claimShellThemeOverride(): void { + const bootstrap = (window.__t3ShellTheme ??= {}); + if (bootstrap.applyOverride) return; + // Older shells do not supply the override payload, so keep their DOM owner. + if (bootstrap.observer && !bootstrap.override) return; + bootstrap.observer?.disconnect(); + bootstrap.observer = null; + bootstrap.applyOverride = setDocumentThemeOverride; + if (bootstrap.override) setDocumentThemeOverride(bootstrap.override); +} diff --git a/apps/web/src/shell/shellThemeState.ts b/apps/web/src/shell/shellThemeState.ts new file mode 100644 index 000000000000..7d3622f1ecba --- /dev/null +++ b/apps/web/src/shell/shellThemeState.ts @@ -0,0 +1,133 @@ +import type { ShellThemeState } from "@t3tools/contracts/shell"; + +import type { ThemeColorRole } from "../themePalette"; + +type ShellProjectedThemeRole = ThemeColorRole | "success" | "info"; + +/** Theme role → the semantic CSS variable the page paints it with. */ +export const SHELL_THEME_ROLE_VARIABLES: Readonly> = { + canvas: "--background", + chrome: "--app-chrome-background", + toolbar: "--toolbar-background", + toolbarForeground: "--toolbar-foreground", + toolbarBorder: "--toolbar-border", + toolbarControl: "--toolbar-control", + toolbarControlForeground: "--toolbar-control-foreground", + toolbarControlHover: "--toolbar-control-hover", + surface: "--card", + surfaceRaised: "--surface-raised", + surfaceOverlay: "--popover", + text: "--foreground", + textMuted: "--muted-foreground", + border: "--border", + input: "--input", + focus: "--ring", + accent: "--primary", + accentForeground: "--primary-foreground", + secondary: "--secondary", + secondaryForeground: "--secondary-foreground", + muted: "--muted", + mutedForeground: "--muted-foreground", + placeholder: "--placeholder", + secondaryLabel: "--secondary-label", + iconMuted: "--icon-muted", + error: "--destructive", + errorForeground: "--destructive-foreground", + errorSurface: "--error-surface", + warning: "--warning", + warningForeground: "--warning-foreground", + warningSurface: "--warning-surface", + update: "--update", + updateForeground: "--update-foreground", + updateSurface: "--update-surface", + accentSurface: "--accent", + accentSurfaceForeground: "--accent-foreground", + messageSurface: "--message-surface", + messageForeground: "--message-foreground", + messageAction: "--message-action", + messageActionForeground: "--message-action-foreground", + messageActionHover: "--message-action-hover", + sidebar: "--sidebar", + sidebarForeground: "--sidebar-foreground", + sidebarMutedForeground: "--sidebar-muted-foreground", + sidebarControlSurface: "--sidebar-control-surface", + sidebarRowHover: "--sidebar-row-hover", + sidebarRowActive: "--sidebar-row-active", + sidebarRowSelected: "--sidebar-row-selected", + sidebarBorder: "--sidebar-border", + success: "--success", + info: "--info", +} satisfies Readonly>>; + +function toHex(channel: number): string { + return channel.toString(16).padStart(2, "0"); +} + +/** + * Resolves any CSS colour (oklch, color-mix, var chains…) to `#rrggbb[aa]` by + * painting it: the canvas converts to sRGB for us, which QML can parse. + */ +type CssColorResolver = ((value: string) => string | null) & { dispose: () => void }; + +export function createCssColorResolver(root: HTMLElement): CssColorResolver { + const canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + const context = canvas.getContext("2d", { willReadFrequently: true }); + const probe = document.createElement("span"); + probe.style.position = "absolute"; + probe.style.visibility = "hidden"; + root.appendChild(probe); + + const resolve = ((value: string) => { + const trimmed = value.trim(); + if (!context || trimmed.length === 0) return null; + probe.style.color = ""; + probe.style.color = trimmed; + const resolved = getComputedStyle(probe).color; + if (resolved.length === 0) return null; + context.clearRect(0, 0, 1, 1); + context.fillStyle = "#000"; + context.fillStyle = resolved; + context.fillRect(0, 0, 1, 1); + const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data; + if (r === undefined || g === undefined || b === undefined || a === undefined) return null; + return a === 255 + ? `#${toHex(r)}${toHex(g)}${toHex(b)}` + : `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(a)}`; + }) as CssColorResolver; + resolve.dispose = () => probe.remove(); + return resolve; +} + +export function readShellThemeState(root: HTMLElement): ShellThemeState { + const computed = getComputedStyle(root); + const resolve = createCssColorResolver(root); + const colors: Record = {}; + try { + for (const [role, variable] of Object.entries(SHELL_THEME_ROLE_VARIABLES)) { + const raw = computed.getPropertyValue(variable); + const hex = resolve(raw); + if (hex !== null) colors[role] = hex; + } + } finally { + resolve.dispose(); + } + const probe = document.createElement("div"); + probe.style.position = "absolute"; + probe.style.visibility = "hidden"; + probe.style.borderRadius = "var(--radius)"; + root.appendChild(probe); + const radius = Number.parseFloat(getComputedStyle(probe).borderTopLeftRadius) || 0; + probe.remove(); + const fontUi = computed.getPropertyValue("--font-sans").trim(); + const fontMono = computed.getPropertyValue("--font-mono").trim(); + return { + id: root.dataset.themeId ?? null, + appearance: root.classList.contains("dark") ? "dark" : "light", + colors, + radius, + fontUi: fontUi.length > 0 ? fontUi : null, + fontMono: fontMono.length > 0 ? fontMono : null, + }; +} diff --git a/apps/web/src/shell/shellWorkspaceState.test.ts b/apps/web/src/shell/shellWorkspaceState.test.ts new file mode 100644 index 000000000000..3e139c7e4d3a --- /dev/null +++ b/apps/web/src/shell/shellWorkspaceState.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { EditorId, ProjectScript, VcsStatusResult } from "@t3tools/contracts"; + +import { buildShellWorkspaceState } from "./shellWorkspaceState"; + +const gitStatus = { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feat/x", + hasWorkingTreeChanges: true, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 2, + behindCount: 0, + pr: { number: 12, title: "Feature X", url: "https://example.test/pr/12", state: "open" }, +} as unknown as VcsStatusResult; + +const script = { id: "dev", name: "Dev server", command: "vp run dev" } as ProjectScript; + +function baseInput() { + return { + threadKey: "env:thread", + projectTitle: "t3code", + projectRoot: "/repo", + threadTitle: "Feature X", + isDraft: false, + envMode: "worktree" as const, + envModeChangeable: false, + startFromOrigin: false, + branch: "feat/x", + worktreePath: "/repo/.worktrees/x", + gitStatus, + canOpenPullRequest: true, + terminalAvailable: true, + terminalOpen: false, + terminalHeight: 320, + terminalEmbedPath: "/embed/env/thread?surface=terminal", + availableEditors: ["vscode", "zed"] as EditorId[], + preferredEditorId: "zed" as EditorId, + scripts: [script], + preferredScriptId: "dev", + environments: [{ environmentId: "env", label: "Local" }], + activeEnvironmentId: "env", + environmentChangeable: false, + renameRequestId: 0, + branchQuery: "", + branches: [ + { name: "main", isDefault: true, current: false }, + { name: "origin/feat/y", isRemote: true }, + ] as never, + branchesTotal: 2, + branchesLoading: false, + branchSwitchPending: false, + branchChangeable: true, + }; +} + +describe("buildShellWorkspaceState", () => { + it("labels a locked worktree checkout and projects git, editors and scripts", () => { + const state = buildShellWorkspaceState(baseInput()); + expect(state.envModeLabel).toBe("Worktree"); + expect(state.git).toEqual({ + isRepo: true, + hasWorkingTreeChanges: true, + aheadCount: 2, + behindCount: 0, + hasUpstream: true, + pullRequest: { + number: 12, + title: "Feature X", + url: "https://example.test/pr/12", + state: "open", + }, + }); + expect(state.canOpenPullRequest).toBe(true); + expect(state.terminalAvailable).toBe(true); + expect(state.terminalOpen).toBe(false); + expect(state.terminalHeight).toBe(320); + expect(state.terminalEmbedPath).toBe("/embed/env/thread?surface=terminal"); + expect(state.editors).toEqual([ + { id: "vscode", label: "VS Code" }, + { id: "zed", label: "Zed" }, + ]); + expect(state.scripts).toEqual([{ id: "dev", name: "Dev server", command: "vp run dev" }]); + expect(state.branches).toEqual([ + { name: "main", isRemote: false, isDefault: true, current: false }, + { name: "origin/feat/y", isRemote: true, isDefault: false, current: false }, + ]); + }); + + it("labels a changeable checkout by mode and drops the PR affordance without a PR", () => { + const state = buildShellWorkspaceState({ + ...baseInput(), + envModeChangeable: true, + envMode: "local", + gitStatus: { ...gitStatus, pr: null } as VcsStatusResult, + }); + expect(state.envModeLabel).toBe("Current checkout"); + expect(state.canOpenPullRequest).toBe(false); + expect(state.git?.pullRequest).toBeNull(); + }); + + it("handles missing git status", () => { + expect(buildShellWorkspaceState({ ...baseInput(), gitStatus: null }).git).toBeNull(); + }); +}); diff --git a/apps/web/src/shell/shellWorkspaceState.ts b/apps/web/src/shell/shellWorkspaceState.ts new file mode 100644 index 000000000000..55cda0c9e976 --- /dev/null +++ b/apps/web/src/shell/shellWorkspaceState.ts @@ -0,0 +1,114 @@ +import { + EDITORS, + type EditorId, + type ProjectScript, + type VcsRef, + type VcsStatusResult, +} from "@t3tools/contracts"; +import type { ShellWorkspaceState } from "@t3tools/contracts/shell"; + +import { + resolveEnvModeLabel, + resolveLockedWorkspaceLabel, + type EnvMode, +} from "../components/BranchToolbar.logic"; + +export interface ShellWorkspaceStateInput { + readonly threadKey: string; + readonly projectTitle: string | null; + readonly projectRoot: string | null; + readonly threadTitle: string; + readonly isDraft: boolean; + readonly envMode: EnvMode; + readonly envModeChangeable: boolean; + readonly startFromOrigin: boolean; + readonly branch: string | null; + readonly worktreePath: string | null; + readonly gitStatus: VcsStatusResult | null; + readonly canOpenPullRequest: boolean; + readonly terminalAvailable: boolean; + readonly terminalOpen: boolean; + readonly terminalHeight: number; + readonly terminalEmbedPath: string; + readonly availableEditors: ReadonlyArray; + readonly preferredEditorId: EditorId | null; + readonly scripts: ReadonlyArray; + readonly preferredScriptId: string | null; + readonly environments: ReadonlyArray<{ environmentId: string; label: string }>; + readonly activeEnvironmentId: string; + readonly environmentChangeable: boolean; + readonly renameRequestId: number; + readonly branchQuery: string; + readonly branches: ReadonlyArray; + readonly branchesTotal: number; + readonly branchesLoading: boolean; + readonly branchSwitchPending: boolean; + readonly branchChangeable: boolean; +} + +export function buildShellWorkspaceState(input: ShellWorkspaceStateInput): ShellWorkspaceState { + const editorLabelById = new Map(EDITORS.map((editor) => [editor.id, editor.label] as const)); + const git = input.gitStatus; + return { + threadKey: input.threadKey, + projectTitle: input.projectTitle, + projectRoot: input.projectRoot, + threadTitle: input.threadTitle, + isDraft: input.isDraft, + envMode: input.envMode, + envModeLabel: input.envModeChangeable + ? resolveEnvModeLabel(input.envMode) + : resolveLockedWorkspaceLabel(input.worktreePath), + envModeChangeable: input.envModeChangeable, + startFromOrigin: input.startFromOrigin, + branch: input.branch, + worktreePath: input.worktreePath, + git: + git === null + ? null + : { + isRepo: git.isRepo, + hasWorkingTreeChanges: git.hasWorkingTreeChanges, + aheadCount: git.aheadCount, + behindCount: git.behindCount, + hasUpstream: git.hasUpstream, + pullRequest: + git.pr === null + ? null + : { + number: git.pr.number, + title: git.pr.title, + url: git.pr.url, + state: git.pr.state, + }, + }, + canOpenPullRequest: input.canOpenPullRequest && git?.pr != null, + terminalAvailable: input.terminalAvailable, + terminalOpen: input.terminalOpen, + terminalHeight: input.terminalHeight, + terminalEmbedPath: input.terminalEmbedPath, + editors: input.availableEditors.map((id) => ({ id, label: editorLabelById.get(id) ?? id })), + preferredEditorId: input.preferredEditorId, + scripts: input.scripts.map((script) => ({ + id: script.id, + name: script.name, + command: script.command, + })), + preferredScriptId: input.preferredScriptId, + environments: input.environments, + activeEnvironmentId: input.activeEnvironmentId, + environmentChangeable: input.environmentChangeable, + renameRequestId: input.renameRequestId, + branchQuery: input.branchQuery, + branches: input.branches.map((ref) => ({ + name: ref.name, + isRemote: ref.isRemote === true, + isDefault: ref.isDefault === true, + current: ref.current === true, + })), + branchesTotal: input.branchesTotal, + branchesLoading: input.branchesLoading, + branchSwitchPending: input.branchSwitchPending, + branchChangeable: input.branchChangeable, + }; +} diff --git a/apps/web/src/shell/useShellActions.ts b/apps/web/src/shell/useShellActions.ts new file mode 100644 index 000000000000..a014100c8c0d --- /dev/null +++ b/apps/web/src/shell/useShellActions.ts @@ -0,0 +1,44 @@ +import { ShellAction } from "@t3tools/contracts/shell"; +import * as Schema from "effect/Schema"; +import { useEffect, useRef } from "react"; + +const isShellAction = Schema.is(ShellAction); + +/** Reassembles an action from the wire (`type` travels beside its payload) and validates it. */ +export function decodeShellAction(type: string, payload: unknown): ShellAction | null { + const candidate = { + ...(typeof payload === "object" && payload !== null ? payload : {}), + type, + }; + return isShellAction(candidate) ? candidate : null; +} + +/** + * Runs `handler` for every valid action the shell dispatches, for the + * component's lifetime. One subscription per mount: the handler is read + * through a ref, so it may close over the latest props and hooks without + * resubscribing (each subscription round-trips the channel). + */ +export function useShellActions(handler: (action: ShellAction) => void): void { + const handlerRef = useRef(handler); + handlerRef.current = handler; + useEffect(() => { + const shell = window.t3Shell; + if (!shell) return; + let disposed = false; + let unsubscribe: (() => void) | null = null; + void shell + .onAction((type, payload) => { + const action = decodeShellAction(type, payload); + if (action !== null) handlerRef.current(action); + }) + .then((dispose) => { + if (disposed) dispose(); + else unsubscribe = dispose; + }); + return () => { + disposed = true; + unsubscribe?.(); + }; + }, []); +} diff --git a/apps/web/src/shell/useShellPublish.ts b/apps/web/src/shell/useShellPublish.ts new file mode 100644 index 000000000000..9a8400d25220 --- /dev/null +++ b/apps/web/src/shell/useShellPublish.ts @@ -0,0 +1,28 @@ +import { useEffect, useRef } from "react"; + +/** + * Publishes a view model to the Qt shell under `key`, skipping republishes + * whose JSON is unchanged (the bridges rebuild their state on every parent + * render, and each publish re-evaluates every QML binding on `Shell.state`). + * Unmounting publishes `null`: leaving the route (settings, no thread) must + * not leave stale chrome behind. + */ +export function useShellPublish(key: string, state: unknown) { + const lastJson = useRef(""); + useEffect(() => { + const shell = window.t3Shell; + if (!shell) return; + const json = JSON.stringify(state); + if (json === lastJson.current) return; + lastJson.current = json; + void shell.publish(key, state); + }, [key, state]); + useEffect(() => { + const shell = window.t3Shell; + if (!shell) return; + return () => { + lastJson.current = ""; + void shell.publish(key, null); + }; + }, [key]); +} diff --git a/apps/web/src/shell/useShellThreadRowActions.ts b/apps/web/src/shell/useShellThreadRowActions.ts new file mode 100644 index 000000000000..15c71a631cf5 --- /dev/null +++ b/apps/web/src/shell/useShellThreadRowActions.ts @@ -0,0 +1,197 @@ +import { + parseScopedThreadKey, + scopeProjectRef, + scopeThreadRef, + scopedThreadKey, +} from "@t3tools/client-runtime/environment"; +import { + type AtomCommandResult, + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; +import { useRouter } from "@tanstack/react-router"; +import { useCallback, useRef } from "react"; + +import type { SidebarThreadPartition } from "../components/Sidebar.logic"; +import { resolveSnoozePresets, snoozeWakeDescription } from "../components/Sidebar.snooze"; +import { stackedThreadToast, toastManager } from "../components/ui/toast"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { useClientSettings } from "../hooks/useSettings"; +import { useThreadActions } from "../hooks/useThreadActions"; +import { readThreadShell } from "../state/entities"; +import { buildThreadRouteParams } from "../threadRoutes"; +import { useUiStateStore } from "../uiStateStore"; +import { showShellContextMenu } from "./shellContextMenu"; +import { useThreadParking } from "../threadParking"; + +interface ShellThreadRowActionsInput { + readonly partition: SidebarThreadPartition; + readonly activeThreadKey: string | null; +} + +function keyOf(thread: EnvironmentThreadShell): string { + return scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); +} + +function failureToast(title: string, error: unknown) { + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); +} + +/** + * The hover / keyboard actions on a native sidebar row — settle, un-settle, + * snooze, wake, dismiss the woke pill — with the HTML sidebar's semantics: + * parking the open thread moves you to the next remaining card (or a fresh + * draft in the same project), failures toast, and a snooze offers Undo. + */ +export function useShellThreadRowActions(input: ShellThreadRowActionsInput) { + const router = useRouter(); + const handleNewThread = useNewThreadHandler(); + const { settleThread, unsettleThread, snoozeThread, unsnoozeThread } = useThreadActions(); + const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); + const timestampFormat = useClientSettings((settings) => settings.timestampFormat); + // Read at call time, not at render time: a plan snapshots the list the + // user was looking at, and the post-await check sees the current route. + const inputRef = useRef(input); + inputRef.current = input; + const timestampFormatRef = useRef(timestampFormat); + timestampFormatRef.current = timestampFormat; + + const navigateToKey = useCallback( + (key: string) => { + const threadRef = parseScopedThreadKey(key); + if (threadRef === null) return; + void router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }); + }, + [router], + ); + + const parking = useThreadParking(() => { + const { partition, activeThreadKey } = inputRef.current; + const parked = new Set([...partition.snoozedThreads, ...partition.settledThreads].map(keyOf)); + return { + activeThreadKey, + orderedThreadKeys: [ + ...partition.pinnedThreads, + ...partition.activeThreads, + ...partition.snoozedThreads, + ...partition.settledThreads, + ].map(keyOf), + isParked: (key) => parked.has(key), + planNext: (key) => () => navigateToKey(key), + planFallback: (key) => { + const threadRef = parseScopedThreadKey(key); + const shell = threadRef ? readThreadShell(threadRef) : undefined; + return shell + ? () => void handleNewThread(scopeProjectRef(shell.environmentId, shell.projectId)) + : () => void router.navigate({ to: "/" }); + }, + }; + }); + + const park = useCallback( + async ( + threadKey: string, + failureTitle: string, + run: () => Promise>, + ): Promise => { + const outcome = await parking.run(threadKey, run); + if (outcome.status === "failure") failureToast(failureTitle, outcome.error); + return outcome.status === "success"; + }, + [parking], + ); + + const settle = useCallback( + (key: string) => { + const threadRef = parseScopedThreadKey(key); + if (threadRef === null) return; + void park(key, "Failed to settle thread", () => settleThread(threadRef)); + }, + [park, settleThread], + ); + + const unsettle = useCallback( + (key: string) => { + const threadRef = parseScopedThreadKey(key); + if (threadRef === null) return; + void unsettleThread(threadRef).then((result) => { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + failureToast("Failed to un-settle thread", squashAtomCommandFailure(result)); + } + }); + }, + [unsettleThread], + ); + + const unsnooze = useCallback( + (key: string) => { + const threadRef = parseScopedThreadKey(key); + if (threadRef === null) return; + void unsnoozeThread(threadRef).then((result) => { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + failureToast("Failed to wake thread", squashAtomCommandFailure(result)); + } + }); + }, + [unsnoozeThread], + ); + + const openSnoozeMenu = useCallback( + (key: string, position: { x: number; y: number }) => { + const threadRef = parseScopedThreadKey(key); + if (threadRef === null) return; + void (async () => { + const presets = resolveSnoozePresets(new Date(), timestampFormatRef.current); + const chosen = await showShellContextMenu( + presets.map((preset) => ({ + id: `snooze:${preset.id}` as const, + label: `${preset.label} (${preset.whenLabel})`, + })), + { ...position, surface: "shell" }, + ); + const preset = presets.find((candidate) => `snooze:${candidate.id}` === chosen); + if (!preset) return; + const snoozed = await park(key, "Failed to snooze thread", () => + snoozeThread(threadRef, preset.snoozedUntil), + ); + if (!snoozed) return; + toastManager.add( + stackedThreadToast({ + type: "success", + title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormatRef.current)}`, + timeout: 5_000, + actionProps: { + children: "Undo", + onClick: () => unsnooze(key), + }, + }), + ); + })(); + }, + [park, snoozeThread, unsnooze], + ); + + const dismissWoke = useCallback( + (key: string) => { + const threadRef = parseScopedThreadKey(key); + const shell = threadRef ? readThreadShell(threadRef) : undefined; + if (!shell) return; + const wokeAt = threadWokeAt(shell, { now: new Date().toISOString() }); + if (wokeAt !== null) markThreadVisited(key, wokeAt); + }, + [markThreadVisited], + ); + + return { settle, unsettle, unsnooze, openSnoozeMenu, dismissWoke }; +} diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts index 297ae5717df1..face87ac2baa 100644 --- a/apps/web/src/state/sourceControlActions.ts +++ b/apps/web/src/state/sourceControlActions.ts @@ -41,7 +41,7 @@ export interface SourceControlActionScope { readonly cwd: string | null; } -interface SourceControlActionState< +export interface SourceControlActionState< TArgs extends ReadonlyArray, R extends AtomCommandResult, > { diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index ee3240b41b08..bb779ef5ac0c 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -172,7 +172,7 @@ describe("GhosttyTerminalSurface visibility", () => { canvas.dispatchEvent( Object.assign(new Event(type, { cancelable: true }), { clientX, - clientY: 5, + clientY: 13, pointerId: 1, button: 0, buttons, @@ -210,6 +210,27 @@ describe("GhosttyTerminalSurface visibility", () => { vi.restoreAllMocks(); }); + it("insets the rendered grid and bottom prompt while fitting all cells inside the viewport", async () => { + const harness = createHarness(); + const surface = await harness.create(); + surface.write("hello"); + harness.flushFrame(); + expect(harness.renderedSnapshot).toMatchObject({ cols: 18, rows: 5 }); + expect(harness.paint.mock.calls).toContainEqual(["fillText", ["hello", 12, 23, 40]]); + + surface.focus(); + surface.write("\r\n".repeat(10) + "prompt"); + harness.flushFrame(); + expect(harness.renderedSnapshot).toMatchObject({ cursorX: 6, cursorY: 4 }); + expect(harness.paint.mock.calls).toContainEqual(["fillRect", [60, 76, 8, 16]]); + + surface.write("\x1b[?1000h\x1b[?1006h"); + harness.pointer("pointerdown", 13, 1); + expect(harness.onData).toHaveBeenCalledWith("\x1b[<0;1;1M"); + harness.pointer("pointerup", 13, 0); + expect(harness.onData).toHaveBeenCalledWith("\x1b[<0;1;1m"); + }); + it("stops hidden snapshots and paint while preserving live VT replies and the next cursor", async () => { const harness = createHarness(); const surface = await harness.create(); @@ -243,7 +264,7 @@ describe("GhosttyTerminalSurface visibility", () => { expect(harness.snapshot).toHaveBeenCalledTimes(1); expect(harness.renderedSnapshot).toMatchObject({ cursorX: 10, cursorY: 0 }); expect(harness.renderedSnapshot.rowData[0]?.text).toContain("hidden"); - expect(harness.paint.mock.calls).toContainEqual(["fillRect", [84, 4, 8, 16]]); + expect(harness.paint.mock.calls).toContainEqual(["fillRect", [92, 12, 8, 16]]); expect(harness.frames.size).toBe(0); }); @@ -252,9 +273,9 @@ describe("GhosttyTerminalSurface visibility", () => { const surface = await harness.create(); surface.write("hello world"); harness.flushFrame(); - harness.pointer("pointerdown", 5, 1); - harness.pointer("pointermove", 37, 1); - harness.pointer("pointerup", 37, 0); + harness.pointer("pointerdown", 13, 1); + harness.pointer("pointermove", 45, 1); + harness.pointer("pointerup", 45, 0); harness.flushFrame(); expect(surface.getSelection()).toBe("hello"); const position = surface.getSelectionPosition(); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 29aaac6f6abd..7f3029017058 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -33,7 +33,7 @@ const TERMINAL_GLYPH_FALLBACKS = // reject the whole string. export const DEFAULT_TERMINAL_FONT_FAMILY = '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", ' + TERMINAL_GLYPH_FALLBACKS; -const CONTENT_PADDING = 4; +const CONTENT_PADDING = 12; const MIN_SCROLLBAR_THUMB_HEIGHT = 18; /** Half a blink cycle: the visible and hidden phases are equally long. */ const CURSOR_BLINK_INTERVAL_MS = 500; diff --git a/apps/web/src/terminalUiStateStore.ts b/apps/web/src/terminalUiStateStore.ts index 290ca8e5954c..8eae3c557ee1 100644 --- a/apps/web/src/terminalUiStateStore.ts +++ b/apps/web/src/terminalUiStateStore.ts @@ -17,7 +17,7 @@ import { type ThreadTerminalGroup, } from "./types"; -interface ThreadTerminalUiState { +export interface ThreadTerminalUiState { terminalOpen: boolean; terminalHeight: number; terminalIds: string[]; @@ -27,7 +27,7 @@ interface ThreadTerminalUiState { } // Keep the old storage key so existing drawer layout preferences migrate. -const TERMINAL_UI_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; +export const TERMINAL_UI_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; interface PersistedTerminalUiStateStoreState { terminalUiStateByThreadKey?: Record; diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index ef776dced9b7..fec4f5eec498 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -2,6 +2,7 @@ import * as Equal from "effect/Equal"; import * as Schema from "effect/Schema"; import "culori/css"; import { converter, parse } from "culori/fn"; +import { applyDocumentThemeOverride } from "./documentThemeOverride"; import { BUILT_IN_THEMES, EMBER_THEME, @@ -1634,6 +1635,7 @@ export function applyThemeColorPreview(colors: ThemeColors, appearance: ThemeApp // A half-typed hex keeps the last good value instead of blanking the role. if (isThemeColor(value)) root.style.setProperty(APP_THEME_VARIABLES[role], value); } + applyDocumentThemeOverride(); } export function applyThemePalette(theme: ThemePreference, appearance?: ThemeAppearance): void { @@ -1652,6 +1654,7 @@ export function applyThemePalette(theme: ThemePreference, appearance?: ThemeAppe for (const [role, value] of Object.entries(colors) as Array<[ThemeColorRole, string]>) { root.style.setProperty(APP_THEME_VARIABLES[role], value); } + applyDocumentThemeOverride(); return; } @@ -1659,6 +1662,7 @@ export function applyThemePalette(theme: ThemePreference, appearance?: ThemeAppe for (const variable of Object.values(APP_THEME_VARIABLES)) { root.style.removeProperty(variable); } + applyDocumentThemeOverride(); } export function resolveThemeAppearance( diff --git a/apps/web/src/threadParking.test.ts b/apps/web/src/threadParking.test.ts new file mode 100644 index 000000000000..5db2e0b6e6d2 --- /dev/null +++ b/apps/web/src/threadParking.test.ts @@ -0,0 +1,185 @@ +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { act, createElement, useEffect } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { createThreadParking, useThreadParking } from "./threadParking"; + +function deferredSuccess() { + let resolve!: (value: ReturnType>) => void; + const promise = new Promise>>((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +function setup() { + let activeThreadKey: string | null = "a"; + let orderedThreadKeys = ["a", "b", "c"]; + const parked = new Set(); + const navigate = vi.fn(); + const fallback = vi.fn(); + const read = () => ({ + activeThreadKey, + orderedThreadKeys, + isParked: (key: string) => parked.has(key), + planNext: (key: string) => () => navigate(key), + planFallback: (key: string) => () => fallback(key), + }); + return { + parking: createThreadParking(read), + anotherScope: () => createThreadParking(read), + setActive: (key: string | null) => { + activeThreadKey = key; + }, + setOrder: (keys: string[]) => { + orderedThreadKeys = keys; + }, + parked, + navigate, + fallback, + }; +} + +const success = () => Promise.resolve(AsyncResult.success(undefined)); + +describe("thread parking", () => { + it("keeps the pending scope across renders and reads the latest route", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const navigate = vi.fn(); + let parking!: ReturnType; + function Probe({ active }: { active: string }) { + const workflow = useThreadParking(() => ({ + activeThreadKey: active, + orderedThreadKeys: ["a", "b", "c"], + isParked: () => false, + planNext: (key) => () => navigate(key), + planFallback: () => navigate, + })); + useEffect(() => { + parking = workflow; + }, [workflow]); + return null; + } + let renderer: ReactTestRenderer | undefined; + try { + await act(() => { + renderer = create(createElement(Probe, { active: "a" })); + }); + const completion = deferredSuccess(); + const pending = parking.run("a", () => completion.promise); + await act(() => renderer?.update(createElement(Probe, { active: "c" }))); + await expect(parking.run("a", success)).resolves.toEqual({ status: "skipped" }); + completion.resolve(AsyncResult.success(undefined)); + await pending; + expect(navigate).not.toHaveBeenCalled(); + expect(parking.isPending("a")).toBe(false); + } finally { + await act(() => renderer?.unmount()); + vi.unstubAllGlobals(); + } + }); + + it("captures the next thread before the command changes the list", async () => { + const s = setup(); + const completion = deferredSuccess(); + const pending = s.parking.run("a", () => completion.promise); + s.setOrder(["c"]); + completion.resolve(AsyncResult.success(undefined)); + await expect(pending).resolves.toEqual({ status: "success" }); + expect(s.navigate).toHaveBeenCalledWith("b"); + }); + + it("respects navigation while an action is pending", async () => { + const s = setup(); + const completion = deferredSuccess(); + const pending = s.parking.run("a", () => completion.promise); + s.setActive("c"); + completion.resolve(AsyncResult.success(undefined)); + await pending; + expect(s.navigate).not.toHaveBeenCalled(); + expect(s.fallback).not.toHaveBeenCalled(); + }); + + it("does not navigate when parking a background thread", async () => { + const s = setup(); + await s.parking.run("b", success); + expect(s.navigate).not.toHaveBeenCalled(); + expect(s.fallback).not.toHaveBeenCalled(); + }); + + it("skips parked threads and every thread leaving in the same batch", async () => { + const s = setup(); + s.setOrder(["d", "a", "b", "c"]); + s.parked.add("b"); + await s.parking.run("a", success, new Set(["a", "c"])); + expect(s.navigate).toHaveBeenCalledWith("d"); + }); + + it("uses the project draft fallback when no remaining thread exists", async () => { + const s = setup(); + s.parked.add("b"); + s.parked.add("c"); + await s.parking.run("a", success); + expect(s.fallback).toHaveBeenCalledWith("a"); + expect(s.navigate).not.toHaveBeenCalled(); + }); + + it("falls back when the current thread is absent or the successor disappeared", async () => { + const s = setup(); + s.setOrder([]); + await s.parking.run("a", success); + expect(s.fallback).toHaveBeenCalledWith("a"); + const fallback = vi.fn(); + const parking = createThreadParking(() => ({ + activeThreadKey: "a", + orderedThreadKeys: ["a", "b"], + isParked: () => false, + planNext: () => null, + planFallback: () => fallback, + })); + await parking.run("a", success); + expect(fallback).toHaveBeenCalledOnce(); + }); + + it("suppresses duplicate commands within the same pending scope", async () => { + const s = setup(); + const completion = deferredSuccess(); + const pending = s.parking.run("a", () => completion.promise); + const duplicate = vi.fn(success); + expect(s.parking.isPending("a")).toBe(true); + await expect(s.parking.run("a", duplicate)).resolves.toEqual({ status: "skipped" }); + expect(duplicate).not.toHaveBeenCalled(); + await s.anotherScope().run("a", duplicate); + expect(duplicate).toHaveBeenCalledOnce(); + completion.resolve(AsyncResult.success(undefined)); + await pending; + expect(s.parking.isPending("a")).toBe(false); + }); + + it("reports typed failure without navigation and releases the pending guard", async () => { + const s = setup(); + const error = new Error("failed"); + await expect( + s.parking.run("a", async () => AsyncResult.failure(Cause.fail(error))), + ).resolves.toEqual({ status: "failure", error }); + expect(s.navigate).not.toHaveBeenCalled(); + await s.parking.run("a", success); + expect(s.navigate).toHaveBeenCalledOnce(); + }); + + it("keeps interruptions silent and releases the pending guard on thrown failures", async () => { + const s = setup(); + await expect( + s.parking.run("a", async () => AsyncResult.failure(Cause.interrupt())), + ).resolves.toEqual({ status: "interrupted" }); + expect(s.navigate).not.toHaveBeenCalled(); + await expect( + s.parking.run("a", async () => { + throw new Error("rejected"); + }), + ).rejects.toThrow("rejected"); + expect(s.parking.isPending("a")).toBe(false); + }); +}); diff --git a/apps/web/src/threadParking.ts b/apps/web/src/threadParking.ts new file mode 100644 index 000000000000..57aa319e0cc5 --- /dev/null +++ b/apps/web/src/threadParking.ts @@ -0,0 +1,74 @@ +import { + type AtomCommandResult, + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useRef, useState } from "react"; + +interface ThreadParkingContext { + readonly activeThreadKey: string | null; + readonly orderedThreadKeys: readonly string[]; + readonly isParked: (key: string) => boolean; + /** Capture the target now, before the command changes the visible thread list. */ + readonly planNext: (key: string) => (() => void) | null; + readonly planFallback: (key: string) => () => void; +} + +export type ThreadParkingOutcome = + | { readonly status: "success" | "interrupted" | "skipped" } + | { readonly status: "failure"; readonly error: unknown }; + +/** One pending-action scope. Callers choose whether settle and snooze share it. */ +export function createThreadParking(readContext: () => ThreadParkingContext) { + const pending = new Set(); + + function planNavigation(threadKey: string, coParkingKeys?: ReadonlySet) { + const context = readContext(); + if (context.activeThreadKey !== threadKey) return null; + const keys = context.orderedThreadKeys; + const index = keys.indexOf(threadKey); + const nextKey = + index === -1 + ? undefined + : [...keys.slice(index + 1), ...keys.slice(0, index)].find( + (key) => !context.isParked(key) && !coParkingKeys?.has(key), + ); + return ( + (nextKey === undefined ? null : context.planNext(nextKey)) ?? context.planFallback(threadKey) + ); + } + + return { + isPending: (key: string) => pending.has(key), + async run( + threadKey: string, + command: () => Promise>, + coParkingKeys?: ReadonlySet, + ): Promise { + if (pending.has(threadKey)) return { status: "skipped" }; + pending.add(threadKey); + try { + const navigateAfter = planNavigation(threadKey, coParkingKeys); + const result = await command(); + if (result._tag === "Failure") { + return isAtomCommandInterrupted(result) + ? { status: "interrupted" } + : { status: "failure", error: squashAtomCommandFailure(result) }; + } + // A navigation made while the command was pending wins over this plan. + if (readContext().activeThreadKey === threadKey) navigateAfter?.(); + return { status: "success" }; + } finally { + pending.delete(threadKey); + } + }, + }; +} + +/** Keep pending actions across renders while commands read the latest caller state. */ +export function useThreadParking(readContext: () => ThreadParkingContext) { + const contextRef = useRef(readContext); + contextRef.current = readContext; + const [parking] = useState(() => createThreadParking(() => contextRef.current())); + return parking; +} diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index b14ce917c861..875c5211846d 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -1,5 +1,6 @@ import { Debouncer } from "@tanstack/react-pacer"; import { create } from "zustand"; +import { isT3ShellEmbed } from "./env"; import { normalizeProjectPathForComparison } from "./lib/projectPaths"; export const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; @@ -435,9 +436,15 @@ export const useUiStateStore = create((set) => ({ ), })); -useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); +if (!isT3ShellEmbed) { + useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); +} -if (typeof window !== "undefined" && typeof window.addEventListener === "function") { +if ( + !isT3ShellEmbed && + typeof window !== "undefined" && + typeof window.addEventListener === "function" +) { window.addEventListener("beforeunload", () => { debouncedPersistState.flush(); }); diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 31260c8ae666..f34453f7305e 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -1,6 +1,8 @@ /// import type { DesktopBridge } from "@t3tools/contracts"; +import type { T3Shell } from "@t3tools/contracts/shell"; +import type { ShellThemeBootstrap } from "./shell/shellThemeOverride"; interface ImportMetaEnv { readonly VITE_HTTP_URL: string; @@ -23,5 +25,7 @@ interface ImportMeta { declare global { interface Window { desktopBridge?: DesktopBridge; + t3Shell?: T3Shell; + __t3ShellTheme?: ShellThemeBootstrap; } } diff --git a/docs/internals/desktop-qt.md b/docs/internals/desktop-qt.md new file mode 100644 index 000000000000..e1dc2d1214e9 --- /dev/null +++ b/docs/internals/desktop-qt.md @@ -0,0 +1,615 @@ +# Desktop (Qt) shell + +`apps/desktop-qt` is a second desktop client next to the Electron app. It is a +compiled Qt 6 / QML binary (`t3code-qt`) that hosts the web app in a +`WebEngineView` and makes everything around the web view - window, chrome, +layout, colours - a set of QML "bricks" a user can rearrange and restyle from +`~/.t3/shell/`. It coexists with `apps/desktop`; nothing in `apps/web` or +`apps/server` may become Qt-specific. + +## Process model + +```text +t3code-qt (C++/QML, the shell) + └─ spawns ─► node apps/desktop-qt/host/main.ts (the desktop host) + └─ spawns ─► node apps/server/src/bin.ts --no-browser (the server) +WebEngineView ──── WebSocket ────────────────────────────────────────────► server +WebEngineView ◄─── WebChannel ───► QML bricks +``` + +- **The web view is the brain.** It keeps its normal WebSocket client to the + server, exactly as in a browser tab. The shell never speaks the app protocol + and holds no domain state. +- **The Node desktop host** owns everything TypeScript-owned: server lifecycle + today; SSH, Tailscale, secrets, saved environments and updates as they are + ported from `apps/desktop`. It reports to the shell over its stdout as + newline-delimited JSON (`{"type":"ready","url":...}`); the shell closes the + host's stdin when it exits, which is the host's cue to shut the server down. +- **QML gets everything from the web view over WebChannel**, nothing else. + State flows web → QML (`t3Shell.publish(key, value)` → `Shell.state[key]`); + actions flow QML → web (`Shell.dispatch(action, payload)` → + `t3Shell.onAction(listener)`). +- The UI-owned parts of `desktopBridge` (open external, window commands, + colour scheme, dialogs/context menus later) are served by the shell over the + same channel; the TypeScript-owned parts stay on the Node side. + +Attach mode (`--url `) skips the host entirely and loads a +running dev server; this is what `vp run dev:qt` uses. + +### Web engine + +- **One profile.** `src/WebProfile.cpp` configures Qt WebEngine's default + profile and registers it as the `WebProfile` singleton: storage and a 64 MiB + disk HTTP cache under `/userdata/shell-web` (`--home-dir`, then + `T3CODE_HOME`, then `~/.t3`), cookies forced persistent, permissions stored. + Every `WebSurface` shares it, so the embed surfaces reuse the primary's + session and the bundle comes from cache on the next start. Chromium cannot + share a profile directory between processes: a second shell on the same + home finds the lock file taken and stays off-the-record for its run. +- **One renderer per surface.** Chromium gives each top-level view its own + renderer process (roughly the app bundle's footprint each), which is the + price of the panel and the terminal drawer being separate documents. Both + surfaces set `sleepsWhenHidden`, so their pages are frozen (no timers, no + painting) while closed and resume where they were; discarding them would + also drop the terminals they hold. The primary surface never sleeps. +- **The channel carries no properties.** QWebChannel re-sends a changed + property to every connected page, so `Shell.state` is not on it: pages talk + to `ShellChannel` (`publish`, `dispatch`, `snapshot`, `actionRequested`, + `stateEntryChanged`), and `shell-connect.js` pulls the map lazily. A page + that never reads it (the primary) is never sent its own publishes back. +- **Per-key bindings.** `Shell.state` is a `QQmlPropertyMap`, so a publish + only re-evaluates bindings on that key. Keys are declared up front in + `ShellBridge.cpp`; a rice can read any of them, but a new key must be added + there before a binding will follow it. +- **Permissions and downloads.** Pages get the async clipboard; every other + permission is denied (there is no notification presenter yet). Downloads + are accepted into the user's download folder. +- **No width animation on the surfaces' neighbours.** Animating the sidebar + or panel width resizes the web view every frame, which is a Chromium + relayout and a new GPU surface each time; both snap instead. + +## Source layout + +| Path | Role | +| ----------------------- | ------------------------------------------------------------------- | +| `src/main.cpp` | CLI flags, config dir resolution, wiring | +| `src/ShellRuntime.*` | QML root generations, `shell.qml` resolution, hot reload, fallback | +| `src/ShellBridge.*` | The `shell` WebChannel object / `Shell` QML singleton | +| `src/ThemeStore.*` | `theme.json` loader + watcher, `Theme` QML singleton, CSS injection | +| `src/BackendProcess.*` | Spawns the Node desktop host, waits for `ready` | +| `qml/T3/Bricks/` | Pure-QML bricks (see below) and the injected `js/shell-connect.js` | +| `scripts/gen-icons.mjs` | Regenerates `js/lucide.js`, the icon paths `ShellIcon` draws | +| `host/main.ts` | Node desktop host | +| `scripts/dev-qt.mjs` | Build, pair with the dev server, launch | +| `examples/` | Starter `theme.json` and `shell.qml` | + +QML modules: `T3.Shell` is C++-only (`Shell`, `Theme`, `Runtime`, and `WebProfile` +singletons, registered once and used by one engine throughout its lifetime). `T3.Bricks` is +QML-only with a hand-written `qmldir` (no `prefer` line) so the same directory +works compiled into the binary and as an on-disk import path. + +The bricks come in two layers. Chrome bricks each own one piece of the page's +chrome and read one key of `Shell.state`: `Sidebar`, `Workspace` (the header +strip), `Composer`, `RightPanel`, `TerminalDrawer`, `SettingsNav`, `GitActions`, +`Notifications`, `ContextMenuHost`, plus `WebSurface`, `DefaultShell` and +`ShellErrorOverlay`. A rice that cards a surface passes the card's inner +radius as `WebSurface.radius` (`TerminalDrawer` and `RightPanel` forward +theirs): the page clips itself to the curve and drops its own backdrop +(`data-shell-surface-radius` in `index.html` and `index.css`), so no QML layer is needed to +round a live web view. Under them sit the primitives a rice composes its own +chrome from, all styled from `Theme`: `ShellWindow` (the root every rice +starts from: theme-driven colour, opacity and frame, `sidebarCollapsed` / +`settingsActive` read from the page, the shell's context menus, the error +overlay and the page's window commands), `ShellCard` (a rounded, hairlined +panel), `ShellButton` (outline, `subtle` ghost, `primary`), `ShellComboBox` +(ghost, `outline: true` for a field), `ShellSplitButton` (the header's action +and chevron pill), `ShellMenu` / `ShellMenuItem`, `ShellTextField`, `ShellIcon`, +`WindowControls` (glyph buttons, or macOS traffic lights with +`trafficLights: true`), `TitleBar` and `T3Wordmark` (the web app's "T3" +mark as a filled `Shape`, sized by its height). `ShellIcon` draws the page's +lucide icons as a `Shape` from the path table in `js/lucide.js`, so bricks +pass an icon name (`iconName: "git-branch"`) and get the same glyph the HTML +shows, at any size or color. + +`DefaultShell` is laid out like the page: the sidebar's brand band ("T3 Code" +plus the collapse toggle), a 52 px header strip with the breadcrumb and the +run / open / git pills, the timeline, and the composer card with the checkout +strip welded under it. Frameless windows get their drag handle and buttons +from the brand band and the header strip (`Sidebar.window`, +`Workspace.window`), not from a `TitleBar`; the examples that want a title +bar row still use that brick. + +## Setup + +Requirements: CMake ≥ 3.21, Ninja, a C++20 compiler, Qt ≥ 6.9 with +`WebEngineQuick` and `WebChannel`, Node (the host runs from TypeScript source). + +- macOS: `brew install qt` (6.11 at time of writing, WebEngine included). +- Linux: distro Qt often lacks WebEngine; prefer the official binaries via + `uvx aqtinstall install-qt linux desktop 6.11.1 -m qtwebengine qtwebchannel qtpositioning` + and set `QT_PREFIX=~/Qt/6.11.1/gcc_64`. +- CI/release builds use `aqtinstall` on every platform for reproducibility. + +```sh +vp run dev # terminal 1: server + web (single origin) +vp run dev:qt # terminal 2: cmake configure/build, `t3 pair`, launch with --url +``` + +`dev:qt` resolves the data directory the way `vp run dev` does (`--home-dir`, +else the worktree's own `.t3`, else `T3CODE_HOME`, else `~/.t3`), pairs with +the server running there, and launches the shell with that directory as its +`T3CODE_HOME` so it rices from the matching `shell/`. Pass the same +`--home-dir` to both commands if you set one. Its other flags are `--url` (skip +pairing), `--release` (no disk QML loading) and `--configure-only` (build, do +not launch); everything else is forwarded to the binary, so +`vp run dev:qt --screenshot out.png --action rightPanel.toggle` works. +`pnpm --filter @t3tools/desktop-qt build:qt` builds without launching. Build +output lands in `apps/desktop-qt/build/` (gitignored). + +Standalone (no dev server): run the binary with no `--url`; it spawns the host, +which starts the server against a built `apps/web` (`vp run build`). + +CLI: `--url`, `--home-dir`, `--config-dir`, `--qml-dir`, `--host-entry`, `--node`, `--screenshot ` +(grab the window after the page loads, then quit — PR evidence without a +screen-recording permission), `--action name[=json]` (repeatable; dispatch shell +actions after the page loads, e.g. `--action rightPanel.toggle`), `--key ` +(repeatable; press a key chord after the page loads, e.g. `--key Ctrl+1`, portable +`QKeySequence` names — `--action` and `--key` run in command-line order, 1.5 s +apart, so a key test can open a thread first); env `T3CODE_HOME`, +`T3CODE_QML_DIR`, `T3CODE_NODE`, `T3CODE_SERVER_ENTRY`. + +## Ricing contract + +Config dir: `$T3CODE_HOME/shell/`, so `~/.t3/shell/` by default on every +platform and `/.t3/shell/` for a sandboxed dev run; `--config-dir` +overrides it. + +### `theme.json` + +The file _is_ the interface for colour propagation: theme managers (omarchy +themes, pywal templates, a hand-written file) write it and the app follows. +The shell itself does not import terminal configs; the generator that comes +closest, `vp run theme:qt [shell-dir]` (`scripts/theme-from-terminal.mjs`), +asks the running terminal for its palette over OSC 10 / 11 / 12 / 17 / 4 and +writes `theme.json` into the shell directory from the answer, keeping the +shell on the file contract. + +Its shape is the web app's own `ThemeFile` (`apps/web/src/themePalette.ts`; +the Settings → Theme editor exports it) plus a shell-only `window` section: + +```json +{ + "version": 1, + "id": "tokyo-night-shell", + "name": "Tokyo Night (shell)", + "appearance": "dark", + "colors": { "canvas": "#1a1b26", "chrome": "#16161e", "text": "#c0caf5", "sidebar": "#16161e" }, + "variants": { "light": { "canvas": "#e1e2e7" } }, + "window": { "opacity": 0.96, "transparent": false, "blur": false, "frameless": true } +} +``` + +- `colors.*` keys are the web's theme roles (`canvas`, `chrome`, `surface`, + `text`, `textMuted`, `accent`, `sidebar`, `terminalBackground`, … — the + `ThemeColorRole` list in `packages/shared/src/themePalettes.ts`). + `variants.` overrides `colors` for that appearance. +- The native document-creation script applies the first-paint colors, then + hands its override to the web theme module through `window.__t3ShellTheme`. + The web module applies the override after stored palettes and editor previews, + without changing saved preferences. Native reinjections deliver data only. + Embedded documents claim their own override without publishing native colors. + Older pages retain the DOM-observer fallback. Supply the full role set + (the files under `examples/*/` do) for consistent colors during startup. +- QML reads the same roles: `Theme.colors`, `Theme.color("chrome", fallback)`, + `Theme.appearance`, `Theme.id`. +- `window.*` is shell-only: `opacity` (whole-window), `transparent` (window and + web view background cleared; compositor rules do the blur on Wayland), + `blur` (advisory for platform hooks), `frameless` (default `true`). +- The file is watched; edits apply live to QML and to the page. A malformed + file keeps the previous good theme and sets `Theme.lastError`. Deleting it + removes the shell's variables; the web app's own theme returns on its next + apply. + +### `shell.qml` + +If `~/.t3/shell/shell.qml` exists it is loaded as the root instead of the +built-in `DefaultShell.qml`. It composes bricks from `T3.Bricks` and reads the +`T3.Shell` singletons: + +- `Shell.pageUrl`, `Shell.state` (whatever the web app published), + `Shell.dispatch(action, payload)`, `Shell.windowCommandRequested(command)`. +- `Theme.*` as above. +- `Runtime.configDir`, `Runtime.userShellPath`, `Runtime.usingUserShell`, + `Runtime.lastError`, `Runtime.reload()`. + +Extra QML modules can live under `~/.t3/shell/qml/` (it is on the import +path). If `shell.qml` fails to load, the default shell takes over with +`ShellErrorOverlay` showing the error; a broken rice never locks the app. + +### Hot reload + +`ShellRuntime` watches the config dir and, in non-release builds, the in-repo +`qml/` directory. A change to any `.qml`/`.js`/`qmldir` file clears the component +cache and loads new root objects in the same engine. The new window loads before +the old roots are dropped, so the app never hits "last window closed". If both +the user shell and default shell fail, the previous roots stay alive. Generations +share C++ singleton state, never QML-created objects with generation-specific +types. The web view is recreated with the window +and reloads the page; keeping it alive across generations is a follow-up. +QmlLive was evaluated and rejected: unmaintained since 2019, Qt 5 only. + +## Page-side API + +`WebSurface` injects `qwebchannel.js` (bundled from Qt's data dir at build +time) and `js/shell-connect.js` at document creation, which exposes: + +```ts +window.t3Shell: { + protocolVersion: number; // 1 + surfaceId: string; // "primary" | "rightPanel" + ready: Promise; // raw WebChannel proxy + publish(key: string, value: unknown): Promise; + dispatch(action: string, payload?: unknown): Promise; + onAction(listener: (action: string, payload: unknown) => void): Promise<() => void>; + getState(): Promise>; // everything published, any document + onState(listener: (state: Record) => void): Promise<() => void>; +} +``` + +`window.t3Shell` is undefined in a browser tab; the web app must keep working +without it. `apps/web/src/env.ts` exports `isT3Shell` (module-load-time, like +`isElectron`). The contract — what gets published under which key and which +actions exist — lives in `packages/contracts/src/shell.ts` and is imported as +`@t3tools/contracts/shell`, not from the package barrel, so browsers never +bundle it. The bridges follow the same rule: `apps/web/src/shell/lazy.tsx` +wraps each one in `React.lazy`, and only a shell-hosted document ever imports +`shell/bridges.ts`. Every bridge decodes actions through `useShellActions` +(one subscription per bridge, handlers read live props) and publishes through +`useShellPublish` (skips unchanged JSON, clears the key on unmount). + +### `sidebar` + +`apps/web/src/shell/T3ShellBridge.tsx` (mounted from the root route when +`isT3Shell`) publishes `ShellSidebarState`: project groups, the current scope, +and the thread list already bucketed (`pinned`/`active`/`snoozed`/`settled`), +sorted, and annotated with status, status label, unread, branch, the snooze +wake label, the woke timestamp and whether settle/snooze apply (`wakeLabel`, +`wokeAt`, `canSettle`, `canSnooze`). It is derived +with the same code as the HTML sidebar — `partitionSidebarThreads` and +`useSidebarProjectGroups` are shared — so the two never disagree. `settled` is +capped at 50 rows with `settledTotal` carrying the real count. When hosted, +`AppSidebarLayout` renders no thread sidebar (the settings nav stays HTML). + +The QML sidebar reconciles publications into a keyed `ListModel`, updating +and moving existing rows instead of replacing the list. This preserves row +hover, keyboard focus and scroll position while thread state changes. + +Actions (`Shell.dispatch(name, payload)` in QML → `ShellAction` on the page): +`thread.open {key}`, `draft.open {draftId}`, `thread.new {projectKey?}`, +`sidebar.scope {projectKey|null}`, `project.add`, `settings.open`, +`pullRequests.open`, `usage.open`, `palette.open`. Row actions run the +handlers the HTML row's hover buttons use (`useShellThreadRowActions`): +`thread.settle {key}`, `thread.unsettle {key}`, `thread.unsnooze {key}`, +`thread.snoozeMenu {key, x, y}` (the snooze durations open through +`contextMenu` at those window coordinates), `thread.wokeDismiss {key}`, and +`thread.menu {key, x, y}` for the thread menu. Unknown or malformed actions +are dropped by the schema guard. + +`SidebarThreadRow` mirrors the HTML row's states: Working/Monitoring, +Approval, Input, Failed, Woke (a pill that dismisses on click while `wokeAt` +is set) and Done (unread) take the age label's place, coloured with the +`info`, `warning`, `accent`, `error` and `success` theme roles; rows that +need nothing recede. Hovering or keyboard-focusing a row swaps that slot for +its actions — snooze and settle on live rows, wake on snoozed rows, un-settle +on settled rows — and a right-click anywhere on the row opens the thread +menu on press. + +The HTML sidebar and native row actions share `threadParking.ts` for navigation +planning and pending commands. The plan is captured before a settle or snooze; +successful commands navigate only if the same thread is still open. HTML keeps +separate settle/snooze pending scopes and batch exclusions; native row actions +share one pending scope. Menus, Undo, and batch feedback stay with their callers. + +The thread list is a Tab stop. Up/Down move a cursor (a ring in the `focus` +theme role) over rows and section headers, Home/End jump to the ends, Enter +or Space open the row or fold the header, and Menu or Shift+F10 open the +thread menu at the row. The cursor starts on the active thread. Row action +buttons are not Tab stops; the thread menu carries the same actions. + +### `composer` + +`apps/web/src/shell/ShellComposerBridge.tsx` is mounted _inside_ +`ChatComposer` when hosted, so approvals, user-input questions, plan +follow-ups, attachments and mentions keep their one implementation. It +publishes `ShellComposerState` — draft text, placeholder, whether sending is +possible and why not, running/connecting flags, enabled provider instances +with their models (and per-model disabled reasons), the selected model, the +provider option descriptors (reasoning effort etc.), runtime modes and the +plan/build toggle. `ChatComposer` hides its editor and footer when hosted; +the editor comes back for approval and user-input flows, which type answers +through it. + +Actions: `composer.text.set {target, text, cursor?, edit?}` (debounced from the QML +editor), `composer.submit {text?, intent?, edit?}` (text rides along so the send is +atomic with the last edit), `composer.interrupt`, `composer.model.select +{instanceId, model}`, `composer.option.set {id, value}`, +`composer.runtimeMode.set {mode}`, `composer.interactionMode.set {mode}`, +`composer.suggest.select {id}`, `composer.suggest.dismiss`. The page's +`modelPicker.toggle` command dispatches `composer.modelPicker.toggle` the +other way, page → shell, since the HTML picker is hidden when hosted; the +`Composer` brick listens on `Shell.actionRequested` and opens its own. + +Text edits and submissions carry an `edit: {clientId, revision}` stamp. The +page publishes the latest applied stamp with the draft and retains it when +page actions change or clear the text. The brick ignores publications that +have not applied its latest edit, preserving newer text and the caret even +when publications are delayed, coalesced, or repeat an earlier value. Switching +targets resets the brick's pending revision. The stamp is optional for older +shells/pages; pages without it retain the legacy text-based synchronization. + +`@file`, `$skill` and `/command` suggestions reuse `ChatComposer`'s own +trigger detection and menu: the QML editor edits the raw prompt (mentions +written out as `[label](path)`), so it sends an _expanded_ caret with each +edit; `ChatComposer` collapses it, re-detects the trigger and publishes +`triggerKind`, `suggestions` and `suggestionsEmptyText`. Selecting sends the +item id back and the page applies the same replacement the HTML menu would, +then publishes the new `text` and `cursor` for the editor to adopt. + +### `rightPanel` + +The right panel is the first brick whose _content_ stays HTML but whose +_placement_ is the shell's: `RightPanel` renders the tab strip natively and +loads the app's embed route (`/embed/$environmentId/$threadId`) in a second +`WebSurface`. Both surfaces share the shell's profile (see Web engine), so +the embed document has the primary's session cookie and authenticates without +a pairing token; it opens its own WebSocket, and sleeps while the panel is +closed. + +The embed route renders `ChatView` with `presentation="rightPanel"`, which +returns only the panel's content — every hook, handler and per-surface +component stays in one place. The two documents converge on the tab model +through localStorage: `shell/shellDocumentSync.ts` rehydrates the right +panel, terminal and diff stores whenever another document wrote them. +Composer drafts are deliberately not synced (both documents write them). + +`ShellRightPanelBridge` (mounted by `ChatView` when hosted) publishes +`ShellRightPanelState`: open flag, surfaces with titles, the active surface, +what can be added, and `embedPath`. `ChatView` hides its inline panel, sheet +and layout toggles when hosted. Actions: `rightPanel.toggle`, +`rightPanel.activate {id}`, `rightPanel.close {id}`, `rightPanel.add {kind}` +(`diff | files | terminal | pull-request | agents`). The embed document +follows the primary one through `ShellEmbedRouteBridge`, which navigates in +place when `rightPanel.threadKey` changes. + +Known gap: the browser/preview surface needs the Electron preview host and +is unavailable under the shell. + +### `workspace` + +`ShellWorkspaceBridge` (mounted by `ChatView` when hosted) publishes +`ShellWorkspaceState`: project and thread titles, checkout mode (and whether +it can still change), branch and worktree, a git summary (dirty, ahead/behind, +linked PR), the environments the logical project spans, available editors +with the preferred one, project scripts, and the terminal drawer's state +(`terminalAvailable`, `terminalOpen`, `terminalHeight`, `terminalEmbedPath`; +see below). `ChatHeader` keeps only the git +control (`shellHosted`), since commit/push/PR flows carry dialogs and progress +UI that live with that control; the branch toolbar under the composer is not +rendered. The `Workspace` brick renders the breadcrumb and the run / open +pills; the branch toolbar's contents (environment, checkout mode, branch +picker, PR badge) are the context strip under the `Composer` brick, where the +page puts them, and the terminal and panel toggles from the page's header. +Actions: `workspace.newThread`, `terminal.toggle`, `terminal.resize {height}`, +`workspace.openInEditor {editorId?}` (same command and preference as the HTML +picker), `workspace.runScript {scriptId}`, `workspace.envMode.set {mode}`, +`workspace.startFromOrigin.set {enabled}`, `workspace.openPullRequest`, +`workspace.environment.set {environmentId}`. + +The terminal drawer is HTML content in a shell-placed surface, like the right +panel: `TerminalDrawer` loads the embed route with `?surface=terminal` in a +third `WebSurface` (kept once created, frozen while closed) and sizes it from +`terminalHeight`, so the page's order, timeline over composer over drawer, +survives the composer moving out of the page. The embed route renders +`ThreadTerminalDocument`, without mounting conversation hooks. It shares +`ThreadTerminals` and `useThreadTerminalActions` with the inline chat drawer. +The module owns retained sessions, allocation, attachment locations, script +launches, and focus. It fills the document (`ThreadTerminalDrawer` in `fill` mode: +no border, no handle, no height of its own); the primary renders no drawer +when hosted. The drawer's open flag and height are the page's +(`terminalUiStateStore`, synced across documents through localStorage), so +`Workspace`'s toggle keeps dispatching `terminal.toggle`, and dragging the +brick's top edge dispatches `terminal.resize` on release, which the primary +persists and publishes back. Known gap: the run pill's terminal is opened by +the primary and the drawer document attaches to the same server-side session, +but the launch context the primary derives for it (cwd and worktree) stays in +the primary; terminals the drawer document opens on its own use the thread's +checkout. + +Branch switching is native too: the selector's brain moved into +`hooks/useThreadBranchSelection.ts` (thread/draft resolution, paginated ref +list for a query, optimistic active branch, `selectBranch`/`createRef` which +stop a live session and rewrite the thread's checkout), and both the HTML +`BranchToolbarBranchSelector` and the bridge consume it. The state carries +`branches` for the current `branchQuery` plus `branchesTotal`, +`branchesLoading`, `branchSwitchPending`; actions `workspace.branch.search +{query}`, `workspace.branch.select {name}`, `workspace.branch.create {name}`. + +Renaming is native too (`workspace.rename {title}`, with `renameRequestId` +bumping when the page asks the brick to start editing) and the title's +context menu comes from `workspace.titleMenu {x, y}`. + +### `settings` + +Settings are whole HTML pages, so only their navigation moves: the root +route mounts `ShellSettingsBridge` when hosted, which publishes +`ShellSettingsState` on every route change — `active` (on `/settings*`), +the sections in sidebar order, the active one, and search results for the +query the shell last sent (the same `searchSettings` catalog the HTML nav +uses). `DefaultShell` swaps the `Sidebar` brick for `SettingsNav` while +`active`. Actions: `settings.navigate {to}`, `settings.openResult {to, +targetId}` (scrolls when already on the page), `settings.search {query}`, +`settings.back` (history back, else `/`). When hosted, `AppSidebarLayout` +renders no sidebar on any route. + +Bridges tied to a thread route (`workspace`, `composer`, `rightPanel`) +publish `null` for their key on unmount, so leaving a thread clears the +native chrome instead of freezing it on the last thread. + +### `theme` (page → shell) + +`ShellThemeBridge` (root route, when hosted) resolves every theme role from +the page's semantic CSS variables — painting each through a canvas so +`oklch()`/`color-mix()` become sRGB hex — plus `--radius` and the font lists, +and republishes on theme, appearance or custom-theme changes. `Theme.color()` +resolves theme.json first, then this page theme, then the brick's fallback; +`Theme.radius`, `Theme.fontUi`, `Theme.fontMono` follow the same order (the +shell-only `radius` / `fonts` keys in theme.json override the page). The +themed controls (`ShellButton` etc.) take radius, surfaces, borders and fonts +from `Theme`, so native chrome matches the page by default and follows +whatever the user picks in Settings. + +### `layout` + +The page keeps owning the main sidebar's open state (its `sidebar.toggle` +keybinding, Mod+B by default, still works when hosted — see `keybindings`) +and publishes it as +`layout {sidebarCollapsed}` from `ShellLayoutBridge`, mounted inside the +sidebar provider. `sidebar.toggle` flips it from native chrome — the +`Workspace` brick shows a toggle when its `sidebarToggle` property is bound +(it takes the sidebar's place at the strip's left edge, as on the page), and +`Sidebar` shows the matching collapse toggle in its brand band when +`showBrand` is on. The right panel's toggle follows the same pattern: +`Workspace.panelToggle` puts it in the header strip and `RightPanel +{ ownToggle: false }` then takes no width while closed; a rice that leaves +`ownToggle` on gets the 36 px rail with the toggle instead. +The shell only animates the result: `DefaultShell` and the examples ease the +sidebar's `Layout.preferredWidth` to 0 and hide it once it is gone +(`visible: !sidebarCollapsed || width > 0` — guard on the collapsed flag, not +on width alone, or a layout-managed item never regains a size). + +`Sidebar` carries the search and new-thread row, the project scope picker +and "add project" under its brand band, and the app's places (Settings, PRs, +Usage) in its footer. A rice that puts those somewhere else — the dashboard +example's icon rail — sets `showScope: false` and `showFooter: false` so the +same action is not reachable from two places; `showBrand` is off by default +because most rices bring their own title bar. + +### `keybindings` + +The page's keybindings are configurable and fire on keydown events the +document sees, so with native chrome focused (the thread list, the composer +editor) they would go dead. `T3ShellBridge` publishes the resolved config as +`keybindings`: `apps/web/src/shell/shellKeybindings.ts` turns every modified +chord into a portable Qt sequence such as `Ctrl+Shift+]` (Qt swaps Ctrl and +Command on macOS, so the builder swaps them back), skips unmodified keys — +those belong to whichever native control has focus — and collapses rules +that share a chord. `ShellWindow` instantiates a window `Shortcut` per entry +while no `WebSurface` has focus; the page handles the real key there. A match +dispatches `keybinding.press {key, ctrlKey, metaKey, shiftKey, altKey}`, +which the page replays as a synthetic keydown on `document.body`, so the same +dispatcher, the same `when` clauses and the user's own config decide what +runs. A body target reads as "not typing", so chords scoped to a focused +editor do nothing from chrome; the QML composer submits through +`composer.submit` instead. + +Secondary documents (the terminal drawer, the right panel) carry no +`T3ShellBridge` and no sidebar, so the handlers behind thread jumps or the +sidebar toggle do not exist there, and while one has focus the shell's own +shortcuts are off. `ShellEmbedRouteBridge` therefore forwards a keydown its +document did not consume as `keybinding.press` when the chord resolves to the +same command with and without the embed's focus context +(`shellKeybindingPressToForward`): Mod+1 jumps threads either way and is +forwarded, Mod+D splits a focused terminal there and is not. A focused +terminal consumes Mod+1 as terminal input before that listener runs, exactly +as in the web app. + +### `notifications` + +`ToastProvider` accepts a `shellMirror` rendered inside it; the mirrored +toasts still get a hidden `Toast.Root` in the HTML viewport, because Base UI +only drops a closed toast once its root has finished leaving. `ShellToastBridge` +mirrors the page's stacked toasts (title, description, buttons, update key) +as `notifications` and runs a toast's button or dismissal on +`notification.action {id, actionId}` / `notification.dismiss {id}` — the +same `onClick`/`onClose` the HTML buttons call. Toasts with React-element +bodies or anchored positioning stay in the page. The `Notifications` brick +renders the rest. + +### `contextMenu` + +`localApi.contextMenu.show` routes to the shell when hosted: the items are +published under `contextMenu` with the surface they belong to (every web +surface tags its document with `window.t3Shell.surfaceId`; `"shell"` means +window coordinates from native chrome) and the choice returns as +`contextMenu.select {requestId, id}`. `ContextMenuHost` lives in each +`WebSurface` and once at the window level. This makes every context menu in +the app native; the thread title menu (`workspace.titleMenu {x, y}`) and +sidebar rows (`thread.menu {key, x, y}`) open the thread action menu through +it, and `workspace.rename {title}` / `renameRequestId` drive an inline rename +in the strip via the shared `useRenameThread` hook. + +### `git` + +`useGitActions` (extracted from `GitActionsControl`) owns the status query, +the stacked-action runner with its progress/result toasts, the default-branch +gate and the thread↔branch sync. When hosted the control renders +`ShellGitBridge` — publishing the quick action, menu items with disabled +reasons, hints, working-tree files and the pending confirmation — plus the +publish-repository dialog (still HTML). Actions: `git.quick`, `git.menu +{id}`, `git.commit {message, filePaths|null, featureBranch}`, +`git.defaultBranch {choice}`, `git.init`, `git.publish`, `git.refresh`. The +`GitActions` brick renders the split button, the commit dialog (file +checklist + message) and the confirmation. + +### Composer layout + +The `Composer` brick is the page's composer card: a centered card (768 px +max) with the attachment chips, the editor and a footer of ghost pickers — +model, effort, permissions, the plan/build toggle — and the round send/stop +button. The context strip hangs under the card with the environment +selector, the checkout-mode picker, the PR badge and the branch button +(`workspace.*` actions); the branch picker pops upward from it. + +### Composer extras + +`composer.attach {files:[{name, mimeType, base64}]}` feeds shell-read image +files into the composer's drop pipeline (the brick reads dropped or picked +files through `Shell.readImageFiles`, 10 MB cap, images only). +`composer.terminalContext.add {…selection}` adds a terminal selection; the +embed document's terminal forwards its selections with `t3Shell.dispatch`, so +they land in the primary's draft. Attached images and terminal contexts are +published as removable chips (`composer.attachment.remove`, +`composer.terminalContext.remove`). + +## Linux and packaging + +`window.blur` is native on macOS (an `NSVisualEffectView` behind the Qt view, +`src/PlatformWindow.mm`, tinted by the theme's appearance). On Linux +`QGuiApplication::setDesktopFileName("t3code")` sets the Wayland app id / +X11 `WM_CLASS`, so compositor rules can target the window — on Hyprland: +`windowrulev2 = opacity 0.9, class:^(t3code)$` and `decorate:blur` — with +`window.transparent: true` in theme.json for the compositor to blur through. +`.github/workflows/desktop-qt.yml` builds Release binaries on Linux and +macOS with the official Qt 6.9 binaries (`jurplel/install-qt-action`) and +packages an AppImage (`scripts/package-linux.sh`, linuxdeploy + its Qt +plugin) and a macOS bundle (`macdeployqt`). Each package carries the Node +executable used to install its native runtime dependencies. The Linux path was +written against the documented tooling but has only been exercised in CI, not +on this machine. + +## Splitting chrome out + +Every piece of chrome from the original list now has a brick: `Sidebar`, +`Composer`, `RightPanel` (+ embed route), `TerminalDrawer` (+ embed route), +`Workspace`, `SettingsNav`. The timeline, the terminal and the settings pages +stay HTML by design. Each split-out piece becomes one brick with a documented +state/action surface; in the shell the SPA simply does not render the parts +that moved out. When an HTML brick needs to live somewhere QML decides, it +becomes another `WebEngineView` loading an embed route with its own server +connection (the right panel and the terminal drawer are the precedents); the +primary view stays the brain. + +## Release targets + +Linux AppImage and macOS `.app` first, Windows later. Release staging bundles +the build's Node executable and license beside the host runtime. diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index f7cb61259c0e..babf67574c5b 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -19,6 +19,8 @@ Terms whose meaning matters across T3 Code. Architecture and lifecycle constrain ## Orchestration +Parking a thread means settling or snoozing it to move it out of active work. + | Term | Meaning | | ----------------------- | -------------------------------------------------------------------------------------------- | | Command | A request to change domain state. Accepting it does not mean its side effects have finished. | diff --git a/package.json b/package.json index 4e5aca36d135..b678e1b2eb71 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "dev:web": "node scripts/dev-runner.ts dev:web", "dev:marketing": "vp run --filter @t3tools/marketing dev", "dev:desktop": "node scripts/dev-runner.ts dev:desktop", + "dev:qt": "node apps/desktop-qt/scripts/dev-qt.mjs", + "theme:qt": "node apps/desktop-qt/scripts/theme-from-terminal.mjs", "migrate-dev-db": "node apps/server/scripts/migrate-dev-db.ts", "start": "vp run --filter t3 start", "start:desktop": "vp run --filter @t3tools/desktop start", diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 411a48da639b..52d85aeb8c75 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -18,6 +18,10 @@ "./relay": { "types": "./src/relay.ts", "import": "./src/relay.ts" + }, + "./shell": { + "types": "./src/shell.ts", + "import": "./src/shell.ts" } }, "scripts": { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index a14a4ed0a08a..ebbf50905234 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -117,7 +117,7 @@ export interface ContextMenuItem { label: string; destructive?: boolean; disabled?: boolean; - /** Renders as a non-interactive section header label. Web fallback only — stripped on desktop native menus. */ + /** Non-interactive section label in web and Qt menus; stripped by Electron native menus. */ header?: boolean; /** Icon keyword resolved by the web fallback. Stripped on desktop native menus. */ icon?: string; @@ -1283,9 +1283,13 @@ export interface LocalApi { openSystemSettings: (pane: SystemSettingsPane) => Promise; }; contextMenu: { + /** + * `surface: "shell"` marks window coordinates from a native shell's own + * chrome rather than a point in this document; browsers ignore it. + */ show: ( items: readonly ContextMenuItem[], - position?: { x: number; y: number }, + position?: { x: number; y: number; surface?: "shell" }, ) => Promise; close: () => Promise; }; diff --git a/packages/contracts/src/shell.ts b/packages/contracts/src/shell.ts new file mode 100644 index 000000000000..5e1b7b0ebb43 --- /dev/null +++ b/packages/contracts/src/shell.ts @@ -0,0 +1,630 @@ +import * as Schema from "effect/Schema"; + +/** + * Contract between the web app and a native shell hosting it (the Qt shell + * in apps/desktop-qt). The shell exposes `window.t3Shell`; the page publishes + * derived view models with `publish(key, value)` and receives user intent + * from shell-rendered chrome as actions. The page stays the only client of + * the server; the shell never sees the app protocol. + * + * Imported as `@t3tools/contracts/shell` so only shell-hosted code pulls + * these schemas into its bundle. + */ + +export const ShellSidebarThreadStatus = Schema.Literals([ + "approval", + "input", + "working", + "monitoring", + "failed", + "ready", +]); +export type ShellSidebarThreadStatus = typeof ShellSidebarThreadStatus.Type; + +export const ShellSidebarThread = Schema.Struct({ + /** `:`, the key `thread.open` sends back. */ + key: Schema.String, + threadId: Schema.String, + environmentId: Schema.String, + /** Logical (grouped) project key, matching `ShellSidebarProject.key`. */ + projectKey: Schema.String, + title: Schema.String, + status: ShellSidebarThreadStatus, + statusLabel: Schema.NullOr(Schema.String), + unread: Schema.Boolean, + branch: Schema.NullOr(Schema.String), + updatedAt: Schema.String, + pinned: Schema.Boolean, + snoozedUntil: Schema.NullOr(Schema.String), + /** "2h" / "3d" while snoozed, for the row's trailing slot. */ + wakeLabel: Schema.NullOr(Schema.String), + /** Set once a snooze has elapsed (or the thread raised its hand) and the user has not visited since. */ + wokeAt: Schema.NullOr(Schema.String), + /** The environment supports settlement, so the row can offer settle / un-settle. */ + canSettle: Schema.Boolean, + /** Snooze is supported and the thread is snoozable right now (no pending approval or question). */ + canSnooze: Schema.Boolean, +}); +export type ShellSidebarThread = typeof ShellSidebarThread.Type; + +export const ShellSidebarProject = Schema.Struct({ + key: Schema.String, + displayName: Schema.String, + environmentId: Schema.String, + projectId: Schema.String, + workspaceRoot: Schema.String, + threadCount: Schema.Number, +}); +export type ShellSidebarProject = typeof ShellSidebarProject.Type; + +export const ShellSidebarDraft = Schema.Struct({ + draftId: Schema.String, + projectKey: Schema.String, + label: Schema.String, +}); +export type ShellSidebarDraft = typeof ShellSidebarDraft.Type; + +/** Published under the `sidebar` key: the thread list already bucketed and sorted. */ +export const ShellSidebarState = Schema.Struct({ + projects: Schema.Array(ShellSidebarProject), + scopeProjectKey: Schema.NullOr(Schema.String), + pinned: Schema.Array(ShellSidebarThread), + active: Schema.Array(ShellSidebarThread), + snoozed: Schema.Array(ShellSidebarThread), + settled: Schema.Array(ShellSidebarThread), + settledTotal: Schema.Number, + drafts: Schema.Array(ShellSidebarDraft), + activeThreadKey: Schema.NullOr(Schema.String), + activeDraftId: Schema.NullOr(Schema.String), +}); +export type ShellSidebarState = typeof ShellSidebarState.Type; + +/** + * Published under the `layout` key by the page's sidebar provider: the + * collapse state the page owns (its keybinding toggles it), for the shell to + * animate its own chrome. + */ +export const ShellLayoutState = Schema.Struct({ + sidebarCollapsed: Schema.Boolean, +}); +export type ShellLayoutState = typeof ShellLayoutState.Type; + +export const ShellComposerModel = Schema.Struct({ + slug: Schema.String, + name: Schema.String, + disabledReason: Schema.NullOr(Schema.String), +}); +export type ShellComposerModel = typeof ShellComposerModel.Type; + +export const ShellComposerInstance = Schema.Struct({ + instanceId: Schema.String, + driverKind: Schema.String, + displayName: Schema.String, + isAvailable: Schema.Boolean, + models: Schema.Array(ShellComposerModel), +}); +export type ShellComposerInstance = typeof ShellComposerInstance.Type; + +/** A provider option (reasoning effort, thinking toggles, …) for the selected model. */ +export const ShellComposerOption = Schema.Struct({ + id: Schema.String, + label: Schema.String, + type: Schema.Literals(["select", "boolean"]), + value: Schema.NullOr(Schema.Union([Schema.String, Schema.Boolean])), + choices: Schema.Array(Schema.Struct({ id: Schema.String, label: Schema.String })), +}); +export type ShellComposerOption = typeof ShellComposerOption.Type; + +export const ShellComposerRuntimeMode = Schema.Struct({ + value: Schema.String, + label: Schema.String, + description: Schema.String, +}); +export type ShellComposerRuntimeMode = typeof ShellComposerRuntimeMode.Type; + +export const ShellComposerSuggestion = Schema.Struct({ + id: Schema.String, + kind: Schema.Literals(["path", "slash-command", "provider-slash-command", "skill"]), + label: Schema.String, + description: Schema.String, +}); +export type ShellComposerSuggestion = typeof ShellComposerSuggestion.Type; + +const ShellComposerEdit = Schema.Struct({ + clientId: Schema.String, + revision: Schema.Number, +}); + +/** Published under the `composer` key while a thread or draft route is open. */ +export const ShellComposerState = Schema.Struct({ + /** `:` or a draft id; null between routes. */ + target: Schema.NullOr(Schema.String), + routeKind: Schema.Literals(["server", "draft"]), + /** Latest shell edit applied to this target, retained across page-originated changes. */ + edit: Schema.optional(Schema.NullOr(ShellComposerEdit)), + text: Schema.String, + /** Caret position in `text` (raw prompt, mentions written out) after the page changed it. */ + cursor: Schema.Number, + /** Active `@`/`$`/`/` trigger at the caret, with what it resolves to. */ + triggerKind: Schema.NullOr(Schema.Literals(["path", "slash-command", "skill"])), + suggestions: Schema.Array(ShellComposerSuggestion), + suggestionsEmptyText: Schema.NullOr(Schema.String), + /** Attached images and terminal selections on the draft (removable chips). */ + attachments: Schema.Array(Schema.Struct({ id: Schema.String, name: Schema.String })), + terminalContexts: Schema.Array( + Schema.Struct({ + id: Schema.String, + label: Schema.String, + lineStart: Schema.Number, + lineEnd: Schema.Number, + }), + ), + placeholder: Schema.String, + editorDisabled: Schema.Boolean, + canSend: Schema.Boolean, + sendDisabledReason: Schema.NullOr(Schema.String), + isRunning: Schema.Boolean, + isSendBusy: Schema.Boolean, + isConnecting: Schema.Boolean, + pendingApprovalCount: Schema.Number, + pendingUserInputCount: Schema.Number, + showPlanFollowUpPrompt: Schema.Boolean, + selectedInstanceId: Schema.NullOr(Schema.String), + selectedModel: Schema.NullOr(Schema.String), + instances: Schema.Array(ShellComposerInstance), + options: Schema.Array(ShellComposerOption), + runtimeMode: Schema.String, + runtimeModes: Schema.Array(ShellComposerRuntimeMode), + interactionMode: Schema.Literals(["default", "plan"]), + showInteractionModeToggle: Schema.Boolean, +}); +export type ShellComposerState = typeof ShellComposerState.Type; + +export const ShellRightPanelKind = Schema.Literals([ + "diff", + "files", + "file", + "preview", + "terminal", + "pull-request", + "agents", +]); +export type ShellRightPanelKind = typeof ShellRightPanelKind.Type; + +export const ShellRightPanelSurface = Schema.Struct({ + id: Schema.String, + kind: ShellRightPanelKind, + title: Schema.String, +}); +export type ShellRightPanelSurface = typeof ShellRightPanelSurface.Type; + +/** + * Published under the `rightPanel` key while a thread route is open. The + * panel's content stays HTML: the shell loads `embedPath` (same origin as the + * page, same session) in a second web view; this state only drives the tabs. + */ +export const ShellRightPanelState = Schema.Struct({ + threadKey: Schema.String, + isOpen: Schema.Boolean, + activeSurfaceId: Schema.NullOr(Schema.String), + surfaces: Schema.Array(ShellRightPanelSurface), + canAdd: Schema.Struct({ + diff: Schema.Boolean, + files: Schema.Boolean, + terminal: Schema.Boolean, + pullRequest: Schema.Boolean, + agents: Schema.Boolean, + }), + embedPath: Schema.String, +}); +export type ShellRightPanelState = typeof ShellRightPanelState.Type; + +export const ShellWorkspaceEnvMode = Schema.Literals(["local", "worktree"]); +export type ShellWorkspaceEnvMode = typeof ShellWorkspaceEnvMode.Type; + +/** + * Published under the `workspace` key while a thread route is open: the + * breadcrumb, checkout/branch context, editors and project scripts. Git + * write actions (commit/push/PR) stay with the page's own control. + */ +export const ShellWorkspaceState = Schema.Struct({ + threadKey: Schema.String, + projectTitle: Schema.NullOr(Schema.String), + projectRoot: Schema.NullOr(Schema.String), + threadTitle: Schema.String, + isDraft: Schema.Boolean, + envMode: ShellWorkspaceEnvMode, + envModeLabel: Schema.String, + envModeChangeable: Schema.Boolean, + startFromOrigin: Schema.Boolean, + branch: Schema.NullOr(Schema.String), + worktreePath: Schema.NullOr(Schema.String), + git: Schema.NullOr( + Schema.Struct({ + isRepo: Schema.Boolean, + hasWorkingTreeChanges: Schema.Boolean, + aheadCount: Schema.Number, + behindCount: Schema.Number, + hasUpstream: Schema.Boolean, + pullRequest: Schema.NullOr( + Schema.Struct({ + number: Schema.Number, + title: Schema.String, + url: Schema.String, + state: Schema.String, + }), + ), + }), + ), + canOpenPullRequest: Schema.Boolean, + /** + * The thread's terminal drawer, which the shell places as its own web + * surface (loading `terminalEmbedPath`): whether one can open here, whether + * it is open, and its height in CSS pixels. `terminal.resize` hands a + * dragged height back so the page persists it with the drawer's other state. + */ + terminalAvailable: Schema.Boolean, + terminalOpen: Schema.Boolean, + terminalHeight: Schema.Number, + terminalEmbedPath: Schema.String, + editors: Schema.Array(Schema.Struct({ id: Schema.String, label: Schema.String })), + preferredEditorId: Schema.NullOr(Schema.String), + scripts: Schema.Array( + Schema.Struct({ id: Schema.String, name: Schema.String, command: Schema.String }), + ), + preferredScriptId: Schema.NullOr(Schema.String), + environments: Schema.Array(Schema.Struct({ environmentId: Schema.String, label: Schema.String })), + activeEnvironmentId: Schema.String, + environmentChangeable: Schema.Boolean, + /** Bumped when the page asks the shell to start renaming the thread. */ + renameRequestId: Schema.Number, + /** Ref list for `branchQuery` (first pages), for a native branch picker. */ + branchQuery: Schema.String, + branches: Schema.Array( + Schema.Struct({ + name: Schema.String, + isRemote: Schema.Boolean, + isDefault: Schema.Boolean, + current: Schema.Boolean, + }), + ), + branchesTotal: Schema.Number, + branchesLoading: Schema.Boolean, + branchSwitchPending: Schema.Boolean, + branchChangeable: Schema.Boolean, +}); +export type ShellWorkspaceState = typeof ShellWorkspaceState.Type; + +/** Published under the `settings` key on every route change. */ +export const ShellSettingsState = Schema.Struct({ + /** True on `/settings*`: the shell shows settings navigation instead of the thread sidebar. */ + active: Schema.Boolean, + sections: Schema.Array(Schema.Struct({ to: Schema.String, label: Schema.String })), + activeSection: Schema.NullOr(Schema.String), + searchQuery: Schema.String, + searchResults: Schema.Array( + Schema.Struct({ + id: Schema.String, + title: Schema.String, + to: Schema.String, + sectionLabel: Schema.String, + targetId: Schema.NullOr(Schema.String), + }), + ), +}); +export type ShellSettingsState = typeof ShellSettingsState.Type; + +/** + * Published under the `theme` key: the page's resolved active theme, so the + * shell's default look is whatever the user picked in Settings. Colours are + * theme roles (see ThemeColorRole) resolved to `#rrggbb[aa]`. + */ +export const ShellThemeState = Schema.Struct({ + id: Schema.NullOr(Schema.String), + appearance: Schema.Literals(["light", "dark"]), + colors: Schema.Record(Schema.String, Schema.String), + /** `--radius` in CSS pixels. */ + radius: Schema.Number, + fontUi: Schema.NullOr(Schema.String), + fontMono: Schema.NullOr(Schema.String), +}); +export type ShellThemeState = typeof ShellThemeState.Type; + +export const ShellNotification = Schema.Struct({ + id: Schema.String, + type: Schema.Literals(["error", "info", "loading", "success", "warning"]), + title: Schema.String, + description: Schema.NullOr(Schema.String), + /** Bumped when the page updates the toast in place. */ + updateKey: Schema.Number, + actions: Schema.Array( + Schema.Struct({ id: Schema.String, label: Schema.String, primary: Schema.Boolean }), + ), +}); +export type ShellNotification = typeof ShellNotification.Type; + +/** Published under the `notifications` key: the page's stacked toasts, newest first. */ +export const ShellNotificationsState = Schema.Struct({ + items: Schema.Array(ShellNotification), +}); +export type ShellNotificationsState = typeof ShellNotificationsState.Type; + +const ShellContextMenuLeaf = Schema.Struct({ + id: Schema.String, + label: Schema.String, + destructive: Schema.optional(Schema.Boolean), + disabled: Schema.optional(Schema.Boolean), + header: Schema.optional(Schema.Boolean), + separatorBefore: Schema.optional(Schema.Boolean), +}); +export const ShellContextMenuItem = Schema.Struct({ + ...ShellContextMenuLeaf.fields, + children: Schema.optional(Schema.Array(ShellContextMenuLeaf)), +}); +export type ShellContextMenuItem = typeof ShellContextMenuItem.Type; + +/** + * Published under the `contextMenu` key while the page waits for a choice: + * every `localApi.contextMenu.show` becomes a native menu. `surfaceId` names + * the web surface whose coordinates `x`/`y` are in (`"shell"` = window + * coordinates, for menus opened from native chrome). Null when closed. + */ +export const ShellContextMenuState = Schema.Struct({ + requestId: Schema.String, + surfaceId: Schema.String, + x: Schema.Number, + y: Schema.Number, + items: Schema.Array(ShellContextMenuItem), +}); +export type ShellContextMenuState = typeof ShellContextMenuState.Type; + +export const ShellGitMenuItem = Schema.Struct({ + id: Schema.Literals(["commit", "push", "pr"]), + label: Schema.String, + disabledReason: Schema.NullOr(Schema.String), +}); +export type ShellGitMenuItem = typeof ShellGitMenuItem.Type; + +/** + * Published under the `git` key while a thread route is open: the git + * control's model. Progress and results arrive as notifications; the commit + * and default-branch dialogs are the shell's to render from this state. + */ +export const ShellGitState = Schema.Struct({ + available: Schema.Boolean, + isRepo: Schema.Boolean, + busy: Schema.Boolean, + initPending: Schema.Boolean, + quickAction: Schema.Struct({ + label: Schema.String, + disabledReason: Schema.NullOr(Schema.String), + kind: Schema.Literals(["run_action", "run_pull", "open_pr", "open_publish", "show_hint"]), + }), + menu: Schema.Array(ShellGitMenuItem), + canPublish: Schema.Boolean, + hints: Schema.Array(Schema.String), + branch: Schema.NullOr(Schema.String), + isDefaultRef: Schema.Boolean, + /** Working-tree files for the commit dialog. */ + files: Schema.Array( + Schema.Struct({ path: Schema.String, insertions: Schema.Number, deletions: Schema.Number }), + ), + /** Set while the page waits for the default-branch confirmation. */ + pendingDefaultBranch: Schema.NullOr( + Schema.Struct({ + title: Schema.String, + description: Schema.String, + continueLabel: Schema.String, + featureBranchLabel: Schema.String, + }), + ), +}); +export type ShellGitState = typeof ShellGitState.Type; + +/** + * Published under the `keybindings` key: the page's shortcuts that need a + * modifier, as Qt key sequences. The shell fires them while native chrome has + * focus and sends `keybinding.press` back; the page replays that as a + * keydown so its own handlers (and the user's `when` clauses) decide. + */ +export const ShellKeybinding = Schema.Struct({ + /** Portable Qt sequence, e.g. "Ctrl+Shift+]" ("Ctrl" is Command on macOS). */ + sequence: Schema.String, + /** KeyboardEvent.key the page replays, lowercased. */ + key: Schema.String, + ctrlKey: Schema.Boolean, + metaKey: Schema.Boolean, + shiftKey: Schema.Boolean, + altKey: Schema.Boolean, +}); +export type ShellKeybinding = typeof ShellKeybinding.Type; + +export const ShellKeybindingsState = Schema.Array(ShellKeybinding); +export type ShellKeybindingsState = typeof ShellKeybindingsState.Type; + +/** Actions the shell's chrome dispatches; `type` is the action name on the wire. */ +export const ShellAction = Schema.Union([ + Schema.Struct({ type: Schema.Literal("thread.open"), key: Schema.String }), + /** Row actions; the page runs the same mutations as its sidebar and toasts failures. */ + Schema.Struct({ type: Schema.Literal("thread.settle"), key: Schema.String }), + Schema.Struct({ type: Schema.Literal("thread.unsettle"), key: Schema.String }), + Schema.Struct({ type: Schema.Literal("thread.unsnooze"), key: Schema.String }), + /** Open the snooze presets at window coordinates; picking one snoozes the thread. */ + Schema.Struct({ + type: Schema.Literal("thread.snoozeMenu"), + key: Schema.String, + x: Schema.Number, + y: Schema.Number, + }), + /** Clear the "Woke" indicator without opening the thread. */ + Schema.Struct({ type: Schema.Literal("thread.wokeDismiss"), key: Schema.String }), + /** A published keybinding fired while native chrome had focus. */ + Schema.Struct({ + type: Schema.Literal("keybinding.press"), + key: Schema.String, + ctrlKey: Schema.Boolean, + metaKey: Schema.Boolean, + shiftKey: Schema.Boolean, + altKey: Schema.Boolean, + }), + /** Page → shell: the `modelPicker.toggle` command opens the native picker. */ + Schema.Struct({ type: Schema.Literal("composer.modelPicker.toggle") }), + Schema.Struct({ type: Schema.Literal("draft.open"), draftId: Schema.String }), + Schema.Struct({ type: Schema.Literal("thread.new"), projectKey: Schema.optional(Schema.String) }), + Schema.Struct({ + type: Schema.Literal("sidebar.scope"), + projectKey: Schema.NullOr(Schema.String), + }), + Schema.Struct({ type: Schema.Literal("sidebar.toggle") }), + Schema.Struct({ type: Schema.Literal("project.add") }), + Schema.Struct({ type: Schema.Literal("settings.open") }), + Schema.Struct({ type: Schema.Literal("pullRequests.open") }), + Schema.Struct({ type: Schema.Literal("usage.open") }), + Schema.Struct({ type: Schema.Literal("palette.open") }), + Schema.Struct({ + type: Schema.Literal("composer.text.set"), + target: Schema.String, + edit: Schema.optional(ShellComposerEdit), + text: Schema.String, + /** Caret in `text`; drives @/$// suggestions. */ + cursor: Schema.optional(Schema.Number), + }), + Schema.Struct({ type: Schema.Literal("composer.suggest.select"), id: Schema.String }), + /** Files the shell read for the user (drop or picker); go through the drop pipeline. */ + Schema.Struct({ + type: Schema.Literal("composer.attach"), + files: Schema.Array( + Schema.Struct({ name: Schema.String, mimeType: Schema.String, base64: Schema.String }), + ), + }), + Schema.Struct({ type: Schema.Literal("composer.attachment.remove"), id: Schema.String }), + Schema.Struct({ type: Schema.Literal("composer.terminalContext.remove"), id: Schema.String }), + /** A terminal selection from any document, added to the primary's composer. */ + Schema.Struct({ + type: Schema.Literal("composer.terminalContext.add"), + terminalId: Schema.String, + terminalLabel: Schema.String, + lineStart: Schema.Number, + lineEnd: Schema.Number, + text: Schema.String, + }), + Schema.Struct({ type: Schema.Literal("composer.suggest.dismiss") }), + // `text` rides along so the send is atomic with the latest edit. + Schema.Struct({ + type: Schema.Literal("composer.submit"), + edit: Schema.optional(ShellComposerEdit), + text: Schema.optional(Schema.String), + intent: Schema.optional(Schema.Literals(["foreground", "background"])), + }), + Schema.Struct({ type: Schema.Literal("composer.interrupt") }), + Schema.Struct({ + type: Schema.Literal("composer.model.select"), + instanceId: Schema.String, + model: Schema.String, + }), + Schema.Struct({ + type: Schema.Literal("composer.option.set"), + id: Schema.String, + value: Schema.Union([Schema.String, Schema.Boolean]), + }), + Schema.Struct({ type: Schema.Literal("composer.runtimeMode.set"), mode: Schema.String }), + Schema.Struct({ + type: Schema.Literal("composer.interactionMode.set"), + mode: Schema.Literals(["default", "plan"]), + }), + Schema.Struct({ type: Schema.Literal("rightPanel.toggle") }), + Schema.Struct({ type: Schema.Literal("terminal.toggle") }), + Schema.Struct({ type: Schema.Literal("terminal.resize"), height: Schema.Number }), + Schema.Struct({ type: Schema.Literal("rightPanel.activate"), id: Schema.String }), + Schema.Struct({ type: Schema.Literal("rightPanel.close"), id: Schema.String }), + Schema.Struct({ + type: Schema.Literal("rightPanel.add"), + kind: Schema.Literals(["diff", "files", "terminal", "pull-request", "agents"]), + }), + Schema.Struct({ type: Schema.Literal("workspace.newThread") }), + Schema.Struct({ + type: Schema.Literal("workspace.openInEditor"), + editorId: Schema.optional(Schema.String), + }), + Schema.Struct({ type: Schema.Literal("workspace.runScript"), scriptId: Schema.String }), + Schema.Struct({ type: Schema.Literal("workspace.envMode.set"), mode: ShellWorkspaceEnvMode }), + Schema.Struct({ type: Schema.Literal("workspace.startFromOrigin.set"), enabled: Schema.Boolean }), + Schema.Struct({ type: Schema.Literal("workspace.openPullRequest") }), + Schema.Struct({ + type: Schema.Literal("workspace.environment.set"), + environmentId: Schema.String, + }), + Schema.Struct({ type: Schema.Literal("workspace.branch.search"), query: Schema.String }), + Schema.Struct({ type: Schema.Literal("workspace.branch.select"), name: Schema.String }), + Schema.Struct({ type: Schema.Literal("workspace.branch.create"), name: Schema.String }), + Schema.Struct({ type: Schema.Literal("settings.navigate"), to: Schema.String }), + Schema.Struct({ + type: Schema.Literal("settings.openResult"), + to: Schema.String, + targetId: Schema.NullOr(Schema.String), + }), + Schema.Struct({ type: Schema.Literal("settings.search"), query: Schema.String }), + Schema.Struct({ type: Schema.Literal("settings.back") }), + Schema.Struct({ + type: Schema.Literal("notification.action"), + id: Schema.String, + actionId: Schema.String, + }), + Schema.Struct({ type: Schema.Literal("notification.dismiss"), id: Schema.String }), + Schema.Struct({ + type: Schema.Literal("contextMenu.select"), + requestId: Schema.String, + id: Schema.NullOr(Schema.String), + }), + /** Open the thread's action menu at window coordinates. */ + Schema.Struct({ + type: Schema.Literal("workspace.titleMenu"), + x: Schema.Number, + y: Schema.Number, + }), + Schema.Struct({ type: Schema.Literal("workspace.rename"), title: Schema.String }), + Schema.Struct({ type: Schema.Literal("git.quick") }), + Schema.Struct({ + type: Schema.Literal("git.menu"), + id: Schema.Literals(["commit", "push", "pr"]), + }), + Schema.Struct({ type: Schema.Literal("git.init") }), + Schema.Struct({ type: Schema.Literal("git.publish") }), + Schema.Struct({ type: Schema.Literal("git.refresh") }), + Schema.Struct({ + type: Schema.Literal("git.commit"), + message: Schema.String, + /** Null commits everything; otherwise the chosen subset. */ + filePaths: Schema.NullOr(Schema.Array(Schema.String)), + featureBranch: Schema.Boolean, + }), + Schema.Struct({ + type: Schema.Literal("git.defaultBranch"), + choice: Schema.Literals(["abort", "continue", "featureBranch"]), + }), + /** Open a sidebar thread's action menu at window coordinates. */ + Schema.Struct({ + type: Schema.Literal("thread.menu"), + key: Schema.String, + x: Schema.Number, + y: Schema.Number, + }), +]); +export type ShellAction = typeof ShellAction.Type; + +/** `window.t3Shell`, injected by the shell before any page script runs. */ +export interface T3Shell { + readonly protocolVersion: number; + /** Which web surface this document is in (`"primary"`, `"rightPanel"`, …). */ + readonly surfaceId: string; + readonly ready: Promise; + publish(key: string, value: unknown): Promise; + /** Resolves to an unsubscribe function once the channel is connected. */ + onAction(listener: (action: string, payload: unknown) => void): Promise<() => void>; + /** Everything published so far, keyed as published (any document's view models). */ + getState(): Promise>>; + /** Calls `listener` now and on every publish; resolves to an unsubscribe function. */ + onState(listener: (state: Readonly>) => void): Promise<() => void>; + /** Dispatch an action as native chrome would (lets a secondary document reach the primary). */ + dispatch(action: string, payload?: unknown): Promise; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95403e871a9b..982aa178ef47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -195,6 +195,12 @@ importers: specifier: 'catalog:' version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + apps/desktop-qt: + devDependencies: + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + apps/marketing: dependencies: '@t3tools/shared': diff --git a/vite.config.ts b/vite.config.ts index 258555e04b5a..99dcfefe7f72 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -41,6 +41,10 @@ export default defineConfig({ "**/routeTree.gen.ts", "apps/mobile/android/**", "apps/mobile/ios/**", + // Generated QML-dialect JS (`.pragma library`), see scripts/gen-icons.mjs. + "apps/desktop-qt/qml/T3/Bricks/js/lucide.js", + // Exported Lottie animation, kept as the tool wrote it. + "apps/desktop-qt/examples/dashboard/cat-playing.json", "apps/mobile/uniwind-types.d.ts", "*.icon/**", ],