Merge remote-tracking branch 'origin/main' into camoufox-novnc-browser-use

# Conflicts:
#	.github/workflows/release.yml
#	electron/desktopWindow.js
#	electron/launcher/launcher.js
#	electron/main.js
#	electron/preload.cjs
#	package.json
#	scripts/release/prepare-desktop-app.js
#	server/modules/websocket/services/websocket-server.service.ts
#	src/components/main-content/view/MainContent.tsx
#	src/components/main-content/view/subcomponents/MainContentTitle.tsx
This commit is contained in:
Simos Mikelatos
2026-06-29 12:34:48 +00:00
17 changed files with 1489 additions and 236 deletions

305
.github/workflows/desktop-release.yml vendored Normal file
View File

@@ -0,0 +1,305 @@
name: Desktop Release
on:
workflow_dispatch:
inputs:
tag:
description: "Release tag to create or update (defaults to v<package version>)"
required: false
type: string
release_name:
description: 'Release name (defaults to "CloudCLI Desktop <tag>")'
required: false
type: string
prerelease:
description: "Mark the GitHub release as a prerelease"
required: true
default: false
type: boolean
jobs:
resolve-release:
name: Resolve release metadata
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
tag: ${{ steps.release.outputs.tag }}
release_name: ${{ steps.release.outputs.release_name }}
server_bundle_tag: ${{ steps.release.outputs.server_bundle_tag }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Resolve release metadata
id: release
env:
TAG_INPUT: ${{ inputs.tag }}
RELEASE_NAME_INPUT: ${{ inputs.release_name }}
run: |
VERSION="$(node -p "require('./package.json').version")"
TAG="$TAG_INPUT"
if [ -z "$TAG" ]; then
TAG="v${VERSION}"
fi
TAG="$(printf '%s' "$TAG" | tr -d '\r\n' | sed 's/[^A-Za-z0-9._-]/-/g')"
if [ -z "$TAG" ]; then
echo "Resolved release tag is empty after normalization." >&2
exit 1
fi
RELEASE_NAME="$RELEASE_NAME_INPUT"
if [ -z "$RELEASE_NAME" ]; then
RELEASE_NAME="CloudCLI Desktop ${TAG}"
fi
RELEASE_NAME_DELIMITER="release_name_${GITHUB_RUN_ID}_${GITHUB_RUN_ATTEMPT}"
{
echo "tag=$TAG"
echo "release_name<<$RELEASE_NAME_DELIMITER"
printf '%s\n' "$RELEASE_NAME"
echo "$RELEASE_NAME_DELIMITER"
echo "server_bundle_tag=cloudcli-local-server-${TAG}"
} >> "$GITHUB_OUTPUT"
build-macos:
name: Build signed macOS desktop app
needs: resolve-release
runs-on: macos-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Configure release server bundle source
env:
SERVER_BUNDLE_TAG: ${{ needs.resolve-release.outputs.server_bundle_tag }}
run: printf '{"releaseTag":"%s"}\n' "$SERVER_BUNDLE_TAG" > electron/server-bundle-config.json
- name: Verify macOS signing secrets are configured
run: |
test -n "$CSC_LINK"
test -n "$CSC_KEY_PASSWORD"
test -n "$APPLE_ID"
test -n "$APPLE_APP_SPECIFIC_PASSWORD"
test -n "$APPLE_TEAM_ID"
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Build signed and notarized macOS artifacts
run: npm run desktop:dist:mac -- --publish never
env:
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Build macOS local server bundle
run: node scripts/release/build-server-bundle.js
- name: Stage macOS release assets
run: |
mkdir -p desktop-release-assets server-release-assets
test -n "$(find release/desktop -maxdepth 1 -name '*.dmg' -print -quit)"
shasum -a 256 release/desktop/*.dmg > desktop-release-assets/SHASUMS256-macos.txt
cp release/desktop/*.dmg desktop-release-assets/
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
cp release/local-server/* server-release-assets/
- name: Upload macOS desktop assets
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: desktop-release-macos
path: desktop-release-assets/*
if-no-files-found: error
- name: Upload macOS server assets
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: server-release-macos
path: server-release-assets/*
if-no-files-found: error
build-windows:
name: Build Windows desktop app
needs: resolve-release
runs-on: windows-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Typecheck
run: npm run typecheck
- name: Configure release server bundle source
shell: bash
env:
SERVER_BUNDLE_TAG: ${{ needs.resolve-release.outputs.server_bundle_tag }}
run: printf '{"releaseTag":"%s"}\n' "$SERVER_BUNDLE_TAG" > electron/server-bundle-config.json
- name: Check Windows signing secrets
id: windows-signing
shell: bash
env:
WINDOWS_CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }}
WINDOWS_CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }}
run: |
if [ -n "$WINDOWS_CSC_LINK" ] && [ -n "$WINDOWS_CSC_KEY_PASSWORD" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
fi
- name: Build signed Windows artifacts
if: steps.windows-signing.outputs.enabled == 'true'
run: npm run desktop:dist:win -- --publish never
env:
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }}
- name: Build unsigned Windows artifacts
if: steps.windows-signing.outputs.enabled != 'true'
run: npm run desktop:dist:win -- --publish never
env:
CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1"
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Build Windows local server bundle
run: node scripts/release/build-server-bundle.js
- name: Stage Windows release assets
shell: bash
run: |
mkdir -p desktop-release-assets server-release-assets
test -n "$(find release/desktop -maxdepth 1 -name '*.exe' -print -quit)"
sha256sum release/desktop/*.exe > desktop-release-assets/SHASUMS256-windows.txt
cp release/desktop/*.exe desktop-release-assets/
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
cp release/local-server/* server-release-assets/
- name: Upload Windows desktop assets
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: desktop-release-windows
path: desktop-release-assets/*
if-no-files-found: error
- name: Upload Windows server assets
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: server-release-windows
path: server-release-assets/*
if-no-files-found: error
publish:
name: Publish desktop release
needs:
- resolve-release
- build-macos
- build-windows
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download desktop assets
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
pattern: desktop-release-*
path: release/desktop
merge-multiple: true
- name: Download server assets
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
with:
pattern: server-release-*
path: release/local-server
merge-multiple: true
- name: Verify release assets
run: |
test -n "$(find release/desktop -maxdepth 1 -name '*.dmg' -print -quit)"
test -n "$(find release/desktop -maxdepth 1 -name '*.exe' -print -quit)"
test -f release/desktop/SHASUMS256-macos.txt
test -f release/desktop/SHASUMS256-windows.txt
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)"
test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)"
find release -maxdepth 2 -type f -print | sort
- name: Publish local server runtime assets
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ needs.resolve-release.outputs.server_bundle_tag }}
target_commitish: ${{ github.sha }}
name: CloudCLI Local Server Runtime (${{ needs.resolve-release.outputs.tag }})
body: |
This prerelease contains the Local mode runtime for CloudCLI Desktop.
Download CloudCLI Desktop from the main ${{ needs.resolve-release.outputs.tag }} release. When you open Local CloudCLI, the desktop app automatically downloads the matching runtime from this prerelease.
You do not need to download these runtime files manually.
prerelease: true
fail_on_unmatched_files: false
overwrite_files: true
files: |
release/local-server/*
- name: Publish GitHub release assets
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ needs.resolve-release.outputs.tag }}
target_commitish: ${{ github.sha }}
name: ${{ needs.resolve-release.outputs.release_name }}
body: |
Download the CloudCLI Desktop installer for your platform.
The local server runtime used by local mode is installed automatically by the desktop app. You do not need to download any server bundle manually.
prerelease: ${{ inputs.prerelease }}
fail_on_unmatched_files: false
overwrite_files: true
files: |
release/desktop/*

View File

@@ -4,9 +4,9 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
increment: increment:
description: 'Version bump: patch, minor, major, or explicit (e.g. 1.27.0)' description: "Version bump: patch, minor, major, or explicit (e.g. 1.27.0)"
required: true required: true
default: 'patch' default: "patch"
type: string type: string
release_name: release_name:
description: 'Custom release name (optional, defaults to "CloudCLI UI vX.Y.Z")' description: 'Custom release name (optional, defaults to "CloudCLI UI vX.Y.Z")'
@@ -124,6 +124,9 @@ jobs:
path: server/modules/computer-use/semantics/bin path: server/modules/computer-use/semantics/bin
merge-multiple: true merge-multiple: true
- name: Restore semantic helper permissions
run: find server/modules/computer-use/semantics/bin -path '*/darwin-*/CloudCLISemantics' -type f -exec chmod 755 {} +
- name: Verify bundled semantic helpers - name: Verify bundled semantic helpers
run: | run: |
test -x server/modules/computer-use/semantics/bin/darwin-arm64/CloudCLISemantics test -x server/modules/computer-use/semantics/bin/darwin-arm64/CloudCLISemantics

View File

@@ -3,6 +3,59 @@
All notable changes to CloudCLI UI will be documented in this file. All notable changes to CloudCLI UI will be documented in this file.
## [1.35.0](https://github.com/siteboon/claudecodeui/compare/v1.34.0...v1.35.0) (2026-06-29)
### New Features
* add Electron desktop app ([97c9b67](https://github.com/siteboon/claudecodeui/commit/97c9b67bfc2d803560cd1559a4e79eea9731c7b5))
* **chat:** derive activity indicator from per-session state and unify provider lifecycle events ([afc717e](https://github.com/siteboon/claudecodeui/commit/afc717e69e67f53173c30d2230722236f9180d39))
* **chat:** unify session gateway with stable IDs and a single WS protocol ([f5eac2e](https://github.com/siteboon/claudecodeui/commit/f5eac2ec12c8575bf80202fafe807d9e04720105))
* **i18n:** add French (fr) locale ([#878](https://github.com/siteboon/claudecodeui/issues/878)) ([f319d2c](https://github.com/siteboon/claudecodeui/commit/f319d2cf8d61452deaf6adf345494dd3e6898284))
* play sound for pending tool requests ([#918](https://github.com/siteboon/claudecodeui/issues/918)) ([c947eaa](https://github.com/siteboon/claudecodeui/commit/c947eaaee5fbc959563efb917f4ec7c88847dd6b))
* render changelog as markdown in version upgrade modal ([6a53c31](https://github.com/siteboon/claudecodeui/commit/6a53c31e907fffa79320997c27f99660c946b4a6))
* **sidebar:** improve running session state tracking ([591b18e](https://github.com/siteboon/claudecodeui/commit/591b18e9e343fda23affe100a53911f76aaa8f57))
* **skills:** add provider skill management ([#909](https://github.com/siteboon/claudecodeui/issues/909)) ([c5fe127](https://github.com/siteboon/claudecodeui/commit/c5fe127958d830eee19d008d8634c0e7d77fe1b9))
* **version:** warn when the server was updated but not restarted ([#898](https://github.com/siteboon/claudecodeui/issues/898)) ([f6326c8](https://github.com/siteboon/claudecodeui/commit/f6326c8082dfbe8a65dcdb836d3e71c635594c26))
### Bug Fixes
* changes provider logos to svg for fast load ([7bed675](https://github.com/siteboon/claudecodeui/commit/7bed675ad5fd1ecf7912d1a04afe9db5b1032823))
* **chat:** prevent chat interface crash on malformed AskUserQuestion payload ([#920](https://github.com/siteboon/claudecodeui/issues/920)) ([ed4ae31](https://github.com/siteboon/claudecodeui/commit/ed4ae3114aafc1d4ecb0b621eaf9d3b26dbca5b1))
* **chat:** prevent normalizeInlineCodeFences from breaking adjacent fenced code blocks ([#903](https://github.com/siteboon/claudecodeui/issues/903)) ([4712431](https://github.com/siteboon/claudecodeui/commit/4712431be81718dfb559ef43d7d7d5315bf4e01a))
* **chat:** sort messages appropriately ([123ae31](https://github.com/siteboon/claudecodeui/commit/123ae310207fe5969c3b313f62b9dee27e5d7489))
* **claude-sync:** skip subagent transcripts to prevent main session corruption ([#854](https://github.com/siteboon/claudecodeui/issues/854)) ([a12ca8e](https://github.com/siteboon/claudecodeui/commit/a12ca8eed373ef56cd37fbdd097845eaab34dee9))
* correct notification session id ([881e72d](https://github.com/siteboon/claudecodeui/commit/881e72d4a00ec9c1a5e1ae4799bffa900f27c1f8))
* create one unified function for frontend session processing ([677d330](https://github.com/siteboon/claudecodeui/commit/677d330981ef29a856f09e62b9f69bac0fa580d4))
* **i18n:** add missing sidebar message keys to all locales ([#896](https://github.com/siteboon/claudecodeui/issues/896)) ([7ca3556](https://github.com/siteboon/claudecodeui/commit/7ca355651f0a805965bc27af3d75def626c5fb96))
* keep running-session polling active ([39b0473](https://github.com/siteboon/claudecodeui/commit/39b0473e38201c29ff1e5388946452d2eed44527))
* normalize project session payloads ([d0adddb](https://github.com/siteboon/claudecodeui/commit/d0adddbbdafecfd5713a8ac5b95c87a8f7fc54f8))
* **opencode:** bind watcher sessions to app rows early ([5b9adbb](https://github.com/siteboon/claudecodeui/commit/5b9adbbdee8561439a27ad90744388225823427b))
* **opencode:** pass workspace dir explicitly ([416a737](https://github.com/siteboon/claudecodeui/commit/416a737d76e654d2fc649206c2b921a7db150775))
* recover pending permission requests ([56b2e14](https://github.com/siteboon/claudecodeui/commit/56b2e1405967c50301d0c773567349763edc8560))
* remove provider specific token usage calculator ([2abb456](https://github.com/siteboon/claudecodeui/commit/2abb45636b5e1109733cfa58c8ab92fd4c812165))
* resolve session provider on backend reads ([9fb2d91](https://github.com/siteboon/claudecodeui/commit/9fb2d91b26bef9579337d953a29718802c466fed))
* **sessions:** canonicalize sidebar ids and timestamps ([3bbb42c](https://github.com/siteboon/claudecodeui/commit/3bbb42c23324c3cbb5587f2bcab09b1dc23086a8))
* **shell:** prioritize user npm binaries ([#913](https://github.com/siteboon/claudecodeui/issues/913)) ([4a503b1](https://github.com/siteboon/claudecodeui/commit/4a503b1dc87ff58821670c8bfb1d8a8c1dab2bcf))
* **shell:** use correct session id ([89f0524](https://github.com/siteboon/claudecodeui/commit/89f05247eddec4fe53bd1616c6a5563e3ae2427a))
* **sidebar:** align session status controls across layouts ([1b336e9](https://github.com/siteboon/claudecodeui/commit/1b336e9aa9d2cccf0676d852815d9ba613ac04d2))
* upgrade gemini logo ([9cb2afd](https://github.com/siteboon/claudecodeui/commit/9cb2afd67eb25a4f869b88abcf86f7748b2b6d71))
* voice tts format settings ([#919](https://github.com/siteboon/claudecodeui/issues/919)) ([591e8e7](https://github.com/siteboon/claudecodeui/commit/591e8e7642589b0584f9b29b46b881aaab54624e))
### Documentation
* update available plugin readmes ([f549bd9](https://github.com/siteboon/claudecodeui/commit/f549bd99e7106362a27cf4ccee6e9d434b8b5363))
* update session activity guard comment ([e23e6af](https://github.com/siteboon/claudecodeui/commit/e23e6af06a44cc4b016df5778984602d49e52629))
### Maintenance
* add github issues board plugin ([21b0f14](https://github.com/siteboon/claudecodeui/commit/21b0f14e7a86f257c65484742c43b9f85152b32c))
* add more plugins list ([bc34085](https://github.com/siteboon/claudecodeui/commit/bc34085af9912da8d8592881a5845cff84a53f7d))
* move tests to appropriate folder ([d7a38a5](https://github.com/siteboon/claudecodeui/commit/d7a38a567a5e9039935353a886310b3c32b25a79))
* move tests to appropriate folder ([c6c153e](https://github.com/siteboon/claudecodeui/commit/c6c153e7f2a60572b08d687b59f010b4ad4f5d72))
* remove a log ([00e526b](https://github.com/siteboon/claudecodeui/commit/00e526b6e90ee0baf09ebf48873bc10824ab80ba))
* remove unused modelConstants from the project ([92de0ed](https://github.com/siteboon/claudecodeui/commit/92de0ed6137bf4571056deb3b930cc9fd22e2a08))
* upgrade gemini models ([3d94821](https://github.com/siteboon/claudecodeui/commit/3d948217ef3084e764171ebc5dda55f663150b2c))
## [](https://github.com/siteboon/claudecodeui/compare/v1.33.3...vnull) (2026-06-09) ## [](https://github.com/siteboon/claudecodeui/compare/v1.33.3...vnull) (2026-06-09)
### New Features ### New Features

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
<title>CloudCLI UI</title> <title>CloudCLI UI</title>
<!-- PWA Manifest --> <!-- PWA Manifest -->

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@cloudcli-ai/cloudcli", "name": "@cloudcli-ai/cloudcli",
"version": "1.34.0", "version": "1.35.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@cloudcli-ai/cloudcli", "name": "@cloudcli-ai/cloudcli",
"version": "1.34.0", "version": "1.35.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@cloudcli-ai/cloudcli", "name": "@cloudcli-ai/cloudcli",
"version": "1.34.0", "version": "1.35.0",
"productName": "CloudCLI", "productName": "CloudCLI",
"description": "A web-based UI for Claude Code CLI", "description": "A web-based UI for Claude Code CLI",
"type": "module", "type": "module",

View File

@@ -1,6 +1,5 @@
import type { ITerminalOptions } from '@xterm/xterm'; import type { ITerminalOptions } from '@xterm/xterm';
export const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/codex/device';
export const SHELL_RESTART_DELAY_MS = 200; export const SHELL_RESTART_DELAY_MS = 200;
export const TERMINAL_INIT_DELAY_MS = 100; export const TERMINAL_INIT_DELAY_MS = 100;
export const TERMINAL_RESIZE_DELAY_MS = 50; export const TERMINAL_RESIZE_DELAY_MS = 50;

View File

@@ -24,7 +24,6 @@ type UseShellConnectionOptions = {
autoConnect: boolean; autoConnect: boolean;
closeSocket: () => void; closeSocket: () => void;
clearTerminalScreen: () => void; clearTerminalScreen: () => void;
setAuthUrl: (nextAuthUrl: string) => void;
onOutputRef?: MutableRefObject<(() => void) | null>; onOutputRef?: MutableRefObject<(() => void) | null>;
}; };
@@ -49,7 +48,6 @@ export function useShellConnection({
autoConnect, autoConnect,
closeSocket, closeSocket,
clearTerminalScreen, clearTerminalScreen,
setAuthUrl,
onOutputRef, onOutputRef,
}: UseShellConnectionOptions): UseShellConnectionResult { }: UseShellConnectionOptions): UseShellConnectionResult {
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
@@ -100,14 +98,8 @@ export function useShellConnection({
return; return;
} }
if (message.type === 'auth_url' || message.type === 'url_open') {
const nextAuthUrl = typeof message.url === 'string' ? message.url : '';
if (nextAuthUrl) {
setAuthUrl(nextAuthUrl);
}
}
}, },
[handleProcessCompletion, onOutputRef, setAuthUrl, terminalRef], [handleProcessCompletion, onOutputRef, terminalRef],
); );
const connectWebSocket = useCallback( const connectWebSocket = useCallback(
@@ -133,7 +125,6 @@ export function useShellConnection({
setIsConnected(true); setIsConnected(true);
setIsConnecting(false); setIsConnecting(false);
connectingRef.current = false; connectingRef.current = false;
setAuthUrl('');
window.setTimeout(() => { window.setTimeout(() => {
const currentTerminal = terminalRef.current; const currentTerminal = terminalRef.current;
@@ -196,7 +187,6 @@ export function useShellConnection({
isPlainShellRef, isPlainShellRef,
selectedProjectRef, selectedProjectRef,
selectedSessionRef, selectedSessionRef,
setAuthUrl,
terminalRef, terminalRef,
wsRef, wsRef,
], ],
@@ -225,8 +215,7 @@ export function useShellConnection({
setIsConnecting(false); setIsConnecting(false);
connectingRef.current = false; connectingRef.current = false;
forceRestartOnInitRef.current = false; forceRestartOnInitRef.current = false;
setAuthUrl(''); }, [clearTerminalScreen, closeSocket]);
}, [clearTerminalScreen, closeSocket, setAuthUrl]);
useEffect(() => { useEffect(() => {
if ( if (

View File

@@ -1,8 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import type { FitAddon } from '@xterm/addon-fit'; import type { FitAddon } from '@xterm/addon-fit';
import type { Terminal } from '@xterm/xterm'; import type { Terminal } from '@xterm/xterm';
import type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types'; import type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types';
import { copyTextToClipboard } from '../../../utils/clipboard';
import { useShellConnection } from './useShellConnection'; import { useShellConnection } from './useShellConnection';
import { useShellTerminal } from './useShellTerminal'; import { useShellTerminal } from './useShellTerminal';
@@ -22,15 +23,11 @@ export function useShellRuntime({
const fitAddonRef = useRef<FitAddon | null>(null); const fitAddonRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const [authUrl, setAuthUrl] = useState('');
const [authUrlVersion, setAuthUrlVersion] = useState(0);
const selectedProjectRef = useRef(selectedProject); const selectedProjectRef = useRef(selectedProject);
const selectedSessionRef = useRef(selectedSession); const selectedSessionRef = useRef(selectedSession);
const initialCommandRef = useRef(initialCommand); const initialCommandRef = useRef(initialCommand);
const isPlainShellRef = useRef(isPlainShell); const isPlainShellRef = useRef(isPlainShell);
const onProcessCompleteRef = useRef(onProcessComplete); const onProcessCompleteRef = useRef(onProcessComplete);
const authUrlRef = useRef('');
const lastSessionIdRef = useRef<string | null>(selectedSession?.id ?? null); const lastSessionIdRef = useRef<string | null>(selectedSession?.id ?? null);
// Keep mutable values in refs so websocket handlers always read current data. // Keep mutable values in refs so websocket handlers always read current data.
@@ -42,12 +39,6 @@ export function useShellRuntime({
onProcessCompleteRef.current = onProcessComplete; onProcessCompleteRef.current = onProcessComplete;
}, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]); }, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]);
const setCurrentAuthUrl = useCallback((nextAuthUrl: string) => {
authUrlRef.current = nextAuthUrl;
setAuthUrl(nextAuthUrl);
setAuthUrlVersion((previous) => previous + 1);
}, []);
const closeSocket = useCallback(() => { const closeSocket = useCallback(() => {
const activeSocket = wsRef.current; const activeSocket = wsRef.current;
if (!activeSocket) { if (!activeSocket) {
@@ -64,32 +55,6 @@ export function useShellRuntime({
wsRef.current = null; wsRef.current = null;
}, []); }, []);
const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => {
if (!url) {
return false;
}
const popup = window.open(url, '_blank');
if (popup) {
try {
popup.opener = null;
} catch {
// Ignore cross-origin restrictions when trying to null opener.
}
return true;
}
return false;
}, []);
const copyAuthUrlToClipboard = useCallback(async (url = authUrlRef.current) => {
if (!url) {
return false;
}
return copyTextToClipboard(url);
}, []);
const { isInitialized, clearTerminalScreen, disposeTerminal } = useShellTerminal({ const { isInitialized, clearTerminalScreen, disposeTerminal } = useShellTerminal({
terminalContainerRef, terminalContainerRef,
terminalRef, terminalRef,
@@ -98,10 +63,6 @@ export function useShellRuntime({
selectedProject, selectedProject,
minimal, minimal,
isRestarting, isRestarting,
initialCommandRef,
isPlainShellRef,
authUrlRef,
copyAuthUrlToClipboard,
closeSocket, closeSocket,
}); });
@@ -118,7 +79,6 @@ export function useShellRuntime({
autoConnect, autoConnect,
closeSocket, closeSocket,
clearTerminalScreen, clearTerminalScreen,
setAuthUrl: setCurrentAuthUrl,
onOutputRef, onOutputRef,
}); });
@@ -156,11 +116,7 @@ export function useShellRuntime({
isConnected, isConnected,
isInitialized, isInitialized,
isConnecting, isConnecting,
authUrl,
authUrlVersion,
connectToShell, connectToShell,
disconnectFromShell, disconnectFromShell,
openAuthUrlInBrowser,
copyAuthUrlToClipboard,
}; };
} }

View File

@@ -4,15 +4,18 @@ import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links'; import { WebLinksAddon } from '@xterm/addon-web-links';
import { WebglAddon } from '@xterm/addon-webgl'; import { WebglAddon } from '@xterm/addon-webgl';
import { Terminal } from '@xterm/xterm'; import { Terminal } from '@xterm/xterm';
import type { Project } from '../../../types/app'; import type { Project } from '../../../types/app';
import { copyTextToClipboard } from '../../../utils/clipboard';
import { import {
CODEX_DEVICE_AUTH_URL,
TERMINAL_INIT_DELAY_MS, TERMINAL_INIT_DELAY_MS,
TERMINAL_OPTIONS, TERMINAL_OPTIONS,
TERMINAL_RESIZE_DELAY_MS, TERMINAL_RESIZE_DELAY_MS,
} from '../constants/constants'; } from '../constants/constants';
import { copyTextToClipboard } from '../../../utils/clipboard'; import {
import { isCodexLoginCommand } from '../utils/auth'; installMobileTerminalSelection,
type MobileTerminalSelectionManager,
} from '../utils/mobileTerminalSelection';
import { sendSocketMessage } from '../utils/socket'; import { sendSocketMessage } from '../utils/socket';
import { ensureXtermFocusStyles } from '../utils/terminalStyles'; import { ensureXtermFocusStyles } from '../utils/terminalStyles';
@@ -24,10 +27,6 @@ type UseShellTerminalOptions = {
selectedProject: Project | null | undefined; selectedProject: Project | null | undefined;
minimal: boolean; minimal: boolean;
isRestarting: boolean; isRestarting: boolean;
initialCommandRef: MutableRefObject<string | null | undefined>;
isPlainShellRef: MutableRefObject<boolean>;
authUrlRef: MutableRefObject<string>;
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
closeSocket: () => void; closeSocket: () => void;
}; };
@@ -45,14 +44,11 @@ export function useShellTerminal({
selectedProject, selectedProject,
minimal, minimal,
isRestarting, isRestarting,
initialCommandRef,
isPlainShellRef,
authUrlRef,
copyAuthUrlToClipboard,
closeSocket, closeSocket,
}: UseShellTerminalOptions): UseShellTerminalResult { }: UseShellTerminalOptions): UseShellTerminalResult {
const [isInitialized, setIsInitialized] = useState(false); const [isInitialized, setIsInitialized] = useState(false);
const resizeTimeoutRef = useRef<number | null>(null); const resizeTimeoutRef = useRef<number | null>(null);
const mobileSelectionRef = useRef<MobileTerminalSelectionManager | null>(null);
const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || ''; const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || '';
const hasSelectedProject = Boolean(selectedProject); const hasSelectedProject = Boolean(selectedProject);
@@ -70,6 +66,11 @@ export function useShellTerminal({
}, [terminalRef]); }, [terminalRef]);
const disposeTerminal = useCallback(() => { const disposeTerminal = useCallback(() => {
if (mobileSelectionRef.current) {
mobileSelectionRef.current.dispose();
mobileSelectionRef.current = null;
}
if (terminalRef.current) { if (terminalRef.current) {
terminalRef.current.dispose(); terminalRef.current.dispose();
terminalRef.current = null; terminalRef.current = null;
@@ -80,7 +81,8 @@ export function useShellTerminal({
}, [fitAddonRef, terminalRef]); }, [fitAddonRef, terminalRef]);
useEffect(() => { useEffect(() => {
if (!terminalContainerRef.current || !hasSelectedProject || isRestarting || terminalRef.current) { const terminalContainer = terminalContainerRef.current;
if (!terminalContainer || !hasSelectedProject || isRestarting || terminalRef.current) {
return; return;
} }
@@ -102,7 +104,28 @@ export function useShellTerminal({
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback'); console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
} }
nextTerminal.open(terminalContainerRef.current); nextTerminal.open(terminalContainer);
mobileSelectionRef.current = installMobileTerminalSelection(
nextTerminal,
terminalContainer,
{
onFontSizeChange: (fontSize) => {
nextTerminal.options.fontSize = fontSize;
const currentFitAddon = fitAddonRef.current;
if (currentFitAddon) {
currentFitAddon.fit();
sendSocketMessage(wsRef.current, {
type: 'resize',
cols: nextTerminal.cols,
rows: nextTerminal.rows,
});
} else {
nextTerminal.refresh(0, nextTerminal.rows - 1);
}
},
},
);
const copyTerminalSelection = async () => { const copyTerminalSelection = async () => {
const selection = nextTerminal.getSelection(); const selection = nextTerminal.getSelection();
@@ -133,29 +156,9 @@ export function useShellTerminal({
void copyTextToClipboard(selection); void copyTextToClipboard(selection);
}; };
terminalContainerRef.current.addEventListener('copy', handleTerminalCopy); terminalContainer.addEventListener('copy', handleTerminalCopy);
nextTerminal.attachCustomKeyEventHandler((event) => { nextTerminal.attachCustomKeyEventHandler((event) => {
const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
? CODEX_DEVICE_AUTH_URL
: authUrlRef.current;
if (
event.type === 'keydown' &&
minimal &&
isPlainShellRef.current &&
activeAuthUrl &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
event.key?.toLowerCase() === 'c'
) {
event.preventDefault();
event.stopPropagation();
void copyAuthUrlToClipboard(activeAuthUrl);
return false;
}
if ( if (
event.type === 'keydown' && event.type === 'keydown' &&
(event.ctrlKey || event.metaKey) && (event.ctrlKey || event.metaKey) &&
@@ -240,10 +243,10 @@ export function useShellTerminal({
}, TERMINAL_RESIZE_DELAY_MS); }, TERMINAL_RESIZE_DELAY_MS);
}); });
resizeObserver.observe(terminalContainerRef.current); resizeObserver.observe(terminalContainer);
return () => { return () => {
terminalContainerRef.current?.removeEventListener('copy', handleTerminalCopy); terminalContainer.removeEventListener('copy', handleTerminalCopy);
resizeObserver.disconnect(); resizeObserver.disconnect();
if (resizeTimeoutRef.current !== null) { if (resizeTimeoutRef.current !== null) {
window.clearTimeout(resizeTimeoutRef.current); window.clearTimeout(resizeTimeoutRef.current);
@@ -254,16 +257,12 @@ export function useShellTerminal({
disposeTerminal(); disposeTerminal();
}; };
}, [ }, [
authUrlRef,
closeSocket, closeSocket,
copyAuthUrlToClipboard,
disposeTerminal, disposeTerminal,
fitAddonRef, fitAddonRef,
initialCommandRef,
isPlainShellRef,
isRestarting, isRestarting,
minimal,
hasSelectedProject, hasSelectedProject,
minimal,
selectedProjectKey, selectedProjectKey,
terminalContainerRef, terminalContainerRef,
terminalRef, terminalRef,

View File

@@ -4,8 +4,6 @@ import type { Terminal } from '@xterm/xterm';
import type { Project, ProjectSession } from '../../../types/app'; import type { Project, ProjectSession } from '../../../types/app';
export type AuthCopyStatus = 'idle' | 'copied' | 'failed';
export type ShellInitMessage = { export type ShellInitMessage = {
type: 'init'; type: 'init';
projectPath: string; projectPath: string;
@@ -54,7 +52,6 @@ export type ShellSharedRefs = {
wsRef: MutableRefObject<WebSocket | null>; wsRef: MutableRefObject<WebSocket | null>;
terminalRef: MutableRefObject<Terminal | null>; terminalRef: MutableRefObject<Terminal | null>;
fitAddonRef: MutableRefObject<FitAddon | null>; fitAddonRef: MutableRefObject<FitAddon | null>;
authUrlRef: MutableRefObject<string>;
selectedProjectRef: MutableRefObject<Project | null | undefined>; selectedProjectRef: MutableRefObject<Project | null | undefined>;
selectedSessionRef: MutableRefObject<ProjectSession | null | undefined>; selectedSessionRef: MutableRefObject<ProjectSession | null | undefined>;
initialCommandRef: MutableRefObject<string | null | undefined>; initialCommandRef: MutableRefObject<string | null | undefined>;
@@ -69,10 +66,6 @@ export type UseShellRuntimeResult = {
isConnected: boolean; isConnected: boolean;
isInitialized: boolean; isInitialized: boolean;
isConnecting: boolean; isConnecting: boolean;
authUrl: string;
authUrlVersion: number;
connectToShell: (options?: { forceRestart?: boolean }) => void; connectToShell: (options?: { forceRestart?: boolean }) => void;
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void; disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
openAuthUrlInBrowser: (url?: string) => boolean;
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
}; };

View File

@@ -1,17 +1,4 @@
import type { ProjectSession } from '../../../types/app'; import type { ProjectSession } from '../../../types/app';
import { CODEX_DEVICE_AUTH_URL } from '../constants/constants';
export function isCodexLoginCommand(command: string | null | undefined): boolean {
return typeof command === 'string' && /\bcodex\s+login\b/i.test(command);
}
export function resolveAuthUrlForDisplay(command: string | null | undefined, authUrl: string): string {
if (isCodexLoginCommand(command)) {
return CODEX_DEVICE_AUTH_URL;
}
return authUrl;
}
export function getSessionDisplayName(session: ProjectSession | null | undefined): string | null { export function getSessionDisplayName(session: ProjectSession | null | undefined): string | null {
if (!session) { if (!session) {
@@ -21,4 +8,4 @@ export function getSessionDisplayName(session: ProjectSession | null | undefined
return session.__provider === 'cursor' return session.__provider === 'cursor'
? session.name || 'Untitled Session' ? session.name || 'Untitled Session'
: session.summary || 'New Session'; : session.summary || 'New Session';
} }

File diff suppressed because it is too large Load Diff

View File

@@ -59,12 +59,8 @@ export default function Shell({
isConnected, isConnected,
isInitialized, isInitialized,
isConnecting, isConnecting,
authUrl,
authUrlVersion,
connectToShell, connectToShell,
disconnectFromShell, disconnectFromShell,
openAuthUrlInBrowser,
copyAuthUrlToClipboard,
} = useShellRuntime({ } = useShellRuntime({
selectedProject, selectedProject,
selectedSession, selectedSession,
@@ -243,15 +239,7 @@ export default function Shell({
if (minimal) { if (minimal) {
return ( return (
<> <>
<ShellMinimalView <ShellMinimalView terminalContainerRef={terminalContainerRef} />
terminalContainerRef={terminalContainerRef}
authUrl={authUrl}
authUrlVersion={authUrlVersion}
initialCommand={initialCommand}
isConnected={isConnected}
openAuthUrlInBrowser={openAuthUrlInBrowser}
copyAuthUrlToClipboard={copyAuthUrlToClipboard}
/>
<TerminalShortcutsPanel <TerminalShortcutsPanel
wsRef={wsRef} wsRef={wsRef}
terminalRef={terminalRef} terminalRef={terminalRef}

View File

@@ -1,45 +1,12 @@
import { useEffect, useMemo, useState } from 'react';
import type { RefObject } from 'react'; import type { RefObject } from 'react';
import type { AuthCopyStatus } from '../../types/types';
import { resolveAuthUrlForDisplay } from '../../utils/auth';
type ShellMinimalViewProps = { type ShellMinimalViewProps = {
terminalContainerRef: RefObject<HTMLDivElement>; terminalContainerRef: RefObject<HTMLDivElement>;
authUrl: string;
authUrlVersion: number;
initialCommand: string | null | undefined;
isConnected: boolean;
openAuthUrlInBrowser: (url: string) => boolean;
copyAuthUrlToClipboard: (url: string) => Promise<boolean>;
}; };
export default function ShellMinimalView({ export default function ShellMinimalView({
terminalContainerRef, terminalContainerRef,
authUrl,
authUrlVersion,
initialCommand,
isConnected,
openAuthUrlInBrowser,
copyAuthUrlToClipboard,
}: ShellMinimalViewProps) { }: ShellMinimalViewProps) {
const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState<AuthCopyStatus>('idle');
const [isAuthPanelHidden, setIsAuthPanelHidden] = useState(false);
const displayAuthUrl = useMemo(
() => resolveAuthUrlForDisplay(initialCommand, authUrl),
[authUrl, initialCommand],
);
// Keep auth panel UI state local to minimal mode and reset it when connection/url changes.
useEffect(() => {
setAuthUrlCopyStatus('idle');
setIsAuthPanelHidden(false);
}, [authUrlVersion, displayAuthUrl, isConnected]);
const hasAuthUrl = Boolean(displayAuthUrl);
const showMobileAuthPanel = hasAuthUrl && !isAuthPanelHidden;
const showMobileAuthPanelToggle = hasAuthUrl && isAuthPanelHidden;
return ( return (
<div className="relative h-full w-full bg-gray-900"> <div className="relative h-full w-full bg-gray-900">
<div <div
@@ -47,67 +14,6 @@ export default function ShellMinimalView({
className="h-full w-full focus:outline-none" className="h-full w-full focus:outline-none"
style={{ outline: 'none' }} style={{ outline: 'none' }}
/> />
{showMobileAuthPanel && (
<div className="absolute inset-x-0 bottom-14 z-20 border-t border-gray-700/80 bg-gray-900/95 p-3 backdrop-blur-sm md:hidden">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-gray-300">Open or copy the login URL:</p>
<button
type="button"
onClick={() => setIsAuthPanelHidden(true)}
className="rounded bg-gray-700 px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-gray-100 hover:bg-gray-600"
>
Hide
</button>
</div>
<input
type="text"
value={displayAuthUrl}
readOnly
onClick={(event) => event.currentTarget.select()}
className="w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
aria-label="Authentication URL"
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
openAuthUrlInBrowser(displayAuthUrl);
}}
className="flex-1 rounded bg-blue-600 px-3 py-2 text-xs font-medium text-white hover:bg-blue-700"
>
Open URL
</button>
<button
type="button"
onClick={async () => {
const copied = await copyAuthUrlToClipboard(displayAuthUrl);
setAuthUrlCopyStatus(copied ? 'copied' : 'failed');
}}
className="flex-1 rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-600"
>
{authUrlCopyStatus === 'copied' ? 'Copied' : 'Copy URL'}
</button>
</div>
</div>
</div>
)}
{showMobileAuthPanelToggle && (
<div className="absolute bottom-14 right-3 z-20 md:hidden">
<button
type="button"
onClick={() => setIsAuthPanelHidden(false)}
className="rounded bg-gray-800/95 px-3 py-2 text-xs font-medium text-gray-100 shadow-lg backdrop-blur-sm hover:bg-gray-700"
>
Show login URL
</button>
</div>
)}
</div> </div>
); );
} }

View File

@@ -26,6 +26,7 @@ const MOBILE_KEYS: Shortcut[] = [
{ type: 'arrow', id: 'arrow-down', sequence: '\x1b[B', icon: 'down' }, { type: 'arrow', id: 'arrow-down', sequence: '\x1b[B', icon: 'down' },
{ type: 'arrow', id: 'arrow-left', sequence: '\x1b[D', icon: 'left' }, { type: 'arrow', id: 'arrow-left', sequence: '\x1b[D', icon: 'left' },
{ type: 'arrow', id: 'arrow-right', sequence: '\x1b[C', icon: 'right' }, { type: 'arrow', id: 'arrow-right', sequence: '\x1b[C', icon: 'right' },
{ type: 'key', id: 'ctrl-c', label: 'Ctrl+C', sequence: '\x03' },
]; ];
const ARROW_ICONS = { const ARROW_ICONS = {

View File

@@ -139,6 +139,12 @@
height: 100%; height: 100%;
margin: 0; margin: 0;
padding: 0; padding: 0;
/* The app shell is a fixed inset-0 container (see AppContent), so the
document itself never needs to scroll. Clipping it removes the phantom
full-height page scrollbar and disables the browser pull-to-refresh
gesture that reloads the page when scrolling up on mobile. */
overflow: hidden;
overscroll-behavior-y: contain;
} }
/* Root element with safe area padding for PWA */ /* Root element with safe area padding for PWA */