mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-01 01:23:06 +08:00
Compare commits
69 Commits
v1.35.0
...
electron-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46ba8e56b4 | ||
|
|
a0899a252e | ||
|
|
3bc2c777a3 | ||
|
|
63f3c3941d | ||
|
|
e6c6f89dda | ||
|
|
6f712269e8 | ||
|
|
52244404a3 | ||
|
|
8ad18f8587 | ||
|
|
fe116a7138 | ||
|
|
490e66ebdb | ||
|
|
81eb966904 | ||
|
|
0d68dc2cd0 | ||
|
|
bb630ef739 | ||
|
|
1c05fe0905 | ||
|
|
077baee5f2 | ||
|
|
f150fa6b09 | ||
|
|
9f8cee8919 | ||
|
|
bb323fc566 | ||
|
|
5ef40be2d3 | ||
|
|
cf4b28273e | ||
|
|
f4c68942a5 | ||
|
|
4d70a2588c | ||
|
|
218e8e2e38 | ||
|
|
53c3c4c27a | ||
|
|
901c6fc956 | ||
|
|
278fe4f7b1 | ||
|
|
d7f4d4c342 | ||
|
|
d1930fecdb | ||
|
|
1726705459 | ||
|
|
a35200f340 | ||
|
|
06c9745489 | ||
|
|
0dd22db2bb | ||
|
|
e7aa72c41e | ||
|
|
9f24f80f33 | ||
|
|
25ab273b05 | ||
|
|
5be100ea1b | ||
|
|
2af3d38afe | ||
|
|
531833bc87 | ||
|
|
b2333e7d93 | ||
|
|
f75ae385dd | ||
|
|
7786763dd1 | ||
|
|
1dbf545fd9 | ||
|
|
ac37213269 | ||
|
|
65fdc38f2e | ||
|
|
6c2652aee6 | ||
|
|
bf50d29c20 | ||
|
|
ffc0cd7501 | ||
|
|
59194d1502 | ||
|
|
7e6028b113 | ||
|
|
9881e5e366 | ||
|
|
496a895e8a | ||
|
|
086df034b4 | ||
|
|
fc71fc7d2b | ||
|
|
a0d56429a7 | ||
|
|
6af4afe6f2 | ||
|
|
7aeca52669 | ||
|
|
56532af33a | ||
|
|
9438a365f2 | ||
|
|
e5c6e5e596 | ||
|
|
0426522406 | ||
|
|
6e7e2ff4c1 | ||
|
|
e6263dbd1f | ||
|
|
260070bae0 | ||
|
|
daac6e3fd3 | ||
|
|
861cfecbaa | ||
|
|
a182765e10 | ||
|
|
828d1a2302 | ||
|
|
d427004bd7 | ||
|
|
243e6cecd5 |
151
.github/workflows/desktop-macos-release.yml
vendored
Normal file
151
.github/workflows/desktop-macos-release.yml
vendored
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
name: Desktop macOS 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 macOS <tag>")'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
prerelease:
|
||||||
|
description: 'Mark the GitHub release as a prerelease'
|
||||||
|
required: true
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-macos:
|
||||||
|
name: Build signed macOS desktop app
|
||||||
|
runs-on: macos-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
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: 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 macOS ${TAG}"
|
||||||
|
fi
|
||||||
|
RELEASE_NAME_DELIMITER="release_name_$(uuidgen)"
|
||||||
|
|
||||||
|
{
|
||||||
|
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"
|
||||||
|
|
||||||
|
- name: Configure release server bundle source
|
||||||
|
env:
|
||||||
|
SERVER_BUNDLE_TAG: ${{ steps.release.outputs.server_bundle_tag }}
|
||||||
|
run: printf '{"releaseTag":"%s"}\n' "$SERVER_BUNDLE_TAG" > electron/server-bundle-config.json
|
||||||
|
|
||||||
|
- name: Verify 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 local server bundle
|
||||||
|
run: node scripts/release/build-server-bundle.js
|
||||||
|
|
||||||
|
- name: Verify local server runtime artifacts
|
||||||
|
run: |
|
||||||
|
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)"
|
||||||
|
|
||||||
|
- name: Publish local server runtime assets
|
||||||
|
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.release.outputs.server_bundle_tag }}
|
||||||
|
target_commitish: ${{ github.sha }}
|
||||||
|
name: CloudCLI Local Server Runtime (${{ steps.release.outputs.tag }})
|
||||||
|
body: |
|
||||||
|
This prerelease contains the Local mode runtime for CloudCLI Desktop.
|
||||||
|
|
||||||
|
Download CloudCLI Desktop from the main ${{ steps.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: Verify macOS artifacts
|
||||||
|
run: |
|
||||||
|
test -n "$(find release/desktop -maxdepth 1 -name '*.dmg' -print -quit)"
|
||||||
|
shasum -a 256 release/desktop/*.dmg > release/SHASUMS256.txt
|
||||||
|
cat release/SHASUMS256.txt
|
||||||
|
|
||||||
|
- name: Publish GitHub release assets
|
||||||
|
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||||
|
with:
|
||||||
|
tag_name: ${{ steps.release.outputs.tag }}
|
||||||
|
target_commitish: ${{ github.sha }}
|
||||||
|
name: ${{ steps.release.outputs.release_name }}
|
||||||
|
body: |
|
||||||
|
Download the CloudCLI Desktop installer for your Mac.
|
||||||
|
|
||||||
|
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
|
||||||
|
files: |
|
||||||
|
release/desktop/*.dmg
|
||||||
|
release/SHASUMS256.txt
|
||||||
305
.github/workflows/desktop-release.yml
vendored
305
.github/workflows/desktop-release.yml
vendored
@@ -1,305 +0,0 @@
|
|||||||
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/*
|
|
||||||
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
@@ -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,9 +124,6 @@ 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
|
||||||
|
|||||||
53
CHANGELOG.md
53
CHANGELOG.md
@@ -3,59 +3,6 @@
|
|||||||
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
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@cloudcli-ai/cloudcli",
|
"name": "@cloudcli-ai/cloudcli",
|
||||||
"version": "1.35.0",
|
"version": "1.34.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@cloudcli-ai/cloudcli",
|
"name": "@cloudcli-ai/cloudcli",
|
||||||
"version": "1.35.0",
|
"version": "1.34.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@cloudcli-ai/cloudcli",
|
"name": "@cloudcli-ai/cloudcli",
|
||||||
"version": "1.35.0",
|
"version": "1.34.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",
|
||||||
|
|||||||
@@ -207,15 +207,6 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A result with a toolId but no matching tool_use in the loaded set is
|
|
||||||
// almost always a tool_use/tool_result pair split across a pagination
|
|
||||||
// boundary (older page not loaded yet). Rendering its raw content here
|
|
||||||
// produces an unstyled dump that "fixes itself" once the older page
|
|
||||||
// loads; skip it and let it attach to its tool_use when that arrives.
|
|
||||||
if (msg.toolId) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = formatToolResultContent(msg.content || '');
|
const content = formatToolResultContent(msg.content || '');
|
||||||
if (!content.trim()) {
|
if (!content.trim()) {
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { Project } from '../../../types/app';
|
|||||||
import type { SubagentChildTool } from '../types/types';
|
import type { SubagentChildTool } from '../types/types';
|
||||||
|
|
||||||
import { getToolConfig } from './configs/toolConfigs';
|
import { getToolConfig } from './configs/toolConfigs';
|
||||||
import { OneLineDisplay, BashCommandDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
|
import { OneLineDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
|
||||||
import { PlanDisplay } from './components/PlanDisplay';
|
import { PlanDisplay } from './components/PlanDisplay';
|
||||||
import { ToolStatusBadge } from './components/ToolStatusBadge';
|
import { ToolStatusBadge } from './components/ToolStatusBadge';
|
||||||
import type { ToolStatus } from './components/ToolStatusBadge';
|
import type { ToolStatus } from './components/ToolStatusBadge';
|
||||||
@@ -125,39 +125,6 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
|||||||
|
|
||||||
if (!displayConfig) return null;
|
if (!displayConfig) return null;
|
||||||
|
|
||||||
// Bash renders as a Codex-style command row: the command on a single line with
|
|
||||||
// a chevron that expands to show the output inline. The combined view lives on
|
|
||||||
// the input render; the separate result section is suppressed in MessageComponent.
|
|
||||||
if (toolName === 'Bash' && mode === 'input') {
|
|
||||||
const command = typeof parsedData === 'object' && parsedData !== null && 'command' in parsedData
|
|
||||||
? String(parsedData.command || '')
|
|
||||||
: typeof toolInput === 'string'
|
|
||||||
? toolInput
|
|
||||||
: typeof rawToolInput === 'string'
|
|
||||||
? rawToolInput
|
|
||||||
: '';
|
|
||||||
const description = typeof parsedData === 'object' && parsedData !== null && 'description' in parsedData
|
|
||||||
? String(parsedData.description || '')
|
|
||||||
: undefined;
|
|
||||||
const output = typeof toolResult?.content === 'string'
|
|
||||||
? toolResult.content
|
|
||||||
: toolResult?.content != null
|
|
||||||
? String(toolResult.content)
|
|
||||||
: '';
|
|
||||||
return (
|
|
||||||
<BashCommandDisplay
|
|
||||||
command={command}
|
|
||||||
description={description}
|
|
||||||
output={output}
|
|
||||||
isError={Boolean(toolResult?.isError)}
|
|
||||||
status={toolStatus !== 'completed' ? toolStatus : undefined}
|
|
||||||
// Commands stay collapsed by default (even consecutive ones); only
|
|
||||||
// failures auto-expand so they remain visible.
|
|
||||||
defaultOpen={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (displayConfig.type === 'one-line') {
|
if (displayConfig.type === 'one-line') {
|
||||||
const value = displayConfig.getValue?.(parsedData) || '';
|
const value = displayConfig.getValue?.(parsedData) || '';
|
||||||
const secondary = displayConfig.getSecondary?.(parsedData);
|
const secondary = displayConfig.getSecondary?.(parsedData);
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
|
||||||
import { ChevronRight, Copy, Check } from 'lucide-react';
|
|
||||||
|
|
||||||
import { cn } from '../../../../lib/utils';
|
|
||||||
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
|
||||||
import { ToolStatusBadge } from './ToolStatusBadge';
|
|
||||||
import type { ToolStatus } from './ToolStatusBadge';
|
|
||||||
|
|
||||||
interface BashCommandDisplayProps {
|
|
||||||
command: string;
|
|
||||||
description?: string;
|
|
||||||
/** Combined stdout/stderr from the tool result (empty while running). */
|
|
||||||
output?: string;
|
|
||||||
isError?: boolean;
|
|
||||||
status?: ToolStatus;
|
|
||||||
defaultOpen?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Codex-in-VSCode style command row: a compact, single-line command with a
|
|
||||||
* chevron on the left. When the command produced output, the row becomes a
|
|
||||||
* dropdown that expands to reveal the output inline. Theme-integrated surfaces
|
|
||||||
* keep it clean in both light and dark mode; consecutive commands stack tightly
|
|
||||||
* into a clean list.
|
|
||||||
*/
|
|
||||||
export const BashCommandDisplay: React.FC<BashCommandDisplayProps> = ({
|
|
||||||
command,
|
|
||||||
description,
|
|
||||||
output,
|
|
||||||
isError = false,
|
|
||||||
status,
|
|
||||||
defaultOpen = false,
|
|
||||||
}) => {
|
|
||||||
const trimmedOutput = (output || '').replace(/\s+$/, '');
|
|
||||||
const hasOutput = trimmedOutput.length > 0;
|
|
||||||
const outputLineCount = hasOutput ? trimmedOutput.split('\n').length : 0;
|
|
||||||
const isRunning = status === 'running';
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
// Output (and errors) often arrive after this component first mounts, so apply
|
|
||||||
// the auto-open intent once when there is finally something to show. After that
|
|
||||||
// the user is in control of the toggle.
|
|
||||||
const autoAppliedRef = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!autoAppliedRef.current && hasOutput && (defaultOpen || isError)) {
|
|
||||||
autoAppliedRef.current = true;
|
|
||||||
setOpen(true);
|
|
||||||
}
|
|
||||||
}, [hasOutput, defaultOpen, isError]);
|
|
||||||
|
|
||||||
const toggle = () => {
|
|
||||||
if (hasOutput) {
|
|
||||||
setOpen((prev) => !prev);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopy = async (event: React.MouseEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
const didCopy = await copyTextToClipboard(command);
|
|
||||||
if (!didCopy) return;
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'group/cmd overflow-hidden rounded-lg border bg-muted/40 backdrop-blur-sm transition-all duration-200',
|
|
||||||
isError ? 'border-red-500/30' : 'border-border/60',
|
|
||||||
hasOutput && !open && 'hover:border-border hover:bg-muted/60',
|
|
||||||
open && 'bg-muted/50 shadow-sm',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Command header — clickable when there is output to expand */}
|
|
||||||
<div
|
|
||||||
role={hasOutput ? 'button' : undefined}
|
|
||||||
tabIndex={hasOutput ? 0 : undefined}
|
|
||||||
aria-expanded={hasOutput ? open : undefined}
|
|
||||||
onClick={toggle}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (hasOutput && (event.key === 'Enter' || event.key === ' ')) {
|
|
||||||
event.preventDefault();
|
|
||||||
toggle();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-2 px-2.5 py-1.5 outline-none',
|
|
||||||
hasOutput && 'cursor-pointer focus-visible:ring-1 focus-visible:ring-ring',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ChevronRight
|
|
||||||
className={cn(
|
|
||||||
'h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/70 transition-transform duration-200',
|
|
||||||
open && 'rotate-90',
|
|
||||||
!hasOutput && 'opacity-0',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="flex-shrink-0 select-none font-mono text-xs font-semibold text-emerald-500 dark:text-emerald-400">
|
|
||||||
$
|
|
||||||
</span>
|
|
||||||
<code
|
|
||||||
className={cn(
|
|
||||||
'min-w-0 flex-1 font-mono text-xs text-foreground',
|
|
||||||
open ? 'whitespace-pre-wrap break-all' : 'truncate',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{command}
|
|
||||||
</code>
|
|
||||||
|
|
||||||
{isRunning && (
|
|
||||||
<span className="h-2.5 w-2.5 flex-shrink-0 animate-spin rounded-full border-[1.5px] border-muted-foreground/30 border-t-emerald-400" />
|
|
||||||
)}
|
|
||||||
{status && status !== 'running' && <ToolStatusBadge status={status} className="flex-shrink-0" />}
|
|
||||||
{!open && hasOutput && !isRunning && (
|
|
||||||
<span className="flex-shrink-0 text-[10px] tabular-nums text-muted-foreground/70 transition-opacity group-hover/cmd:opacity-0">
|
|
||||||
{outputLineCount} {outputLineCount === 1 ? 'line' : 'lines'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleCopy}
|
|
||||||
onKeyDown={(event) => event.stopPropagation()}
|
|
||||||
className="flex-shrink-0 rounded p-0.5 text-muted-foreground/60 opacity-0 transition-all hover:bg-foreground/10 hover:text-foreground focus:opacity-100 group-hover/cmd:opacity-100"
|
|
||||||
title="Copy command"
|
|
||||||
aria-label="Copy command"
|
|
||||||
>
|
|
||||||
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{description && !open && (
|
|
||||||
<div className="truncate px-2.5 pb-1.5 pl-[2.4rem] text-[11px] italic text-muted-foreground/70">
|
|
||||||
{description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Expanded output */}
|
|
||||||
{open && hasOutput && (
|
|
||||||
<div className="settings-content-enter border-t border-border/50 bg-background/50">
|
|
||||||
{description && (
|
|
||||||
<div className="px-3 pt-2 text-[11px] italic text-muted-foreground/70">{description}</div>
|
|
||||||
)}
|
|
||||||
<pre
|
|
||||||
className={cn(
|
|
||||||
'max-h-80 overflow-auto whitespace-pre-wrap break-all px-3 py-2 font-mono text-xs leading-relaxed',
|
|
||||||
isError ? 'text-red-600 dark:text-red-400' : 'text-muted-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{trimmedOutput}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import test from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import React from 'react';
|
|
||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
|
||||||
import { QuestionAnswerContent } from './QuestionAnswerContent';
|
|
||||||
|
|
||||||
// Regression coverage for the chat-interface crash where an AskUserQuestion
|
|
||||||
// payload loaded from a session transcript arrives with a non-array `questions`
|
|
||||||
// or a question missing its `options` array. Rendering must degrade gracefully
|
|
||||||
// instead of throwing "TypeError: e.map is not a function".
|
|
||||||
|
|
||||||
test('renders without throwing when questions is a non-array value', () => {
|
|
||||||
assert.doesNotThrow(() => {
|
|
||||||
renderToStaticMarkup(
|
|
||||||
React.createElement(QuestionAnswerContent, {
|
|
||||||
// Malformed: object instead of an array
|
|
||||||
questions: { 0: { question: 'q?', options: [{ label: 'a' }] } } as never,
|
|
||||||
answers: {},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders without throwing when a question is missing options[]', () => {
|
|
||||||
assert.doesNotThrow(() => {
|
|
||||||
renderToStaticMarkup(
|
|
||||||
React.createElement(QuestionAnswerContent, {
|
|
||||||
questions: [{ question: 'Pick one?', header: 'H' } as never],
|
|
||||||
answers: { 'Pick one?': 'X' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders without throwing when options[] contains malformed entries', () => {
|
|
||||||
assert.doesNotThrow(() => {
|
|
||||||
renderToStaticMarkup(
|
|
||||||
React.createElement(QuestionAnswerContent, {
|
|
||||||
questions: [{ question: 'Pick one?', options: [null, 'oops', { label: 'A' }] } as never],
|
|
||||||
answers: { 'Pick one?': 'A, Custom' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders without throwing when a questions entry is null/non-object', () => {
|
|
||||||
assert.doesNotThrow(() => {
|
|
||||||
renderToStaticMarkup(
|
|
||||||
React.createElement(QuestionAnswerContent, {
|
|
||||||
questions: [null, 'oops', { question: 'Ok?', options: [{ label: 'A' }] }] as never,
|
|
||||||
answers: {},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders without throwing when an answer is a non-string value', () => {
|
|
||||||
assert.doesNotThrow(() => {
|
|
||||||
renderToStaticMarkup(
|
|
||||||
React.createElement(QuestionAnswerContent, {
|
|
||||||
questions: [{ question: 'Pick one?', options: [{ label: 'A' }] }],
|
|
||||||
// Malformed: answer is an object instead of the expected string
|
|
||||||
answers: { 'Pick one?': { unexpected: true } } as never,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('still renders a well-formed question + answer', () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
React.createElement(QuestionAnswerContent, {
|
|
||||||
questions: [{ question: 'Pick one?', header: 'H', options: [{ label: 'A' }, { label: 'B' }] }],
|
|
||||||
answers: { 'Pick one?': 'A' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
assert.ok(html.includes('Pick one?'));
|
|
||||||
});
|
|
||||||
@@ -15,11 +15,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
|
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
|
||||||
|
|
||||||
// Tool inputs are runtime data loaded from session transcripts and may be
|
if (!questions || questions.length === 0) {
|
||||||
// malformed (e.g. `questions` arriving as a non-array). Guard with
|
|
||||||
// Array.isArray so a single bad payload can't crash the whole chat view
|
|
||||||
// with "e.map is not a function".
|
|
||||||
if (!Array.isArray(questions) || questions.length === 0) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,23 +24,11 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-2 ${className}`}>
|
<div className={`space-y-2 ${className}`}>
|
||||||
{questions.map((rawQuestion, idx) => {
|
{questions.map((q, idx) => {
|
||||||
// Entries come from session transcripts and may be malformed; skip
|
|
||||||
// anything that isn't a proper question object with a string prompt.
|
|
||||||
if (!rawQuestion || typeof rawQuestion !== 'object' || typeof rawQuestion.question !== 'string') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const q = rawQuestion;
|
|
||||||
const answer = answers?.[q.question];
|
const answer = answers?.[q.question];
|
||||||
// `answer` may be a non-string (or absent) in malformed payloads.
|
const answerLabels = answer ? answer.split(', ') : [];
|
||||||
const answerLabels = typeof answer === 'string' ? answer.split(', ') : [];
|
|
||||||
const skipped = !answer;
|
const skipped = !answer;
|
||||||
const isExpanded = expandedIdx === idx;
|
const isExpanded = expandedIdx === idx;
|
||||||
// `options` is typed as an array but comes from untrusted runtime data;
|
|
||||||
// keep only valid entries so `.some`/`.map` below never throw.
|
|
||||||
const options = Array.isArray(q.options)
|
|
||||||
? q.options.filter((opt) => opt && typeof opt === 'object' && typeof opt.label === 'string')
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -90,7 +74,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
|||||||
{!isExpanded && answerLabels.length > 0 && (
|
{!isExpanded && answerLabels.length > 0 && (
|
||||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
{answerLabels.map((lbl) => {
|
{answerLabels.map((lbl) => {
|
||||||
const isCustom = !options.some(o => o.label === lbl);
|
const isCustom = !q.options.some(o => o.label === lbl);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={lbl}
|
key={lbl}
|
||||||
@@ -126,7 +110,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
|||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="border-t border-gray-100 px-3 pb-2.5 pt-0.5 dark:border-gray-700/40">
|
<div className="border-t border-gray-100 px-3 pb-2.5 pt-0.5 dark:border-gray-700/40">
|
||||||
<div className="ml-6.5 space-y-1">
|
<div className="ml-6.5 space-y-1">
|
||||||
{options.map((opt) => {
|
{q.options.map((opt) => {
|
||||||
const wasSelected = answerLabels.includes(opt.label);
|
const wasSelected = answerLabels.includes(opt.label);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -164,7 +148,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{answerLabels.filter(lbl => !options.some(o => o.label === lbl)).map(lbl => (
|
{answerLabels.filter(lbl => !q.options.some(o => o.label === lbl)).map(lbl => (
|
||||||
<div
|
<div
|
||||||
key={lbl}
|
key={lbl}
|
||||||
className="flex items-start gap-2 rounded-lg border border-blue-200/60 bg-blue-50/80 px-2.5 py-1.5 text-[12px] dark:border-blue-800/40 dark:bg-blue-900/20"
|
className="flex items-start gap-2 rounded-lg border border-blue-200/60 bg-blue-50/80 px-2.5 py-1.5 text-[12px] dark:border-blue-800/40 dark:bg-blue-900/20"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
export { CollapsibleSection } from './CollapsibleSection';
|
export { CollapsibleSection } from './CollapsibleSection';
|
||||||
export { ToolDiffViewer } from './ToolDiffViewer';
|
export { ToolDiffViewer } from './ToolDiffViewer';
|
||||||
export { OneLineDisplay } from './OneLineDisplay';
|
export { OneLineDisplay } from './OneLineDisplay';
|
||||||
export { BashCommandDisplay } from './BashCommandDisplay';
|
|
||||||
export { CollapsibleDisplay } from './CollapsibleDisplay';
|
export { CollapsibleDisplay } from './CollapsibleDisplay';
|
||||||
export { SubagentContainer } from './SubagentContainer';
|
export { SubagentContainer } from './SubagentContainer';
|
||||||
export * from './ContentRenderers';
|
export * from './ContentRenderers';
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { useMemo, useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
|
Check,
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
|
Clipboard,
|
||||||
Coins,
|
Coins,
|
||||||
Cpu,
|
Cpu,
|
||||||
Gauge,
|
Gauge,
|
||||||
@@ -57,6 +59,19 @@ type ModelOption = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatUpdatedAt = (value?: string) => {
|
||||||
|
if (!value) {
|
||||||
|
return 'Not cached yet';
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
return 'Not cached yet';
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
const PROVIDER_LABELS: Record<string, string> = {
|
const PROVIDER_LABELS: Record<string, string> = {
|
||||||
claude: 'Claude',
|
claude: 'Claude',
|
||||||
cursor: 'Cursor',
|
cursor: 'Cursor',
|
||||||
@@ -231,6 +246,7 @@ function HelpContent({ data }: { data: HelpCommandData }) {
|
|||||||
function ModelsContent({
|
function ModelsContent({
|
||||||
data,
|
data,
|
||||||
providerModelCatalog,
|
providerModelCatalog,
|
||||||
|
providerModelCacheCatalog,
|
||||||
providerModelsRefreshing,
|
providerModelsRefreshing,
|
||||||
onHardRefreshProviderModels,
|
onHardRefreshProviderModels,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
@@ -238,12 +254,14 @@ function ModelsContent({
|
|||||||
}: {
|
}: {
|
||||||
data: ModelCommandData;
|
data: ModelCommandData;
|
||||||
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
|
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
|
||||||
|
providerModelCacheCatalog: Partial<Record<LLMProvider, ProviderModelsCacheInfo>>;
|
||||||
providerModelsRefreshing: boolean;
|
providerModelsRefreshing: boolean;
|
||||||
onHardRefreshProviderModels: () => void;
|
onHardRefreshProviderModels: () => void;
|
||||||
currentSessionId: string | null;
|
currentSessionId: string | null;
|
||||||
onSelectProviderModel: CommandResultModalProps['onSelectProviderModel'];
|
onSelectProviderModel: CommandResultModalProps['onSelectProviderModel'];
|
||||||
}) {
|
}) {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
|
const [copiedModel, setCopiedModel] = useState<string | null>(null);
|
||||||
const [changingModel, setChangingModel] = useState<string | null>(null);
|
const [changingModel, setChangingModel] = useState<string | null>(null);
|
||||||
const [pendingSessionModel, setPendingSessionModel] = useState<string | null>(null);
|
const [pendingSessionModel, setPendingSessionModel] = useState<string | null>(null);
|
||||||
const [selectionNotice, setSelectionNotice] = useState<string | null>(null);
|
const [selectionNotice, setSelectionNotice] = useState<string | null>(null);
|
||||||
@@ -251,6 +269,7 @@ function ModelsContent({
|
|||||||
const currentModel = data?.current?.model || 'Unknown';
|
const currentModel = data?.current?.model || 'Unknown';
|
||||||
const providerLabel = data?.current?.providerLabel || getProviderLabel(currentProvider);
|
const providerLabel = data?.current?.providerLabel || getProviderLabel(currentProvider);
|
||||||
const liveDefinition = providerModelCatalog[currentProvider];
|
const liveDefinition = providerModelCatalog[currentProvider];
|
||||||
|
const currentCache = providerModelCacheCatalog[currentProvider] ?? data?.cache;
|
||||||
const availableOptions = useMemo<ModelOption[]>(() => {
|
const availableOptions = useMemo<ModelOption[]>(() => {
|
||||||
if (liveDefinition?.OPTIONS && liveDefinition.OPTIONS.length > 0) {
|
if (liveDefinition?.OPTIONS && liveDefinition.OPTIONS.length > 0) {
|
||||||
return liveDefinition.OPTIONS;
|
return liveDefinition.OPTIONS;
|
||||||
@@ -263,6 +282,7 @@ function ModelsContent({
|
|||||||
const availableModels = Array.isArray(data?.availableModels) ? data.availableModels : [];
|
const availableModels = Array.isArray(data?.availableModels) ? data.availableModels : [];
|
||||||
return availableModels.map((model) => ({ value: model, label: model }));
|
return availableModels.map((model) => ({ value: model, label: model }));
|
||||||
}, [data, liveDefinition]);
|
}, [data, liveDefinition]);
|
||||||
|
const defaultModel = liveDefinition?.DEFAULT || data?.defaultModel || currentModel;
|
||||||
|
|
||||||
const filteredOptions = useMemo(() => {
|
const filteredOptions = useMemo(() => {
|
||||||
const normalized = query.trim().toLowerCase();
|
const normalized = query.trim().toLowerCase();
|
||||||
@@ -276,8 +296,18 @@ function ModelsContent({
|
|||||||
});
|
});
|
||||||
}, [availableOptions, query]);
|
}, [availableOptions, query]);
|
||||||
|
|
||||||
|
const activeOption = availableOptions.find((option) => option.value === currentModel);
|
||||||
const hasConcreteSessionId = typeof currentSessionId === 'string' && currentSessionId.trim().length > 0;
|
const hasConcreteSessionId = typeof currentSessionId === 'string' && currentSessionId.trim().length > 0;
|
||||||
const showSearch = availableOptions.length > 6;
|
|
||||||
|
const copyModel = (model: string) => {
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
||||||
|
void navigator.clipboard.writeText(model).catch(() => undefined);
|
||||||
|
}
|
||||||
|
setCopiedModel(model);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setCopiedModel((current) => (current === model ? null : current));
|
||||||
|
}, 1300);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSelectModel = async (model: string) => {
|
const handleSelectModel = async (model: string) => {
|
||||||
setChangingModel(model);
|
setChangingModel(model);
|
||||||
@@ -300,55 +330,106 @@ function ModelsContent({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col gap-3">
|
<div className="flex h-full min-h-0 flex-col gap-2.5">
|
||||||
{/* Compact context bar: active model + refresh, no clutter */}
|
<div className="rounded-2xl border border-border/70 bg-muted/20 p-2.5">
|
||||||
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border/70 bg-muted/20 px-3.5 py-2.5">
|
<div className="grid gap-2.5 lg:grid-cols-[minmax(0,1.55fr)_minmax(12rem,0.7fr)_minmax(15rem,0.9fr)] lg:items-start">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
Active model · {providerLabel}
|
<Badge variant="secondary" className="rounded-lg border border-primary/20 bg-primary/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-primary">
|
||||||
</p>
|
{providerLabel}
|
||||||
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
</Badge>
|
||||||
<span className="break-all font-mono text-sm font-semibold text-foreground">{currentModel}</span>
|
<Badge variant="secondary" className="rounded-lg px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-foreground">
|
||||||
{pendingSessionModel && pendingSessionModel !== currentModel && (
|
{availableOptions.length} models
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-emerald-500 dark:text-emerald-400">
|
</Badge>
|
||||||
→ {pendingSessionModel} next
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 rounded-xl border border-primary/15 bg-primary/[0.06] px-3 py-2">
|
||||||
|
<p className="text-[11px] font-bold uppercase tracking-[0.2em] text-primary">Active Model</p>
|
||||||
|
<p className="mt-1 break-all font-mono text-[0.98rem] font-semibold leading-5 text-foreground sm:text-[1.05rem]">
|
||||||
|
{currentModel}
|
||||||
|
</p>
|
||||||
|
{activeOption?.label && activeOption.label !== currentModel && (
|
||||||
|
<p className="mt-1 text-[11px] font-medium text-foreground/85">{activeOption.label}</p>
|
||||||
|
)}
|
||||||
|
{activeOption?.description && (
|
||||||
|
<p className="mt-0.5 line-clamp-1 text-[11px] text-muted-foreground">{activeOption.description}</p>
|
||||||
|
)}
|
||||||
|
{pendingSessionModel && pendingSessionModel !== currentModel && (
|
||||||
|
<p className="mt-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-primary">
|
||||||
|
Next response: {pendingSessionModel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-1">
|
||||||
|
<div className="rounded-xl border border-border/60 bg-background/55 px-2.5 py-1.5">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">Default</p>
|
||||||
|
<p className="mt-1 break-all font-mono text-[11px] font-medium text-foreground">{defaultModel}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border/60 bg-background/55 px-2.5 py-1.5">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">Updated</p>
|
||||||
|
<p className="mt-1 text-[11px] font-medium text-foreground">{formatUpdatedAt(currentCache?.updatedAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-border/60 bg-background/55 p-2.5">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-[0.18em] text-foreground/80">
|
||||||
|
Catalog Refresh
|
||||||
|
</p>
|
||||||
|
<Badge variant="secondary" className="rounded-md px-1.5 py-0 text-[9px] uppercase tracking-[0.14em]">
|
||||||
|
All providers
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-[11px] leading-4 text-muted-foreground">
|
||||||
|
Model lists are cached for 3 days. Refresh after CLI, auth, or config changes,
|
||||||
|
or when a new model is missing.
|
||||||
|
</p>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="icon"
|
size="sm"
|
||||||
onClick={onHardRefreshProviderModels}
|
onClick={onHardRefreshProviderModels}
|
||||||
disabled={providerModelsRefreshing}
|
disabled={providerModelsRefreshing}
|
||||||
title="Refresh model list from providers"
|
className="mt-2 h-8 w-full rounded-xl px-3"
|
||||||
aria-label="Refresh model list from providers"
|
|
||||||
className="h-9 w-9 shrink-0 rounded-xl text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 ${providerModelsRefreshing ? 'animate-spin' : ''}`} />
|
<RefreshCw className={providerModelsRefreshing ? 'animate-spin' : ''} />
|
||||||
|
{providerModelsRefreshing ? 'Refreshing catalogs...' : 'Refresh from providers'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{showSearch && (
|
<div className="mt-2 border-t border-border/50 pt-1.5 text-[11px] text-muted-foreground">
|
||||||
|
{hasConcreteSessionId
|
||||||
|
? 'Selecting a model stores a session override and applies it on the next response for this session.'
|
||||||
|
: 'Selecting a model updates the default model used for new turns in this provider.'}
|
||||||
|
{selectionNotice && <span className="ml-2 text-foreground">{selectionNotice}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col rounded-3xl border border-border/70 bg-muted/15 p-3 sm:p-4">
|
||||||
|
<div className="mb-2.5 grid gap-2 sm:grid-cols-[1fr_auto] sm:items-center">
|
||||||
|
<div className="min-w-0">
|
||||||
<SearchField value={query} onChange={setQuery} placeholder={`Search ${providerLabel} models...`} />
|
<SearchField value={query} onChange={setQuery} placeholder={`Search ${providerLabel} models...`} />
|
||||||
)}
|
</div>
|
||||||
|
<Badge variant="secondary" className="h-9 justify-center rounded-xl px-3 font-mono text-xs">
|
||||||
|
{filteredOptions.length} shown
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
{filteredOptions.length > 0 ? (
|
{filteredOptions.length > 0 ? (
|
||||||
<div className="scrollbar-thin -mr-1 min-h-0 flex-1 overflow-y-auto pr-1">
|
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto pr-1">
|
||||||
<div className="grid gap-2 md:grid-cols-2">
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
{filteredOptions.map((option, index) => {
|
{filteredOptions.map((option, index) => {
|
||||||
const isCurrent = option.value === currentModel;
|
const isCurrent = option.value === currentModel;
|
||||||
|
const wasCopied = copiedModel === option.value;
|
||||||
const isPendingSelection = option.value === pendingSessionModel;
|
const isPendingSelection = option.value === pendingSessionModel;
|
||||||
const isChanging = option.value === changingModel;
|
const isChanging = option.value === changingModel;
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={option.value}
|
key={option.value}
|
||||||
type="button"
|
className={`settings-content-enter group flex min-h-[4.5rem] items-start gap-3 rounded-2xl border p-3 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md ${
|
||||||
onClick={() => handleSelectModel(option.value)}
|
|
||||||
disabled={Boolean(changingModel)}
|
|
||||||
aria-label={`Select model ${option.value}`}
|
|
||||||
className={`settings-content-enter group flex min-h-[4rem] flex-col rounded-2xl border p-3 text-left shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-60 ${
|
|
||||||
isCurrent
|
isCurrent
|
||||||
? 'border-primary/45 bg-primary/10'
|
? 'border-primary/45 bg-primary/10'
|
||||||
: isPendingSelection
|
: isPendingSelection
|
||||||
@@ -357,29 +438,44 @@ function ModelsContent({
|
|||||||
}`}
|
}`}
|
||||||
style={{ animationDelay: `${Math.min(index * 14, 180)}ms` }}
|
style={{ animationDelay: `${Math.min(index * 14, 180)}ms` }}
|
||||||
>
|
>
|
||||||
<span className="flex items-center justify-between gap-2">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectModel(option.value)}
|
||||||
|
disabled={Boolean(changingModel)}
|
||||||
|
className="min-w-0 flex-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
aria-label={`Use model ${option.value}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
<span className="break-all font-mono text-sm font-semibold text-foreground">{option.value}</span>
|
<span className="break-all font-mono text-sm font-semibold text-foreground">{option.value}</span>
|
||||||
{isCurrent ? (
|
{isCurrent && <BadgeCheck className="h-4 w-4 shrink-0 text-primary" />}
|
||||||
<BadgeCheck className="h-4 w-4 shrink-0 text-primary" />
|
|
||||||
) : isChanging ? (
|
|
||||||
<RefreshCw className="h-4 w-4 shrink-0 animate-spin text-primary" />
|
|
||||||
) : null}
|
|
||||||
</span>
|
</span>
|
||||||
{option.label && option.label !== option.value && (
|
{option.label && option.label !== option.value && (
|
||||||
<span className="mt-1 text-xs font-medium text-foreground/85">{option.label}</span>
|
<span className="mt-1 block text-xs text-muted-foreground">{option.label}</span>
|
||||||
)}
|
)}
|
||||||
{option.description && (
|
{option.description && (
|
||||||
<span className="mt-1 text-xs leading-5 text-muted-foreground">{option.description}</span>
|
<span className="mt-1 block text-xs leading-5 text-muted-foreground">{option.description}</span>
|
||||||
)}
|
|
||||||
{isCurrent && (
|
|
||||||
<span className="mt-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">Current selection</span>
|
|
||||||
)}
|
)}
|
||||||
|
{isCurrent && <span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">Current selection</span>}
|
||||||
{isPendingSelection && !isCurrent && (
|
{isPendingSelection && !isCurrent && (
|
||||||
<span className="mt-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-500 dark:text-emerald-400">
|
<span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-400">
|
||||||
Applies next response
|
Next response selection
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isChanging && (
|
||||||
|
<span className="mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">
|
||||||
|
Applying...
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyModel(option.value)}
|
||||||
|
className="rounded-lg border border-border/70 bg-muted/30 p-2 text-muted-foreground transition-colors group-hover:text-primary"
|
||||||
|
aria-label={`Copy model id ${option.value}`}
|
||||||
|
>
|
||||||
|
{wasCopied ? <Check className="h-4 w-4" /> : <Clipboard className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -389,17 +485,7 @@ function ModelsContent({
|
|||||||
No models match that search.
|
No models match that search.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{/* Single quiet line of guidance / feedback */}
|
|
||||||
<p className="shrink-0 text-[11px] leading-4 text-muted-foreground">
|
|
||||||
{selectionNotice ? (
|
|
||||||
<span className="text-foreground">{selectionNotice}</span>
|
|
||||||
) : hasConcreteSessionId ? (
|
|
||||||
'Your choice applies to this session on the next response.'
|
|
||||||
) : (
|
|
||||||
'Your choice becomes the default model for new turns.'
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -520,6 +606,7 @@ export default function CommandResultModal({
|
|||||||
payload,
|
payload,
|
||||||
onClose,
|
onClose,
|
||||||
providerModelCatalog,
|
providerModelCatalog,
|
||||||
|
providerModelCacheCatalog,
|
||||||
providerModelsRefreshing,
|
providerModelsRefreshing,
|
||||||
onHardRefreshProviderModels,
|
onHardRefreshProviderModels,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
@@ -537,9 +624,9 @@ export default function CommandResultModal({
|
|||||||
icon: CircleHelp,
|
icon: CircleHelp,
|
||||||
},
|
},
|
||||||
models: {
|
models: {
|
||||||
eyebrow: 'Model selection',
|
eyebrow: 'Model inventory',
|
||||||
title: 'Choose a Model',
|
title: 'Available Models',
|
||||||
subtitle: 'Pick the model this provider should use.',
|
subtitle: 'Browse, search, and copy model IDs for the active provider.',
|
||||||
icon: Cpu,
|
icon: Cpu,
|
||||||
},
|
},
|
||||||
cost: {
|
cost: {
|
||||||
@@ -613,6 +700,7 @@ export default function CommandResultModal({
|
|||||||
<ModelsContent
|
<ModelsContent
|
||||||
data={payload.data as ModelCommandData}
|
data={payload.data as ModelCommandData}
|
||||||
providerModelCatalog={providerModelCatalog}
|
providerModelCatalog={providerModelCatalog}
|
||||||
|
providerModelCacheCatalog={providerModelCacheCatalog}
|
||||||
providerModelsRefreshing={providerModelsRefreshing}
|
providerModelsRefreshing={providerModelsRefreshing}
|
||||||
onHardRefreshProviderModels={onHardRefreshProviderModels}
|
onHardRefreshProviderModels={onHardRefreshProviderModels}
|
||||||
currentSessionId={currentSessionId}
|
currentSessionId={currentSessionId}
|
||||||
|
|||||||
@@ -8,48 +8,12 @@ import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
|
import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
|
||||||
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
||||||
import { usePaletteOps } from '../../../../contexts/PaletteOpsContext';
|
|
||||||
|
|
||||||
type MarkdownProps = {
|
type MarkdownProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Links to the wider web (or in-page anchors) keep normal browser navigation;
|
|
||||||
// everything else is treated as a workspace file reference.
|
|
||||||
const isExternalHref = (href?: string): boolean =>
|
|
||||||
!!href && (/^(https?:|mailto:|tel:|data:)/i.test(href) || href.startsWith('#'));
|
|
||||||
|
|
||||||
// Strip a trailing `:line` / `:line:col` suffix (e.g. `src/foo.ts:130`).
|
|
||||||
const stripLineSuffix = (value: string): string => value.replace(/:\d+(?::\d+)?$/, '');
|
|
||||||
|
|
||||||
// A usable file path contains a separator or a filename with an extension.
|
|
||||||
const looksLikeFilePath = (value?: string): value is string => {
|
|
||||||
if (!value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const cleaned = stripLineSuffix(value.trim());
|
|
||||||
if (!cleaned || cleaned === '#') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return /[\\/]/.test(cleaned) || /\.[a-z0-9]+$/i.test(cleaned);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Extract plain text from link children so a reference rendered only as link
|
|
||||||
// text (e.g. `[src/foo.ts]()` with an empty href) can still be opened.
|
|
||||||
const childrenToText = (children: React.ReactNode): string => {
|
|
||||||
if (typeof children === 'string' || typeof children === 'number') {
|
|
||||||
return String(children);
|
|
||||||
}
|
|
||||||
if (Array.isArray(children)) {
|
|
||||||
return children.map(childrenToText).join('');
|
|
||||||
}
|
|
||||||
if (React.isValidElement(children)) {
|
|
||||||
return childrenToText((children.props as { children?: React.ReactNode }).children);
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
type CodeBlockProps = {
|
type CodeBlockProps = {
|
||||||
node?: any;
|
node?: any;
|
||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
@@ -159,6 +123,11 @@ const markdownComponents = {
|
|||||||
{children}
|
{children}
|
||||||
</blockquote>
|
</blockquote>
|
||||||
),
|
),
|
||||||
|
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
|
||||||
|
<a href={href} className="text-blue-600 hover:underline dark:text-blue-400" target="_blank" rel="noopener noreferrer">
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
p: ({ children }: { children?: React.ReactNode }) => <div className="mb-2 last:mb-0">{children}</div>,
|
p: ({ children }: { children?: React.ReactNode }) => <div className="mb-2 last:mb-0">{children}</div>,
|
||||||
table: ({ children }: { children?: React.ReactNode }) => (
|
table: ({ children }: { children?: React.ReactNode }) => (
|
||||||
<div className="my-2 overflow-x-auto">
|
<div className="my-2 overflow-x-auto">
|
||||||
@@ -178,50 +147,10 @@ export function Markdown({ children, className }: MarkdownProps) {
|
|||||||
const content = normalizeInlineCodeFences(String(children ?? ''));
|
const content = normalizeInlineCodeFences(String(children ?? ''));
|
||||||
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
|
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
|
||||||
const rehypePlugins = useMemo(() => [rehypeKatex], []);
|
const rehypePlugins = useMemo(() => [rehypeKatex], []);
|
||||||
const { openFileInEditor } = usePaletteOps();
|
|
||||||
|
|
||||||
const components = useMemo(
|
|
||||||
() => ({
|
|
||||||
...markdownComponents,
|
|
||||||
a: ({ href, children: linkChildren }: { href?: string; children?: React.ReactNode }) => {
|
|
||||||
// Prefer the href when it is a real path; otherwise fall back to the
|
|
||||||
// link text, since models often emit `[src/foo.ts]()` with an empty href.
|
|
||||||
const linkText = childrenToText(linkChildren);
|
|
||||||
const fileRef = looksLikeFilePath(href) ? href : looksLikeFilePath(linkText) ? linkText : undefined;
|
|
||||||
|
|
||||||
if (fileRef && !isExternalHref(href)) {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={href || fileRef}
|
|
||||||
className="cursor-pointer text-blue-600 hover:underline dark:text-blue-400"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
openFileInEditor(stripLineSuffix(fileRef));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{linkChildren}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
className="text-blue-600 hover:underline dark:text-blue-400"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{linkChildren}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[openFileInEditor],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={components as any}>
|
<ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={markdownComponents as any}>
|
||||||
{content}
|
{content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -218,8 +218,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tool Result Section — Bash renders its output inside the command row above. */}
|
{/* Tool Result Section */}
|
||||||
{message.toolResult && message.toolName !== 'Bash' && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (
|
{message.toolResult && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (
|
||||||
message.toolResult.isError ? (
|
message.toolResult.isError ? (
|
||||||
// Error results - red error box with content
|
// Error results - red error box with content
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { useTaskMaster } from '../../../contexts/TaskMasterContext';
|
|||||||
import { usePaletteOpsRegister } from '../../../contexts/PaletteOpsContext';
|
import { usePaletteOpsRegister } from '../../../contexts/PaletteOpsContext';
|
||||||
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
||||||
import { useUiPreferences } from '../../../hooks/useUiPreferences';
|
import { useUiPreferences } from '../../../hooks/useUiPreferences';
|
||||||
import { useFileOpenResolver } from '../../../hooks/useFileOpenResolver';
|
|
||||||
import { COMPUTER_USE_MENUS_ENABLED } from '../../../constants/featureFlags';
|
import { COMPUTER_USE_MENUS_ENABLED } from '../../../constants/featureFlags';
|
||||||
import { authenticatedFetch } from '../../../utils/api';
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar';
|
import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar';
|
||||||
@@ -82,10 +81,6 @@ function MainContent({
|
|||||||
isMobile,
|
isMobile,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Resolves bare/partial file references (e.g. links inside chat messages) to
|
|
||||||
// real project files before opening them in the in-app editor.
|
|
||||||
const resolvedFileOpen = useFileOpenResolver(selectedProject, handleFileOpen);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Identify projects by DB `projectId`; the TaskMaster context uses the
|
// Identify projects by DB `projectId`; the TaskMaster context uses the
|
||||||
// same identifier to key its internal maps.
|
// same identifier to key its internal maps.
|
||||||
@@ -184,10 +179,6 @@ function MainContent({
|
|||||||
setActiveTab('files');
|
setActiveTab('files');
|
||||||
handleFileOpen(filePath);
|
handleFileOpen(filePath);
|
||||||
},
|
},
|
||||||
// Opens the editor side panel in place, keeping the current tab (e.g. chat).
|
|
||||||
openFileInEditor: (filePath: string) => {
|
|
||||||
resolvedFileOpen(filePath);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ function HighlightedSnippet({ snippet, highlights }: { snippet: string; highligh
|
|||||||
parts.push(snippet.slice(cursor));
|
parts.push(snippet.slice(cursor));
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="min-w-0 flex-1 break-words text-xs leading-relaxed text-muted-foreground">
|
<span className="text-xs leading-relaxed text-muted-foreground">
|
||||||
{parts}
|
{parts}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react';
|
|||||||
import { Check, Edit2, Loader2, Trash2, X } from 'lucide-react';
|
import { Check, Edit2, Loader2, Trash2, X } from 'lucide-react';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
|
|
||||||
import { Badge, Tooltip, buttonVariants } from '../../../../shared/view/ui';
|
import { Badge, Button, Tooltip } from '../../../../shared/view/ui';
|
||||||
import { cn } from '../../../../lib/utils';
|
import { cn } from '../../../../lib/utils';
|
||||||
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
||||||
import type { SessionWithProvider } from '../../types/types';
|
import type { SessionWithProvider } from '../../types/types';
|
||||||
@@ -195,10 +195,9 @@ export default function SidebarSessionItem({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<a
|
<Button
|
||||||
href={`/session/${session.id}`}
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({ variant: 'ghost' }),
|
|
||||||
'h-auto w-full justify-start rounded-md border bg-card p-2 text-left font-normal transition-all duration-150',
|
'h-auto w-full justify-start rounded-md border bg-card p-2 text-left font-normal transition-all duration-150',
|
||||||
isSelected ? 'border-primary/20 bg-primary/5' : 'border-border/30',
|
isSelected ? 'border-primary/20 bg-primary/5' : 'border-border/30',
|
||||||
!isSelected && isProcessing
|
!isSelected && isProcessing
|
||||||
@@ -207,13 +206,7 @@ export default function SidebarSessionItem({
|
|||||||
? 'border-green-500/30 bg-green-50/5 hover:bg-green-50/10 dark:bg-green-900/5 dark:hover:bg-green-900/10'
|
? 'border-green-500/30 bg-green-50/5 hover:bg-green-50/10 dark:bg-green-900/5 dark:hover:bg-green-900/10'
|
||||||
: 'hover:bg-accent/50',
|
: 'hover:bg-accent/50',
|
||||||
)}
|
)}
|
||||||
// Left-click keeps in-app navigation; Ctrl/Cmd/middle-click and the
|
onClick={() => onSessionSelect(session, project.projectId)}
|
||||||
// native right-click menu use the href to open a new tab/window.
|
|
||||||
onClick={(event) => {
|
|
||||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
||||||
event.preventDefault();
|
|
||||||
onSessionSelect(session, project.projectId);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="flex w-full min-w-0 items-center gap-2">
|
<div className="flex w-full min-w-0 items-center gap-2">
|
||||||
<div
|
<div
|
||||||
@@ -256,7 +249,7 @@ export default function SidebarSessionItem({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</Button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={editingContainerRef}
|
ref={editingContainerRef}
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ import type { MutableRefObject, ReactNode } from 'react';
|
|||||||
|
|
||||||
export type PaletteOps = {
|
export type PaletteOps = {
|
||||||
openFile: (path: string) => void;
|
openFile: (path: string) => void;
|
||||||
// Opens a file in the editor side panel without changing the active tab
|
|
||||||
// (used by in-chat file links so they behave like the inline edit view).
|
|
||||||
openFileInEditor: (path: string) => void;
|
|
||||||
openSettings: (tab?: string) => void;
|
openSettings: (tab?: string) => void;
|
||||||
refreshProjects: () => Promise<void> | void;
|
refreshProjects: () => Promise<void> | void;
|
||||||
};
|
};
|
||||||
@@ -16,7 +13,6 @@ const PaletteOpsContext = createContext<Registry | null>(null);
|
|||||||
|
|
||||||
const defaultOps: PaletteOps = {
|
const defaultOps: PaletteOps = {
|
||||||
openFile: () => undefined,
|
openFile: () => undefined,
|
||||||
openFileInEditor: () => undefined,
|
|
||||||
openSettings: () => undefined,
|
openSettings: () => undefined,
|
||||||
refreshProjects: () => undefined,
|
refreshProjects: () => undefined,
|
||||||
};
|
};
|
||||||
@@ -31,8 +27,6 @@ export function usePaletteOps(): PaletteOps {
|
|||||||
return useMemo<PaletteOps>(
|
return useMemo<PaletteOps>(
|
||||||
() => ({
|
() => ({
|
||||||
openFile: (path) => (ref?.current.openFile ?? defaultOps.openFile)(path),
|
openFile: (path) => (ref?.current.openFile ?? defaultOps.openFile)(path),
|
||||||
openFileInEditor: (path) =>
|
|
||||||
(ref?.current.openFileInEditor ?? defaultOps.openFileInEditor)(path),
|
|
||||||
openSettings: (tab) => (ref?.current.openSettings ?? defaultOps.openSettings)(tab),
|
openSettings: (tab) => (ref?.current.openSettings ?? defaultOps.openSettings)(tab),
|
||||||
refreshProjects: () => (ref?.current.refreshProjects ?? defaultOps.refreshProjects)(),
|
refreshProjects: () => (ref?.current.refreshProjects ?? defaultOps.refreshProjects)(),
|
||||||
}),
|
}),
|
||||||
@@ -42,20 +36,18 @@ export function usePaletteOps(): PaletteOps {
|
|||||||
|
|
||||||
export function usePaletteOpsRegister(partial: Partial<PaletteOps>) {
|
export function usePaletteOpsRegister(partial: Partial<PaletteOps>) {
|
||||||
const ref = useContext(PaletteOpsContext);
|
const ref = useContext(PaletteOpsContext);
|
||||||
const { openFile, openFileInEditor, openSettings, refreshProjects } = partial;
|
const { openFile, openSettings, refreshProjects } = partial;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref) return undefined;
|
if (!ref) return undefined;
|
||||||
const prev = { ...ref.current };
|
const prev = { ...ref.current };
|
||||||
if (openFile) ref.current.openFile = openFile;
|
if (openFile) ref.current.openFile = openFile;
|
||||||
if (openFileInEditor) ref.current.openFileInEditor = openFileInEditor;
|
|
||||||
if (openSettings) ref.current.openSettings = openSettings;
|
if (openSettings) ref.current.openSettings = openSettings;
|
||||||
if (refreshProjects) ref.current.refreshProjects = refreshProjects;
|
if (refreshProjects) ref.current.refreshProjects = refreshProjects;
|
||||||
return () => {
|
return () => {
|
||||||
if (openFile && ref.current.openFile === openFile) ref.current.openFile = prev.openFile;
|
if (openFile && ref.current.openFile === openFile) ref.current.openFile = prev.openFile;
|
||||||
if (openFileInEditor && ref.current.openFileInEditor === openFileInEditor) ref.current.openFileInEditor = prev.openFileInEditor;
|
|
||||||
if (openSettings && ref.current.openSettings === openSettings) ref.current.openSettings = prev.openSettings;
|
if (openSettings && ref.current.openSettings === openSettings) ref.current.openSettings = prev.openSettings;
|
||||||
if (refreshProjects && ref.current.refreshProjects === refreshProjects) ref.current.refreshProjects = prev.refreshProjects;
|
if (refreshProjects && ref.current.refreshProjects === refreshProjects) ref.current.refreshProjects = prev.refreshProjects;
|
||||||
};
|
};
|
||||||
}, [ref, openFile, openFileInEditor, openSettings, refreshProjects]);
|
}, [ref, openFile, openSettings, refreshProjects]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
import { useCallback, useRef } from 'react';
|
|
||||||
|
|
||||||
import { api } from '../utils/api';
|
|
||||||
import type { Project } from '../types/app';
|
|
||||||
|
|
||||||
type FileNode = {
|
|
||||||
type: 'file' | 'directory';
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
children?: FileNode[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type FlatFile = {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// `diffInfo` is intentionally `any` so this resolver can wrap editor handlers
|
|
||||||
// that expect a concrete diff payload type as well as generic callers.
|
|
||||||
type OnFileOpen = (filePath: string, diffInfo?: any) => void;
|
|
||||||
|
|
||||||
const normalize = (value: string): string => value.replace(/\\/g, '/');
|
|
||||||
|
|
||||||
const flatten = (nodes: FileNode[], out: FlatFile[]): void => {
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (node.type === 'file') {
|
|
||||||
out.push({ name: node.name, path: node.path });
|
|
||||||
} else if (node.children && node.children.length > 0) {
|
|
||||||
flatten(node.children, out);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// References inside chat messages are often bare basenames (`foo.ts`) or partial
|
|
||||||
// paths (`utils/foo.ts`) rather than full paths, so match by path suffix and
|
|
||||||
// fall back to filename equality.
|
|
||||||
const findBestMatch = (files: FlatFile[], ref: string): string | null => {
|
|
||||||
const target = normalize(ref).replace(/^\.\//, '').replace(/^\/+/, '');
|
|
||||||
if (!target) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const suffixMatch = files.find((file) => {
|
|
||||||
const filePath = normalize(file.path);
|
|
||||||
return filePath === target || filePath.endsWith(`/${target}`);
|
|
||||||
});
|
|
||||||
if (suffixMatch) {
|
|
||||||
return suffixMatch.path;
|
|
||||||
}
|
|
||||||
|
|
||||||
const base = target.split('/').pop() || target;
|
|
||||||
return files.find((file) => file.name === base)?.path ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps an `onFileOpen` handler so a possibly bare/partial file reference is
|
|
||||||
* resolved against the project's file tree (cached per project) before the file
|
|
||||||
* is opened in the in-app editor.
|
|
||||||
*/
|
|
||||||
export function useFileOpenResolver(
|
|
||||||
selectedProject: Project | null | undefined,
|
|
||||||
onFileOpen: OnFileOpen,
|
|
||||||
): OnFileOpen {
|
|
||||||
const projectId = selectedProject?.projectId;
|
|
||||||
const cacheRef = useRef<{ projectId?: string; files: Promise<FlatFile[]> | null }>({
|
|
||||||
projectId: undefined,
|
|
||||||
files: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadFiles = useCallback((): Promise<FlatFile[]> => {
|
|
||||||
if (!projectId) {
|
|
||||||
return Promise.resolve([]);
|
|
||||||
}
|
|
||||||
if (cacheRef.current.projectId === projectId && cacheRef.current.files) {
|
|
||||||
return cacheRef.current.files;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filesPromise = (async () => {
|
|
||||||
try {
|
|
||||||
const response = await api.getFiles(projectId);
|
|
||||||
if (!response.ok) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
const tree: FileNode[] = Array.isArray(data) ? data : [];
|
|
||||||
const flat: FlatFile[] = [];
|
|
||||||
flatten(tree, flat);
|
|
||||||
return flat;
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
cacheRef.current = { projectId, files: filesPromise };
|
|
||||||
return filesPromise;
|
|
||||||
}, [projectId]);
|
|
||||||
|
|
||||||
return useCallback(
|
|
||||||
(filePath: string, diffInfo?: any) => {
|
|
||||||
const ref = normalize(filePath).trim();
|
|
||||||
void loadFiles().then((files) => {
|
|
||||||
const match = findBestMatch(files, ref);
|
|
||||||
onFileOpen(match ?? filePath, diffInfo);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[loadFiles, onFileOpen],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -37,7 +37,7 @@ export default function LanguageSelector({ compact = false }: LanguageSelectorPr
|
|||||||
<select
|
<select
|
||||||
value={i18n.language}
|
value={i18n.language}
|
||||||
onChange={handleLanguageChange}
|
onChange={handleLanguageChange}
|
||||||
className="w-auto min-w-[120px] max-w-[160px] rounded-lg border border-input bg-card p-2 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-[100px] rounded-lg border border-input bg-card p-2 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
>
|
>
|
||||||
{languages.map((lang) => (
|
{languages.map((lang) => (
|
||||||
<option key={lang.value} value={lang.value}>
|
<option key={lang.value} value={lang.value}>
|
||||||
|
|||||||
Reference in New Issue
Block a user