Compare commits
113 Commits
codenomad/
...
ready/ui-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f59e66065 | ||
|
|
51ac7f152d | ||
|
|
df74c06ba2 | ||
|
|
5f144ca24d | ||
|
|
de66b1349a | ||
|
|
3d888fee64 | ||
|
|
1abcc8ee3c | ||
|
|
d0d5c309e6 | ||
|
|
d15340a4b8 | ||
|
|
108cad82d0 | ||
|
|
823dd2d687 | ||
|
|
313e82880b | ||
|
|
68407a01a4 | ||
|
|
0283493f2a | ||
|
|
e989795de3 | ||
|
|
103d2bf1a8 | ||
|
|
0ce7a47e03 | ||
|
|
5df8809c82 | ||
|
|
6e22614648 | ||
|
|
5d87e1e563 | ||
|
|
d735b189f5 | ||
|
|
3d575f4f68 | ||
|
|
b58728dc0e | ||
|
|
672177f570 | ||
|
|
6961efde0b | ||
|
|
b3e0233f4b | ||
|
|
fcebcb0174 | ||
|
|
eaab5e2e9f | ||
|
|
b12825f923 | ||
|
|
8245f474b8 | ||
|
|
3a15b311a8 | ||
|
|
6cb6c0af32 | ||
|
|
7f631611fd | ||
|
|
9d91ecc649 | ||
|
|
87afb06d34 | ||
|
|
4402d9afb0 | ||
|
|
7c3f808d69 | ||
|
|
a59e929b12 | ||
|
|
8ff4019839 | ||
|
|
d9068ac8c6 | ||
|
|
51f8eff3f7 | ||
|
|
627ff2d42b | ||
|
|
0d9da40102 | ||
|
|
ff94c9714e | ||
|
|
429825f434 | ||
|
|
d836d2e62d | ||
|
|
f77fb1562e | ||
|
|
b33421a375 | ||
|
|
c64a9a03f9 | ||
|
|
0d215342e3 | ||
|
|
beb14ea0a2 | ||
|
|
6a4e548d2c | ||
|
|
ad943b2bd4 | ||
|
|
6dac8a6209 | ||
|
|
bec1af6523 | ||
|
|
1719802c0f | ||
|
|
3719dcecf8 | ||
|
|
3dae143830 | ||
|
|
f050273a8e | ||
|
|
8f955cf21c | ||
|
|
a893fca66e | ||
|
|
4f8aba5658 | ||
|
|
219e012c1b | ||
|
|
17716a730b | ||
|
|
c57170d122 | ||
|
|
24c1b7e8ad | ||
|
|
3c76f9776c | ||
|
|
80a02b68b9 | ||
|
|
c766b5ab62 | ||
|
|
133e937772 | ||
|
|
95df743339 | ||
|
|
cd6266757d | ||
|
|
ec0bffe0c2 | ||
|
|
ed322a16bf | ||
|
|
044e46cd6b | ||
|
|
38f75ab06d | ||
|
|
b6bf58ea8f | ||
|
|
2c27fc53ad | ||
|
|
4c5acefa07 | ||
|
|
224cab6a42 | ||
|
|
48b2d7c5ee | ||
|
|
594809538d | ||
|
|
13802537b4 | ||
|
|
ca2b3c232f | ||
|
|
c51e71c7a2 | ||
|
|
482313f662 | ||
|
|
9a4d378238 | ||
|
|
5d5fbfb5f2 | ||
|
|
d147ad49ff | ||
|
|
9b435e3621 | ||
|
|
ab9e188b02 | ||
|
|
2991de528a | ||
|
|
f1bd681618 | ||
|
|
b91dbb1a60 | ||
|
|
688b127c6d | ||
|
|
0f9c99e3bd | ||
|
|
1122070b9c | ||
|
|
57b81f00f8 | ||
|
|
362105fe78 | ||
|
|
5834d2df1b | ||
|
|
ef4c8ef425 | ||
|
|
5f755a7e1c | ||
|
|
8607fab5b5 | ||
|
|
0368fe8248 | ||
|
|
b970281fa7 | ||
|
|
8e5a7fc213 | ||
|
|
15f362e8b5 | ||
|
|
7bbd0a1787 | ||
|
|
f8aae56728 | ||
|
|
027d7fc97d | ||
|
|
e90aef4b3c | ||
|
|
e4e89008b2 | ||
|
|
96fe1b86dd |
151
.github/workflows/build-and-upload.yml
vendored
151
.github/workflows/build-and-upload.yml
vendored
@@ -28,6 +28,21 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: true
|
default: true
|
||||||
type: boolean
|
type: boolean
|
||||||
|
upload_actions_artifacts:
|
||||||
|
description: "Upload built artifacts to GitHub Actions run artifacts"
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
actions_artifacts_retention_days:
|
||||||
|
description: "Retention (days) for GitHub Actions artifacts"
|
||||||
|
required: false
|
||||||
|
default: 7
|
||||||
|
type: number
|
||||||
|
actions_artifacts_name_prefix:
|
||||||
|
description: "Optional prefix for Actions artifact names"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
type: string
|
||||||
set_versions:
|
set_versions:
|
||||||
description: "Run npm version to set workspace versions"
|
description: "Run npm version to set workspace versions"
|
||||||
required: false
|
required: false
|
||||||
@@ -61,7 +76,21 @@ jobs:
|
|||||||
|
|
||||||
- name: Set workspace versions
|
- name: Set workspace versions
|
||||||
if: ${{ inputs.set_versions && inputs.version != '' }}
|
if: ${{ inputs.set_versions && inputs.version != '' }}
|
||||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
shell: bash
|
||||||
|
env:
|
||||||
|
NPM_CONFIG_FETCH_RETRIES: 5
|
||||||
|
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: 20000
|
||||||
|
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: 120000
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if npm version "${VERSION}" --workspaces --include-workspace-root --no-git-tag-version --allow-same-version; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "npm version failed (attempt $attempt/3); retrying..." >&2
|
||||||
|
sleep $((attempt * 10))
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --workspaces --include=optional
|
run: npm ci --workspaces --include=optional
|
||||||
@@ -72,6 +101,37 @@ jobs:
|
|||||||
- name: Build macOS binaries (Electron)
|
- name: Build macOS binaries (Electron)
|
||||||
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
|
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
|
||||||
|
|
||||||
|
- name: Ad-hoc sign Electron macOS app bundles (seal resources)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
release_root="packages/electron-app/release"
|
||||||
|
apps=()
|
||||||
|
while IFS= read -r -d '' app; do
|
||||||
|
apps+=("$app")
|
||||||
|
done < <(find "$release_root" -type d -name 'CodeNomad.app' -print0)
|
||||||
|
|
||||||
|
if [ "${#apps[@]}" -eq 0 ]; then
|
||||||
|
echo "No CodeNomad.app found under $release_root" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# GitHub macOS runners typically have no signing identity. Without any signature,
|
||||||
|
# the shipped .app can fail Gatekeeper with:
|
||||||
|
# code has no resources but signature indicates they must be present
|
||||||
|
# Ad-hoc signing seals bundle resources and makes the signature internally consistent.
|
||||||
|
if security find-identity -p codesigning -v | grep -q "0 valid identities found"; then
|
||||||
|
echo "No valid macOS codesigning identity found; applying ad-hoc signature"
|
||||||
|
for app in "${apps[@]}"; do
|
||||||
|
echo "codesign (adhoc): $app"
|
||||||
|
codesign --force --deep --sign - "$app"
|
||||||
|
codesign --verify --deep --strict --verbose=2 "$app"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "macOS codesigning identity present; skipping ad-hoc signing"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Repackage Electron macOS zips (ditto)
|
- name: Repackage Electron macOS zips (ditto)
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -84,9 +144,12 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
release_root="packages/electron-app/release"
|
release_root="packages/electron-app/release"
|
||||||
shopt -s nullglob globstar
|
# macOS GitHub runners ship /bin/bash 3.2 which doesn't support `shopt -s globstar`.
|
||||||
|
# Use find to locate built app bundles instead of ** globs.
|
||||||
apps=("$release_root"/**/CodeNomad.app)
|
apps=()
|
||||||
|
while IFS= read -r -d '' app; do
|
||||||
|
apps+=("$app")
|
||||||
|
done < <(find "$release_root" -type d -name 'CodeNomad.app' -print0)
|
||||||
if [ "${#apps[@]}" -eq 0 ]; then
|
if [ "${#apps[@]}" -eq 0 ]; then
|
||||||
echo "No CodeNomad.app found under $release_root" >&2
|
echo "No CodeNomad.app found under $release_root" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -155,6 +218,15 @@ jobs:
|
|||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
done
|
done
|
||||||
|
|
||||||
|
- name: Upload Actions artifacts (Electron macOS)
|
||||||
|
if: ${{ inputs.upload_actions_artifacts }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.actions_artifacts_name_prefix }}electron-macos
|
||||||
|
path: packages/electron-app/release/*.zip
|
||||||
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
runs-on: windows-2025
|
runs-on: windows-2025
|
||||||
env:
|
env:
|
||||||
@@ -196,6 +268,15 @@ jobs:
|
|||||||
gh release upload $env:TAG $_.FullName --clobber
|
gh release upload $env:TAG $_.FullName --clobber
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- name: Upload Actions artifacts (Electron Windows)
|
||||||
|
if: ${{ inputs.upload_actions_artifacts }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.actions_artifacts_name_prefix }}electron-windows
|
||||||
|
path: packages/electron-app/release/*.zip
|
||||||
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
env:
|
env:
|
||||||
@@ -238,6 +319,15 @@ jobs:
|
|||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
done
|
done
|
||||||
|
|
||||||
|
- name: Upload Actions artifacts (Electron Linux)
|
||||||
|
if: ${{ inputs.upload_actions_artifacts }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
|
||||||
|
path: packages/electron-app/release/*.zip
|
||||||
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
build-tauri-macos:
|
build-tauri-macos:
|
||||||
runs-on: macos-15-intel
|
runs-on: macos-15-intel
|
||||||
env:
|
env:
|
||||||
@@ -291,7 +381,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (macOS)
|
- name: Package Tauri artifacts (macOS)
|
||||||
if: ${{ inputs.upload }}
|
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||||
@@ -302,6 +392,15 @@ jobs:
|
|||||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
|
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Upload Actions artifacts (Tauri macOS)
|
||||||
|
if: ${{ inputs.upload_actions_artifacts }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos
|
||||||
|
path: packages/tauri-app/release-tauri/*.zip
|
||||||
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
- name: Upload Tauri release assets (macOS)
|
- name: Upload Tauri release assets (macOS)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
@@ -366,7 +465,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (macOS arm64)
|
- name: Package Tauri artifacts (macOS arm64)
|
||||||
if: ${{ inputs.upload }}
|
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
|
||||||
@@ -377,6 +476,15 @@ jobs:
|
|||||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
|
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Upload Actions artifacts (Tauri macOS arm64)
|
||||||
|
if: ${{ inputs.upload_actions_artifacts }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos-arm64
|
||||||
|
path: packages/tauri-app/release-tauri/*.zip
|
||||||
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
- name: Upload Tauri release assets (macOS arm64)
|
- name: Upload Tauri release assets (macOS arm64)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
@@ -444,7 +552,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (Windows)
|
- name: Package Tauri artifacts (Windows)
|
||||||
if: ${{ inputs.upload }}
|
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$bundleRoot = "packages/tauri-app/target/release/bundle"
|
$bundleRoot = "packages/tauri-app/target/release/bundle"
|
||||||
@@ -457,6 +565,15 @@ jobs:
|
|||||||
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
|
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- name: Upload Actions artifacts (Tauri Windows)
|
||||||
|
if: ${{ inputs.upload_actions_artifacts }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-windows
|
||||||
|
path: packages/tauri-app/release-tauri/*.zip
|
||||||
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
- name: Upload Tauri release assets (Windows)
|
- name: Upload Tauri release assets (Windows)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
@@ -534,7 +651,7 @@ jobs:
|
|||||||
run: npm exec -- tauri build
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (Linux)
|
- name: Package Tauri artifacts (Linux)
|
||||||
if: ${{ inputs.upload }}
|
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
SEARCH_ROOT="packages/tauri-app/target"
|
SEARCH_ROOT="packages/tauri-app/target"
|
||||||
@@ -560,6 +677,15 @@ jobs:
|
|||||||
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
|
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
|
||||||
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
|
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
|
||||||
|
|
||||||
|
- name: Upload Actions artifacts (Tauri Linux)
|
||||||
|
if: ${{ inputs.upload_actions_artifacts }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-linux
|
||||||
|
path: packages/tauri-app/release-tauri/*
|
||||||
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
- name: Upload Tauri release assets (Linux)
|
- name: Upload Tauri release assets (Linux)
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
@@ -718,3 +844,12 @@ jobs:
|
|||||||
echo "Uploading $file"
|
echo "Uploading $file"
|
||||||
gh release upload "$TAG" "$file" --clobber
|
gh release upload "$TAG" "$file" --clobber
|
||||||
done
|
done
|
||||||
|
|
||||||
|
- name: Upload Actions artifacts (Electron Linux RPM)
|
||||||
|
if: ${{ inputs.upload_actions_artifacts }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux-rpm
|
||||||
|
path: packages/electron-app/release/*.rpm
|
||||||
|
retention-days: ${{ inputs.actions_artifacts_retention_days }}
|
||||||
|
if-no-files-found: error
|
||||||
|
|||||||
121
.github/workflows/comment-pr-artifacts.yml
vendored
Normal file
121
.github/workflows/comment-pr-artifacts.yml
vendored
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
name: Comment PR Artifacts
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- synchronize
|
||||||
|
- reopened
|
||||||
|
- ready_for_review
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
comment:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
||||||
|
ACTOR: ${{ github.actor }}
|
||||||
|
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||||
|
IS_DRAFT: ${{ github.event.pull_request.draft }}
|
||||||
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
|
RETENTION_DAYS: 7
|
||||||
|
steps:
|
||||||
|
- name: Check PR authorization
|
||||||
|
id: auth
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ "$BASE_REF" = "dev" ]; then
|
||||||
|
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
normalized=",${ALLOWED_ACTORS},"
|
||||||
|
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
||||||
|
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Wait for PR build and comment
|
||||||
|
if: ${{ steps.auth.outputs.allowed == 'true' && env.IS_DRAFT != 'true' }}
|
||||||
|
uses: actions/github-script@v8
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
script: |
|
||||||
|
const owner = context.repo.owner;
|
||||||
|
const repo = context.repo.repo;
|
||||||
|
const prNumber = Number(process.env.PR_NUMBER);
|
||||||
|
const headSha = process.env.HEAD_SHA;
|
||||||
|
const retentionDays = Number(process.env.RETENTION_DAYS || '7');
|
||||||
|
const marker = '<!-- codenomad-pr-artifacts -->';
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
let matchedRun = null;
|
||||||
|
for (let attempt = 1; attempt <= 30; attempt += 1) {
|
||||||
|
const runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
workflow_id: 'pr-build.yml',
|
||||||
|
event: 'pull_request',
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchingRuns = runs
|
||||||
|
.filter((run) => run.head_sha === headSha)
|
||||||
|
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||||
|
|
||||||
|
matchedRun = matchingRuns[0] || null;
|
||||||
|
if (matchedRun && matchedRun.status === 'completed') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
core.info(`Waiting for PR Build Validation run for ${headSha} (attempt ${attempt}/30)`);
|
||||||
|
await sleep(10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchedRun) {
|
||||||
|
core.setFailed(`Could not find PR Build Validation run for ${headSha}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedRun.status !== 'completed') {
|
||||||
|
core.setFailed(`PR Build Validation run ${matchedRun.id} did not complete in time.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const artifacts = await github.paginate(
|
||||||
|
github.rest.actions.listWorkflowRunArtifacts,
|
||||||
|
{ owner, repo, run_id: matchedRun.id, per_page: 100 }
|
||||||
|
);
|
||||||
|
const active = artifacts.filter((artifact) => !artifact.expired);
|
||||||
|
|
||||||
|
const runUrl = matchedRun.html_url;
|
||||||
|
const artifactsBlock = active.length
|
||||||
|
? ['Artifacts:', ...active.map((artifact) => `- ${artifact.name}`)].join('\n')
|
||||||
|
: 'Artifacts: (none found on this run)';
|
||||||
|
|
||||||
|
const body = [
|
||||||
|
marker,
|
||||||
|
'PR builds are available as GitHub Actions artifacts:',
|
||||||
|
'',
|
||||||
|
runUrl,
|
||||||
|
'',
|
||||||
|
`Artifacts expire in ${retentionDays} days.`,
|
||||||
|
artifactsBlock,
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const created = await github.rest.issues.createComment({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: prNumber,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
core.info(`Created artifacts comment: ${created.data.html_url}`);
|
||||||
57
.github/workflows/pr-build.yml
vendored
Normal file
57
.github/workflows/pr-build.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
name: PR Build Validation
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- synchronize
|
||||||
|
- reopened
|
||||||
|
- ready_for_review
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pr-build-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
authorize:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
allowed: ${{ steps.auth.outputs.allowed }}
|
||||||
|
env:
|
||||||
|
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
||||||
|
ACTOR: ${{ github.actor }}
|
||||||
|
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||||
|
steps:
|
||||||
|
- name: Check PR authorization
|
||||||
|
id: auth
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ "$BASE_REF" = "dev" ]; then
|
||||||
|
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
normalized=",${ALLOWED_ACTORS},"
|
||||||
|
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
||||||
|
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Skipping builds for unauthorized PR targeting $BASE_REF" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: authorize
|
||||||
|
if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }}
|
||||||
|
uses: ./.github/workflows/build-and-upload.yml
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
upload: false
|
||||||
|
upload_actions_artifacts: true
|
||||||
|
actions_artifacts_retention_days: 7
|
||||||
|
actions_artifacts_name_prefix: pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}-
|
||||||
|
set_versions: false
|
||||||
54
.github/workflows/restrict-non-dev-prs.yml
vendored
Normal file
54
.github/workflows/restrict-non-dev-prs.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
name: Restrict Non-Dev PRs
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- reopened
|
||||||
|
- synchronize
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
restrict-non-dev-prs:
|
||||||
|
if: ${{ github.event.pull_request.base.ref != 'dev' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
|
||||||
|
ACTOR: ${{ github.actor }}
|
||||||
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||||
|
steps:
|
||||||
|
- name: Check allowed actor
|
||||||
|
id: auth
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
normalized=",${ALLOWED_ACTORS},"
|
||||||
|
if [[ "$normalized" == *",${ACTOR},"* ]]; then
|
||||||
|
echo "authorized=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "authorized=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Comment on unauthorized PR
|
||||||
|
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
gh pr comment "$PR_NUMBER" --body "Thanks for the contribution. PRs need to target \`dev\` branch. Please retarget this PR to the dev branch"
|
||||||
|
|
||||||
|
- name: Close unauthorized PR
|
||||||
|
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
gh pr close "$PR_NUMBER"
|
||||||
|
|
||||||
|
- name: Fail unauthorized PR
|
||||||
|
if: ${{ steps.auth.outputs.authorized != 'true' }}
|
||||||
|
run: |
|
||||||
|
echo "Actor $ACTOR is not allowed to open PRs targeting $BASE_REF" >&2
|
||||||
|
exit 1
|
||||||
87
package-lock.json
generated
87
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.11.4",
|
"version": "0.12.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.11.4",
|
"version": "0.12.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
@@ -3253,9 +3253,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tauri-apps/api": {
|
"node_modules/@tauri-apps/api": {
|
||||||
"version": "2.9.1",
|
"version": "2.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
|
||||||
"integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==",
|
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
|
||||||
"license": "Apache-2.0 OR MIT",
|
"license": "Apache-2.0 OR MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -3305,6 +3305,32 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||||
|
"version": "2.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.4.tgz",
|
||||||
|
"integrity": "sha512-EdYd4c9wGvtPB95kqtEyY+bUR+k4kRw3IA30mAQ1jPH6z57AftT8q84qwv0RDp6kkEqOBKxeInKfqi4BESYuqg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-dialog": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tauri-apps/plugin-notification": {
|
"node_modules/@tauri-apps/plugin-notification": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||||
@@ -10218,14 +10244,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/tauri-plugin-keepawake-api": {
|
|
||||||
"version": "0.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tauri-plugin-keepawake-api/-/tauri-plugin-keepawake-api-0.1.0.tgz",
|
|
||||||
"integrity": "sha512-XPUl66zUYiB7kCRxsTdmCoNjFM/++NWCJ4kdTo2NUOgBUa8UVYfayDWnnTzGIQbhT7qNAHs+jgKSjhqSKs/QHA==",
|
|
||||||
"dependencies": {
|
|
||||||
"@tauri-apps/api": ">=2.0.0-beta.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/temp-dir": {
|
"node_modules/temp-dir": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
||||||
@@ -10966,6 +10984,36 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/virtua": {
|
||||||
|
"version": "0.48.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.48.8.tgz",
|
||||||
|
"integrity": "sha512-jpsxOw5V4B6hg44JePRLo9DL0TV7N1lBEVtPjKpAJebXyhI2s9lfiXJESaLapNtr3vtiSk/pWHiLf7B2a6UcgQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.14.0",
|
||||||
|
"react-dom": ">=16.14.0",
|
||||||
|
"solid-js": ">=1.0",
|
||||||
|
"svelte": ">=5.0",
|
||||||
|
"vue": ">=3.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"solid-js": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"svelte": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vue": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -11985,7 +12033,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron-app": {
|
"packages/electron-app": {
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.11.4",
|
"version": "0.12.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codenomad/ui": "file:../ui",
|
"@codenomad/ui": "file:../ui",
|
||||||
@@ -11995,6 +12043,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
"app-builder-bin": "^4.2.0",
|
"app-builder-bin": "^4.2.0",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
"electron": "39.0.0",
|
"electron": "39.0.0",
|
||||||
"electron-builder": "^24.0.0",
|
"electron-builder": "^24.0.0",
|
||||||
"electron-vite": "4.0.1",
|
"electron-vite": "4.0.1",
|
||||||
@@ -12021,7 +12070,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.11.4",
|
"version": "0.12.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
@@ -12062,7 +12111,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tauri-app": {
|
"packages/tauri-app": {
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.11.4",
|
"version": "0.12.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
@@ -12070,7 +12119,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.11.4",
|
"version": "0.12.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
@@ -12080,6 +12129,8 @@
|
|||||||
"@suid/icons-material": "^0.9.0",
|
"@suid/icons-material": "^0.9.0",
|
||||||
"@suid/material": "^0.19.0",
|
"@suid/material": "^0.19.0",
|
||||||
"@suid/system": "^0.14.0",
|
"@suid/system": "^0.14.0",
|
||||||
|
"@tauri-apps/api": "^2.10.1",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
"ansi-sequence-parser": "^1.1.3",
|
"ansi-sequence-parser": "^1.1.3",
|
||||||
@@ -12092,7 +12143,7 @@
|
|||||||
"shiki": "^3.13.0",
|
"shiki": "^3.13.0",
|
||||||
"solid-js": "^1.8.0",
|
"solid-js": "^1.8.0",
|
||||||
"solid-toast": "^0.5.0",
|
"solid-toast": "^0.5.0",
|
||||||
"tauri-plugin-keepawake-api": "^0.1.0",
|
"virtua": "^0.48.8",
|
||||||
"yaml": "^2.4.2"
|
"yaml": "^2.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.11.4",
|
"version": "0.12.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CodeNomad monorepo workspace",
|
"description": "CodeNomad monorepo workspace",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -31,4 +31,4 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.11"
|
"baseline-browser-mapping": "^2.9.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"minServerVersion": "0.11.4",
|
"minServerVersion": "0.12.3",
|
||||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
||||||
|
import fs from "fs"
|
||||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||||
|
|
||||||
let wakeLockId: number | null = null
|
let wakeLockId: number | null = null
|
||||||
@@ -65,6 +66,24 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
|||||||
return { canceled: result.canceled, paths: result.filePaths }
|
return { canceled: result.canceled, paths: result.filePaths }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle("filesystem:getDirectoryPaths", async (_event, paths: unknown): Promise<string[]> => {
|
||||||
|
if (!Array.isArray(paths)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const directories = paths.filter((value): value is string => {
|
||||||
|
if (typeof value !== "string" || value.trim().length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return fs.statSync(value).isDirectory()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return directories
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
|
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
|
||||||
const next = Boolean(enabled)
|
const next = Boolean(enabled)
|
||||||
if (next) {
|
if (next) {
|
||||||
|
|||||||
@@ -431,7 +431,9 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
|
|
||||||
if (options.dev) {
|
if (options.dev) {
|
||||||
const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000"
|
const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000"
|
||||||
args.push("--ui-dev-server", devServer, "--log-level", "debug")
|
const rawLogLevel = (process.env.CLI_LOG_LEVEL ?? "info").trim()
|
||||||
|
const logLevel = rawLogLevel.length > 0 ? rawLogLevel.toLowerCase() : "info"
|
||||||
|
args.push("--ui-dev-server", devServer, "--log-level", logLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
return args
|
return args
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { contextBridge, ipcRenderer } = require("electron")
|
const { contextBridge, ipcRenderer, webUtils } = require("electron")
|
||||||
|
|
||||||
const electronAPI = {
|
const electronAPI = {
|
||||||
onCliStatus: (callback) => {
|
onCliStatus: (callback) => {
|
||||||
@@ -12,6 +12,14 @@ const electronAPI = {
|
|||||||
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
||||||
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
||||||
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
||||||
|
getDirectoryPaths: (paths) => ipcRenderer.invoke("filesystem:getDirectoryPaths", paths),
|
||||||
|
getPathForFile: (file) => {
|
||||||
|
try {
|
||||||
|
return webUtils.getPathForFile(file)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||||
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.11.4",
|
"version": "0.12.3",
|
||||||
"description": "CodeNomad - AI coding assistant",
|
"description": "CodeNomad - AI coding assistant",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -15,7 +15,10 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://github.com/NeuralNomadsAI/CodeNomad",
|
"homepage": "https://github.com/NeuralNomadsAI/CodeNomad",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "electron-vite dev",
|
"dev": "npm run dev:info",
|
||||||
|
"dev:info": "cross-env CLI_LOG_LEVEL=info electron-vite dev",
|
||||||
|
"dev:debug": "cross-env CLI_LOG_LEVEL=debug electron-vite dev",
|
||||||
|
"dev:trace": "cross-env CLI_LOG_LEVEL=trace electron-vite dev",
|
||||||
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
|
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
|
||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
@@ -42,6 +45,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
"app-builder-bin": "^4.2.0",
|
"app-builder-bin": "^4.2.0",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
"electron": "39.0.0",
|
"electron": "39.0.0",
|
||||||
"electron-builder": "^24.0.0",
|
"electron-builder": "^24.0.0",
|
||||||
"electron-vite": "4.0.1",
|
"electron-vite": "4.0.1",
|
||||||
|
|||||||
@@ -4,6 +4,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "1.2.10"
|
"@opencode-ai/plugin": "1.2.24"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.11.4",
|
"version": "0.12.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.11.4",
|
"version": "0.12.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^9.8.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.11.4",
|
"version": "0.12.3",
|
||||||
"description": "CodeNomad Server",
|
"description": "CodeNomad Server",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -46,4 +46,4 @@
|
|||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
2433
packages/tauri-app/Cargo.lock
generated
2433
packages/tauri-app/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.11.4",
|
"version": "0.12.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const serverDevInstallCommand =
|
|||||||
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||||
const uiDevInstallCommand =
|
const uiDevInstallCommand =
|
||||||
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||||
|
const serverPrepareUiCommand = "npm run prepare-ui --workspace @neuralnomads/codenomad"
|
||||||
|
|
||||||
const envWithRootBin = {
|
const envWithRootBin = {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -91,6 +92,15 @@ function ensureUiBuild() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncServerUiBundle() {
|
||||||
|
console.log("[prebuild] syncing server public UI bundle...")
|
||||||
|
execSync(serverPrepareUiCommand, {
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
env: envWithRootBin,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function ensureServerDevDependencies() {
|
function ensureServerDevDependencies() {
|
||||||
if (fs.existsSync(braceExpansionPath)) {
|
if (fs.existsSync(braceExpansionPath)) {
|
||||||
return
|
return
|
||||||
@@ -246,6 +256,7 @@ function copyUiLoadingAssets() {
|
|||||||
ensureServerDependencies()
|
ensureServerDependencies()
|
||||||
ensureServerBuild()
|
ensureServerBuild()
|
||||||
ensureUiBuild()
|
ensureUiBuild()
|
||||||
|
syncServerUiBundle()
|
||||||
copyServerArtifacts()
|
copyServerArtifacts()
|
||||||
stripNodeModuleBins()
|
stripNodeModuleBins()
|
||||||
copyUiLoadingAssets()
|
copyUiLoadingAssets()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "codenomad-tauri"
|
name = "codenomad-tauri"
|
||||||
version = "0.1.0"
|
version = "0.12.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
@@ -19,9 +19,12 @@ thiserror = "1"
|
|||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
which = "4"
|
which = "4"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
keepawake = "0.6"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
url = "2"
|
url = "2"
|
||||||
tauri-plugin-keepawake = "0.1.1"
|
|
||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
windows-sys = { version = "0.59", features = ["Win32_UI_Shell"] }
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"core:menu:default",
|
"core:menu:default",
|
||||||
"dialog:allow-open",
|
"dialog:allow-open",
|
||||||
"opener:allow-default-urls",
|
"opener:allow-default-urls",
|
||||||
|
"opener:allow-open-url",
|
||||||
"notification:allow-is-permission-granted",
|
"notification:allow-is-permission-granted",
|
||||||
"notification:allow-request-permission",
|
"notification:allow-request-permission",
|
||||||
"notification:allow-notify",
|
"notification:allow-notify",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
|
||||||
|
|||||||
@@ -2378,36 +2378,6 @@
|
|||||||
"const": "dialog:deny-save",
|
"const": "dialog:deny-save",
|
||||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
|
|
||||||
"type": "string",
|
|
||||||
"const": "keepawake:default",
|
|
||||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the start command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "keepawake:allow-start",
|
|
||||||
"markdownDescription": "Enables the start command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the stop command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "keepawake:allow-stop",
|
|
||||||
"markdownDescription": "Enables the stop command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the start command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "keepawake:deny-start",
|
|
||||||
"markdownDescription": "Denies the start command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the stop command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "keepawake:deny-stop",
|
|
||||||
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -2378,36 +2378,6 @@
|
|||||||
"const": "dialog:deny-save",
|
"const": "dialog:deny-save",
|
||||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
|
|
||||||
"type": "string",
|
|
||||||
"const": "keepawake:default",
|
|
||||||
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the start command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "keepawake:allow-start",
|
|
||||||
"markdownDescription": "Enables the start command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Enables the stop command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "keepawake:allow-stop",
|
|
||||||
"markdownDescription": "Enables the stop command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the start command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "keepawake:deny-start",
|
|
||||||
"markdownDescription": "Denies the start command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Denies the stop command without any pre-configured scope.",
|
|
||||||
"type": "string",
|
|
||||||
"const": "keepawake:deny-stop",
|
|
||||||
"markdownDescription": "Denies the stop command without any pre-configured scope."
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ use std::ffi::OsStr;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{BufRead, BufReader, Read, Write};
|
use std::io::{BufRead, BufReader, Read, Write};
|
||||||
use std::net::TcpStream;
|
use std::net::TcpStream;
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@@ -17,10 +19,24 @@ use std::thread;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
fn log_line(message: &str) {
|
fn log_line(message: &str) {
|
||||||
println!("[tauri-cli] {message}");
|
println!("[tauri-cli] {message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn configure_spawn(command: &mut Command) {
|
||||||
|
command.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn configure_spawn(_command: &mut Command) {}
|
||||||
|
|
||||||
fn workspace_root() -> Option<PathBuf> {
|
fn workspace_root() -> Option<PathBuf> {
|
||||||
std::env::current_dir().ok().and_then(|mut dir| {
|
std::env::current_dir().ok().and_then(|mut dir| {
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
@@ -36,6 +52,46 @@ const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
|||||||
|
|
||||||
const CLI_STOP_GRACE_SECS: u64 = 30;
|
const CLI_STOP_GRACE_SECS: u64 = 30;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn configure_posix_process_group(command: &mut Command) {
|
||||||
|
// Ensure the CLI runs in its own process group so we can terminate wrapper
|
||||||
|
// processes (login shell/tsx) without leaving the server orphaned.
|
||||||
|
unsafe {
|
||||||
|
command.pre_exec(|| {
|
||||||
|
if libc::setpgid(0, 0) != 0 {
|
||||||
|
return Err(std::io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn kill_process_tree_windows(pid: u32, force: bool) -> bool {
|
||||||
|
let mut args = vec!["/PID".to_string(), pid.to_string(), "/T".to_string()];
|
||||||
|
if force {
|
||||||
|
args.push("/F".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut command = Command::new("taskkill");
|
||||||
|
command.args(&args);
|
||||||
|
configure_spawn(&mut command);
|
||||||
|
|
||||||
|
match command.output() {
|
||||||
|
Ok(output) => {
|
||||||
|
if output.status.success() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the PID is already gone, treat it as success.
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
|
||||||
|
let combined = format!("{stdout}\n{stderr}");
|
||||||
|
combined.contains("not found") || combined.contains("no running instance")
|
||||||
|
}
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
fn navigate_main(app: &AppHandle, url: &str) {
|
fn navigate_main(app: &AppHandle, url: &str) {
|
||||||
if let Some(win) = app.webview_windows().get("main") {
|
if let Some(win) = app.webview_windows().get("main") {
|
||||||
let mut display = url.to_string();
|
let mut display = url.to_string();
|
||||||
@@ -348,11 +404,19 @@ impl CliProcessManager {
|
|||||||
log_line(&format!("stopping CLI pid={}", child.id()));
|
log_line(&format!("stopping CLI pid={}", child.id()));
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
unsafe {
|
unsafe {
|
||||||
libc::kill(child.id() as i32, libc::SIGTERM);
|
let pid = child.id() as i32;
|
||||||
|
// Prefer signaling the process group to avoid orphaning children
|
||||||
|
// when the CLI was launched via a wrapper shell.
|
||||||
|
let group_res = libc::kill(-pid, libc::SIGTERM);
|
||||||
|
if group_res != 0 {
|
||||||
|
let _ = libc::kill(pid, libc::SIGTERM);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
let _ = child.kill();
|
if !kill_process_tree_windows(child.id(), false) {
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
@@ -368,11 +432,17 @@ impl CliProcessManager {
|
|||||||
));
|
));
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
unsafe {
|
unsafe {
|
||||||
libc::kill(child.id() as i32, libc::SIGKILL);
|
let pid = child.id() as i32;
|
||||||
|
let group_res = libc::kill(-pid, libc::SIGKILL);
|
||||||
|
if group_res != 0 {
|
||||||
|
let _ = libc::kill(pid, libc::SIGKILL);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
let _ = child.kill();
|
if !kill_process_tree_windows(child.id(), true) {
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -450,9 +520,12 @@ impl CliProcessManager {
|
|||||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped());
|
.stderr(Stdio::piped());
|
||||||
|
configure_spawn(&mut c);
|
||||||
if let Some(ref cwd) = cwd {
|
if let Some(ref cwd) = cwd {
|
||||||
c.current_dir(cwd);
|
c.current_dir(cwd);
|
||||||
}
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
configure_posix_process_group(&mut c);
|
||||||
c.spawn()?
|
c.spawn()?
|
||||||
}
|
}
|
||||||
ShellCommandType::Direct(cmd) => {
|
ShellCommandType::Direct(cmd) => {
|
||||||
@@ -462,9 +535,12 @@ impl CliProcessManager {
|
|||||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
.env("ELECTRON_RUN_AS_NODE", "1")
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped());
|
.stderr(Stdio::piped());
|
||||||
|
configure_spawn(&mut c);
|
||||||
if let Some(ref cwd) = cwd {
|
if let Some(ref cwd) = cwd {
|
||||||
c.current_dir(cwd);
|
c.current_dir(cwd);
|
||||||
}
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
configure_posix_process_group(&mut c);
|
||||||
c.spawn()?
|
c.spawn()?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -537,7 +613,24 @@ impl CliProcessManager {
|
|||||||
locked.error = Some("CLI did not start in time".to_string());
|
locked.error = Some("CLI did not start in time".to_string());
|
||||||
log_line("timeout waiting for CLI readiness");
|
log_line("timeout waiting for CLI readiness");
|
||||||
if let Some(child) = child_holder_clone.lock().as_mut() {
|
if let Some(child) = child_holder_clone.lock().as_mut() {
|
||||||
let _ = child.kill();
|
#[cfg(unix)]
|
||||||
|
unsafe {
|
||||||
|
let pid = child.id() as i32;
|
||||||
|
let group_res = libc::kill(-pid, libc::SIGKILL);
|
||||||
|
if group_res != 0 {
|
||||||
|
let _ = libc::kill(pid, libc::SIGKILL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
if !kill_process_tree_windows(child.id(), true) {
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(any(unix, windows)))]
|
||||||
|
{
|
||||||
|
let _ = child.kill();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
|
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
|
||||||
Self::emit_status(&app_clone, &locked);
|
Self::emit_status(&app_clone, &locked);
|
||||||
@@ -828,14 +921,31 @@ impl CliEntry {
|
|||||||
|
|
||||||
if dev {
|
if dev {
|
||||||
// Dev: plain HTTP + Vite dev server proxy.
|
// Dev: plain HTTP + Vite dev server proxy.
|
||||||
|
let ui_dev_server = std::env::var("VITE_DEV_SERVER_URL")
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.or_else(|| {
|
||||||
|
std::env::var("ELECTRON_RENDERER_URL")
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "http://localhost:3000".to_string());
|
||||||
|
let log_level = std::env::var("CLI_LOG_LEVEL")
|
||||||
|
.ok()
|
||||||
|
.map(|value| value.trim().to_lowercase())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or_else(|| "info".to_string());
|
||||||
|
|
||||||
args.push("--https".to_string());
|
args.push("--https".to_string());
|
||||||
args.push("false".to_string());
|
args.push("false".to_string());
|
||||||
args.push("--http".to_string());
|
args.push("--http".to_string());
|
||||||
args.push("true".to_string());
|
args.push("true".to_string());
|
||||||
|
args.push("--http-port".to_string());
|
||||||
|
args.push("0".to_string());
|
||||||
args.push("--ui-dev-server".to_string());
|
args.push("--ui-dev-server".to_string());
|
||||||
args.push("http://localhost:3000".to_string());
|
args.push(ui_dev_server);
|
||||||
args.push("--log-level".to_string());
|
args.push("--log-level".to_string());
|
||||||
args.push("debug".to_string());
|
args.push(log_level);
|
||||||
} else {
|
} else {
|
||||||
// Prod desktop: always keep loopback HTTP enabled.
|
// Prod desktop: always keep loopback HTTP enabled.
|
||||||
args.push("--https".to_string());
|
args.push("--https".to_string());
|
||||||
@@ -900,6 +1010,11 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
|||||||
|
|
||||||
if let Ok(exe) = std::env::current_exe() {
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
if let Some(dir) = exe.parent() {
|
if let Some(dir) = exe.parent() {
|
||||||
|
candidates.push(Some(dir.join("resources/server/dist/bin.js")));
|
||||||
|
candidates.push(Some(dir.join("resources/server/dist/index.js")));
|
||||||
|
candidates.push(Some(dir.join("resources/server/dist/server/bin.js")));
|
||||||
|
candidates.push(Some(dir.join("resources/server/dist/server/index.js")));
|
||||||
|
|
||||||
let resources = dir.join("../Resources");
|
let resources = dir.join("../Resources");
|
||||||
candidates.push(Some(resources.join("server/dist/bin.js")));
|
candidates.push(Some(resources.join("server/dist/bin.js")));
|
||||||
candidates.push(Some(resources.join("server/dist/index.js")));
|
candidates.push(Some(resources.join("server/dist/index.js")));
|
||||||
@@ -995,9 +1110,18 @@ fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_path(path: PathBuf) -> String {
|
fn normalize_path(path: PathBuf) -> String {
|
||||||
if let Ok(clean) = path.canonicalize() {
|
let resolved = if let Ok(clean) = path.canonicalize() {
|
||||||
clean.to_string_lossy().to_string()
|
clean
|
||||||
} else {
|
} else {
|
||||||
path.to_string_lossy().to_string()
|
path
|
||||||
|
};
|
||||||
|
|
||||||
|
let rendered = resolved.to_string_lossy().to_string();
|
||||||
|
if let Some(stripped) = rendered.strip_prefix("\\\\?\\UNC\\") {
|
||||||
|
format!("\\\\{}", stripped)
|
||||||
|
} else if let Some(stripped) = rendered.strip_prefix("\\\\?\\") {
|
||||||
|
stripped.to_string()
|
||||||
|
} else {
|
||||||
|
rendered
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
mod cli_manager;
|
mod cli_manager;
|
||||||
|
|
||||||
use cli_manager::{CliProcessManager, CliStatus};
|
use cli_manager::{CliProcessManager, CliStatus};
|
||||||
|
use keepawake::KeepAwake;
|
||||||
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Mutex;
|
||||||
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
||||||
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
||||||
use tauri::webview::Webview;
|
use tauri::webview::Webview;
|
||||||
@@ -12,11 +15,31 @@ use tauri::{AppHandle, Emitter, Manager, Runtime, Wry};
|
|||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
#[cfg(windows)]
|
||||||
|
use std::iter;
|
||||||
|
#[cfg(windows)]
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
#[cfg(windows)]
|
||||||
|
use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
|
||||||
|
|
||||||
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[cfg(windows)]
|
||||||
|
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub manager: CliProcessManager,
|
pub manager: CliProcessManager,
|
||||||
|
pub wake_lock: Mutex<Option<KeepAwake>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
#[serde(default, rename_all = "camelCase")]
|
||||||
|
struct WakeLockConfig {
|
||||||
|
display: bool,
|
||||||
|
idle: bool,
|
||||||
|
sleep: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -35,6 +58,38 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
|
|||||||
Ok(state.manager.status())
|
Ok(state.manager.status())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn wake_lock_start(
|
||||||
|
state: tauri::State<AppState>,
|
||||||
|
config: Option<WakeLockConfig>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let config = config.unwrap_or(WakeLockConfig {
|
||||||
|
display: true,
|
||||||
|
idle: false,
|
||||||
|
sleep: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut builder = keepawake::Builder::default();
|
||||||
|
builder
|
||||||
|
.display(config.display)
|
||||||
|
.idle(config.idle)
|
||||||
|
.sleep(config.sleep)
|
||||||
|
.reason("CodeNomad active session")
|
||||||
|
.app_name("CodeNomad")
|
||||||
|
.app_reverse_domain("ai.neuralnomads.codenomad.client");
|
||||||
|
|
||||||
|
let wake_lock = builder.create().map_err(|err| err.to_string())?;
|
||||||
|
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
|
||||||
|
*state_lock = Some(wake_lock);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn wake_lock_stop(state: tauri::State<AppState>) -> Result<(), String> {
|
||||||
|
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
|
||||||
|
state_lock.take();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn is_dev_mode() -> bool {
|
fn is_dev_mode() -> bool {
|
||||||
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
|
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
|
||||||
@@ -46,7 +101,10 @@ fn should_allow_internal(url: &Url) -> bool {
|
|||||||
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
|
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
|
||||||
// This must be treated as an internal origin or the navigation guard will
|
// This must be treated as an internal origin or the navigation guard will
|
||||||
// redirect it to the system browser and the app will appear blank.
|
// redirect it to the system browser and the app will appear blank.
|
||||||
"http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "tauri.localhost")),
|
"http" | "https" => matches!(
|
||||||
|
url.host_str(),
|
||||||
|
Some("127.0.0.1" | "localhost" | "tauri.localhost")
|
||||||
|
),
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,6 +124,55 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
|
||||||
|
paths
|
||||||
|
.iter()
|
||||||
|
.filter_map(|path| match std::fs::metadata(path) {
|
||||||
|
Ok(metadata) if metadata.is_dir() => Some(path.to_string_lossy().to_string()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_window_event(app_handle: &AppHandle, window_label: &str, event_name: &str) {
|
||||||
|
if let Some(window) = app_handle.get_webview_window(window_label) {
|
||||||
|
let _ = window.emit(event_name, ());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_folder_drop_event(
|
||||||
|
app_handle: &AppHandle,
|
||||||
|
window_label: &str,
|
||||||
|
event_name: &str,
|
||||||
|
paths: &[std::path::PathBuf],
|
||||||
|
) {
|
||||||
|
let directories = collect_directory_paths(paths);
|
||||||
|
|
||||||
|
if directories.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(window) = app_handle.get_webview_window(window_label) {
|
||||||
|
let _ = window.emit(event_name, json!({ "paths": directories }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn set_windows_app_user_model_id() {
|
||||||
|
let app_id: Vec<u16> = OsStr::new(WINDOWS_APP_USER_MODEL_ID)
|
||||||
|
.encode_wide()
|
||||||
|
.chain(iter::once(0))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let result = unsafe { SetCurrentProcessExplicitAppUserModelID(app_id.as_ptr()) };
|
||||||
|
if result < 0 {
|
||||||
|
eprintln!("[tauri] failed to set AppUserModelID: {result}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn set_windows_app_user_model_id() {}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
||||||
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
||||||
@@ -74,13 +181,14 @@ fn main() {
|
|||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_keepawake::init())
|
|
||||||
.plugin(tauri_plugin_notification::init())
|
.plugin(tauri_plugin_notification::init())
|
||||||
.plugin(navigation_guard)
|
.plugin(navigation_guard)
|
||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
manager: CliProcessManager::new(),
|
manager: CliProcessManager::new(),
|
||||||
|
wake_lock: Mutex::new(None),
|
||||||
})
|
})
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
|
set_windows_app_user_model_id();
|
||||||
build_menu(&app.handle())?;
|
build_menu(&app.handle())?;
|
||||||
let dev_mode = is_dev_mode();
|
let dev_mode = is_dev_mode();
|
||||||
let app_handle = app.handle().clone();
|
let app_handle = app.handle().clone();
|
||||||
@@ -92,7 +200,12 @@ fn main() {
|
|||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![cli_get_status, cli_restart])
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
cli_get_status,
|
||||||
|
cli_restart,
|
||||||
|
wake_lock_start,
|
||||||
|
wake_lock_stop
|
||||||
|
])
|
||||||
.on_menu_event(|app_handle, event| {
|
.on_menu_event(|app_handle, event| {
|
||||||
match event.id().0.as_str() {
|
match event.id().0.as_str() {
|
||||||
// File menu
|
// File menu
|
||||||
@@ -187,6 +300,27 @@ fn main() {
|
|||||||
app.exit(0);
|
app.exit(0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
tauri::RunEvent::WindowEvent {
|
||||||
|
label,
|
||||||
|
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Enter { paths, .. }),
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
emit_folder_drop_event(&app_handle, &label, "desktop:folder-drag-enter", &paths);
|
||||||
|
}
|
||||||
|
tauri::RunEvent::WindowEvent {
|
||||||
|
label,
|
||||||
|
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }),
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
emit_folder_drop_event(&app_handle, &label, "desktop:folder-drop", &paths);
|
||||||
|
}
|
||||||
|
tauri::RunEvent::WindowEvent {
|
||||||
|
label,
|
||||||
|
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Leave),
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
emit_window_event(&app_handle, &label, "desktop:folder-drag-leave");
|
||||||
|
}
|
||||||
tauri::RunEvent::WindowEvent {
|
tauri::RunEvent::WindowEvent {
|
||||||
event: tauri::WindowEvent::CloseRequested { api, .. },
|
event: tauri::WindowEvent::CloseRequested { api, .. },
|
||||||
..
|
..
|
||||||
@@ -234,13 +368,16 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
|||||||
"new_instance",
|
"new_instance",
|
||||||
"New Instance",
|
"New Instance",
|
||||||
true,
|
true,
|
||||||
Some("CmdOrCtrl+N")
|
Some("CmdOrCtrl+N"),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let file_menu = SubmenuBuilder::new(app, "File")
|
let file_menu = SubmenuBuilder::new(app, "File")
|
||||||
.item(&new_instance_item)
|
.item(&new_instance_item)
|
||||||
.separator()
|
.separator()
|
||||||
.text(if is_mac { "close" } else { "quit" }, if is_mac { "Close" } else { "Quit" })
|
.text(
|
||||||
|
if is_mac { "close" } else { "quit" },
|
||||||
|
if is_mac { "Close" } else { "Quit" },
|
||||||
|
)
|
||||||
.build()?;
|
.build()?;
|
||||||
submenus.push(file_menu);
|
submenus.push(file_menu);
|
||||||
|
|
||||||
@@ -263,7 +400,6 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
|||||||
.text("force_reload", "Force Reload")
|
.text("force_reload", "Force Reload")
|
||||||
.text("toggle_devtools", "Toggle Developer Tools")
|
.text("toggle_devtools", "Toggle Developer Tools")
|
||||||
.separator()
|
.separator()
|
||||||
|
|
||||||
.separator()
|
.separator()
|
||||||
.text("toggle_fullscreen", "Toggle Full Screen")
|
.text("toggle_fullscreen", "Toggle Full Screen")
|
||||||
.build()?;
|
.build()?;
|
||||||
@@ -277,9 +413,12 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
|||||||
submenus.push(window_menu);
|
submenus.push(window_menu);
|
||||||
|
|
||||||
// Build the main menu with all submenus
|
// Build the main menu with all submenus
|
||||||
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus.iter().map(|s| s as &dyn tauri::menu::IsMenuItem<_>).collect();
|
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus
|
||||||
|
.iter()
|
||||||
|
.map(|s| s as &dyn tauri::menu::IsMenuItem<_>)
|
||||||
|
.collect();
|
||||||
let menu = MenuBuilder::new(app).items(&submenu_refs).build()?;
|
let menu = MenuBuilder::new(app).items(&submenu_refs).build()?;
|
||||||
|
|
||||||
app.set_menu(menu)?;
|
app.set_menu(menu)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "CodeNomad",
|
"productName": "CodeNomad",
|
||||||
"version": "0.1.0",
|
"version": "0.12.3",
|
||||||
"identifier": "ai.opencode.client",
|
"identifier": "ai.neuralnomads.codenomad.client",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev:bootstrap",
|
"beforeDevCommand": "npm run dev:bootstrap",
|
||||||
"beforeBuildCommand": "npm run bundle:server",
|
"beforeBuildCommand": "npm run bundle:server",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.11.4",
|
"version": "0.12.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -18,8 +18,10 @@
|
|||||||
"@suid/icons-material": "^0.9.0",
|
"@suid/icons-material": "^0.9.0",
|
||||||
"@suid/material": "^0.19.0",
|
"@suid/material": "^0.19.0",
|
||||||
"@suid/system": "^0.14.0",
|
"@suid/system": "^0.14.0",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
"@tauri-apps/api": "^2.10.1",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
"ansi-sequence-parser": "^1.1.3",
|
"ansi-sequence-parser": "^1.1.3",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"github-markdown-css": "^5.8.1",
|
"github-markdown-css": "^5.8.1",
|
||||||
@@ -30,7 +32,7 @@
|
|||||||
"shiki": "^3.13.0",
|
"shiki": "^3.13.0",
|
||||||
"solid-js": "^1.8.0",
|
"solid-js": "^1.8.0",
|
||||||
"solid-toast": "^0.5.0",
|
"solid-toast": "^0.5.0",
|
||||||
"tauri-plugin-keepawake-api": "^0.1.0",
|
"virtua": "^0.48.8",
|
||||||
"yaml": "^2.4.2"
|
"yaml": "^2.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -43,4 +45,4 @@
|
|||||||
"vite-plugin-pwa": "^1.2.0",
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
"vite-plugin-solid": "^2.10.0"
|
"vite-plugin-solid": "^2.10.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,12 +9,10 @@ import { showConfirmDialog } from "./stores/alerts"
|
|||||||
import InstanceTabs from "./components/instance-tabs"
|
import InstanceTabs from "./components/instance-tabs"
|
||||||
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
||||||
import InstanceShell from "./components/instance/instance-shell2"
|
import InstanceShell from "./components/instance/instance-shell2"
|
||||||
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
|
import { SettingsScreen } from "./components/settings-screen"
|
||||||
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||||
import { initMarkdown } from "./lib/markdown"
|
|
||||||
import { initGithubStars } from "./stores/github-stars"
|
import { initGithubStars } from "./stores/github-stars"
|
||||||
|
|
||||||
import { useTheme } from "./lib/theme"
|
|
||||||
import { useCommands } from "./lib/hooks/use-commands"
|
import { useCommands } from "./lib/hooks/use-commands"
|
||||||
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
||||||
import { getLogger } from "./lib/logger"
|
import { getLogger } from "./lib/logger"
|
||||||
@@ -54,11 +52,11 @@ import {
|
|||||||
} from "./stores/sessions"
|
} from "./stores/sessions"
|
||||||
|
|
||||||
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
||||||
|
import { openSettings } from "./stores/settings-screen"
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
const App: Component = () => {
|
const App: Component = () => {
|
||||||
const { isDark } = useTheme()
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const {
|
const {
|
||||||
preferences,
|
preferences,
|
||||||
@@ -77,8 +75,6 @@ const App: Component = () => {
|
|||||||
setToolInputsVisibility,
|
setToolInputsVisibility,
|
||||||
} = useConfig()
|
} = useConfig()
|
||||||
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
||||||
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
|
|
||||||
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
|
||||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||||
|
|
||||||
const phoneQuery = useMediaQuery("(max-width: 767px)")
|
const phoneQuery = useMediaQuery("(max-width: 767px)")
|
||||||
@@ -184,10 +180,6 @@ const App: Component = () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
initReleaseNotifications()
|
initReleaseNotifications()
|
||||||
})
|
})
|
||||||
@@ -252,7 +244,6 @@ const App: Component = () => {
|
|||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
const instanceId = await createInstance(folderPath, selectedBinary)
|
const instanceId = await createInstance(folderPath, selectedBinary)
|
||||||
setShowFolderSelection(false)
|
setShowFolderSelection(false)
|
||||||
setIsAdvancedSettingsOpen(false)
|
|
||||||
|
|
||||||
log.info("Created instance", {
|
log.info("Created instance", {
|
||||||
instanceId,
|
instanceId,
|
||||||
@@ -274,7 +265,7 @@ const App: Component = () => {
|
|||||||
|
|
||||||
function handleLaunchErrorAdvanced() {
|
function handleLaunchErrorAdvanced() {
|
||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
setIsAdvancedSettingsOpen(true)
|
openSettings("opencode")
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNewInstanceRequest() {
|
function handleNewInstanceRequest() {
|
||||||
@@ -487,7 +478,6 @@ const App: Component = () => {
|
|||||||
onSelect={setActiveInstanceId}
|
onSelect={setActiveInstanceId}
|
||||||
onClose={handleCloseInstance}
|
onClose={handleCloseInstance}
|
||||||
onNew={handleNewInstanceRequest}
|
onNew={handleNewInstanceRequest}
|
||||||
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
@@ -496,17 +486,24 @@ const App: Component = () => {
|
|||||||
const isActiveInstance = () => activeInstanceId() === instance.id
|
const isActiveInstance = () => activeInstanceId() === instance.id
|
||||||
const isVisible = () => isActiveInstance() && !showFolderSelection()
|
const isVisible = () => isActiveInstance() && !showFolderSelection()
|
||||||
return (
|
return (
|
||||||
<div class="flex-1 min-h-0 overflow-hidden" style={{ display: isVisible() ? "flex" : "none" }}>
|
<div
|
||||||
<InstanceMetadataProvider instance={instance}>
|
class="flex-1 min-h-0 overflow-hidden"
|
||||||
<InstanceShell
|
style={{ display: isVisible() ? "flex" : "none" }}
|
||||||
instance={instance}
|
data-instance-id={instance.id}
|
||||||
escapeInDebounce={escapeInDebounce()}
|
data-instance-active={isActiveInstance() ? "true" : "false"}
|
||||||
paletteCommands={paletteCommands}
|
data-instance-visible={isVisible() ? "true" : "false"}
|
||||||
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
|
>
|
||||||
onNewSession={() => handleNewSession(instance.id)}
|
<InstanceMetadataProvider instance={instance}>
|
||||||
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
<InstanceShell
|
||||||
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
instance={instance}
|
||||||
onExecuteCommand={executeCommand}
|
isActiveInstance={isActiveInstance()}
|
||||||
|
escapeInDebounce={escapeInDebounce()}
|
||||||
|
paletteCommands={paletteCommands}
|
||||||
|
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
|
||||||
|
onNewSession={() => handleNewSession(instance.id)}
|
||||||
|
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
||||||
|
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
||||||
|
onExecuteCommand={executeCommand}
|
||||||
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
|
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
|
||||||
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
|
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
|
||||||
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
|
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
|
||||||
@@ -526,10 +523,6 @@ const App: Component = () => {
|
|||||||
<FolderSelectionView
|
<FolderSelectionView
|
||||||
onSelectFolder={handleSelectFolder}
|
onSelectFolder={handleSelectFolder}
|
||||||
isLoading={isSelectingFolder()}
|
isLoading={isSelectingFolder()}
|
||||||
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
|
||||||
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
|
||||||
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
|
||||||
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
@@ -539,12 +532,8 @@ const App: Component = () => {
|
|||||||
<FolderSelectionView
|
<FolderSelectionView
|
||||||
onSelectFolder={handleSelectFolder}
|
onSelectFolder={handleSelectFolder}
|
||||||
isLoading={isSelectingFolder()}
|
isLoading={isSelectingFolder()}
|
||||||
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
|
||||||
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
|
||||||
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowFolderSelection(false)
|
setShowFolderSelection(false)
|
||||||
setIsAdvancedSettingsOpen(false)
|
|
||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -552,7 +541,7 @@ const App: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} />
|
<SettingsScreen />
|
||||||
|
|
||||||
<AlertDialog />
|
<AlertDialog />
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createSignal, onMount, Show, createEffect } from "solid-js"
|
import { createSignal, onMount, Show, createEffect } from "solid-js"
|
||||||
import type { Highlighter } from "shiki/bundle/full"
|
import type { Highlighter } from "shiki/bundle/full"
|
||||||
import { useTheme } from "../lib/theme"
|
import { useTheme } from "../lib/theme"
|
||||||
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
|
import { getSharedHighlighter } from "../lib/markdown"
|
||||||
|
import { escapeHtml } from "../lib/text-render-utils"
|
||||||
import { copyToClipboard } from "../lib/clipboard"
|
import { copyToClipboard } from "../lib/clipboard"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
|
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
|
||||||
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
||||||
|
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
||||||
import { disableCache } from "@git-diff-view/core"
|
import { disableCache } from "@git-diff-view/core"
|
||||||
import type { DiffHighlighterLang } from "@git-diff-view/core"
|
import type { DiffHighlighterLang } from "@git-diff-view/core"
|
||||||
import { ErrorBoundary } from "solid-js"
|
import { ErrorBoundary } from "solid-js"
|
||||||
import { getLanguageFromPath } from "../lib/markdown"
|
import { getLanguageFromPath } from "../lib/text-render-utils"
|
||||||
import { normalizeDiffText } from "../lib/diff-utils"
|
import { normalizeDiffText } from "../lib/diff-utils"
|
||||||
import { setCacheEntry } from "../lib/global-cache"
|
import { setCacheEntry } from "../lib/global-cache"
|
||||||
import type { CacheEntryParams } from "../lib/global-cache"
|
import type { CacheEntryParams } from "../lib/global-cache"
|
||||||
@@ -134,4 +135,4 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,32 +2,32 @@ import { Select } from "@kobalte/core/select"
|
|||||||
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
||||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid"
|
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import AdvancedSettingsModal from "./advanced-settings-modal"
|
|
||||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import { ThemeModeToggle } from "./theme-mode-toggle"
|
|
||||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||||
|
import { useFolderDrop } from "../lib/hooks/use-folder-drop"
|
||||||
import VersionPill from "./version-pill"
|
import VersionPill from "./version-pill"
|
||||||
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
||||||
import { githubStars } from "../stores/github-stars"
|
import { githubStars } from "../stores/github-stars"
|
||||||
import { formatCompactCount } from "../lib/formatters"
|
import { formatCompactCount } from "../lib/formatters"
|
||||||
import { useI18n, type Locale } from "../lib/i18n"
|
import { useI18n, type Locale } from "../lib/i18n"
|
||||||
|
import { showAlertDialog } from "../stores/alerts"
|
||||||
|
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
||||||
|
import { openExternalUrl } from "../lib/external-url"
|
||||||
|
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
|
const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
|
||||||
|
const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
||||||
|
|
||||||
|
|
||||||
interface FolderSelectionViewProps {
|
interface FolderSelectionViewProps {
|
||||||
onSelectFolder: (folder: string, binaryPath?: string) => void
|
onSelectFolder: (folder: string, binaryPath?: string) => void
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
advancedSettingsOpen?: boolean
|
|
||||||
onAdvancedSettingsOpen?: () => void
|
|
||||||
onAdvancedSettingsClose?: () => void
|
|
||||||
onOpenRemoteAccess?: () => void
|
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||||
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings, updateLastUsedBinary } = useConfig()
|
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings } = useConfig()
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||||
@@ -193,6 +193,31 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function dropTargetBlocked() {
|
||||||
|
return isLoading() || isFolderBrowserOpen() || settingsOpen()
|
||||||
|
}
|
||||||
|
|
||||||
|
function showInvalidFolderDropAlert() {
|
||||||
|
showAlertDialog(t("folderSelection.drop.invalidMessage"), {
|
||||||
|
title: t("folderSelection.drop.invalidTitle"),
|
||||||
|
variant: "warning",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const folderDrop = useFolderDrop({
|
||||||
|
enabled: () => !dropTargetBlocked(),
|
||||||
|
onInvalidDrop: showInvalidFolderDropAlert,
|
||||||
|
onDrop: async (paths) => {
|
||||||
|
const firstPath = paths[0]
|
||||||
|
if (!firstPath) {
|
||||||
|
showInvalidFolderDropAlert()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleFolderSelect(firstPath)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
function formatRelativeTime(timestamp: number): string {
|
function formatRelativeTime(timestamp: number): string {
|
||||||
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
||||||
const minutes = Math.floor(seconds / 60)
|
const minutes = Math.floor(seconds / 60)
|
||||||
@@ -210,11 +235,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
props.onSelectFolder(path, selectedBinary())
|
props.onSelectFolder(path, selectedBinary())
|
||||||
}
|
}
|
||||||
|
|
||||||
const openExternalLink = (url: string) => {
|
|
||||||
if (typeof window === "undefined") return
|
|
||||||
window.open(url, "_blank", "noopener,noreferrer")
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleBrowse() {
|
async function handleBrowse() {
|
||||||
if (isLoading()) return
|
if (isLoading()) return
|
||||||
setFocusMode("new")
|
setFocusMode("new")
|
||||||
@@ -237,11 +257,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
handleFolderSelect(path)
|
handleFolderSelect(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBinaryChange(binary: string) {
|
|
||||||
|
|
||||||
setSelectedBinary(binary)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRemove(path: string, e?: Event) {
|
function handleRemove(path: string, e?: Event) {
|
||||||
if (isLoading()) return
|
if (isLoading()) return
|
||||||
e?.stopPropagation()
|
e?.stopPropagation()
|
||||||
@@ -317,6 +332,10 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<div
|
<div
|
||||||
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 px-4 sm:px-6 relative"
|
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 px-4 sm:px-6 relative"
|
||||||
style="background-color: var(--surface-secondary)"
|
style="background-color: var(--surface-secondary)"
|
||||||
|
onDragEnter={folderDrop.bind.onDragEnter}
|
||||||
|
onDragOver={folderDrop.bind.onDragOver}
|
||||||
|
onDragLeave={folderDrop.bind.onDragLeave}
|
||||||
|
onDrop={folderDrop.bind.onDrop}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
||||||
@@ -367,16 +386,24 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute top-4 right-6 flex items-center gap-2">
|
<div class="absolute top-4 right-6 flex items-center gap-2">
|
||||||
<ThemeModeToggle class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" />
|
<button
|
||||||
<Show when={props.onOpenRemoteAccess}>
|
type="button"
|
||||||
<button
|
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||||
type="button"
|
onClick={() => openSettings("appearance")}
|
||||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
aria-label={t("settings.open.title")}
|
||||||
onClick={() => props.onOpenRemoteAccess?.()}
|
title={t("settings.open.title")}
|
||||||
>
|
>
|
||||||
<MonitorUp class="w-4 h-4" />
|
<Settings class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||||
|
onClick={() => openSettings("remote")}
|
||||||
|
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||||
|
title={t("instanceTabs.remote.title")}
|
||||||
|
>
|
||||||
|
<MonitorUp class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
<Show when={props.onClose}>
|
<Show when={props.onClose}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -396,7 +423,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||||
<div class="mt-3 flex justify-center gap-2">
|
<div class="mt-3 flex justify-center gap-2">
|
||||||
<a
|
<a
|
||||||
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
href={GITHUB_URL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||||
@@ -404,13 +431,13 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
title={t("folderSelection.links.github")}
|
title={t("folderSelection.links.github")}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
void openExternalUrl(GITHUB_URL, "folder-selection")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<GitHubMarkIcon class="w-4 h-4" />
|
<GitHubMarkIcon class="w-4 h-4" />
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
href={GITHUB_URL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
|
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
|
||||||
@@ -418,7 +445,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
title={t("folderSelection.links.githubStars")}
|
title={t("folderSelection.links.githubStars")}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
void openExternalUrl(GITHUB_URL, "folder-selection")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Star class="w-4 h-4" />
|
<Star class="w-4 h-4" />
|
||||||
@@ -427,7 +454,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
href={DISCORD_URL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||||
@@ -435,9 +462,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
title={t("folderSelection.links.discord")}
|
title={t("folderSelection.links.discord")}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
openExternalLink(
|
void openExternalUrl(DISCORD_URL, "folder-selection")
|
||||||
"https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945",
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DiscordSymbolIcon class="w-4 h-4" />
|
<DiscordSymbolIcon class="w-4 h-4" />
|
||||||
@@ -564,12 +589,12 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Advanced settings section */}
|
{/* OpenCode settings section */}
|
||||||
<div class="panel-section w-full">
|
<div class="panel-section w-full">
|
||||||
<button onClick={() => props.onAdvancedSettingsOpen?.()} class="panel-section-header w-full justify-between">
|
<button onClick={() => openSettings("opencode")} class="panel-section-header w-full justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Settings class="w-4 h-4 icon-muted" />
|
<Settings class="w-4 h-4 icon-muted" />
|
||||||
<span class="text-sm font-medium text-secondary">{t("folderSelection.advancedSettings")}</span>
|
<span class="text-sm font-medium text-secondary">{t("folderSelection.opencode")}</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight class="w-4 h-4 icon-muted" />
|
<ChevronRight class="w-4 h-4 icon-muted" />
|
||||||
</button>
|
</button>
|
||||||
@@ -619,16 +644,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
<Show when={folderDrop.isSupported && folderDrop.isActive() && !dropTargetBlocked()}>
|
||||||
|
<div class="folder-drop-overlay" aria-hidden="true">
|
||||||
|
<div class="folder-drop-card">
|
||||||
|
<FolderPlus class="w-8 h-8 icon-muted" />
|
||||||
|
<p class="folder-drop-title">{t("folderSelection.drop.title")}</p>
|
||||||
|
<p class="folder-drop-subtext">{t("folderSelection.drop.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AdvancedSettingsModal
|
|
||||||
open={Boolean(props.advancedSettingsOpen)}
|
|
||||||
onClose={() => props.onAdvancedSettingsClose?.()}
|
|
||||||
selectedBinary={selectedBinary()}
|
|
||||||
onBinaryChange={handleBinaryChange}
|
|
||||||
isLoading={props.isLoading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DirectoryBrowserDialog
|
<DirectoryBrowserDialog
|
||||||
open={isFolderBrowserOpen()}
|
open={isFolderBrowserOpen()}
|
||||||
title={t("folderSelection.dialog.title")}
|
title={t("folderSelection.dialog.title")}
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
import { Component, For, Show, createMemo } from "solid-js"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import type { Instance } from "../types/instance"
|
import type { Instance } from "../types/instance"
|
||||||
import InstanceTab from "./instance-tab"
|
import InstanceTab from "./instance-tab"
|
||||||
import KeyboardHint from "./keyboard-hint"
|
import KeyboardHint from "./keyboard-hint"
|
||||||
import { Plus, MonitorUp, Bell, BellOff } from "lucide-solid"
|
import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
|
||||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import { ThemeModeToggle } from "./theme-mode-toggle"
|
|
||||||
import NotificationsSettingsModal from "./notifications-settings-modal"
|
|
||||||
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
|
import { openSettings } from "../stores/settings-screen"
|
||||||
|
|
||||||
interface InstanceTabsProps {
|
interface InstanceTabsProps {
|
||||||
instances: Map<string, Instance>
|
instances: Map<string, Instance>
|
||||||
@@ -17,13 +16,11 @@ interface InstanceTabsProps {
|
|||||||
onSelect: (instanceId: string) => void
|
onSelect: (instanceId: string) => void
|
||||||
onClose: (instanceId: string) => void
|
onClose: (instanceId: string) => void
|
||||||
onNew: () => void
|
onNew: () => void
|
||||||
onOpenRemoteAccess?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { preferences } = useConfig()
|
const { preferences } = useConfig()
|
||||||
const [notificationsOpen, setNotificationsOpen] = createSignal(false)
|
|
||||||
|
|
||||||
const notificationsSupported = createMemo(() => isOsNotificationSupportedSync())
|
const notificationsSupported = createMemo(() => isOsNotificationSupportedSync())
|
||||||
const notificationsEnabled = createMemo(() => Boolean(preferences().osNotificationsEnabled))
|
const notificationsEnabled = createMemo(() => Boolean(preferences().osNotificationsEnabled))
|
||||||
@@ -33,8 +30,10 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const notificationTitle = createMemo(() => {
|
const notificationTitle = createMemo(() => {
|
||||||
if (!notificationsSupported()) return "Notifications unsupported"
|
if (!notificationsSupported()) return t("settings.notifications.status.unsupported")
|
||||||
return notificationsEnabled() ? "Notifications enabled" : "Notifications disabled"
|
return notificationsEnabled()
|
||||||
|
? t("settings.notifications.status.enabled")
|
||||||
|
: t("settings.notifications.status.disabled")
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -72,32 +71,35 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<ThemeModeToggle class="new-tab-button" />
|
<button
|
||||||
|
class="new-tab-button"
|
||||||
|
onClick={() => openSettings("appearance")}
|
||||||
|
title={t("settings.open.title")}
|
||||||
|
aria-label={t("settings.open.ariaLabel")}
|
||||||
|
>
|
||||||
|
<Settings class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
|
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
|
||||||
onClick={() => setNotificationsOpen(true)}
|
onClick={() => openSettings("notifications")}
|
||||||
title={notificationTitle()}
|
title={notificationTitle()}
|
||||||
aria-label={notificationTitle()}
|
aria-label={notificationTitle()}
|
||||||
>
|
>
|
||||||
<Dynamic component={notificationIcon()} class="w-4 h-4" />
|
<Dynamic component={notificationIcon()} class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Show when={Boolean(props.onOpenRemoteAccess)}>
|
<button
|
||||||
<button
|
class="new-tab-button tab-remote-button"
|
||||||
class="new-tab-button tab-remote-button"
|
onClick={() => openSettings("remote")}
|
||||||
onClick={() => props.onOpenRemoteAccess?.()}
|
title={t("instanceTabs.remote.title")}
|
||||||
title={t("instanceTabs.remote.title")}
|
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||||
aria-label={t("instanceTabs.remote.ariaLabel")}
|
>
|
||||||
>
|
<MonitorUp class="w-4 h-4" />
|
||||||
<MonitorUp class="w-4 h-4" />
|
</button>
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NotificationsSettingsModal open={notificationsOpen()} onClose={() => setNotificationsOpen(false)} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ const log = getLogger("session")
|
|||||||
|
|
||||||
interface InstanceShellProps {
|
interface InstanceShellProps {
|
||||||
instance: Instance
|
instance: Instance
|
||||||
|
// Provided by App-level instance tabs; lets us pause heavy rendering
|
||||||
|
// work for inactive instances while keeping them mounted for fast switching.
|
||||||
|
isActiveInstance?: boolean
|
||||||
escapeInDebounce: boolean
|
escapeInDebounce: boolean
|
||||||
paletteCommands: Accessor<Command[]>
|
paletteCommands: Accessor<Command[]>
|
||||||
onCloseSession: (sessionId: string) => Promise<void> | void
|
onCloseSession: (sessionId: string) => Promise<void> | void
|
||||||
@@ -115,6 +118,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
const desktopQuery = useMediaQuery("(min-width: 1280px)")
|
const desktopQuery = useMediaQuery("(min-width: 1280px)")
|
||||||
|
|
||||||
const tabletQuery = useMediaQuery("(min-width: 768px)")
|
const tabletQuery = useMediaQuery("(min-width: 768px)")
|
||||||
|
const compactHeaderQuery = useMediaQuery("(max-width: 1024px)")
|
||||||
|
|
||||||
const layoutMode = createMemo<LayoutMode>(() => {
|
const layoutMode = createMemo<LayoutMode>(() => {
|
||||||
if (desktopQuery()) return "desktop"
|
if (desktopQuery()) return "desktop"
|
||||||
@@ -123,6 +127,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isPhoneLayout = createMemo(() => layoutMode() === "phone")
|
const isPhoneLayout = createMemo(() => layoutMode() === "phone")
|
||||||
|
const compactHeaderLayout = createMemo(() => isPhoneLayout() || compactHeaderQuery())
|
||||||
const mobileFullscreen = createMemo(() => props.mobileFullscreenMode && isPhoneLayout())
|
const mobileFullscreen = createMemo(() => props.mobileFullscreenMode && isPhoneLayout())
|
||||||
const compactPromptLayout = createMemo(() => layoutMode() !== "desktop")
|
const compactPromptLayout = createMemo(() => layoutMode() !== "desktop")
|
||||||
const leftPinningSupported = createMemo(() => layoutMode() !== "phone")
|
const leftPinningSupported = createMemo(() => layoutMode() !== "phone")
|
||||||
@@ -596,7 +601,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
|
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
|
||||||
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
|
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
|
||||||
<Show
|
<Show
|
||||||
when={!isPhoneLayout()}
|
when={!compactHeaderLayout()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="flex flex-col w-full gap-1.5">
|
<div class="flex flex-col w-full gap-1.5">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
|
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
|
||||||
@@ -634,8 +639,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
</button>
|
</button>
|
||||||
<span class="connection-status-shortcut-hint kbd-hint">
|
<span class="connection-status-shortcut-hint kbd-hint">
|
||||||
<Kbd shortcut="cmd+shift+p" />
|
<Kbd shortcut="cmd+shift+p" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 flex items-center justify-center min-w-0">
|
<div class="flex-1 flex items-center justify-center min-w-0">
|
||||||
<span
|
<span
|
||||||
@@ -646,7 +651,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={!props.mobileFullscreenMode}>
|
<Show when={isPhoneLayout() && !props.mobileFullscreenMode}>
|
||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={props.onEnterMobileFullscreen}
|
onClick={props.onEnterMobileFullscreen}
|
||||||
@@ -670,16 +675,18 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
{rightAppBarButtonIcon()}
|
{rightAppBarButtonIcon()}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
|
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
|
||||||
<ContextMeter
|
<Show when={!showingInfoView()}>
|
||||||
usedTokens={tokenStats().used}
|
<ContextMeter
|
||||||
availableTokens={tokenStats().avail}
|
usedTokens={tokenStats().used}
|
||||||
formatTokens={formatTokenTotal}
|
availableTokens={tokenStats().avail}
|
||||||
usedLabel={t("instanceShell.metrics.usedLabel")}
|
formatTokens={formatTokenTotal}
|
||||||
availableLabel={t("instanceShell.metrics.availableLabel")}
|
usedLabel={t("instanceShell.metrics.usedLabel")}
|
||||||
/>
|
availableLabel={t("instanceShell.metrics.availableLabel")}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -796,12 +803,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<For each={cachedSessionIds()}>
|
<For each={cachedSessionIds()}>
|
||||||
{(sessionId) => {
|
{(sessionId) => {
|
||||||
const isActive = () => activeSessionIdForInstance() === sessionId
|
const isActive = () => Boolean(props.isActiveInstance) && activeSessionIdForInstance() === sessionId
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class="session-cache-pane flex flex-col flex-1 min-h-0"
|
class="session-cache-pane flex flex-col flex-1 min-h-0"
|
||||||
style={{ display: isActive() ? "flex" : "none" }}
|
style={{ display: isActive() ? "flex" : "none" }}
|
||||||
data-session-id={sessionId}
|
data-session-id={sessionId}
|
||||||
|
data-instance-id={props.instance.id}
|
||||||
|
data-session-active={isActive() ? "true" : "false"}
|
||||||
aria-hidden={!isActive()}
|
aria-hidden={!isActive()}
|
||||||
>
|
>
|
||||||
<SessionView
|
<SessionView
|
||||||
@@ -837,7 +846,10 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="instance-shell2 flex flex-col flex-1 min-h-0">
|
<div
|
||||||
|
class="instance-shell2 flex flex-col flex-1 min-h-0"
|
||||||
|
data-instance-id={props.instance.id}
|
||||||
|
>
|
||||||
<Show when={hasSessions()} fallback={<InstanceWelcomeView instance={props.instance} />}>
|
<Show when={hasSessions()} fallback={<InstanceWelcomeView instance={props.instance} />}>
|
||||||
{sessionLayout}
|
{sessionLayout}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
type Accessor,
|
type Accessor,
|
||||||
type Component,
|
type Component,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk"
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
||||||
import IconButton from "@suid/material/IconButton"
|
import IconButton from "@suid/material/IconButton"
|
||||||
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
|
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
|
import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
||||||
|
|
||||||
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
|
||||||
|
|
||||||
import DiffToolbar from "../components/DiffToolbar"
|
import DiffToolbar from "../components/DiffToolbar"
|
||||||
import SplitFilePanel from "../components/SplitFilePanel"
|
import SplitFilePanel from "../components/SplitFilePanel"
|
||||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||||
|
|
||||||
|
const LazyMonacoDiffViewer = lazy(() =>
|
||||||
|
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
|
||||||
|
)
|
||||||
|
|
||||||
interface ChangesTabProps {
|
interface ChangesTabProps {
|
||||||
t: (key: string, vars?: Record<string, any>) => string
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
|
|
||||||
@@ -32,14 +34,18 @@ interface ChangesTabProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ChangesTab: Component<ChangesTabProps> = (props) => {
|
const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||||
const renderContent = (): JSX.Element => {
|
const sessionId = createMemo(() => props.activeSessionId())
|
||||||
const sessionId = props.activeSessionId()
|
const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
|
||||||
|
const diffs = createMemo(() => (hasSession() ? props.activeSessionDiffs() : null))
|
||||||
|
|
||||||
const hasSession = Boolean(sessionId && sessionId !== "info")
|
const sorted = createMemo<any[]>(() => {
|
||||||
const diffs = hasSession ? props.activeSessionDiffs() : null
|
const list = diffs()
|
||||||
|
if (!Array.isArray(list)) return []
|
||||||
|
return [...list].sort((a, b) => String(a.file || "").localeCompare(String(b.file || "")))
|
||||||
|
})
|
||||||
|
|
||||||
const sorted = Array.isArray(diffs) ? [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) : []
|
const totals = createMemo(() => {
|
||||||
const totals = sorted.reduce(
|
return sorted().reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
acc.additions += typeof item.additions === "number" ? item.additions : 0
|
acc.additions += typeof item.additions === "number" ? item.additions : 0
|
||||||
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
|
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
|
||||||
@@ -47,41 +53,61 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
|||||||
},
|
},
|
||||||
{ additions: 0, deletions: 0 },
|
{ additions: 0, deletions: 0 },
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const mostChanged = sorted.length
|
const mostChanged = createMemo<any | null>(() => {
|
||||||
? sorted.reduce((best, item) => {
|
const items = sorted()
|
||||||
const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0
|
if (items.length === 0) return null
|
||||||
const bestDel = typeof (best as any)?.deletions === "number" ? (best as any).deletions : 0
|
return items.reduce((best, item) => {
|
||||||
const bestScore = bestAdd + bestDel
|
const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0
|
||||||
|
const bestDel = typeof (best as any)?.deletions === "number" ? (best as any).deletions : 0
|
||||||
|
const bestScore = bestAdd + bestDel
|
||||||
|
|
||||||
const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0
|
const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0
|
||||||
const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0
|
const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0
|
||||||
const score = add + del
|
const score = add + del
|
||||||
|
|
||||||
if (score > bestScore) return item
|
if (score > bestScore) return item
|
||||||
if (score < bestScore) return best
|
if (score < bestScore) return best
|
||||||
return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best
|
return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best
|
||||||
}, sorted[0])
|
}, items[0])
|
||||||
: null
|
})
|
||||||
|
|
||||||
// Auto-select the most-changed file if none selected.
|
const selectedFileData = createMemo<any | null>(() => {
|
||||||
const currentSelected = props.selectedFile()
|
const currentSelected = props.selectedFile()
|
||||||
const selectedFileData = sorted.find((f) => f.file === currentSelected) || mostChanged
|
const items = sorted()
|
||||||
|
if (currentSelected) {
|
||||||
const scopeKey = `${props.instanceId}:${hasSession ? sessionId : "no-session"}`
|
const match = items.find((f) => f.file === currentSelected)
|
||||||
|
if (match) return match
|
||||||
const emptyViewerMessage = () => {
|
|
||||||
if (!hasSession) return props.t("instanceShell.sessionChanges.noSessionSelected")
|
|
||||||
if (diffs === undefined) return props.t("instanceShell.sessionChanges.loading")
|
|
||||||
if (!Array.isArray(diffs) || diffs.length === 0) return props.t("instanceShell.sessionChanges.empty")
|
|
||||||
return props.t("instanceShell.filesShell.viewerEmpty")
|
|
||||||
}
|
}
|
||||||
|
return mostChanged()
|
||||||
|
})
|
||||||
|
|
||||||
|
const scopeKey = createMemo(() => `${props.instanceId}:${hasSession() ? sessionId() : "no-session"}`)
|
||||||
|
|
||||||
|
const emptyViewerMessage = createMemo(() => {
|
||||||
|
if (!hasSession()) return props.t("instanceShell.sessionChanges.noSessionSelected")
|
||||||
|
const currentDiffs = diffs()
|
||||||
|
if (currentDiffs === undefined) return props.t("instanceShell.sessionChanges.loading")
|
||||||
|
if (!Array.isArray(currentDiffs) || currentDiffs.length === 0) return props.t("instanceShell.sessionChanges.empty")
|
||||||
|
return props.t("instanceShell.filesShell.viewerEmpty")
|
||||||
|
})
|
||||||
|
|
||||||
|
const headerPath = createMemo(() => {
|
||||||
|
const file = selectedFileData()
|
||||||
|
return file?.file ? String(file.file) : props.t("instanceShell.rightPanel.tabs.changes")
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderContent = (): JSX.Element => {
|
||||||
|
const sortedList = sorted()
|
||||||
|
const totalsValue = totals()
|
||||||
|
const selected = selectedFileData()
|
||||||
|
|
||||||
const renderViewer = () => (
|
const renderViewer = () => (
|
||||||
<div class="file-viewer-panel flex-1">
|
<div class="file-viewer-panel flex-1">
|
||||||
<div class="file-viewer-content file-viewer-content--monaco">
|
<div class="file-viewer-content file-viewer-content--monaco">
|
||||||
<Show
|
<Show
|
||||||
when={selectedFileData && hasSession && Array.isArray(diffs) && diffs.length > 0 ? selectedFileData : null}
|
when={selected && hasSession() && sortedList.length > 0 ? selected : null}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="file-viewer-empty">
|
<div class="file-viewer-empty">
|
||||||
<span class="file-viewer-empty-text">{emptyViewerMessage()}</span>
|
<span class="file-viewer-empty-text">{emptyViewerMessage()}</span>
|
||||||
@@ -89,15 +115,23 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(file) => (
|
{(file) => (
|
||||||
<MonacoDiffViewer
|
<Suspense
|
||||||
scopeKey={scopeKey}
|
fallback={
|
||||||
path={String(file().file || "")}
|
<div class="file-viewer-empty">
|
||||||
before={String((file() as any).before || "")}
|
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||||
after={String((file() as any).after || "")}
|
</div>
|
||||||
viewMode={props.diffViewMode()}
|
}
|
||||||
contextMode={props.diffContextMode()}
|
>
|
||||||
wordWrap={props.diffWordWrapMode()}
|
<LazyMonacoDiffViewer
|
||||||
/>
|
scopeKey={scopeKey()}
|
||||||
|
path={String(file().file || "")}
|
||||||
|
before={String((file() as any).before || "")}
|
||||||
|
after={String((file() as any).after || "")}
|
||||||
|
viewMode={props.diffViewMode()}
|
||||||
|
contextMode={props.diffContextMode()}
|
||||||
|
wordWrap={props.diffWordWrapMode()}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,11 +143,11 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const renderListPanel = () => (
|
const renderListPanel = () => (
|
||||||
<Show when={sorted.length > 0} fallback={renderEmptyList()}>
|
<Show when={sortedList.length > 0} fallback={renderEmptyList()}>
|
||||||
<For each={sorted}>
|
<For each={sortedList}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<div
|
<div
|
||||||
class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`}
|
class={`file-list-item ${selected?.file === item.file ? "file-list-item-active" : ""}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
props.onSelectFile(item.file, props.isPhoneLayout())
|
props.onSelectFile(item.file, props.isPhoneLayout())
|
||||||
}}
|
}}
|
||||||
@@ -134,11 +168,11 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const renderListOverlay = () => (
|
const renderListOverlay = () => (
|
||||||
<Show when={sorted.length > 0} fallback={renderEmptyList()}>
|
<Show when={sortedList.length > 0} fallback={renderEmptyList()}>
|
||||||
<For each={sorted}>
|
<For each={sortedList}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<div
|
<div
|
||||||
class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`}
|
class={`file-list-item ${selected?.file === item.file ? "file-list-item-active" : ""}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
props.onSelectFile(item.file, true)
|
props.onSelectFile(item.file, true)
|
||||||
}}
|
}}
|
||||||
@@ -159,8 +193,6 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
|
||||||
const headerPath = () => (selectedFileData?.file ? selectedFileData.file : props.t("instanceShell.rightPanel.tabs.changes"))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SplitFilePanel
|
<SplitFilePanel
|
||||||
header={
|
header={
|
||||||
@@ -171,10 +203,10 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
|||||||
|
|
||||||
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
|
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
|
||||||
<span class="files-tab-stat files-tab-stat-additions">
|
<span class="files-tab-stat files-tab-stat-additions">
|
||||||
<span class="files-tab-stat-value">+{totals.additions}</span>
|
<span class="files-tab-stat-value">+{totalsValue.additions}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="files-tab-stat files-tab-stat-deletions">
|
<span class="files-tab-stat files-tab-stat-deletions">
|
||||||
<span class="files-tab-stat-value">-{totals.deletions}</span>
|
<span class="files-tab-stat-value">-{totalsValue.deletions}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -198,7 +230,7 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
|||||||
onResizeMouseDown={props.onResizeMouseDown}
|
onResizeMouseDown={props.onResizeMouseDown}
|
||||||
onResizeTouchStart={props.onResizeTouchStart}
|
onResizeTouchStart={props.onResizeTouchStart}
|
||||||
isPhoneLayout={props.isPhoneLayout()}
|
isPhoneLayout={props.isPhoneLayout()}
|
||||||
overlayAriaLabel="Changes"
|
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.changes")}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
|
import { For, Show, Suspense, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
||||||
import type { FileNode } from "@opencode-ai/sdk/v2/client"
|
import type { FileNode } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
import { RefreshCw } from "lucide-solid"
|
import { RefreshCw } from "lucide-solid"
|
||||||
|
|
||||||
import { MonacoFileViewer } from "../../../../file-viewer/monaco-file-viewer"
|
|
||||||
|
|
||||||
import SplitFilePanel from "../components/SplitFilePanel"
|
import SplitFilePanel from "../components/SplitFilePanel"
|
||||||
|
|
||||||
|
const LazyMonacoFileViewer = lazy(() =>
|
||||||
|
import("../../../../file-viewer/monaco-file-viewer").then((module) => ({ default: module.MonacoFileViewer })),
|
||||||
|
)
|
||||||
|
|
||||||
interface FilesTabProps {
|
interface FilesTabProps {
|
||||||
t: (key: string, vars?: Record<string, any>) => string
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
|
|
||||||
@@ -51,8 +53,8 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
|
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
|
||||||
|
|
||||||
const emptyViewerMessage = () => {
|
const emptyViewerMessage = () => {
|
||||||
if (props.browserLoading() && entriesValue === null) return "Loading files..."
|
if (props.browserLoading() && entriesValue === null) return props.t("instanceInfo.loading")
|
||||||
return "Select a file to preview"
|
return props.t("instanceShell.filesShell.viewerEmpty")
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderViewer = () => (
|
const renderViewer = () => (
|
||||||
@@ -77,7 +79,15 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(payload) => (
|
{(payload) => (
|
||||||
<MonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div class="file-viewer-empty">
|
||||||
|
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LazyMonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
@@ -91,7 +101,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="file-viewer-empty">
|
<div class="file-viewer-empty">
|
||||||
<span class="file-viewer-empty-text">Loading…</span>
|
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,7 +123,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={props.browserLoading() && entriesValue === null}>
|
<Show when={props.browserLoading() && entriesValue === null}>
|
||||||
<div class="p-3 text-xs text-secondary">Loading files...</div>
|
<div class="p-3 text-xs text-secondary">{props.t("instanceInfo.loading")}</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<For each={sorted}>
|
<For each={sorted}>
|
||||||
@@ -154,7 +164,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<Show when={props.browserLoading()}>
|
<Show when={props.browserLoading()}>
|
||||||
<span>Loading…</span>
|
<span>{props.t("instanceInfo.loading")}</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,7 +190,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
|||||||
onResizeMouseDown={props.onResizeMouseDown}
|
onResizeMouseDown={props.onResizeMouseDown}
|
||||||
onResizeTouchStart={props.onResizeTouchStart}
|
onResizeTouchStart={props.onResizeTouchStart}
|
||||||
isPhoneLayout={props.isPhoneLayout()}
|
isPhoneLayout={props.isPhoneLayout()}
|
||||||
overlayAriaLabel="Files"
|
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.files")}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
|
import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js"
|
||||||
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
import { RefreshCw } from "lucide-solid"
|
import { RefreshCw } from "lucide-solid"
|
||||||
|
|
||||||
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
|
||||||
|
|
||||||
import DiffToolbar from "../components/DiffToolbar"
|
import DiffToolbar from "../components/DiffToolbar"
|
||||||
import SplitFilePanel from "../components/SplitFilePanel"
|
import SplitFilePanel from "../components/SplitFilePanel"
|
||||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||||
|
|
||||||
|
const LazyMonacoDiffViewer = lazy(() =>
|
||||||
|
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
|
||||||
|
)
|
||||||
|
|
||||||
interface GitChangesTabProps {
|
interface GitChangesTabProps {
|
||||||
t: (key: string, vars?: Record<string, any>) => string
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
|
|
||||||
@@ -46,17 +48,18 @@ interface GitChangesTabProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||||
const renderContent = (): JSX.Element => {
|
const sessionId = createMemo(() => props.activeSessionId())
|
||||||
const sessionId = props.activeSessionId()
|
const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
|
||||||
|
const entries = createMemo(() => (hasSession() ? props.entries() : null))
|
||||||
|
|
||||||
const hasSession = Boolean(sessionId && sessionId !== "info")
|
const sorted = createMemo<GitFileStatus[]>(() => {
|
||||||
const entries = hasSession ? props.entries() : null
|
const list = entries()
|
||||||
|
if (!Array.isArray(list)) return []
|
||||||
|
return [...list].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
|
||||||
|
})
|
||||||
|
|
||||||
const sorted = Array.isArray(entries)
|
const totals = createMemo(() => {
|
||||||
? [...entries].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
|
return sorted().reduce(
|
||||||
: []
|
|
||||||
|
|
||||||
const totals = sorted.reduce(
|
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
acc.additions += typeof item.added === "number" ? item.added : 0
|
acc.additions += typeof item.added === "number" ? item.added : 0
|
||||||
acc.deletions += typeof item.removed === "number" ? item.removed : 0
|
acc.deletions += typeof item.removed === "number" ? item.removed : 0
|
||||||
@@ -64,21 +67,33 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
},
|
},
|
||||||
{ additions: 0, deletions: 0 },
|
{ additions: 0, deletions: 0 },
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const nonDeleted = sorted.filter((item) => item && item.status !== "deleted")
|
const nonDeleted = createMemo(() => sorted().filter((item) => item && item.status !== "deleted"))
|
||||||
|
|
||||||
const emptyViewerMessage = () => {
|
|
||||||
if (!hasSession) return "Select a session to view changes."
|
|
||||||
if (entries === null) return "Loading git changes…"
|
|
||||||
if (nonDeleted.length === 0) return "No git changes yet."
|
|
||||||
return "No file selected."
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const selectedEntry = createMemo<GitFileStatus | null>(() => {
|
||||||
|
const list = sorted()
|
||||||
const selectedPath = props.selectedPath()
|
const selectedPath = props.selectedPath()
|
||||||
const fallbackPath = props.mostChangedPath()
|
const fallbackPath = props.mostChangedPath()
|
||||||
const selectedEntry =
|
const found =
|
||||||
sorted.find((item) => item.path === selectedPath) ||
|
list.find((item) => item.path === selectedPath) ||
|
||||||
(fallbackPath ? sorted.find((item) => item.path === fallbackPath) : null)
|
(fallbackPath ? list.find((item) => item.path === fallbackPath) : undefined)
|
||||||
|
return found ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const emptyViewerMessage = createMemo(() => {
|
||||||
|
if (!hasSession()) return props.t("instanceShell.sessionChanges.noSessionSelected")
|
||||||
|
const currentEntries = entries()
|
||||||
|
if (currentEntries === null) return props.t("instanceShell.gitChanges.loading")
|
||||||
|
if (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty")
|
||||||
|
return props.t("instanceShell.filesShell.viewerEmpty")
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderContent = (): JSX.Element => {
|
||||||
|
const totalsValue = totals()
|
||||||
|
const selected = selectedEntry()
|
||||||
|
const sortedList = sorted()
|
||||||
|
const nonDeletedList = nonDeleted()
|
||||||
|
|
||||||
const renderViewer = () => (
|
const renderViewer = () => (
|
||||||
<div class="file-viewer-panel flex-1">
|
<div class="file-viewer-panel flex-1">
|
||||||
@@ -91,12 +106,12 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
fallback={
|
fallback={
|
||||||
<Show
|
<Show
|
||||||
when={
|
when={
|
||||||
selectedEntry &&
|
selected &&
|
||||||
props.selectedBefore() !== null &&
|
props.selectedBefore() !== null &&
|
||||||
props.selectedAfter() !== null &&
|
props.selectedAfter() !== null &&
|
||||||
selectedEntry.status !== "deleted"
|
selected.status !== "deleted"
|
||||||
? {
|
? {
|
||||||
path: selectedEntry.path,
|
path: selected.path,
|
||||||
before: props.selectedBefore() as string,
|
before: props.selectedBefore() as string,
|
||||||
after: props.selectedAfter() as string,
|
after: props.selectedAfter() as string,
|
||||||
}
|
}
|
||||||
@@ -109,15 +124,23 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(file) => (
|
{(file) => (
|
||||||
<MonacoDiffViewer
|
<Suspense
|
||||||
scopeKey={props.scopeKey()}
|
fallback={
|
||||||
path={String(file().path || "")}
|
<div class="file-viewer-empty">
|
||||||
before={String((file() as any).before || "")}
|
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||||
after={String((file() as any).after || "")}
|
</div>
|
||||||
viewMode={props.diffViewMode()}
|
}
|
||||||
contextMode={props.diffContextMode()}
|
>
|
||||||
wordWrap={props.diffWordWrapMode()}
|
<LazyMonacoDiffViewer
|
||||||
/>
|
scopeKey={props.scopeKey()}
|
||||||
|
path={String(file().path || "")}
|
||||||
|
before={String((file() as any).before || "")}
|
||||||
|
after={String((file() as any).after || "")}
|
||||||
|
viewMode={props.diffViewMode()}
|
||||||
|
contextMode={props.diffContextMode()}
|
||||||
|
wordWrap={props.diffWordWrapMode()}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
@@ -131,7 +154,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="file-viewer-empty">
|
<div class="file-viewer-empty">
|
||||||
<span class="file-viewer-empty-text">Loading…</span>
|
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,8 +164,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
|
const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
|
||||||
|
|
||||||
const renderListPanel = () => (
|
const renderListPanel = () => (
|
||||||
<Show when={nonDeleted.length > 0} fallback={renderEmptyList()}>
|
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
|
||||||
<For each={sorted}>
|
<For each={sortedList}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<div
|
<div
|
||||||
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||||
@@ -156,7 +179,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="file-list-item-stats">
|
<div class="file-list-item-stats">
|
||||||
<Show when={item.status === "deleted"}>
|
<Show when={item.status === "deleted"}>
|
||||||
<span class="text-[10px] text-secondary">deleted</span>
|
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={item.status !== "deleted"}>
|
<Show when={item.status !== "deleted"}>
|
||||||
<>
|
<>
|
||||||
@@ -173,8 +196,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const renderListOverlay = () => (
|
const renderListOverlay = () => (
|
||||||
<Show when={nonDeleted.length > 0} fallback={renderEmptyList()}>
|
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
|
||||||
<For each={sorted}>
|
<For each={sortedList}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<div
|
<div
|
||||||
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||||
@@ -187,7 +210,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="file-list-item-stats">
|
<div class="file-list-item-stats">
|
||||||
<Show when={item.status === "deleted"}>
|
<Show when={item.status === "deleted"}>
|
||||||
<span class="text-[10px] text-secondary">deleted</span>
|
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={item.status !== "deleted"}>
|
<Show when={item.status !== "deleted"}>
|
||||||
<>
|
<>
|
||||||
@@ -204,19 +227,19 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SplitFilePanel
|
<SplitFilePanel
|
||||||
header={
|
header={
|
||||||
<>
|
<>
|
||||||
<span class="files-tab-selected-path" title={selectedEntry?.path || "Git Changes"}>
|
<span class="files-tab-selected-path" title={selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}>
|
||||||
<span class="file-path-text">{selectedEntry?.path || "Git Changes"}</span>
|
<span class="file-path-text">{selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
|
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
|
||||||
<span class="files-tab-stat files-tab-stat-additions">
|
<span class="files-tab-stat files-tab-stat-additions">
|
||||||
<span class="files-tab-stat-value">+{totals.additions}</span>
|
<span class="files-tab-stat-value">+{totalsValue.additions}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="files-tab-stat files-tab-stat-deletions">
|
<span class="files-tab-stat files-tab-stat-deletions">
|
||||||
<span class="files-tab-stat-value">-{totals.deletions}</span>
|
<span class="files-tab-stat-value">-{totalsValue.deletions}</span>
|
||||||
</span>
|
</span>
|
||||||
<Show when={props.statusError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
<Show when={props.statusError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,23 +249,23 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
class="files-header-icon-button"
|
class="files-header-icon-button"
|
||||||
title={props.t("instanceShell.rightPanel.actions.refresh")}
|
title={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||||
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
|
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||||
disabled={!hasSession || props.statusLoading() || entries === null}
|
disabled={!hasSession() || props.statusLoading() || entries() === null}
|
||||||
style={{ "margin-left": "auto" }}
|
style={{ "margin-left": "auto" }}
|
||||||
onClick={() => props.onRefresh()}
|
onClick={() => props.onRefresh()}
|
||||||
>
|
>
|
||||||
<RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} />
|
<RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<DiffToolbar
|
<DiffToolbar
|
||||||
viewMode={props.diffViewMode()}
|
viewMode={props.diffViewMode()}
|
||||||
contextMode={props.diffContextMode()}
|
contextMode={props.diffContextMode()}
|
||||||
wordWrapMode={props.diffWordWrapMode()}
|
wordWrapMode={props.diffWordWrapMode()}
|
||||||
onViewModeChange={props.onViewModeChange}
|
onViewModeChange={props.onViewModeChange}
|
||||||
onContextModeChange={props.onContextModeChange}
|
onContextModeChange={props.onContextModeChange}
|
||||||
onWordWrapModeChange={props.onWordWrapModeChange}
|
onWordWrapModeChange={props.onWordWrapModeChange}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
list={{ panel: renderListPanel, overlay: renderListOverlay }}
|
list={{ panel: renderListPanel, overlay: renderListOverlay }}
|
||||||
viewer={renderViewer()}
|
viewer={renderViewer()}
|
||||||
listOpen={props.listOpen()}
|
listOpen={props.listOpen()}
|
||||||
@@ -251,7 +274,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
onResizeMouseDown={props.onResizeMouseDown}
|
onResizeMouseDown={props.onResizeMouseDown}
|
||||||
onResizeTouchStart={props.onResizeTouchStart}
|
onResizeTouchStart={props.onResizeTouchStart}
|
||||||
isPhoneLayout={props.isPhoneLayout()}
|
isPhoneLayout={props.isPhoneLayout()}
|
||||||
overlayAriaLabel="Git Changes"
|
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.gitChanges")}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { For, Show, type Accessor, type Component } from "solid-js"
|
import { For, Show, type Accessor, type Component } from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk"
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
import { Accordion } from "@kobalte/core"
|
import { Accordion } from "@kobalte/core"
|
||||||
import { Tooltip } from "@kobalte/core/tooltip"
|
import { Tooltip } from "@kobalte/core/tooltip"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { batch, createMemo, type Accessor } from "solid-js"
|
import { batch, createMemo, type Accessor } from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk"
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
import type { Session } from "../../../types/session"
|
import type { Session } from "../../../types/session"
|
||||||
import {
|
import {
|
||||||
activeParentSessionId,
|
activeParentSessionId,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||||
import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities, setMarkdownTheme } from "../lib/markdown"
|
|
||||||
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||||
import type { TextPart, RenderCache } from "../types/message"
|
import type { TextPart, RenderCache } from "../types/message"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
@@ -8,6 +7,20 @@ import { useI18n } from "../lib/i18n"
|
|||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
type MarkdownModule = typeof import("../lib/markdown")
|
||||||
|
|
||||||
|
let markdownModulePromise: Promise<MarkdownModule> | null = null
|
||||||
|
|
||||||
|
function loadMarkdownModule(): Promise<MarkdownModule> {
|
||||||
|
if (!markdownModulePromise) {
|
||||||
|
markdownModulePromise = import("../lib/markdown").catch((error) => {
|
||||||
|
markdownModulePromise = null
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return markdownModulePromise
|
||||||
|
}
|
||||||
|
|
||||||
function hashText(value: string): string {
|
function hashText(value: string): string {
|
||||||
let hash = 2166136261
|
let hash = 2166136261
|
||||||
for (let index = 0; index < value.length; index++) {
|
for (let index = 0; index < value.length; index++) {
|
||||||
@@ -24,6 +37,45 @@ function resolvePartVersion(part: TextPart, text: string): string {
|
|||||||
return `text-${hashText(text)}`
|
return `text-${hashText(text)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvePartCacheId(part: TextPart, text: string): string {
|
||||||
|
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
|
||||||
|
if (partId) {
|
||||||
|
return partId
|
||||||
|
}
|
||||||
|
|
||||||
|
return `anonymous:${hashText(text)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHtmlEntitiesLocally(content: string): string {
|
||||||
|
if (!content.includes("&") || typeof document === "undefined") {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = document.createElement("textarea")
|
||||||
|
textarea.innerHTML = content
|
||||||
|
return textarea.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(content: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
}
|
||||||
|
|
||||||
|
return content.replace(/[&<>"']/g, (match) => map[match] ?? match)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFallbackHtml(content: string): string {
|
||||||
|
if (!content) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return escapeHtml(content).replace(/\n/g, "<br />")
|
||||||
|
}
|
||||||
|
|
||||||
interface MarkdownProps {
|
interface MarkdownProps {
|
||||||
part: TextPart
|
part: TextPart
|
||||||
instanceId?: string
|
instanceId?: string
|
||||||
@@ -38,7 +90,8 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [html, setHtml] = createSignal("")
|
const [html, setHtml] = createSignal("")
|
||||||
let containerRef: HTMLDivElement | undefined
|
let containerRef: HTMLDivElement | undefined
|
||||||
let latestRequestedText = ""
|
let latestRequestKey = ""
|
||||||
|
let cleanupLanguageListener: (() => void) | undefined
|
||||||
|
|
||||||
const notifyRendered = () => {
|
const notifyRendered = () => {
|
||||||
Promise.resolve().then(() => props.onRendered?.())
|
Promise.resolve().then(() => props.onRendered?.())
|
||||||
@@ -47,15 +100,14 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
const resolved = createMemo(() => {
|
const resolved = createMemo(() => {
|
||||||
const part = props.part
|
const part = props.part
|
||||||
const rawText = typeof part.text === "string" ? part.text : ""
|
const rawText = typeof part.text === "string" ? part.text : ""
|
||||||
const text = decodeHtmlEntities(rawText)
|
const text = decodeHtmlEntitiesLocally(rawText)
|
||||||
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
||||||
const highlightEnabled = !props.disableHighlight
|
const highlightEnabled = !props.disableHighlight
|
||||||
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
|
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
|
||||||
if (!partId) {
|
const cacheId = resolvePartCacheId(part, text)
|
||||||
throw new Error("Markdown rendering requires a part id")
|
|
||||||
}
|
|
||||||
const version = resolvePartVersion(part, text)
|
const version = resolvePartVersion(part, text)
|
||||||
return { part, text, themeKey, highlightEnabled, partId, version }
|
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${version}`
|
||||||
|
return { part, text, themeKey, highlightEnabled, partId, cacheId, version, requestKey }
|
||||||
})
|
})
|
||||||
|
|
||||||
const cacheHandle = useGlobalCache({
|
const cacheHandle = useGlobalCache({
|
||||||
@@ -63,26 +115,46 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
sessionId: () => props.sessionId,
|
sessionId: () => props.sessionId,
|
||||||
scope: "markdown",
|
scope: "markdown",
|
||||||
cacheId: () => {
|
cacheId: () => {
|
||||||
const { partId, themeKey, highlightEnabled } = resolved()
|
const { cacheId, themeKey, highlightEnabled } = resolved()
|
||||||
return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
||||||
},
|
},
|
||||||
version: () => resolved().version,
|
version: () => resolved().version,
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(async () => {
|
const commitCacheEntry = (snapshot: ReturnType<typeof resolved>, renderedHtml: string) => {
|
||||||
const { part, text, themeKey, highlightEnabled, version } = resolved()
|
const cacheEntry: RenderCache = {
|
||||||
|
text: snapshot.text,
|
||||||
|
html: renderedHtml,
|
||||||
|
theme: snapshot.themeKey,
|
||||||
|
mode: snapshot.version,
|
||||||
|
}
|
||||||
|
setHtml(renderedHtml)
|
||||||
|
cacheHandle.set(cacheEntry)
|
||||||
|
notifyRendered()
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure the markdown highlighter theme matches the active UI theme.
|
const renderSnapshot = async (snapshot: ReturnType<typeof resolved>) => {
|
||||||
setMarkdownTheme(themeKey === "dark")
|
const markdown = await loadMarkdownModule()
|
||||||
|
markdown.setMarkdownTheme(snapshot.themeKey === "dark")
|
||||||
|
const rendered = await markdown.renderMarkdown(snapshot.text, {
|
||||||
|
suppressHighlight: !snapshot.highlightEnabled,
|
||||||
|
})
|
||||||
|
|
||||||
latestRequestedText = text
|
if (latestRequestKey === snapshot.requestKey) {
|
||||||
|
commitCacheEntry(snapshot, rendered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const snapshot = resolved()
|
||||||
|
latestRequestKey = snapshot.requestKey
|
||||||
|
|
||||||
const cacheMatches = (cache: RenderCache | undefined) => {
|
const cacheMatches = (cache: RenderCache | undefined) => {
|
||||||
if (!cache) return false
|
if (!cache) return false
|
||||||
return cache.theme === themeKey && cache.mode === version
|
return cache.theme === snapshot.themeKey && cache.mode === snapshot.version
|
||||||
}
|
}
|
||||||
|
|
||||||
const localCache = part.renderCache
|
const localCache = snapshot.part.renderCache
|
||||||
if (localCache && cacheMatches(localCache)) {
|
if (localCache && cacheMatches(localCache)) {
|
||||||
setHtml(localCache.html)
|
setHtml(localCache.html)
|
||||||
notifyRendered()
|
notifyRendered()
|
||||||
@@ -92,115 +164,91 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
const globalCache = cacheHandle.get<RenderCache>()
|
const globalCache = cacheHandle.get<RenderCache>()
|
||||||
if (globalCache && cacheMatches(globalCache)) {
|
if (globalCache && cacheMatches(globalCache)) {
|
||||||
setHtml(globalCache.html)
|
setHtml(globalCache.html)
|
||||||
part.renderCache = globalCache
|
|
||||||
notifyRendered()
|
notifyRendered()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const commitCacheEntry = (renderedHtml: string) => {
|
setHtml(renderFallbackHtml(snapshot.text))
|
||||||
const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version }
|
notifyRendered()
|
||||||
setHtml(renderedHtml)
|
|
||||||
part.renderCache = cacheEntry
|
|
||||||
cacheHandle.set(cacheEntry)
|
|
||||||
notifyRendered()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!highlightEnabled) {
|
void renderSnapshot(snapshot).catch((error) => {
|
||||||
part.renderCache = undefined
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rendered = await renderMarkdown(text, { suppressHighlight: true })
|
|
||||||
|
|
||||||
if (latestRequestedText === text) {
|
|
||||||
commitCacheEntry(rendered)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log.error("Failed to render markdown:", error)
|
|
||||||
if (latestRequestedText === text) {
|
|
||||||
commitCacheEntry(text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rendered = await renderMarkdown(text)
|
|
||||||
if (latestRequestedText === text) {
|
|
||||||
commitCacheEntry(rendered)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log.error("Failed to render markdown:", error)
|
log.error("Failed to render markdown:", error)
|
||||||
if (latestRequestedText === text) {
|
if (latestRequestKey === snapshot.requestKey) {
|
||||||
commitCacheEntry(text)
|
commitCacheEntry(snapshot, renderFallbackHtml(snapshot.text))
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const handleClick = async (e: Event) => {
|
const handleClick = async (event: Event) => {
|
||||||
const target = e.target as HTMLElement
|
const target = event.target as HTMLElement
|
||||||
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
|
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
|
||||||
|
|
||||||
if (copyButton) {
|
if (!copyButton) {
|
||||||
e.preventDefault()
|
return
|
||||||
const code = copyButton.getAttribute("data-code")
|
|
||||||
if (code) {
|
|
||||||
const decodedCode = decodeURIComponent(code)
|
|
||||||
const success = await copyToClipboard(decodedCode)
|
|
||||||
const copyText = copyButton.querySelector(".copy-text")
|
|
||||||
if (copyText) {
|
|
||||||
if (success) {
|
|
||||||
copyText.textContent = t("markdown.codeBlock.copy.copied")
|
|
||||||
setTimeout(() => {
|
|
||||||
copyText.textContent = t("markdown.codeBlock.copy.label")
|
|
||||||
}, 2000)
|
|
||||||
} else {
|
|
||||||
copyText.textContent = t("markdown.codeBlock.copy.failed")
|
|
||||||
setTimeout(() => {
|
|
||||||
copyText.textContent = t("markdown.codeBlock.copy.label")
|
|
||||||
}, 2000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
const code = copyButton.getAttribute("data-code")
|
||||||
|
if (!code) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedCode = decodeURIComponent(code)
|
||||||
|
const success = await copyToClipboard(decodedCode)
|
||||||
|
const copyText = copyButton.querySelector(".copy-text")
|
||||||
|
if (!copyText) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
copyText.textContent = success ? t("markdown.codeBlock.copy.copied") : t("markdown.codeBlock.copy.failed")
|
||||||
|
setTimeout(() => {
|
||||||
|
copyText.textContent = t("markdown.codeBlock.copy.label")
|
||||||
|
}, 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
containerRef?.addEventListener("click", handleClick)
|
containerRef?.addEventListener("click", handleClick)
|
||||||
|
|
||||||
const cleanupLanguageListener = onLanguagesLoaded(async () => {
|
let disposed = false
|
||||||
if (props.disableHighlight) {
|
void loadMarkdownModule()
|
||||||
return
|
.then((markdown) => {
|
||||||
}
|
if (disposed) {
|
||||||
|
return
|
||||||
const { part, text, themeKey, version } = resolved()
|
|
||||||
|
|
||||||
setMarkdownTheme(themeKey === "dark")
|
|
||||||
|
|
||||||
if (latestRequestedText !== text) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rendered = await renderMarkdown(text)
|
|
||||||
if (latestRequestedText === text) {
|
|
||||||
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version }
|
|
||||||
setHtml(rendered)
|
|
||||||
part.renderCache = cacheEntry
|
|
||||||
cacheHandle.set(cacheEntry)
|
|
||||||
notifyRendered()
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
log.error("Failed to re-render markdown after language load:", error)
|
cleanupLanguageListener = markdown.onLanguagesLoaded(() => {
|
||||||
}
|
const snapshot = resolved()
|
||||||
})
|
if (!snapshot.highlightEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
latestRequestKey = snapshot.requestKey
|
||||||
|
void renderSnapshot(snapshot).catch((error) => {
|
||||||
|
log.error("Failed to re-render markdown after language load:", error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
log.error("Failed to load markdown module:", error)
|
||||||
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
disposed = true
|
||||||
containerRef?.removeEventListener("click", handleClick)
|
containerRef?.removeEventListener("click", handleClick)
|
||||||
cleanupLanguageListener()
|
cleanupLanguageListener?.()
|
||||||
|
cleanupLanguageListener = undefined
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const proseClass = () => "markdown-body"
|
return (
|
||||||
|
<div
|
||||||
return <div ref={containerRef} class={proseClass()} innerHTML={html()} />
|
ref={containerRef}
|
||||||
|
class="markdown-body"
|
||||||
|
data-view="markdown"
|
||||||
|
data-part-id={resolved().partId}
|
||||||
|
data-markdown-theme={resolved().themeKey}
|
||||||
|
data-markdown-highlight={resolved().highlightEnabled ? "true" : "false"}
|
||||||
|
innerHTML={html()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
9
packages/ui/src/components/message-anchors.ts
Normal file
9
packages/ui/src/components/message-anchors.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export const MESSAGE_ANCHOR_PREFIX = "message-anchor-"
|
||||||
|
|
||||||
|
export function getMessageAnchorId(messageId: string) {
|
||||||
|
return `${MESSAGE_ANCHOR_PREFIX}${messageId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessageIdFromAnchorId(anchorId: string) {
|
||||||
|
return anchorId.startsWith(MESSAGE_ANCHOR_PREFIX) ? anchorId.slice(MESSAGE_ANCHOR_PREFIX.length) : anchorId
|
||||||
|
}
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { Index, type Accessor } from "solid-js"
|
|
||||||
import VirtualItem from "./virtual-item"
|
|
||||||
import MessageBlock from "./message-block"
|
|
||||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
|
||||||
|
|
||||||
export function getMessageAnchorId(messageId: string) {
|
|
||||||
return `message-anchor-${messageId}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const VIRTUAL_ITEM_MARGIN_PX = 800
|
|
||||||
|
|
||||||
interface MessageBlockListProps {
|
|
||||||
instanceId: string
|
|
||||||
sessionId: string
|
|
||||||
store: () => InstanceMessageStore
|
|
||||||
messageIds: () => string[]
|
|
||||||
lastAssistantIndex: () => number
|
|
||||||
showThinking: () => boolean
|
|
||||||
thinkingDefaultExpanded: () => boolean
|
|
||||||
showUsageMetrics: () => boolean
|
|
||||||
scrollContainer: Accessor<HTMLDivElement | undefined>
|
|
||||||
loading?: boolean
|
|
||||||
onRevert?: (messageId: string) => void
|
|
||||||
onFork?: (messageId?: string) => void
|
|
||||||
onContentRendered?: () => void
|
|
||||||
setBottomSentinel: (element: HTMLDivElement | null) => void
|
|
||||||
suspendMeasurements?: () => boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MessageBlockList(props: MessageBlockListProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Index each={props.messageIds()}>
|
|
||||||
{(messageId, index) => (
|
|
||||||
<VirtualItem
|
|
||||||
id={getMessageAnchorId(messageId())}
|
|
||||||
cacheKey={messageId()}
|
|
||||||
scrollContainer={props.scrollContainer}
|
|
||||||
threshold={VIRTUAL_ITEM_MARGIN_PX}
|
|
||||||
placeholderClass="message-stream-placeholder"
|
|
||||||
virtualizationEnabled={() => !props.loading}
|
|
||||||
suspendMeasurements={props.suspendMeasurements}
|
|
||||||
>
|
|
||||||
<MessageBlock
|
|
||||||
messageId={messageId()}
|
|
||||||
instanceId={props.instanceId}
|
|
||||||
sessionId={props.sessionId}
|
|
||||||
store={props.store}
|
|
||||||
messageIndex={index}
|
|
||||||
lastAssistantIndex={props.lastAssistantIndex}
|
|
||||||
showThinking={props.showThinking}
|
|
||||||
thinkingDefaultExpanded={props.thinkingDefaultExpanded}
|
|
||||||
showUsageMetrics={props.showUsageMetrics}
|
|
||||||
onRevert={props.onRevert}
|
|
||||||
onFork={props.onFork}
|
|
||||||
onContentRendered={props.onContentRendered}
|
|
||||||
/>
|
|
||||||
</VirtualItem>
|
|
||||||
)}
|
|
||||||
</Index>
|
|
||||||
<div ref={props.setBottomSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
|
||||||
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, Trash2 } from "lucide-solid"
|
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
|
||||||
import MessageItem from "./message-item"
|
import MessageItem from "./message-item"
|
||||||
import ToolCall from "./tool-call"
|
import ToolCall from "./tool-call"
|
||||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
@@ -12,8 +12,17 @@ import { formatTokenTotal } from "../lib/formatters"
|
|||||||
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
|
||||||
import { setActiveInstanceId } from "../stores/instances"
|
import { setActiveInstanceId } from "../stores/instances"
|
||||||
import { showAlertDialog } from "../stores/alerts"
|
import { showAlertDialog } from "../stores/alerts"
|
||||||
import { deleteMessagePart } from "../stores/session-actions"
|
import { deleteMessage } from "../stores/session-actions"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
|
import type { DeleteHoverState } from "../types/delete-hover"
|
||||||
|
|
||||||
|
function DeleteUpToIcon() {
|
||||||
|
return (
|
||||||
|
<span class="relative inline-block w-3.5 h-3.5" aria-hidden="true">
|
||||||
|
<ListStart class="absolute inset-0 w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const TOOL_ICON = "🔧"
|
const TOOL_ICON = "🔧"
|
||||||
const USER_BORDER_COLOR = "var(--message-user-border)"
|
const USER_BORDER_COLOR = "var(--message-user-border)"
|
||||||
@@ -23,10 +32,10 @@ const TOOL_BORDER_COLOR = "var(--message-tool-border)"
|
|||||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||||
|
|
||||||
|
|
||||||
type ToolState = import("@opencode-ai/sdk").ToolState
|
type ToolState = import("@opencode-ai/sdk/v2").ToolState
|
||||||
type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
|
type ToolStateRunning = import("@opencode-ai/sdk/v2").ToolStateRunning
|
||||||
type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
|
type ToolStateCompleted = import("@opencode-ai/sdk/v2").ToolStateCompleted
|
||||||
type ToolStateError = import("@opencode-ai/sdk").ToolStateError
|
type ToolStateError = import("@opencode-ai/sdk/v2").ToolStateError
|
||||||
|
|
||||||
function isToolStateRunning(state: ToolState | undefined): state is ToolStateRunning {
|
function isToolStateRunning(state: ToolState | undefined): state is ToolStateRunning {
|
||||||
return Boolean(state && state.status === "running")
|
return Boolean(state && state.status === "running")
|
||||||
@@ -194,8 +203,13 @@ interface MessageContentItemProps {
|
|||||||
messageIndex: number
|
messageIndex: number
|
||||||
lastAssistantIndex: () => number
|
lastAssistantIndex: () => number
|
||||||
onRevert?: (messageId: string) => void
|
onRevert?: (messageId: string) => void
|
||||||
|
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||||
onFork?: (messageId?: string) => void
|
onFork?: (messageId?: string) => void
|
||||||
onContentRendered?: () => void
|
onContentRendered?: () => void
|
||||||
|
showDeleteMessage?: boolean
|
||||||
|
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
||||||
|
selectedMessageIds?: () => Set<string>
|
||||||
|
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSupportedPartType(part: unknown): boolean {
|
function isSupportedPartType(part: unknown): boolean {
|
||||||
@@ -282,7 +296,12 @@ function MessageContentItem(props: MessageContentItemProps) {
|
|||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
isQueued={isQueued()}
|
isQueued={isQueued()}
|
||||||
showAgentMeta={showAgentMeta()}
|
showAgentMeta={showAgentMeta()}
|
||||||
|
showDeleteMessage={props.showDeleteMessage}
|
||||||
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
|
selectedMessageIds={props.selectedMessageIds}
|
||||||
|
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||||
onRevert={props.onRevert}
|
onRevert={props.onRevert}
|
||||||
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
onFork={props.onFork}
|
onFork={props.onFork}
|
||||||
onContentRendered={props.onContentRendered}
|
onContentRendered={props.onContentRendered}
|
||||||
/>
|
/>
|
||||||
@@ -298,11 +317,41 @@ interface ToolCallItemProps {
|
|||||||
messageId: string
|
messageId: string
|
||||||
partId: string
|
partId: string
|
||||||
onContentRendered?: () => void
|
onContentRendered?: () => void
|
||||||
|
showDeleteMessage?: boolean
|
||||||
|
deleteHover?: () => DeleteHoverState
|
||||||
|
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
||||||
|
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||||
|
selectedMessageIds?: () => Set<string>
|
||||||
|
selectedToolPartKeys?: () => Set<string>
|
||||||
|
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToolCallItem(props: ToolCallItemProps) {
|
function ToolCallItem(props: ToolCallItemProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [deleting, setDeleting] = createSignal(false)
|
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
||||||
|
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||||
|
|
||||||
|
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
||||||
|
|
||||||
|
const isSelectedToolPartForDeletion = () => Boolean(props.selectedToolPartKeys?.().has(`${props.messageId}:${props.partId}`))
|
||||||
|
|
||||||
|
const isDeleteOverlayActive = () => {
|
||||||
|
if (isSelectedForDeletion()) return true
|
||||||
|
if (isSelectedToolPartForDeletion()) return true
|
||||||
|
const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState)
|
||||||
|
if (hover.kind === "message") {
|
||||||
|
return hover.messageId === props.messageId
|
||||||
|
}
|
||||||
|
if (hover.kind === "deleteUpTo") {
|
||||||
|
const ids = props.store().getSessionMessageIds(props.sessionId)
|
||||||
|
const targetIndex = ids.indexOf(hover.messageId)
|
||||||
|
if (targetIndex === -1) return false
|
||||||
|
const currentIndex = ids.indexOf(props.messageId)
|
||||||
|
if (currentIndex === -1) return false
|
||||||
|
return currentIndex >= targetIndex
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const record = createMemo(() => props.store().getMessage(props.messageId))
|
const record = createMemo(() => props.store().getMessage(props.messageId))
|
||||||
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
||||||
@@ -319,14 +368,6 @@ function ToolCallItem(props: ToolCallItemProps) {
|
|||||||
const messageVersion = createMemo(() => record()?.revision ?? 0)
|
const messageVersion = createMemo(() => record()?.revision ?? 0)
|
||||||
const partVersion = createMemo(() => partEntry()?.revision ?? 0)
|
const partVersion = createMemo(() => partEntry()?.revision ?? 0)
|
||||||
|
|
||||||
const deleteDisabled = createMemo(() => {
|
|
||||||
if (deleting()) return true
|
|
||||||
// Avoid deleting while a tool is actively running to prevent confusing UI states.
|
|
||||||
if (isToolStateRunning(toolState())) return true
|
|
||||||
// Avoid deleting permission prompts from here; those are interactive.
|
|
||||||
return Boolean(toolPart()?.pendingPermission)
|
|
||||||
})
|
|
||||||
|
|
||||||
const taskSessionId = createMemo(() => {
|
const taskSessionId = createMemo(() => {
|
||||||
const state = toolState()
|
const state = toolState()
|
||||||
if (!state) return ""
|
if (!state) return ""
|
||||||
@@ -350,38 +391,72 @@ function ToolCallItem(props: ToolCallItemProps) {
|
|||||||
navigateToTaskSession(location)
|
navigateToTaskSession(location)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteToolPart = async (event: MouseEvent) => {
|
const handleDeleteMessage = async (event: MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|
||||||
if (deleteDisabled()) return
|
if (!props.showDeleteMessage) return
|
||||||
|
if (deletingMessage()) return
|
||||||
|
|
||||||
setDeleting(true)
|
setDeletingMessage(true)
|
||||||
try {
|
try {
|
||||||
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
|
await deleteMessage(props.instanceId, props.sessionId, props.messageId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showAlertDialog(t("messageBlock.tool.deletePart.failed.message"), {
|
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
|
||||||
title: t("messageBlock.tool.deletePart.failed.title"),
|
title: t("messageItem.actions.deleteMessageFailedTitle"),
|
||||||
detail: error instanceof Error ? error.message : String(error),
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
variant: "error",
|
variant: "error",
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false)
|
setDeletingMessage(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteUpTo = async (event: MouseEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
if (!props.showDeleteMessage) return
|
||||||
|
if (!props.onDeleteMessagesUpTo) return
|
||||||
|
if (deletingUpTo()) return
|
||||||
|
|
||||||
|
setDeletingUpTo(true)
|
||||||
|
try {
|
||||||
|
await props.onDeleteMessagesUpTo(props.messageId)
|
||||||
|
} finally {
|
||||||
|
setDeletingUpTo(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={toolPart()}>
|
<Show when={toolPart()}>
|
||||||
{(resolvedToolPart) => (
|
{(resolvedToolPart) => (
|
||||||
<>
|
<div class="delete-hover-scope" data-delete-part-hover={isDeleteOverlayActive() ? "true" : undefined}>
|
||||||
<div class="tool-call-header-label">
|
<div class="tool-call-header-label">
|
||||||
<div class="tool-call-header-meta">
|
<div class="tool-call-header-meta">
|
||||||
|
<Show when={props.showDeleteMessage}>
|
||||||
|
<input
|
||||||
|
class="message-select-checkbox"
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelectedForDeletion()}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onChange={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
|
||||||
|
props.onToggleSelectedMessage?.(props.messageId, next)
|
||||||
|
}}
|
||||||
|
aria-label={t("messageItem.selection.checkboxAriaLabel")}
|
||||||
|
title={t("messageItem.selection.checkboxAriaLabel")}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<span class="tool-call-icon">{TOOL_ICON}</span>
|
<span class="tool-call-icon">{TOOL_ICON}</span>
|
||||||
<span>{t("messageBlock.tool.header")}</span>
|
<span>{t("messageBlock.tool.header")}</span>
|
||||||
<span class="tool-name">{toolName() || t("messageBlock.tool.unknown")}</span>
|
<span class="tool-name">{toolName() || t("messageBlock.tool.unknown")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-0">
|
||||||
<Show when={taskSessionId()}>
|
<Show when={taskSessionId()}>
|
||||||
<button
|
<button
|
||||||
class="tool-call-header-button"
|
class="tool-call-header-button"
|
||||||
@@ -395,16 +470,33 @@ function ToolCallItem(props: ToolCallItemProps) {
|
|||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<button
|
<Show when={props.showDeleteMessage}>
|
||||||
class="tool-call-header-button"
|
<button
|
||||||
type="button"
|
class="tool-call-header-button"
|
||||||
disabled={deleteDisabled()}
|
type="button"
|
||||||
onClick={handleDeleteToolPart}
|
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
|
||||||
title={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")}
|
onClick={handleDeleteUpTo}
|
||||||
aria-label={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")}
|
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })}
|
||||||
>
|
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||||
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
title={t("messageItem.actions.deleteMessagesUpTo")}
|
||||||
</button>
|
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
|
||||||
|
>
|
||||||
|
<DeleteUpToIcon />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="tool-call-header-button"
|
||||||
|
type="button"
|
||||||
|
disabled={deletingMessage()}
|
||||||
|
onClick={handleDeleteMessage}
|
||||||
|
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId })}
|
||||||
|
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||||
|
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
||||||
|
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
||||||
|
>
|
||||||
|
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -418,7 +510,7 @@ function ToolCallItem(props: ToolCallItemProps) {
|
|||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
onContentRendered={props.onContentRendered}
|
onContentRendered={props.onContentRendered}
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
@@ -470,7 +562,13 @@ interface MessageBlockProps {
|
|||||||
showThinking: () => boolean
|
showThinking: () => boolean
|
||||||
thinkingDefaultExpanded: () => boolean
|
thinkingDefaultExpanded: () => boolean
|
||||||
showUsageMetrics: () => boolean
|
showUsageMetrics: () => boolean
|
||||||
|
deleteHover?: () => DeleteHoverState
|
||||||
|
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
||||||
|
selectedMessageIds?: () => Set<string>
|
||||||
|
selectedToolPartKeys?: () => Set<string>
|
||||||
|
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||||
onRevert?: (messageId: string) => void
|
onRevert?: (messageId: string) => void
|
||||||
|
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||||
onFork?: (messageId?: string) => void
|
onFork?: (messageId?: string) => void
|
||||||
onContentRendered?: () => void
|
onContentRendered?: () => void
|
||||||
}
|
}
|
||||||
@@ -480,6 +578,29 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
const record = createMemo(() => props.store().getMessage(props.messageId))
|
const record = createMemo(() => props.store().getMessage(props.messageId))
|
||||||
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
||||||
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
|
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
|
||||||
|
const isDeleteMessageHovered = () => {
|
||||||
|
const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState)
|
||||||
|
|
||||||
|
const selected = props.selectedMessageIds?.() ?? new Set<string>()
|
||||||
|
if (selected.has(props.messageId)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hover.kind === "message") {
|
||||||
|
return hover.messageId === props.messageId
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hover.kind === "deleteUpTo") {
|
||||||
|
const ids = props.store().getSessionMessageIds(props.sessionId)
|
||||||
|
const targetIndex = ids.indexOf(hover.messageId)
|
||||||
|
if (targetIndex === -1) return false
|
||||||
|
const currentIndex = ids.indexOf(props.messageId)
|
||||||
|
if (currentIndex === -1) return false
|
||||||
|
return currentIndex >= targetIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const block = createMemo<MessageDisplayBlock | null>(() => {
|
const block = createMemo<MessageDisplayBlock | null>(() => {
|
||||||
const current = record()
|
const current = record()
|
||||||
@@ -668,9 +789,13 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
return (
|
return (
|
||||||
<Show when={block()}>
|
<Show when={block()}>
|
||||||
{(resolvedBlock) => (
|
{(resolvedBlock) => (
|
||||||
<div class="message-stream-block" data-message-id={resolvedBlock().record.id}>
|
<div
|
||||||
|
class="message-stream-block"
|
||||||
|
data-message-id={resolvedBlock().record.id}
|
||||||
|
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
|
||||||
|
>
|
||||||
<For each={resolvedBlock().items}>
|
<For each={resolvedBlock().items}>
|
||||||
{(item) => (
|
{(item, index) => (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={item.type === "content"}>
|
<Match when={item.type === "content"}>
|
||||||
<MessageContentItem
|
<MessageContentItem
|
||||||
@@ -681,7 +806,12 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
startPartId={(item as ContentDisplayItem).startPartId}
|
startPartId={(item as ContentDisplayItem).startPartId}
|
||||||
messageIndex={props.messageIndex}
|
messageIndex={props.messageIndex}
|
||||||
lastAssistantIndex={props.lastAssistantIndex}
|
lastAssistantIndex={props.lastAssistantIndex}
|
||||||
|
showDeleteMessage={index() === 0}
|
||||||
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
onRevert={props.onRevert}
|
onRevert={props.onRevert}
|
||||||
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
|
selectedMessageIds={props.selectedMessageIds}
|
||||||
|
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||||
onFork={props.onFork}
|
onFork={props.onFork}
|
||||||
onContentRendered={props.onContentRendered}
|
onContentRendered={props.onContentRendered}
|
||||||
/>
|
/>
|
||||||
@@ -697,6 +827,13 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
store={props.store}
|
store={props.store}
|
||||||
messageId={toolItem.messageId}
|
messageId={toolItem.messageId}
|
||||||
partId={toolItem.partId}
|
partId={toolItem.partId}
|
||||||
|
showDeleteMessage={index() === 0}
|
||||||
|
deleteHover={props.deleteHover}
|
||||||
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
|
selectedMessageIds={props.selectedMessageIds}
|
||||||
|
selectedToolPartKeys={props.selectedToolPartKeys}
|
||||||
|
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||||
onContentRendered={props.onContentRendered}
|
onContentRendered={props.onContentRendered}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -709,6 +846,14 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
part={(item as StepDisplayItem).part}
|
part={(item as StepDisplayItem).part}
|
||||||
messageInfo={(item as StepDisplayItem).messageInfo}
|
messageInfo={(item as StepDisplayItem).messageInfo}
|
||||||
showAgentMeta
|
showAgentMeta
|
||||||
|
showDeleteMessage={index() === 0}
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={props.sessionId}
|
||||||
|
messageId={props.messageId}
|
||||||
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
|
selectedMessageIds={props.selectedMessageIds}
|
||||||
|
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "step-finish"}>
|
<Match when={item.type === "step-finish"}>
|
||||||
@@ -718,6 +863,14 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
messageInfo={(item as StepDisplayItem).messageInfo}
|
messageInfo={(item as StepDisplayItem).messageInfo}
|
||||||
showUsage={props.showUsageMetrics()}
|
showUsage={props.showUsageMetrics()}
|
||||||
borderColor={(item as StepDisplayItem).accentColor}
|
borderColor={(item as StepDisplayItem).accentColor}
|
||||||
|
showDeleteMessage={index() === 0}
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={props.sessionId}
|
||||||
|
messageId={props.messageId}
|
||||||
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
|
selectedMessageIds={props.selectedMessageIds}
|
||||||
|
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "compaction"}>
|
<Match when={item.type === "compaction"}>
|
||||||
@@ -728,7 +881,11 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
messageId={(item as CompactionDisplayItem).messageId}
|
messageId={(item as CompactionDisplayItem).messageId}
|
||||||
partId={(item as CompactionDisplayItem).partId}
|
showDeleteMessage={index() === 0}
|
||||||
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
|
selectedMessageIds={props.selectedMessageIds}
|
||||||
|
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item.type === "reasoning"}>
|
<Match when={item.type === "reasoning"}>
|
||||||
@@ -738,9 +895,13 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
messageId={(item as ReasoningDisplayItem).messageId}
|
messageId={(item as ReasoningDisplayItem).messageId}
|
||||||
partId={(item as ReasoningDisplayItem).partId}
|
|
||||||
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
|
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
|
||||||
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
|
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
|
||||||
|
showDeleteMessage={index() === 0}
|
||||||
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
|
selectedMessageIds={props.selectedMessageIds}
|
||||||
|
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
@@ -759,6 +920,14 @@ interface StepCardProps {
|
|||||||
showAgentMeta?: boolean
|
showAgentMeta?: boolean
|
||||||
showUsage?: boolean
|
showUsage?: boolean
|
||||||
borderColor?: string
|
borderColor?: string
|
||||||
|
showDeleteMessage?: boolean
|
||||||
|
instanceId?: string
|
||||||
|
sessionId?: string
|
||||||
|
messageId?: string
|
||||||
|
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
||||||
|
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||||
|
selectedMessageIds?: () => Set<string>
|
||||||
|
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CompactionCardProps {
|
interface CompactionCardProps {
|
||||||
@@ -768,12 +937,18 @@ interface CompactionCardProps {
|
|||||||
instanceId: string
|
instanceId: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
messageId: string
|
messageId: string
|
||||||
partId: string
|
showDeleteMessage?: boolean
|
||||||
|
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
||||||
|
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||||
|
selectedMessageIds?: () => Set<string>
|
||||||
|
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompactionCard(props: CompactionCardProps) {
|
function CompactionCard(props: CompactionCardProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [deleting, setDeleting] = createSignal(false)
|
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
||||||
|
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||||
|
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
||||||
const isAuto = () => Boolean((props.part as any)?.auto)
|
const isAuto = () => Boolean((props.part as any)?.auto)
|
||||||
const label = () => (isAuto() ? t("messageBlock.compaction.autoLabel") : t("messageBlock.compaction.manualLabel"))
|
const label = () => (isAuto() ? t("messageBlock.compaction.autoLabel") : t("messageBlock.compaction.manualLabel"))
|
||||||
const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR)
|
const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR)
|
||||||
@@ -781,44 +956,98 @@ function CompactionCard(props: CompactionCardProps) {
|
|||||||
const containerClass = () =>
|
const containerClass = () =>
|
||||||
`message-compaction-card ${isAuto() ? "message-compaction-card--auto" : "message-compaction-card--manual"}`
|
`message-compaction-card ${isAuto() ? "message-compaction-card--auto" : "message-compaction-card--manual"}`
|
||||||
|
|
||||||
const canDelete = () => Boolean(props.partId) && !deleting()
|
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
|
||||||
|
|
||||||
const handleDelete = async (event: MouseEvent) => {
|
const handleDeleteMessage = async (event: MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (!canDelete()) return
|
if (!props.showDeleteMessage) return
|
||||||
setDeleting(true)
|
if (!canDeleteMessage()) return
|
||||||
|
setDeletingMessage(true)
|
||||||
try {
|
try {
|
||||||
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
|
await deleteMessage(props.instanceId, props.sessionId, props.messageId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
|
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
|
||||||
title: t("messagePart.actions.deleteFailedTitle"),
|
title: t("messageItem.actions.deleteMessageFailedTitle"),
|
||||||
detail: error instanceof Error ? error.message : String(error),
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
variant: "error",
|
variant: "error",
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false)
|
setDeletingMessage(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteUpTo = async (event: MouseEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
if (!props.showDeleteMessage) return
|
||||||
|
if (!props.onDeleteMessagesUpTo) return
|
||||||
|
if (deletingUpTo()) return
|
||||||
|
|
||||||
|
setDeletingUpTo(true)
|
||||||
|
try {
|
||||||
|
await props.onDeleteMessagesUpTo(props.messageId)
|
||||||
|
} finally {
|
||||||
|
setDeletingUpTo(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={`${containerClass()} relative`}
|
class={`delete-hover-scope ${containerClass()} relative`}
|
||||||
style={{ "border-left": `4px solid ${borderColor()}` }}
|
style={{ "border-left": `4px solid ${borderColor()}` }}
|
||||||
role="status"
|
role="status"
|
||||||
aria-label={t("messageBlock.compaction.ariaLabel")}
|
aria-label={t("messageBlock.compaction.ariaLabel")}
|
||||||
>
|
>
|
||||||
<button
|
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||||
type="button"
|
<Show when={props.showDeleteMessage}>
|
||||||
class="tool-call-header-button absolute right-2 top-1/2 -translate-y-1/2"
|
<button
|
||||||
disabled={!canDelete()}
|
type="button"
|
||||||
onClick={handleDelete}
|
class="tool-call-header-button"
|
||||||
title={t("messagePart.actions.deleteTitle")}
|
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
|
||||||
>
|
onClick={handleDeleteUpTo}
|
||||||
{deleting() ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })}
|
||||||
</button>
|
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||||
|
title={t("messageItem.actions.deleteMessagesUpTo")}
|
||||||
|
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
|
||||||
|
>
|
||||||
|
<DeleteUpToIcon />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool-call-header-button"
|
||||||
|
disabled={!canDeleteMessage()}
|
||||||
|
onClick={handleDeleteMessage}
|
||||||
|
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId })}
|
||||||
|
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||||
|
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
||||||
|
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
||||||
|
>
|
||||||
|
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="message-compaction-row">
|
<div class="message-compaction-row">
|
||||||
|
<Show when={props.showDeleteMessage}>
|
||||||
|
<input
|
||||||
|
class="message-select-checkbox"
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelectedForDeletion()}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onChange={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
|
||||||
|
props.onToggleSelectedMessage?.(props.messageId, next)
|
||||||
|
}}
|
||||||
|
aria-label={t("messageItem.selection.checkboxAriaLabel")}
|
||||||
|
title={t("messageItem.selection.checkboxAriaLabel")}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" />
|
<FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" />
|
||||||
<span class="message-compaction-label">{label()}</span>
|
<span class="message-compaction-label">{label()}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -828,6 +1057,9 @@ function CompactionCard(props: CompactionCardProps) {
|
|||||||
|
|
||||||
function StepCard(props: StepCardProps) {
|
function StepCard(props: StepCardProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
||||||
|
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||||
|
const isSelectedForDeletion = () => Boolean(props.messageId && props.selectedMessageIds?.().has(props.messageId))
|
||||||
const timestamp = () => {
|
const timestamp = () => {
|
||||||
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
|
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
|
||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
@@ -872,6 +1104,42 @@ function StepCard(props: StepCardProps) {
|
|||||||
|
|
||||||
const finishStyle = () => (props.borderColor ? { "border-left-color": props.borderColor } : undefined)
|
const finishStyle = () => (props.borderColor ? { "border-left-color": props.borderColor } : undefined)
|
||||||
|
|
||||||
|
const canDeleteMessage = () =>
|
||||||
|
Boolean(props.showDeleteMessage && props.instanceId && props.sessionId && props.messageId) && !deletingMessage()
|
||||||
|
|
||||||
|
const handleDeleteMessage = async (event: MouseEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
if (!canDeleteMessage()) return
|
||||||
|
setDeletingMessage(true)
|
||||||
|
try {
|
||||||
|
await deleteMessage(props.instanceId!, props.sessionId!, props.messageId!)
|
||||||
|
} catch (error) {
|
||||||
|
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
|
||||||
|
title: t("messageItem.actions.deleteMessageFailedTitle"),
|
||||||
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setDeletingMessage(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteUpTo = async (event: MouseEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
if (!props.messageId) return
|
||||||
|
if (!props.onDeleteMessagesUpTo) return
|
||||||
|
if (deletingUpTo()) return
|
||||||
|
|
||||||
|
setDeletingUpTo(true)
|
||||||
|
try {
|
||||||
|
await props.onDeleteMessagesUpTo(props.messageId)
|
||||||
|
} finally {
|
||||||
|
setDeletingUpTo(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
|
const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
|
||||||
const entries = [
|
const entries = [
|
||||||
@@ -902,17 +1170,83 @@ function StepCard(props: StepCardProps) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div class={`message-step-card message-step-finish message-step-finish-flush`} style={finishStyle()}>
|
<div class={`message-step-card message-step-finish message-step-finish-flush relative`} style={finishStyle()}>
|
||||||
|
<Show when={props.showDeleteMessage && props.messageId}>
|
||||||
|
<input
|
||||||
|
class="message-select-checkbox absolute left-2 top-1/2 -translate-y-1/2"
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelectedForDeletion()}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onChange={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
|
||||||
|
props.onToggleSelectedMessage?.(props.messageId!, next)
|
||||||
|
}}
|
||||||
|
aria-label={t("messageItem.selection.checkboxAriaLabel")}
|
||||||
|
title={t("messageItem.selection.checkboxAriaLabel")}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={props.showDeleteMessage}>
|
||||||
|
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message-action-button"
|
||||||
|
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
|
||||||
|
onClick={handleDeleteUpTo}
|
||||||
|
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId! })}
|
||||||
|
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||||
|
title={t("messageItem.actions.deleteMessagesUpTo")}
|
||||||
|
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
|
||||||
|
>
|
||||||
|
<DeleteUpToIcon />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message-action-button"
|
||||||
|
disabled={!canDeleteMessage()}
|
||||||
|
onClick={handleDeleteMessage}
|
||||||
|
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId! })}
|
||||||
|
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||||
|
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
||||||
|
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
||||||
|
>
|
||||||
|
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
{renderUsageChips(usage)}
|
{renderUsageChips(usage)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`message-step-card message-step-start`}>
|
<div class={`message-step-card message-step-start relative`}>
|
||||||
<div class="message-step-heading">
|
<div class="message-step-heading">
|
||||||
<div class="message-step-title">
|
<div class="message-step-title">
|
||||||
<div class="message-step-title-left">
|
<div class="message-step-title-left">
|
||||||
|
<Show when={props.showDeleteMessage && props.messageId}>
|
||||||
|
<input
|
||||||
|
class="message-select-checkbox"
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelectedForDeletion()}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onChange={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
|
||||||
|
props.onToggleSelectedMessage?.(props.messageId!, next)
|
||||||
|
}}
|
||||||
|
aria-label={t("messageItem.selection.checkboxAriaLabel")}
|
||||||
|
title={t("messageItem.selection.checkboxAriaLabel")}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
|
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
|
||||||
<span class="message-step-meta-inline">
|
<span class="message-step-meta-inline">
|
||||||
<Show when={agentIdentifier()}>{(value) => <span>{t("messageBlock.step.agentLabel", { agent: value() })}</span>}</Show>
|
<Show when={agentIdentifier()}>{(value) => <span>{t("messageBlock.step.agentLabel", { agent: value() })}</span>}</Show>
|
||||||
@@ -939,15 +1273,21 @@ interface ReasoningCardProps {
|
|||||||
instanceId: string
|
instanceId: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
messageId: string
|
messageId: string
|
||||||
partId: string
|
|
||||||
showAgentMeta?: boolean
|
showAgentMeta?: boolean
|
||||||
defaultExpanded?: boolean
|
defaultExpanded?: boolean
|
||||||
|
showDeleteMessage?: boolean
|
||||||
|
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
||||||
|
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||||
|
selectedMessageIds?: () => Set<string>
|
||||||
|
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReasoningCard(props: ReasoningCardProps) {
|
function ReasoningCard(props: ReasoningCardProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
|
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
|
||||||
const [deleting, setDeleting] = createSignal(false)
|
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
||||||
|
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||||
|
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
setExpanded(Boolean(props.defaultExpanded))
|
setExpanded(Boolean(props.defaultExpanded))
|
||||||
@@ -974,6 +1314,8 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
return modelID
|
return modelID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasMeta = () => Boolean(props.showAgentMeta && (agentIdentifier() || modelIdentifier()))
|
||||||
|
|
||||||
const reasoningText = () => {
|
const reasoningText = () => {
|
||||||
const part = props.part as any
|
const part = props.part as any
|
||||||
if (!part) return ""
|
if (!part) return ""
|
||||||
@@ -1014,29 +1356,44 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
const viewHideLabel = () =>
|
const viewHideLabel = () =>
|
||||||
expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")
|
expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")
|
||||||
|
|
||||||
const hasDeleteTarget = () => Boolean(props.partId)
|
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
|
||||||
const canDelete = () => hasDeleteTarget() && !deleting()
|
|
||||||
|
|
||||||
const handleDelete = async (event: MouseEvent) => {
|
const handleDeleteMessage = async (event: MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (!canDelete()) return
|
if (!props.showDeleteMessage) return
|
||||||
setDeleting(true)
|
if (!canDeleteMessage()) return
|
||||||
|
setDeletingMessage(true)
|
||||||
try {
|
try {
|
||||||
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
|
await deleteMessage(props.instanceId, props.sessionId, props.messageId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
|
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
|
||||||
title: t("messagePart.actions.deleteFailedTitle"),
|
title: t("messageItem.actions.deleteMessageFailedTitle"),
|
||||||
detail: error instanceof Error ? error.message : String(error),
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
variant: "error",
|
variant: "error",
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false)
|
setDeletingMessage(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteUpTo = async (event: MouseEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
if (!props.showDeleteMessage) return
|
||||||
|
if (!props.onDeleteMessagesUpTo) return
|
||||||
|
if (deletingUpTo()) return
|
||||||
|
|
||||||
|
setDeletingUpTo(true)
|
||||||
|
try {
|
||||||
|
await props.onDeleteMessagesUpTo(props.messageId)
|
||||||
|
} finally {
|
||||||
|
setDeletingUpTo(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="message-reasoning-card">
|
<div class="delete-hover-scope message-reasoning-card">
|
||||||
<div class="message-reasoning-header">
|
<div class="message-reasoning-header">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1045,22 +1402,28 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
aria-expanded={expanded()}
|
aria-expanded={expanded()}
|
||||||
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
|
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
|
||||||
>
|
>
|
||||||
<span class="message-reasoning-label flex flex-wrap items-center gap-2">
|
<span class="message-reasoning-label">
|
||||||
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
|
<span class="message-reasoning-label-primary">
|
||||||
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
|
<Show when={props.showDeleteMessage}>
|
||||||
<span class="message-step-meta-inline">
|
<input
|
||||||
<Show when={agentIdentifier()}>
|
class="message-select-checkbox"
|
||||||
{(value) => (
|
type="checkbox"
|
||||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
|
checked={isSelectedForDeletion()}
|
||||||
)}
|
onClick={(event) => {
|
||||||
</Show>
|
event.stopPropagation()
|
||||||
<Show when={modelIdentifier()}>
|
}}
|
||||||
{(value) => (
|
onChange={(event) => {
|
||||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
|
event.stopPropagation()
|
||||||
)}
|
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
|
||||||
</Show>
|
props.onToggleSelectedMessage?.(props.messageId, next)
|
||||||
</span>
|
}}
|
||||||
</Show>
|
aria-label={t("messageItem.selection.checkboxAriaLabel")}
|
||||||
|
title={t("messageItem.selection.checkboxAriaLabel")}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -1081,16 +1444,31 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Show when={hasDeleteTarget()}>
|
<Show when={props.showDeleteMessage}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="message-action-button"
|
class="message-action-button"
|
||||||
onClick={handleDelete}
|
onClick={handleDeleteUpTo}
|
||||||
disabled={!canDelete()}
|
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
|
||||||
aria-label={t("messagePart.actions.deleteTitle")}
|
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })}
|
||||||
title={t("messagePart.actions.deleteTitle")}
|
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||||
|
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
|
||||||
|
title={t("messageItem.actions.deleteMessagesUpTo")}
|
||||||
>
|
>
|
||||||
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
<DeleteUpToIcon />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message-action-button"
|
||||||
|
onClick={handleDeleteMessage}
|
||||||
|
disabled={!canDeleteMessage()}
|
||||||
|
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId })}
|
||||||
|
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||||
|
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
||||||
|
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
||||||
|
>
|
||||||
|
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
@@ -1098,6 +1476,23 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Show when={hasMeta()}>
|
||||||
|
<div class="message-reasoning-meta-row">
|
||||||
|
<span class="message-step-meta-inline">
|
||||||
|
<Show when={agentIdentifier()}>
|
||||||
|
{(value) => (
|
||||||
|
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<Show when={modelIdentifier()}>
|
||||||
|
{(value) => (
|
||||||
|
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Show when={expanded()}>
|
<Show when={expanded()}>
|
||||||
<div class="message-reasoning-expanded">
|
<div class="message-reasoning-expanded">
|
||||||
<div class="message-reasoning-body">
|
<div class="message-reasoning-body">
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
import { For, Show, createSignal } from "solid-js"
|
import { For, Show, createEffect, createSignal, onCleanup } from "solid-js"
|
||||||
import { Copy, ExternalLink, Split, Trash2, Undo } from "lucide-solid"
|
import { Portal } from "solid-js/web"
|
||||||
import type { MessageInfo, ClientPart } from "../types/message"
|
import { Copy, ListStart, Split, Trash, Undo } from "lucide-solid"
|
||||||
|
import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message"
|
||||||
import { partHasRenderableText } from "../types/message"
|
import { partHasRenderableText } from "../types/message"
|
||||||
import type { MessageRecord } from "../stores/message-v2/types"
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
import MessagePart from "./message-part"
|
import MessagePart from "./message-part"
|
||||||
import { copyToClipboard } from "../lib/clipboard"
|
import { copyToClipboard } from "../lib/clipboard"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import { showAlertDialog } from "../stores/alerts"
|
import { showAlertDialog } from "../stores/alerts"
|
||||||
import { deleteMessagePart } from "../stores/session-actions"
|
import { deleteMessage } from "../stores/session-actions"
|
||||||
import { isTauriHost } from "../lib/runtime-env"
|
import { isTauriHost } from "../lib/runtime-env"
|
||||||
|
import type { DeleteHoverState } from "../types/delete-hover"
|
||||||
|
|
||||||
|
function DeleteUpToIcon() {
|
||||||
|
return (
|
||||||
|
<span class="relative inline-block w-3.5 h-3.5" aria-hidden="true">
|
||||||
|
<ListStart class="absolute inset-0 w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface MessageItemProps {
|
interface MessageItemProps {
|
||||||
record: MessageRecord
|
record: MessageRecord
|
||||||
@@ -18,15 +28,112 @@ interface MessageItemProps {
|
|||||||
isQueued?: boolean
|
isQueued?: boolean
|
||||||
parts: ClientPart[]
|
parts: ClientPart[]
|
||||||
onRevert?: (messageId: string) => void
|
onRevert?: (messageId: string) => void
|
||||||
|
selectedMessageIds?: () => Set<string>
|
||||||
|
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||||
|
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||||
onFork?: (messageId?: string) => void
|
onFork?: (messageId?: string) => void
|
||||||
showAgentMeta?: boolean
|
showAgentMeta?: boolean
|
||||||
onContentRendered?: () => void
|
onContentRendered?: () => void
|
||||||
|
showDeleteMessage?: boolean
|
||||||
|
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageItem(props: MessageItemProps) {
|
export default function MessageItem(props: MessageItemProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [copied, setCopied] = createSignal(false)
|
const [copied, setCopied] = createSignal(false)
|
||||||
const [deletingParts, setDeletingParts] = createSignal<Set<string>>(new Set())
|
const [deletingMessage, setDeletingMessage] = createSignal(false)
|
||||||
|
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||||
|
|
||||||
|
type ImagePreviewState = {
|
||||||
|
url: string
|
||||||
|
name: string
|
||||||
|
anchor: HTMLElement
|
||||||
|
}
|
||||||
|
|
||||||
|
const [imagePreview, setImagePreview] = createSignal<ImagePreviewState | null>(null)
|
||||||
|
|
||||||
|
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
|
||||||
|
|
||||||
|
const getImagePreviewPosition = () => {
|
||||||
|
const state = imagePreview()
|
||||||
|
if (!state) return null
|
||||||
|
|
||||||
|
const rect = state.anchor.getBoundingClientRect()
|
||||||
|
|
||||||
|
// Outer box: 320px image + 8px padding on each side.
|
||||||
|
const padding = 8
|
||||||
|
const maxImage = 320
|
||||||
|
const gap = 8
|
||||||
|
const chrome = padding * 2
|
||||||
|
const outerWidth = maxImage + chrome
|
||||||
|
const outerHeight = maxImage + chrome
|
||||||
|
|
||||||
|
const viewportW = window.innerWidth
|
||||||
|
const viewportH = window.innerHeight
|
||||||
|
|
||||||
|
const left = clamp(rect.left, 8, Math.max(8, viewportW - outerWidth - 8))
|
||||||
|
|
||||||
|
const fitsAbove = rect.top >= outerHeight + gap + 8
|
||||||
|
const preferredTop = fitsAbove ? rect.top - outerHeight - gap : rect.bottom + gap
|
||||||
|
const top = clamp(preferredTop, 8, Math.max(8, viewportH - outerHeight - 8))
|
||||||
|
|
||||||
|
return { left, top }
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const active = imagePreview()
|
||||||
|
if (!active) return
|
||||||
|
|
||||||
|
// If the user scrolls (message stream scroll container) or resizes, the anchor moves.
|
||||||
|
// Hide the popover to avoid showing it in the wrong place.
|
||||||
|
const hide = () => setImagePreview(null)
|
||||||
|
window.addEventListener("scroll", hide, true)
|
||||||
|
window.addEventListener("resize", hide)
|
||||||
|
onCleanup(() => {
|
||||||
|
window.removeEventListener("scroll", hide, true)
|
||||||
|
window.removeEventListener("resize", hide)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.record.id))
|
||||||
|
|
||||||
|
let topRowEl: HTMLDivElement | undefined
|
||||||
|
let actionsEl: HTMLDivElement | undefined
|
||||||
|
let speakerPrimaryEl: HTMLDivElement | undefined
|
||||||
|
let metaMeasureEl: HTMLSpanElement | undefined
|
||||||
|
const [showMetaInline, setShowMetaInline] = createSignal(true)
|
||||||
|
|
||||||
|
const metaText = () => agentMeta()
|
||||||
|
|
||||||
|
const updateMetaLayout = () => {
|
||||||
|
const text = metaText()
|
||||||
|
if (!text) return
|
||||||
|
if (!topRowEl || !actionsEl || !speakerPrimaryEl || !metaMeasureEl) return
|
||||||
|
|
||||||
|
const rowWidth = topRowEl.getBoundingClientRect().width
|
||||||
|
const actionsWidth = actionsEl.getBoundingClientRect().width
|
||||||
|
const primaryWidth = speakerPrimaryEl.getBoundingClientRect().width
|
||||||
|
const metaWidth = metaMeasureEl.getBoundingClientRect().width
|
||||||
|
|
||||||
|
// Allow for the flex gap between left and actions.
|
||||||
|
const availableLeft = Math.max(0, rowWidth - actionsWidth - 12)
|
||||||
|
setShowMetaInline(primaryWidth + metaWidth + 8 <= availableLeft)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const text = metaText()
|
||||||
|
if (!text || typeof ResizeObserver === "undefined") {
|
||||||
|
setShowMetaInline(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMetaLayout()
|
||||||
|
const observer = new ResizeObserver(() => updateMetaLayout())
|
||||||
|
if (topRowEl) observer.observe(topRowEl)
|
||||||
|
if (actionsEl) observer.observe(actionsEl)
|
||||||
|
if (speakerPrimaryEl) observer.observe(speakerPrimaryEl)
|
||||||
|
onCleanup(() => observer.disconnect())
|
||||||
|
})
|
||||||
|
|
||||||
const isUser = () => props.record.role === "user"
|
const isUser = () => props.record.role === "user"
|
||||||
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
|
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
|
||||||
@@ -123,6 +230,11 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showImagePreview = (anchor: HTMLElement, url: string, name: string) => {
|
||||||
|
if (!url) return
|
||||||
|
setImagePreview({ anchor, url, name })
|
||||||
|
}
|
||||||
|
|
||||||
const errorMessage = () => {
|
const errorMessage = () => {
|
||||||
const info = props.messageInfo
|
const info = props.messageInfo
|
||||||
if (!info || info.role !== "assistant" || !info.error) return null
|
if (!info || info.role !== "assistant" || !info.error) return null
|
||||||
@@ -190,47 +302,30 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
setTimeout(() => setCopied(false), 2000)
|
setTimeout(() => setCopied(false), 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
const deletableTextPartId = () => {
|
const handleDeleteMessage = async () => {
|
||||||
const part = props.parts.find((candidate) => {
|
if (deletingMessage()) return
|
||||||
if (!candidate || candidate.type !== "text") return false
|
setDeletingMessage(true)
|
||||||
const id = (candidate as any).id
|
|
||||||
if (typeof id !== "string" || id.length === 0) return false
|
|
||||||
return !Boolean((candidate as any).synthetic)
|
|
||||||
})
|
|
||||||
return (part as any)?.id as string | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDeletingPart = (partId?: string) => {
|
|
||||||
if (!partId) return false
|
|
||||||
return deletingParts().has(partId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const setPartDeleting = (partId: string, value: boolean) => {
|
|
||||||
setDeletingParts((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
if (value) {
|
|
||||||
next.add(partId)
|
|
||||||
} else {
|
|
||||||
next.delete(partId)
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeletePart = async (partId?: string) => {
|
|
||||||
if (!partId) return
|
|
||||||
if (isDeletingPart(partId)) return
|
|
||||||
setPartDeleting(partId, true)
|
|
||||||
try {
|
try {
|
||||||
await deleteMessagePart(props.instanceId, props.sessionId, props.record.id, partId)
|
await deleteMessage(props.instanceId, props.sessionId, props.record.id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
|
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
|
||||||
title: t("messagePart.actions.deleteFailedTitle"),
|
title: t("messageItem.actions.deleteMessageFailedTitle"),
|
||||||
detail: error instanceof Error ? error.message : String(error),
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
variant: "error",
|
variant: "error",
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setPartDeleting(partId, false)
|
setDeletingMessage(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteUpTo = async () => {
|
||||||
|
if (!props.onDeleteMessagesUpTo) return
|
||||||
|
if (deletingUpTo()) return
|
||||||
|
setDeletingUpTo(true)
|
||||||
|
try {
|
||||||
|
await props.onDeleteMessagesUpTo(props.record.id)
|
||||||
|
} finally {
|
||||||
|
setDeletingUpTo(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,8 +353,16 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
if (!info || info.role !== "assistant") return ""
|
if (!info || info.role !== "assistant") return ""
|
||||||
const modelID = info.modelID || ""
|
const modelID = info.modelID || ""
|
||||||
const providerID = info.providerID || ""
|
const providerID = info.providerID || ""
|
||||||
if (modelID && providerID) return `${providerID}/${modelID}`
|
|
||||||
return modelID
|
const base = modelID && providerID ? `${providerID}/${modelID}` : modelID
|
||||||
|
if (!base) return ""
|
||||||
|
|
||||||
|
const variant = (info as SDKAssistantMessageV2).variant
|
||||||
|
if (typeof variant === "string" && variant.trim().length > 0) {
|
||||||
|
return `${base} (${variant.trim()})`
|
||||||
|
}
|
||||||
|
|
||||||
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
const agentMeta = () => {
|
const agentMeta = () => {
|
||||||
@@ -278,28 +381,68 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={containerClass()}>
|
<div
|
||||||
|
class={containerClass()}
|
||||||
|
data-view="message-item"
|
||||||
|
data-instance-id={props.instanceId}
|
||||||
|
data-session-id={props.sessionId}
|
||||||
|
data-message-id={props.record.id}
|
||||||
|
data-message-role={isUser() ? "user" : "assistant"}
|
||||||
|
data-message-status={props.record.status}
|
||||||
|
>
|
||||||
<header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}>
|
<header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}>
|
||||||
<div class="message-item-header-row message-item-header-row--top">
|
<div class="message-item-header-row message-item-header-row--top" ref={(el) => (topRowEl = el)}>
|
||||||
<div class="message-speaker">
|
<div class="message-header-left">
|
||||||
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
|
<div class="message-speaker-primary" ref={(el) => (speakerPrimaryEl = el)}>
|
||||||
{speakerLabel()}
|
<Show when={props.showDeleteMessage}>
|
||||||
</span>
|
<input
|
||||||
|
class="message-select-checkbox"
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelectedForDeletion()}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onChange={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
|
||||||
|
props.onToggleSelectedMessage?.(props.record.id, next)
|
||||||
|
}}
|
||||||
|
aria-label={t("messageItem.selection.checkboxAriaLabel")}
|
||||||
|
title={t("messageItem.selection.checkboxAriaLabel")}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
|
||||||
|
{speakerLabel()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={metaText() && showMetaInline()}>
|
||||||
|
<span class="message-agent-meta-inline">{metaText()}</span>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={metaText()}>
|
||||||
|
<span
|
||||||
|
ref={(el) => (metaMeasureEl = el)}
|
||||||
|
class="message-agent-meta-inline message-agent-meta-inline--measure"
|
||||||
|
>
|
||||||
|
{metaText()}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="message-item-actions">
|
<div class="message-item-actions" ref={(el) => (actionsEl = el)}>
|
||||||
<Show when={isUser()}>
|
<Show when={isUser()}>
|
||||||
<div class="message-action-group">
|
<div class="message-action-group">
|
||||||
<Show when={props.onRevert}>
|
<button
|
||||||
<button
|
class="message-action-button"
|
||||||
class="message-action-button"
|
onClick={handleCopy}
|
||||||
onClick={handleRevert}
|
title={copyLabel()}
|
||||||
title={t("messageItem.actions.revert")}
|
aria-label={copyLabel()}
|
||||||
aria-label={t("messageItem.actions.revert")}
|
>
|
||||||
>
|
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
<Undo class="w-3.5 h-3.5" aria-hidden="true" />
|
</button>
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
<Show when={props.onFork}>
|
<Show when={props.onFork}>
|
||||||
<button
|
<button
|
||||||
class="message-action-button"
|
class="message-action-button"
|
||||||
@@ -310,14 +453,43 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
<Split class="w-3.5 h-3.5" aria-hidden="true" />
|
<Split class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
<button
|
|
||||||
class="message-action-button"
|
<Show when={props.onRevert}>
|
||||||
onClick={handleCopy}
|
<button
|
||||||
title={copyLabel()}
|
class="message-action-button"
|
||||||
aria-label={copyLabel()}
|
onClick={handleRevert}
|
||||||
>
|
title={t("messageItem.actions.revertTitle")}
|
||||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
aria-label={t("messageItem.actions.revertTitle")}
|
||||||
</button>
|
>
|
||||||
|
<Undo class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={props.showDeleteMessage}>
|
||||||
|
<button
|
||||||
|
class="message-action-button"
|
||||||
|
onClick={() => void handleDeleteUpTo()}
|
||||||
|
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
|
||||||
|
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.record.id })}
|
||||||
|
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||||
|
title={t("messageItem.actions.deleteMessagesUpTo")}
|
||||||
|
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
|
||||||
|
>
|
||||||
|
<DeleteUpToIcon />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="message-action-button"
|
||||||
|
onClick={handleDeleteMessage}
|
||||||
|
disabled={deletingMessage()}
|
||||||
|
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.record.id })}
|
||||||
|
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||||
|
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
||||||
|
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
||||||
|
>
|
||||||
|
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!isUser()}>
|
<Show when={!isUser()}>
|
||||||
@@ -331,18 +503,30 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Show when={deletableTextPartId()}>
|
<Show when={props.showDeleteMessage}>
|
||||||
{(partId) => (
|
<button
|
||||||
<button
|
class="message-action-button"
|
||||||
class="message-action-button"
|
onClick={() => void handleDeleteUpTo()}
|
||||||
onClick={() => void handleDeletePart(partId())}
|
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
|
||||||
disabled={isDeletingPart(partId())}
|
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.record.id })}
|
||||||
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||||
aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
title={t("messageItem.actions.deleteMessagesUpTo")}
|
||||||
>
|
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
|
||||||
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
>
|
||||||
</button>
|
<DeleteUpToIcon />
|
||||||
)}
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="message-action-button"
|
||||||
|
onClick={handleDeleteMessage}
|
||||||
|
disabled={deletingMessage()}
|
||||||
|
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.record.id })}
|
||||||
|
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||||
|
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
||||||
|
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
|
||||||
|
>
|
||||||
|
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -350,12 +534,10 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={agentMeta()}>
|
<Show when={metaText() && !showMetaInline()}>
|
||||||
{(meta) => (
|
<div class="message-item-header-row message-item-header-row--meta">
|
||||||
<div class="message-item-header-row message-item-header-row--bottom">
|
<span class="message-agent-meta-block">{metaText()}</span>
|
||||||
<span class="message-agent-meta">{meta()}</span>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
</header>
|
</header>
|
||||||
@@ -378,16 +560,20 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<For each={messageParts()}>
|
<For each={messageParts()}>
|
||||||
{(part) => (
|
{(part) => {
|
||||||
<MessagePart
|
return (
|
||||||
part={part}
|
<div class="message-part-shell">
|
||||||
messageType={props.record.role}
|
<MessagePart
|
||||||
instanceId={props.instanceId}
|
part={part}
|
||||||
sessionId={props.sessionId}
|
messageType={props.record.role}
|
||||||
primaryUserTextPartId={primaryUserTextPartId()}
|
instanceId={props.instanceId}
|
||||||
onRendered={props.onContentRendered}
|
sessionId={props.sessionId}
|
||||||
/>
|
primaryUserTextPartId={primaryUserTextPartId()}
|
||||||
)}
|
onRendered={props.onContentRendered}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
<Show when={fileAttachments().length > 0}>
|
<Show when={fileAttachments().length > 0}>
|
||||||
@@ -397,7 +583,16 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
const name = getAttachmentName(attachment)
|
const name = getAttachmentName(attachment)
|
||||||
const isImage = isImageAttachment(attachment)
|
const isImage = isImageAttachment(attachment)
|
||||||
return (
|
return (
|
||||||
<div class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`} title={name}>
|
<div
|
||||||
|
class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`}
|
||||||
|
title={name}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isImage) return
|
||||||
|
const el = e.currentTarget as HTMLElement
|
||||||
|
showImagePreview(el, attachment.url || "", name)
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => setImagePreview(null)}
|
||||||
|
>
|
||||||
<Show when={isImage} fallback={
|
<Show when={isImage} fallback={
|
||||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path
|
<path
|
||||||
@@ -425,24 +620,6 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleDeletePart(attachment.id)}
|
|
||||||
class="attachment-remove"
|
|
||||||
disabled={isDeletingPart(attachment.id)}
|
|
||||||
aria-label={t("messagePart.actions.deleteTitle")}
|
|
||||||
title={t("messagePart.actions.deleteTitle")}
|
|
||||||
>
|
|
||||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<Show when={isImage}>
|
|
||||||
<div class="attachment-chip-preview">
|
|
||||||
<img src={attachment.url} alt={name} />
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -450,6 +627,31 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<Show when={imagePreview()}>
|
||||||
|
{(stateAccessor) => {
|
||||||
|
const state = stateAccessor()
|
||||||
|
const pos = () => getImagePreviewPosition()
|
||||||
|
return (
|
||||||
|
<Portal>
|
||||||
|
<Show when={pos()}>
|
||||||
|
{(posAccessor) => {
|
||||||
|
const coords = posAccessor()
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="attachment-image-popover"
|
||||||
|
style={{ left: `${coords.left}px`, top: `${coords.top}px` }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<img src={state.url} alt={state.name} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
</Portal>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Show when={props.record.status === "sending"}>
|
<Show when={props.record.status === "sending"}>
|
||||||
<div class="message-sending">
|
<div class="message-sending">
|
||||||
<span class="generating-spinner">●</span> {t("messageItem.status.sending")}
|
<span class="generating-spinner">●</span> {t("messageItem.status.sending")}
|
||||||
|
|||||||
@@ -131,7 +131,12 @@ export default function MessagePart(props: MessagePartProps) {
|
|||||||
<Switch>
|
<Switch>
|
||||||
<Match when={partType() === "text"}>
|
<Match when={partType() === "text"}>
|
||||||
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
|
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
|
||||||
<div class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()} data-role={textContainerRole()}>
|
<div
|
||||||
|
class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()}
|
||||||
|
data-role={textContainerRole()}
|
||||||
|
data-part-type="text"
|
||||||
|
data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined}
|
||||||
|
>
|
||||||
<Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}>
|
<Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}>
|
||||||
<Markdown
|
<Markdown
|
||||||
part={createTextPartForMarkdown()}
|
part={createTextPartForMarkdown()}
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import type { Component } from "solid-js"
|
import type { Component } from "solid-js"
|
||||||
import MessageBlock from "./message-block"
|
import MessageBlock from "./message-block"
|
||||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
|
import type { DeleteHoverState } from "../types/delete-hover"
|
||||||
|
|
||||||
interface MessagePreviewProps {
|
interface MessagePreviewProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
messageId: string
|
messageId: string
|
||||||
store: () => InstanceMessageStore
|
store: () => InstanceMessageStore
|
||||||
|
deleteHover?: () => DeleteHoverState
|
||||||
|
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
||||||
|
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||||
|
selectedMessageIds?: () => Set<string>
|
||||||
|
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const MessagePreview: Component<MessagePreviewProps> = (props) => {
|
const MessagePreview: Component<MessagePreviewProps> = (props) => {
|
||||||
@@ -24,6 +30,11 @@ const MessagePreview: Component<MessagePreviewProps> = (props) => {
|
|||||||
showThinking={() => false}
|
showThinking={() => false}
|
||||||
thinkingDefaultExpanded={() => false}
|
thinkingDefaultExpanded={() => false}
|
||||||
showUsageMetrics={() => false}
|
showUsageMetrics={() => false}
|
||||||
|
deleteHover={props.deleteHover}
|
||||||
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
|
selectedMessageIds={props.selectedMessageIds}
|
||||||
|
onToggleSelectedMessage={props.onToggleSelectedMessage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,14 @@
|
|||||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Component } from "solid-js"
|
import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component, type Accessor } from "solid-js"
|
||||||
import MessagePreview from "./message-preview"
|
import MessagePreview from "./message-preview"
|
||||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
import type { ClientPart } from "../types/message"
|
import type { ClientPart } from "../types/message"
|
||||||
import type { MessageRecord } from "../stores/message-v2/types"
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||||
|
import { getPartCharCount } from "../lib/token-utils"
|
||||||
import { getToolIcon } from "./tool-call/utils"
|
import { getToolIcon } from "./tool-call/utils"
|
||||||
import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid"
|
import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
|
import type { DeleteHoverState } from "../types/delete-hover"
|
||||||
|
|
||||||
export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction"
|
export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction"
|
||||||
|
|
||||||
@@ -19,18 +21,38 @@ export interface TimelineSegment {
|
|||||||
shortLabel?: string
|
shortLabel?: string
|
||||||
variant?: "auto" | "manual"
|
variant?: "auto" | "manual"
|
||||||
toolPartIds?: string[]
|
toolPartIds?: string[]
|
||||||
|
partIds?: string[]
|
||||||
|
partId?: string
|
||||||
|
totalChars: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MessageTimelineProps {
|
interface MessageTimelineProps {
|
||||||
segments: TimelineSegment[]
|
segments: TimelineSegment[]
|
||||||
onSegmentClick?: (segment: TimelineSegment) => void
|
onSegmentClick?: (segment: TimelineSegment) => void
|
||||||
activeMessageId?: string | null
|
onToggleSelection?: (id: string) => void
|
||||||
|
onLongPressSelection?: (segment: TimelineSegment) => void
|
||||||
|
onSelectRange?: (id: string) => void
|
||||||
|
onClearSelection?: () => void
|
||||||
|
selectedIds?: Accessor<Set<string>>
|
||||||
|
expandedMessageIds?: Accessor<Set<string>>
|
||||||
|
// Optional: restrict histogram/xray overlay to only show for these message ids.
|
||||||
|
// Used to hide ribs for messages before the last compaction.
|
||||||
|
deletableMessageIds?: Accessor<Set<string>>
|
||||||
|
activeSegmentId?: string | null
|
||||||
instanceId: string
|
instanceId: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
showToolSegments?: boolean
|
showToolSegments?: boolean
|
||||||
|
deleteHover?: () => DeleteHoverState
|
||||||
|
onDeleteHoverChange?: (state: DeleteHoverState) => void
|
||||||
|
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
|
||||||
|
selectedMessageIds?: () => Set<string>
|
||||||
|
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_TOOLTIP_LENGTH = 220
|
const MAX_TOOLTIP_LENGTH = 220
|
||||||
|
const LONG_PRESS_MS = 500
|
||||||
|
const JITTER_THRESHOLD = 10
|
||||||
|
const ABSOLUTE_TOKEN_CAP = 10000
|
||||||
|
|
||||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||||
|
|
||||||
@@ -38,10 +60,8 @@ interface PendingSegment {
|
|||||||
type: TimelineSegmentType
|
type: TimelineSegmentType
|
||||||
texts: string[]
|
texts: string[]
|
||||||
reasoningTexts: string[]
|
reasoningTexts: string[]
|
||||||
toolTitles: string[]
|
partIds: string[]
|
||||||
toolTypeLabels: string[]
|
totalChars: number
|
||||||
toolIcons: string[]
|
|
||||||
toolPartIds: string[]
|
|
||||||
hasPrimaryText: boolean
|
hasPrimaryText: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,18 +191,13 @@ export function buildTimelineSegments(
|
|||||||
pending = null
|
pending = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const isToolSegment = pending.type === "tool"
|
const label = segmentLabel(pending.type)
|
||||||
const label = isToolSegment
|
const shortLabel = undefined
|
||||||
? pending.toolTypeLabels[0] || segmentLabel("tool")
|
const tooltip = formatTextsTooltip(
|
||||||
: segmentLabel(pending.type)
|
[...pending.texts, ...pending.reasoningTexts],
|
||||||
const shortLabel = isToolSegment ? pending.toolIcons[0] || getToolIcon("tool") : undefined
|
pending.type === "user" ? t("messageTimeline.tooltip.userFallback") : t("messageTimeline.tooltip.assistantFallback"),
|
||||||
const tooltip = isToolSegment
|
)
|
||||||
? formatToolTooltip(pending.toolTitles, t)
|
|
||||||
: formatTextsTooltip(
|
|
||||||
[...pending.texts, ...pending.reasoningTexts],
|
|
||||||
pending.type === "user" ? t("messageTimeline.tooltip.userFallback") : t("messageTimeline.tooltip.assistantFallback"),
|
|
||||||
)
|
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
id: `${record.id}:${segmentIndex}`,
|
id: `${record.id}:${segmentIndex}`,
|
||||||
messageId: record.id,
|
messageId: record.id,
|
||||||
@@ -190,16 +205,24 @@ export function buildTimelineSegments(
|
|||||||
label,
|
label,
|
||||||
tooltip,
|
tooltip,
|
||||||
shortLabel,
|
shortLabel,
|
||||||
toolPartIds: isToolSegment ? pending.toolPartIds : undefined,
|
partIds: pending.partIds,
|
||||||
|
totalChars: pending.totalChars,
|
||||||
})
|
})
|
||||||
segmentIndex += 1
|
segmentIndex += 1
|
||||||
pending = null
|
pending = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const ensureSegment = (type: TimelineSegmentType): PendingSegment => {
|
const ensureSegment = (type: TimelineSegmentType): PendingSegment => {
|
||||||
if (!pending || pending.type !== type) {
|
if (!pending || pending.type !== type) {
|
||||||
flushPending()
|
flushPending()
|
||||||
pending = { type, texts: [], reasoningTexts: [], toolTitles: [], toolTypeLabels: [], toolIcons: [], toolPartIds: [], hasPrimaryText: type !== "assistant" }
|
pending = {
|
||||||
|
type,
|
||||||
|
texts: [],
|
||||||
|
reasoningTexts: [],
|
||||||
|
partIds: [],
|
||||||
|
totalChars: 0,
|
||||||
|
hasPrimaryText: type !== "assistant",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return pending!
|
return pending!
|
||||||
}
|
}
|
||||||
@@ -211,14 +234,21 @@ export function buildTimelineSegments(
|
|||||||
if (!part || typeof part !== "object") continue
|
if (!part || typeof part !== "object") continue
|
||||||
|
|
||||||
if (part.type === "tool") {
|
if (part.type === "tool") {
|
||||||
const target = ensureSegment("tool")
|
flushPending()
|
||||||
const toolPart = part as ToolCallPart
|
const toolPart = part as ToolCallPart
|
||||||
target.toolTitles.push(getToolTitle(toolPart, t))
|
const partId = typeof toolPart.id === "string" ? toolPart.id : ""
|
||||||
target.toolTypeLabels.push(getToolTypeLabel(toolPart, t))
|
const title = getToolTitle(toolPart, t)
|
||||||
target.toolIcons.push(getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"))
|
result.push({
|
||||||
if (typeof toolPart.id === "string" && toolPart.id.length > 0) {
|
id: `${record.id}:${segmentIndex}`,
|
||||||
target.toolPartIds.push(toolPart.id)
|
messageId: record.id,
|
||||||
}
|
type: "tool",
|
||||||
|
label: getToolTypeLabel(toolPart, t) || segmentLabel("tool"),
|
||||||
|
tooltip: formatToolTooltip([title], t),
|
||||||
|
shortLabel: getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"),
|
||||||
|
toolPartIds: partId ? [partId] : undefined,
|
||||||
|
totalChars: getPartCharCount(part),
|
||||||
|
})
|
||||||
|
segmentIndex += 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,13 +258,18 @@ export function buildTimelineSegments(
|
|||||||
const target = ensureSegment(defaultContentType)
|
const target = ensureSegment(defaultContentType)
|
||||||
if (target) {
|
if (target) {
|
||||||
target.reasoningTexts.push(text)
|
target.reasoningTexts.push(text)
|
||||||
|
if (typeof (part as any).id === "string" && (part as any).id.length > 0) {
|
||||||
|
target.partIds.push((part as any).id)
|
||||||
|
}
|
||||||
|
target.totalChars += getPartCharCount(part)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (part.type === "compaction") {
|
if (part.type === "compaction") {
|
||||||
flushPending()
|
flushPending()
|
||||||
const isAuto = Boolean((part as any)?.auto)
|
const isAuto = Boolean((part as any)?.auto)
|
||||||
|
const partId = typeof (part as any)?.id === "string" ? ((part as any).id as string) : ""
|
||||||
result.push({
|
result.push({
|
||||||
id: `${record.id}:${segmentIndex}`,
|
id: `${record.id}:${segmentIndex}`,
|
||||||
messageId: record.id,
|
messageId: record.id,
|
||||||
@@ -242,6 +277,8 @@ export function buildTimelineSegments(
|
|||||||
label: segmentLabel("compaction"),
|
label: segmentLabel("compaction"),
|
||||||
tooltip: isAuto ? t("messageTimeline.tooltip.compaction.auto") : t("messageTimeline.tooltip.compaction.manual"),
|
tooltip: isAuto ? t("messageTimeline.tooltip.compaction.auto") : t("messageTimeline.tooltip.compaction.manual"),
|
||||||
variant: isAuto ? "auto" : "manual",
|
variant: isAuto ? "auto" : "manual",
|
||||||
|
partId,
|
||||||
|
totalChars: 0,
|
||||||
})
|
})
|
||||||
segmentIndex += 1
|
segmentIndex += 1
|
||||||
continue
|
continue
|
||||||
@@ -250,19 +287,23 @@ export function buildTimelineSegments(
|
|||||||
if (part.type === "step-start" || part.type === "step-finish") {
|
if (part.type === "step-start" || part.type === "step-finish") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = collectTextFromPart(part, t)
|
const text = collectTextFromPart(part, t)
|
||||||
if (text.trim().length === 0) continue
|
if (text.trim().length === 0) continue
|
||||||
const target = ensureSegment(defaultContentType)
|
const target = ensureSegment(defaultContentType)
|
||||||
if (target) {
|
if (target) {
|
||||||
target.texts.push(text)
|
target.texts.push(text)
|
||||||
target.hasPrimaryText = true
|
target.hasPrimaryText = true
|
||||||
|
if (typeof (part as any).id === "string" && (part as any).id.length > 0) {
|
||||||
|
target.partIds.push((part as any).id)
|
||||||
|
}
|
||||||
|
target.totalChars += getPartCharCount(part)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
flushPending()
|
flushPending()
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +319,14 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
let hoverTimer: number | null = null
|
let hoverTimer: number | null = null
|
||||||
let closeTimer: number | null = null
|
let closeTimer: number | null = null
|
||||||
const showTools = () => props.showToolSegments ?? true
|
const showTools = () => props.showToolSegments ?? true
|
||||||
|
const deleteHover = () => props.deleteHover?.() ?? { kind: "none" as const }
|
||||||
|
|
||||||
|
const isHistogramEligible = (segment: TimelineSegment): boolean => {
|
||||||
|
const allowed = props.deletableMessageIds?.()
|
||||||
|
if (!allowed) return true
|
||||||
|
return allowed.has(segment.messageId)
|
||||||
|
}
|
||||||
|
|
||||||
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
|
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
|
||||||
if (element) {
|
if (element) {
|
||||||
buttonRefs.set(segmentId, element)
|
buttonRefs.set(segmentId, element)
|
||||||
@@ -286,7 +334,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
buttonRefs.delete(segmentId)
|
buttonRefs.delete(segmentId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearHoverTimer = () => {
|
const clearHoverTimer = () => {
|
||||||
if (hoverTimer !== null && typeof window !== "undefined") {
|
if (hoverTimer !== null && typeof window !== "undefined") {
|
||||||
window.clearTimeout(hoverTimer)
|
window.clearTimeout(hoverTimer)
|
||||||
@@ -312,8 +360,11 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
setHoverAnchorRect(null)
|
setHoverAnchorRect(null)
|
||||||
}, 160)
|
}, 160)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => {
|
const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => {
|
||||||
|
// Suppress previews during long-press selection gestures.
|
||||||
|
if (longPressTimer !== null) return
|
||||||
|
|
||||||
if (typeof window === "undefined") return
|
if (typeof window === "undefined") return
|
||||||
clearHoverTimer()
|
clearHoverTimer()
|
||||||
clearCloseTimer()
|
clearCloseTimer()
|
||||||
@@ -328,7 +379,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
const handleMouseLeave = () => {
|
const handleMouseLeave = () => {
|
||||||
scheduleClose()
|
scheduleClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (typeof window === "undefined") return
|
if (typeof window === "undefined") return
|
||||||
const anchor = hoverAnchorRect()
|
const anchor = hoverAnchorRect()
|
||||||
@@ -350,13 +401,235 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
clearCloseTimer()
|
clearCloseTimer()
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
// --- Selection & histogram rib state ---
|
||||||
const activeId = props.activeMessageId
|
const isSelectionActive = createMemo(() => (props.selectedIds?.().size ?? 0) > 0)
|
||||||
|
|
||||||
|
// Segments eligible for xray ribs. We intentionally exclude messages before
|
||||||
|
// the last compaction (when provided by the parent) to avoid misleading token
|
||||||
|
// weights for content that's no longer in context.
|
||||||
|
const xraySegments = createMemo(() => {
|
||||||
|
if (!isSelectionActive()) return [] as TimelineSegment[]
|
||||||
|
return props.segments.filter((segment) => isHistogramEligible(segment))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Stable layout offsets per badge (relative to scroll content), recomputed only
|
||||||
|
// on activation, resize, or expansion — NOT on every scroll frame.
|
||||||
|
const [badgeOffsets, setBadgeOffsets] = createSignal<Record<string, { layoutTop: number; height: number }>>({})
|
||||||
|
const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200)
|
||||||
|
let scrollContainerRef: HTMLDivElement | undefined
|
||||||
|
let xrayOverlayRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
// Full layout recomputation: reads every badge's getBoundingClientRect once,
|
||||||
|
// then stores offsets relative to the scroll content so they survive scrolling.
|
||||||
|
const computeBadgeLayout = () => {
|
||||||
|
if (!isSelectionActive() || !scrollContainerRef) return
|
||||||
|
const containerRect = scrollContainerRef.getBoundingClientRect()
|
||||||
|
const scrollTop = scrollContainerRef.scrollTop
|
||||||
|
const offsets: Record<string, { layoutTop: number; height: number }> = {}
|
||||||
|
|
||||||
|
for (const [id, element] of buttonRefs.entries()) {
|
||||||
|
if (!element) continue
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
// Store position relative to scroll content (survives scrolling).
|
||||||
|
offsets[id] = {
|
||||||
|
layoutTop: rect.top - containerRect.top + scrollTop,
|
||||||
|
height: rect.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBadgeOffsets(offsets)
|
||||||
|
if (xrayOverlayRef) {
|
||||||
|
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollTop}px`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
setWindowWidth(window.innerWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!isSelectionActive()) return
|
||||||
|
if (!scrollContainerRef || !xrayOverlayRef) return
|
||||||
|
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (isSelectionActive()) {
|
||||||
|
computeBadgeLayout()
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
// Deferred pass: tool segments become visible when selection activates,
|
||||||
|
// but they may need a layout pass before getBoundingClientRect is accurate.
|
||||||
|
requestAnimationFrame(computeBadgeLayout)
|
||||||
|
window.addEventListener("resize", computeBadgeLayout)
|
||||||
|
onCleanup(() => {
|
||||||
|
window.removeEventListener("resize", computeBadgeLayout)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Re-compute badge layout after expansion changes (tools become visible in DOM)
|
||||||
|
createEffect(() => {
|
||||||
|
props.expandedMessageIds?.()
|
||||||
|
if (isSelectionActive()) {
|
||||||
|
requestAnimationFrame(computeBadgeLayout)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const maxRibWidth = createMemo(() => Math.round(windowWidth() * 0.5))
|
||||||
|
|
||||||
|
// Compute fresh char counts from the store. segment.totalChars can be stale for
|
||||||
|
// tool parts whose output arrived after the timeline segment was first built.
|
||||||
|
const liveSegmentChars = createMemo(() => {
|
||||||
|
if (!isSelectionActive()) return {} as Record<string, number>
|
||||||
|
const result: Record<string, number> = {}
|
||||||
|
const resolvedStore = store()
|
||||||
|
|
||||||
|
// Compute live char counts by reading only the parts that the segment
|
||||||
|
// references (partIds/toolPartIds). This stays accurate for streamed tool
|
||||||
|
// outputs without scanning every part in the message.
|
||||||
|
for (const segment of xraySegments()) {
|
||||||
|
const record = resolvedStore.getMessage(segment.messageId)
|
||||||
|
if (!record) {
|
||||||
|
result[segment.id] = segment.totalChars
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = [...(segment.partIds ?? []), ...(segment.toolPartIds ?? [])]
|
||||||
|
let chars = 0
|
||||||
|
for (const partId of ids) {
|
||||||
|
const part = record.parts?.[partId]?.data
|
||||||
|
if (!part) continue
|
||||||
|
chars += getPartCharCount(part)
|
||||||
|
}
|
||||||
|
|
||||||
|
result[segment.id] = chars > 0 ? chars : segment.totalChars
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// Pre-compute aggregate tokens per message: O(n) once, O(1) per lookup.
|
||||||
|
// Avoids the previous O(n²) pattern of iterating all segments inside each <For> item.
|
||||||
|
const aggregateTokensByMessageId = createMemo(() => {
|
||||||
|
const chars = liveSegmentChars()
|
||||||
|
const result: Record<string, number> = {}
|
||||||
|
for (const s of xraySegments()) {
|
||||||
|
result[s.messageId] = (result[s.messageId] ?? 0) + (chars[s.id] ?? s.totalChars)
|
||||||
|
}
|
||||||
|
for (const id of Object.keys(result)) {
|
||||||
|
result[id] = Math.max(Math.round(result[id] / 4), 1)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const getSegmentTokens = (segment: TimelineSegment): number => {
|
||||||
|
const isExpanded = props.expandedMessageIds?.().has(segment.messageId) ?? false
|
||||||
|
// When tools are hidden (not expanded, not in selection mode), assistant/user
|
||||||
|
// bars show aggregate tokens for the whole message. When tools are visible
|
||||||
|
// (expanded or selection mode active), each segment shows its own tokens to
|
||||||
|
// avoid double-counting.
|
||||||
|
if (!isExpanded && !isSelectionActive() && (segment.type === "assistant" || segment.type === "user")) {
|
||||||
|
return aggregateTokensByMessageId()[segment.messageId] ?? 1
|
||||||
|
}
|
||||||
|
const chars = liveSegmentChars()[segment.id] ?? segment.totalChars
|
||||||
|
return Math.max(Math.round(chars / 4), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMessageAggregateTokens = (messageId: string): number => {
|
||||||
|
return aggregateTokensByMessageId()[messageId] ?? 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTokenLabel = (tokens: number): string => {
|
||||||
|
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
|
||||||
|
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
|
||||||
|
return String(tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxTokens = createMemo(() => {
|
||||||
|
let max = 0
|
||||||
|
for (const s of xraySegments()) {
|
||||||
|
const tokens = getSegmentTokens(s)
|
||||||
|
if (tokens > max) max = tokens
|
||||||
|
}
|
||||||
|
return Math.max(max, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Long-press for mobile selection ---
|
||||||
|
let longPressTimer: number | null = null
|
||||||
|
let wasLongPress = false
|
||||||
|
let pressStartPos = { x: 0, y: 0 }
|
||||||
|
|
||||||
|
const handlePointerDown = (segment: TimelineSegment, event: PointerEvent) => {
|
||||||
|
if (event.button !== 0) return
|
||||||
|
wasLongPress = false
|
||||||
|
pressStartPos = { x: event.clientX, y: event.clientY }
|
||||||
|
|
||||||
|
clearHoverTimer()
|
||||||
|
clearCloseTimer()
|
||||||
|
|
||||||
|
if (longPressTimer !== null && typeof window !== "undefined") {
|
||||||
|
window.clearTimeout(longPressTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
longPressTimer = window.setTimeout(() => {
|
||||||
|
longPressTimer = null
|
||||||
|
wasLongPress = true
|
||||||
|
|
||||||
|
// Scroll anchoring: preserve visual position of the pressed badge.
|
||||||
|
const btn = buttonRefs.get(segment.id)
|
||||||
|
let anchorOffset: number | null = null
|
||||||
|
if (btn && scrollContainerRef) {
|
||||||
|
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.onLongPressSelection) {
|
||||||
|
props.onLongPressSelection(segment)
|
||||||
|
} else {
|
||||||
|
props.onToggleSelection?.(segment.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anchorOffset !== null && btn && scrollContainerRef) {
|
||||||
|
const desired = btn.offsetTop - anchorOffset
|
||||||
|
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
|
||||||
|
scrollContainerRef.scrollTop = desired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, LONG_PRESS_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerUp = () => {
|
||||||
|
if (longPressTimer !== null && typeof window !== "undefined") {
|
||||||
|
window.clearTimeout(longPressTimer)
|
||||||
|
longPressTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerMove = (event: PointerEvent) => {
|
||||||
|
if (longPressTimer !== null) {
|
||||||
|
const dist = Math.sqrt(
|
||||||
|
Math.pow(event.clientX - pressStartPos.x, 2) +
|
||||||
|
Math.pow(event.clientY - pressStartPos.y, 2),
|
||||||
|
)
|
||||||
|
if (dist > JITTER_THRESHOLD) {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.clearTimeout(longPressTimer)
|
||||||
|
}
|
||||||
|
longPressTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleContextMenu = (event: MouseEvent) => {
|
||||||
|
if (wasLongPress) {
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(on(() => props.activeSegmentId, (activeId) => {
|
||||||
if (!activeId) return
|
if (!activeId) return
|
||||||
const targetSegment = props.segments.find((segment) => segment.messageId === activeId)
|
const element = buttonRefs.get(activeId)
|
||||||
if (!targetSegment) return
|
|
||||||
const element = buttonRefs.get(targetSegment.id)
|
|
||||||
if (!element) return
|
if (!element) return
|
||||||
const timer = typeof window !== "undefined" ? window.setTimeout(() => {
|
const timer = typeof window !== "undefined" ? window.setTimeout(() => {
|
||||||
element.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
element.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||||
@@ -366,7 +639,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
window.clearTimeout(timer)
|
window.clearTimeout(timer)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
}))
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const element = tooltipElement()
|
const element = tooltipElement()
|
||||||
@@ -383,92 +656,265 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const previewData = createMemo(() => {
|
const previewData = createMemo(() => {
|
||||||
|
|
||||||
const segment = hoveredSegment()
|
const segment = hoveredSegment()
|
||||||
if (!segment) return null
|
if (!segment) return null
|
||||||
const record = store().getMessage(segment.messageId)
|
const record = store().getMessage(segment.messageId)
|
||||||
if (!record) return null
|
if (!record) return null
|
||||||
return { messageId: segment.messageId }
|
return { messageId: segment.messageId }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Pre-computed set of messageIds that have at least one tool segment.
|
||||||
|
// Used by groupRole() inside <For> to avoid O(n) .some() per segment → O(1) .has().
|
||||||
|
const messagesWithTools = createMemo(() => {
|
||||||
|
const set = new Set<string>()
|
||||||
|
for (const s of props.segments) {
|
||||||
|
if (s.type === "tool") set.add(s.messageId)
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
})
|
||||||
|
|
||||||
|
// Pre-computed index map for session message ordering.
|
||||||
|
// Used by isDeleteHovered() to replace O(n) indexOf with O(1) Map.get().
|
||||||
|
const messageIdToSessionIndex = createMemo(() => {
|
||||||
|
const ids = store().getSessionMessageIds(props.sessionId)
|
||||||
|
const map = new Map<string, number>()
|
||||||
|
for (let i = 0; i < ids.length; i++) map.set(ids[i], i)
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="message-timeline" role="navigation" aria-label={t("messageTimeline.ariaLabel")}>
|
<div class="message-timeline-container">
|
||||||
<For each={props.segments}>
|
<div
|
||||||
{(segment) => {
|
ref={scrollContainerRef}
|
||||||
onCleanup(() => buttonRefs.delete(segment.id))
|
class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`}
|
||||||
const isActive = () => props.activeMessageId === segment.messageId
|
role="navigation"
|
||||||
|
aria-label={t("messageTimeline.ariaLabel")}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
>
|
||||||
|
<For each={props.segments}>
|
||||||
|
{(segment, segIndex) => {
|
||||||
|
onCleanup(() => buttonRefs.delete(segment.id))
|
||||||
|
const isActive = () => props.activeSegmentId === segment.id
|
||||||
|
const isSelected = () => props.selectedIds?.().has(segment.id)
|
||||||
|
|
||||||
const hasActivePermission = () => {
|
const isDeleteHovered = () => {
|
||||||
if (segment.type !== "tool") return false
|
const hover = deleteHover() as DeleteHoverState
|
||||||
const partIds = segment.toolPartIds ?? []
|
if (hover.kind === "message") {
|
||||||
if (partIds.length === 0) return false
|
return hover.messageId === segment.messageId
|
||||||
for (const partId of partIds) {
|
}
|
||||||
const permissionState = store().getPermissionState(segment.messageId, partId)
|
|
||||||
if (permissionState?.active) return true
|
if (hover.kind === "deleteUpTo") {
|
||||||
|
const indexMap = messageIdToSessionIndex()
|
||||||
|
const targetIndex = indexMap.get(hover.messageId)
|
||||||
|
if (targetIndex === undefined) return false
|
||||||
|
const segmentIndex = indexMap.get(segment.messageId)
|
||||||
|
if (segmentIndex === undefined) return false
|
||||||
|
return segmentIndex >= targetIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const isHidden = () => segment.type === "tool" && !(showTools() || isActive() || hasActivePermission())
|
const isDeleteSelected = () => {
|
||||||
|
const selected = props.selectedMessageIds?.()
|
||||||
|
if (!selected) return false
|
||||||
|
return selected.has(segment.messageId)
|
||||||
|
}
|
||||||
|
|
||||||
const shortLabelContent = () => {
|
const hasActivePermission = () => {
|
||||||
if (segment.type === "tool") {
|
if (segment.type !== "tool") return false
|
||||||
if (hasActivePermission()) {
|
const partIds = segment.toolPartIds ?? []
|
||||||
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
|
if (partIds.length === 0) return false
|
||||||
|
for (const partId of partIds) {
|
||||||
|
const permissionState = store().getPermissionState(segment.messageId, partId)
|
||||||
|
if (permissionState?.active) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExpanded = () => props.expandedMessageIds?.().has(segment.messageId) ?? false
|
||||||
|
const isHidden = () =>
|
||||||
|
segment.type === "tool" &&
|
||||||
|
!(showTools() || isExpanded() || isSelectionActive() || isActive() || hasActivePermission() || isDeleteHovered() || isDeleteSelected())
|
||||||
|
|
||||||
|
// Group visual indicators: tools belong to the same message as their
|
||||||
|
// assistant. Uses messageId for correctness (not positional adjacency).
|
||||||
|
const groupRole = (): "child" | "parent" | "none" => {
|
||||||
|
if (segment.type === "tool") return "child"
|
||||||
|
if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent"
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
const isGroupStart = () => {
|
||||||
|
if (segment.type !== "tool") return false
|
||||||
|
const idx = segIndex()
|
||||||
|
const prev = idx > 0 ? props.segments[idx - 1] : null
|
||||||
|
// First tool in the message's run: either nothing before, or previous
|
||||||
|
// segment is from a different message or is not a tool.
|
||||||
|
return !prev || prev.type !== "tool" || prev.messageId !== segment.messageId
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortLabelContent = () => {
|
||||||
|
if (segment.type === "tool") {
|
||||||
|
if (hasActivePermission()) {
|
||||||
|
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
|
||||||
|
}
|
||||||
|
return segment.shortLabel ?? getToolIcon("tool")
|
||||||
}
|
}
|
||||||
return segment.shortLabel ?? getToolIcon("tool")
|
if (segment.type === "compaction") {
|
||||||
|
return <FoldVertical class="message-timeline-icon" aria-hidden="true" />
|
||||||
|
}
|
||||||
|
if (segment.type === "user") {
|
||||||
|
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
|
||||||
|
}
|
||||||
|
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
|
||||||
}
|
}
|
||||||
if (segment.type === "compaction") {
|
|
||||||
return <FoldVertical class="message-timeline-icon" aria-hidden="true" />
|
|
||||||
}
|
|
||||||
if (segment.type === "user") {
|
|
||||||
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
|
|
||||||
}
|
|
||||||
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
ref={(el) => registerButtonRef(segment.id, el)}
|
ref={(el) => registerButtonRef(segment.id, el)}
|
||||||
type="button"
|
type="button"
|
||||||
data-variant={segment.variant}
|
data-variant={segment.variant}
|
||||||
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""}`}
|
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""} ${isGroupStart() ? "message-timeline-group-start" : ""}`}
|
||||||
|
|
||||||
aria-current={isActive() ? "true" : undefined}
|
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
|
||||||
aria-hidden={isHidden() ? "true" : undefined}
|
|
||||||
onClick={() => props.onSegmentClick?.(segment)}
|
aria-current={isActive() ? "true" : undefined}
|
||||||
onMouseEnter={(event) => handleMouseEnter(segment, event)}
|
aria-hidden={isHidden() ? "true" : undefined}
|
||||||
onMouseLeave={handleMouseLeave}
|
onClick={(event) => {
|
||||||
>
|
if (wasLongPress) {
|
||||||
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
|
wasLongPress = false
|
||||||
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
|
return
|
||||||
</button>
|
}
|
||||||
)
|
|
||||||
}}
|
// Capture scroll anchor before selection changes may toggle
|
||||||
</For>
|
// tool segment visibility, which shifts timeline layout.
|
||||||
<Show when={previewData()}>
|
const btn = buttonRefs.get(segment.id)
|
||||||
{(data) => {
|
let anchorOffset: number | null = null
|
||||||
onCleanup(() => setTooltipElement(null))
|
if (btn && scrollContainerRef) {
|
||||||
return (
|
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
|
||||||
<div
|
}
|
||||||
ref={(element) => setTooltipElement(element)}
|
|
||||||
class="message-timeline-tooltip"
|
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
|
||||||
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
|
|
||||||
onMouseEnter={() => clearCloseTimer()}
|
if (event.shiftKey) {
|
||||||
onMouseLeave={() => scheduleClose()}
|
props.onSelectRange?.(segment.id)
|
||||||
>
|
} else if (event.ctrlKey || event.metaKey) {
|
||||||
<MessagePreview
|
props.onToggleSelection?.(segment.id)
|
||||||
messageId={data().messageId}
|
} else if (isMultiSelectActive) {
|
||||||
instanceId={props.instanceId}
|
// In selection mode, plain click scrolls to the message
|
||||||
sessionId={props.sessionId}
|
// instead of clearing. Selection is cleared by clicking
|
||||||
store={store}
|
// anywhere inside the chat container or pressing Esc.
|
||||||
/>
|
props.onSegmentClick?.(segment)
|
||||||
</div>
|
} else {
|
||||||
)
|
props.onSegmentClick?.(segment)
|
||||||
}}
|
}
|
||||||
|
|
||||||
|
// Restore scroll anchor: keep the clicked badge at the same
|
||||||
|
// visual position after hidden tools appear or disappear.
|
||||||
|
if (anchorOffset !== null && btn && scrollContainerRef) {
|
||||||
|
const desired = btn.offsetTop - anchorOffset
|
||||||
|
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
|
||||||
|
scrollContainerRef.scrollTop = desired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => handlePointerDown(segment, e)}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerUp}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
onMouseEnter={(event) => handleMouseEnter(segment, event)}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
|
||||||
|
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
<Show when={previewData()}>
|
||||||
|
{(data) => {
|
||||||
|
onCleanup(() => setTooltipElement(null))
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={(element) => setTooltipElement(element)}
|
||||||
|
class="message-timeline-tooltip"
|
||||||
|
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
|
||||||
|
onMouseEnter={() => clearCloseTimer()}
|
||||||
|
onMouseLeave={() => scheduleClose()}
|
||||||
|
>
|
||||||
|
<MessagePreview
|
||||||
|
messageId={data().messageId}
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={props.sessionId}
|
||||||
|
store={store}
|
||||||
|
deleteHover={props.deleteHover}
|
||||||
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
|
selectedMessageIds={props.selectedMessageIds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={isSelectionActive()}>
|
||||||
|
<div
|
||||||
|
ref={(el) => {
|
||||||
|
xrayOverlayRef = el
|
||||||
|
if (xrayOverlayRef && scrollContainerRef) {
|
||||||
|
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="message-timeline-xray-overlay"
|
||||||
|
style={{ "--max-rib-width": `${maxRibWidth()}px` }}
|
||||||
|
>
|
||||||
|
<div class="message-timeline-xray-overlay-inner">
|
||||||
|
<For each={xraySegments()}>
|
||||||
|
{(segment) => {
|
||||||
|
const pos = () => {
|
||||||
|
const offset = badgeOffsets()[segment.id]
|
||||||
|
if (!offset) return null
|
||||||
|
return { top: offset.layoutTop + offset.height / 2 }
|
||||||
|
}
|
||||||
|
const tokens = () => getSegmentTokens(segment)
|
||||||
|
const relativeWeight = () => tokens() / maxTokens()
|
||||||
|
const absoluteWeight = () => Math.min(tokens() / ABSOLUTE_TOKEN_CAP, 1.0)
|
||||||
|
const isOverflow = () => tokens() > ABSOLUTE_TOKEN_CAP
|
||||||
|
const isParent = segment.type === "assistant" || segment.type === "user"
|
||||||
|
const displayTokens = () =>
|
||||||
|
isParent ? getMessageAggregateTokens(segment.messageId) : tokens()
|
||||||
|
return (
|
||||||
|
<Show when={pos()}>
|
||||||
|
<div
|
||||||
|
class="message-timeline-xray-rib"
|
||||||
|
style={{
|
||||||
|
top: `${pos()!.top}px`,
|
||||||
|
left: "var(--xray-overhang)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="message-timeline-xray-token-label">
|
||||||
|
{formatTokenLabel(displayTokens())}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="message-timeline-relative-bar"
|
||||||
|
style={{ "--segment-weight": relativeWeight() }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class={`message-timeline-absolute-bar${isOverflow() ? " message-timeline-absolute-bar-overflow" : ""}`}
|
||||||
|
style={{ "--segment-weight": absoluteWeight() }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MessageTimeline
|
export default MessageTimeline
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import UnifiedPicker from "./unified-picker"
|
|||||||
import ExpandButton from "./expand-button"
|
import ExpandButton from "./expand-button"
|
||||||
import { clearAttachments, removeAttachment } from "../stores/attachments"
|
import { clearAttachments, removeAttachment } from "../stores/attachments"
|
||||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||||
|
import { createPastedPlaceholderRegex, pastedDisplayCounterRegex } from "./prompt-input/attachmentPlaceholders"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import { getActiveInstance } from "../stores/instances"
|
import { getActiveInstance } from "../stores/instances"
|
||||||
import { agents, executeCustomCommand } from "../stores/sessions"
|
import { agents, executeCustomCommand } from "../stores/sessions"
|
||||||
@@ -13,12 +14,41 @@ import { useI18n } from "../lib/i18n"
|
|||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { preferences } from "../stores/preferences"
|
import { preferences } from "../stores/preferences"
|
||||||
import type { ExpandState, PromptInputApi, PromptInputProps, PromptInsertMode, PromptMode } from "./prompt-input/types"
|
import type { ExpandState, PromptInputApi, PromptInputProps, PromptInsertMode, PromptMode } from "./prompt-input/types"
|
||||||
|
import type { Attachment } from "../types/attachment"
|
||||||
import { usePromptState } from "./prompt-input/usePromptState"
|
import { usePromptState } from "./prompt-input/usePromptState"
|
||||||
import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
|
import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
|
||||||
import { usePromptPicker } from "./prompt-input/usePromptPicker"
|
import { usePromptPicker } from "./prompt-input/usePromptPicker"
|
||||||
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
|
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
|
function getConsumedPastedTextAttachmentIds(text: string, attachments: Attachment[]): string[] {
|
||||||
|
if (!text || attachments.length === 0) return []
|
||||||
|
|
||||||
|
const usedCounters = new Set<string>()
|
||||||
|
for (const match of text.matchAll(createPastedPlaceholderRegex())) {
|
||||||
|
const counter = match?.[1]
|
||||||
|
if (counter) usedCounters.add(counter)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usedCounters.size === 0) return []
|
||||||
|
|
||||||
|
const consumed = new Set<string>()
|
||||||
|
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
if (!attachment?.id) continue
|
||||||
|
if (attachment?.source?.type !== "text") continue
|
||||||
|
const display = attachment.display
|
||||||
|
if (typeof display !== "string") continue
|
||||||
|
const match = display.match(pastedDisplayCounterRegex)
|
||||||
|
if (!match?.[1]) continue
|
||||||
|
if (usedCounters.has(match[1])) {
|
||||||
|
consumed.add(attachment.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(consumed)
|
||||||
|
}
|
||||||
|
|
||||||
export default function PromptInput(props: PromptInputProps) {
|
export default function PromptInput(props: PromptInputProps) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [, setIsFocused] = createSignal(false)
|
const [, setIsFocused] = createSignal(false)
|
||||||
@@ -246,7 +276,12 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
commandName.length > 0 &&
|
commandName.length > 0 &&
|
||||||
getCommands(props.instanceId).some((cmd) => cmd.name === commandName)
|
getCommands(props.instanceId).some((cmd) => cmd.name === commandName)
|
||||||
|
|
||||||
const resolvedPrompt = isKnownSlashCommand ? text : resolvePastedPlaceholders(text, currentAttachments)
|
const resolvedCommandArgs = isKnownSlashCommand ? resolvePastedPlaceholders(commandArgs, currentAttachments) : ""
|
||||||
|
const resolvedPrompt = isKnownSlashCommand
|
||||||
|
? resolvedCommandArgs
|
||||||
|
? `${commandToken} ${resolvedCommandArgs}`
|
||||||
|
: commandToken
|
||||||
|
: resolvePastedPlaceholders(text, currentAttachments)
|
||||||
const historyEntry = resolvedPrompt
|
const historyEntry = resolvedPrompt
|
||||||
|
|
||||||
const refreshHistory = () => recordHistoryEntry(historyEntry)
|
const refreshHistory = () => recordHistoryEntry(historyEntry)
|
||||||
@@ -262,6 +297,10 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
syncAttachmentCounters("")
|
syncAttachmentCounters("")
|
||||||
setIgnoredAtPositions(new Set<number>())
|
setIgnoredAtPositions(new Set<number>())
|
||||||
} else {
|
} else {
|
||||||
|
const consumedIds = getConsumedPastedTextAttachmentIds(commandArgs, currentAttachments)
|
||||||
|
for (const attachmentId of consumedIds) {
|
||||||
|
removeAttachment(props.instanceId, props.sessionId, attachmentId)
|
||||||
|
}
|
||||||
syncAttachmentCounters("")
|
syncAttachmentCounters("")
|
||||||
setIgnoredAtPositions(new Set<number>())
|
setIgnoredAtPositions(new Set<number>())
|
||||||
}
|
}
|
||||||
@@ -281,7 +320,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
await props.onSend(resolvedPrompt, [])
|
await props.onSend(resolvedPrompt, [])
|
||||||
}
|
}
|
||||||
} else if (isKnownSlashCommand) {
|
} else if (isKnownSlashCommand) {
|
||||||
await executeCustomCommand(props.instanceId, props.sessionId, commandName, commandArgs)
|
await executeCustomCommand(props.instanceId, props.sessionId, commandName, resolvedCommandArgs)
|
||||||
} else {
|
} else {
|
||||||
await props.onSend(resolvedPrompt, currentAttachments)
|
await props.onSend(resolvedPrompt, currentAttachments)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { getAttachments, removeAttachment } from "../../stores/attachments"
|
|||||||
import { instances } from "../../stores/instances"
|
import { instances } from "../../stores/instances"
|
||||||
import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
|
import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
|
||||||
import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status"
|
import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status"
|
||||||
|
import { deleteMessage } from "../../stores/session-actions"
|
||||||
import { showAlertDialog } from "../../stores/alerts"
|
import { showAlertDialog } from "../../stores/alerts"
|
||||||
import { getLogger } from "../../lib/logger"
|
import { getLogger } from "../../lib/logger"
|
||||||
import { requestData } from "../../lib/opencode-api"
|
import { requestData } from "../../lib/opencode-api"
|
||||||
@@ -55,12 +56,22 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
|
|
||||||
const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId))
|
const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId))
|
||||||
|
|
||||||
|
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
|
||||||
|
|
||||||
let promptInputApi: PromptInputApi | null = null
|
let promptInputApi: PromptInputApi | null = null
|
||||||
let pendingPromptText: string | null = null
|
let pendingPromptText: string | null = null
|
||||||
let pendingSelectionInsert: { text: string; mode: PromptInsertMode } | null = null
|
let pendingSelectionInsert: { text: string; mode: PromptInsertMode } | null = null
|
||||||
|
|
||||||
let scrollToBottomHandle: (() => void) | undefined
|
let scrollToBottomHandle: (() => void) | undefined
|
||||||
let rootRef: HTMLDivElement | undefined
|
let rootRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
function shouldScrollToBottomOnActivate() {
|
||||||
|
const current = session()
|
||||||
|
if (!current) return true
|
||||||
|
const snapshot = messageStore().getScrollSnapshot(current.id, MESSAGE_SCROLL_CACHE_SCOPE)
|
||||||
|
return !snapshot || snapshot.atBottom
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleScrollToBottom() {
|
function scheduleScrollToBottom() {
|
||||||
if (!scrollToBottomHandle) return
|
if (!scrollToBottomHandle) return
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -69,6 +80,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!props.isActive) return
|
if (!props.isActive) return
|
||||||
|
if (!shouldScrollToBottomOnActivate()) return
|
||||||
scheduleScrollToBottom()
|
scheduleScrollToBottom()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -225,6 +237,35 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDeleteMessagesUpTo(messageId: string) {
|
||||||
|
const ids = messageStore().getSessionMessageIds(props.sessionId)
|
||||||
|
const index = ids.indexOf(messageId)
|
||||||
|
if (index === -1) return
|
||||||
|
|
||||||
|
const restoredText = getUserMessageText(messageId)
|
||||||
|
const toDelete = ids.slice(index)
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let idx = toDelete.length - 1; idx >= 0; idx -= 1) {
|
||||||
|
await deleteMessage(props.instanceId, props.sessionId, toDelete[idx])
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to delete messages up to", error)
|
||||||
|
showAlertDialog(t("sessionView.alerts.deleteUpToFailed.message"), {
|
||||||
|
title: t("sessionView.alerts.deleteUpToFailed.title"),
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
if (restoredText) {
|
||||||
|
if (promptInputApi) {
|
||||||
|
promptInputApi.setPromptText(restoredText, { focus: true })
|
||||||
|
} else {
|
||||||
|
pendingPromptText = restoredText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleFork(messageId?: string) {
|
async function handleFork(messageId?: string) {
|
||||||
if (!messageId) {
|
if (!messageId) {
|
||||||
log.warn("Fork requires a user message id")
|
log.warn("Fork requires a user message id")
|
||||||
@@ -283,14 +324,17 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
<MessageSection
|
<MessageSection
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={activeSession.id}
|
sessionId={activeSession.id}
|
||||||
loading={messagesLoading()}
|
loading={messagesLoading()}
|
||||||
onRevert={handleRevert}
|
onRevert={handleRevert}
|
||||||
onFork={handleFork}
|
onDeleteMessagesUpTo={handleDeleteMessagesUpTo}
|
||||||
isActive={props.isActive}
|
onFork={handleFork}
|
||||||
|
isActive={props.isActive}
|
||||||
registerScrollToBottom={(fn) => {
|
registerScrollToBottom={(fn) => {
|
||||||
scrollToBottomHandle = fn
|
scrollToBottomHandle = fn
|
||||||
if (props.isActive) {
|
if (props.isActive) {
|
||||||
scheduleScrollToBottom()
|
if (shouldScrollToBottomOnActivate()) {
|
||||||
|
scheduleScrollToBottom()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
|||||||
107
packages/ui/src/components/settings-screen.tsx
Normal file
107
packages/ui/src/components/settings-screen.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
|
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, X } from "lucide-solid"
|
||||||
|
import { createMemo, For, type Component } from "solid-js"
|
||||||
|
import { useI18n } from "../lib/i18n"
|
||||||
|
import {
|
||||||
|
activeSettingsSection,
|
||||||
|
closeSettings,
|
||||||
|
settingsOpen,
|
||||||
|
setActiveSettingsSection,
|
||||||
|
type SettingsSectionId,
|
||||||
|
} from "../stores/settings-screen"
|
||||||
|
import { AppearanceSettingsSection } from "./settings/appearance-settings-section"
|
||||||
|
import { NotificationsSettingsSection } from "./settings/notifications-settings-section"
|
||||||
|
import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
|
||||||
|
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
|
||||||
|
|
||||||
|
export const SettingsScreen: Component = () => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const sections = createMemo(() => [
|
||||||
|
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
|
||||||
|
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
|
||||||
|
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
|
||||||
|
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
|
||||||
|
])
|
||||||
|
|
||||||
|
const renderSection = () => {
|
||||||
|
switch (activeSettingsSection()) {
|
||||||
|
case "notifications":
|
||||||
|
return <NotificationsSettingsSection />
|
||||||
|
case "remote":
|
||||||
|
return <RemoteAccessSettingsSection />
|
||||||
|
case "opencode":
|
||||||
|
return <OpenCodeSettingsSection />
|
||||||
|
case "appearance":
|
||||||
|
default:
|
||||||
|
return <AppearanceSettingsSection />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={settingsOpen()} onOpenChange={(open) => !open && closeSettings()}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
|
<div class="settings-screen-frame">
|
||||||
|
<Dialog.Content class="modal-surface settings-screen-shell">
|
||||||
|
<Dialog.Title class="sr-only">{t("settings.title")}</Dialog.Title>
|
||||||
|
|
||||||
|
<aside class="settings-screen-nav">
|
||||||
|
<div class="settings-screen-nav-header">
|
||||||
|
<div class="settings-screen-nav-title-row">
|
||||||
|
<span class="settings-screen-nav-icon-wrap">
|
||||||
|
<Settings class="settings-screen-nav-icon" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 class="settings-screen-title">{t("settings.title")}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="settings-screen-nav-list" aria-label={t("settings.navigationAriaLabel")}>
|
||||||
|
<For each={sections()}>
|
||||||
|
{(section) => {
|
||||||
|
const Icon = section.icon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="settings-nav-button"
|
||||||
|
data-selected={activeSettingsSection() === section.id ? "true" : "false"}
|
||||||
|
onClick={() => setActiveSettingsSection(section.id)}
|
||||||
|
>
|
||||||
|
<Icon class="settings-nav-button-icon" />
|
||||||
|
<span>{section.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="settings-screen-content">
|
||||||
|
<header class="settings-screen-content-header">
|
||||||
|
<div class="settings-screen-content-header-title-group">
|
||||||
|
<p class="settings-screen-content-eyebrow">{t("settings.content.eyebrow")}</p>
|
||||||
|
<h1 class="settings-screen-content-title">
|
||||||
|
{sections().find((section) => section.id === activeSettingsSection())?.label}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-secondary settings-screen-close"
|
||||||
|
onClick={closeSettings}
|
||||||
|
aria-label={t("settings.close")}
|
||||||
|
title={t("settings.close")}
|
||||||
|
>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="settings-screen-scroll">{renderSection()}</div>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</div>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { Select } from "@kobalte/core/select"
|
||||||
|
import { createEffect, createMemo, createSignal, For, type Component } from "solid-js"
|
||||||
|
import { Check, ChevronDown, Laptop, Moon, Sun } from "lucide-solid"
|
||||||
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
import { useTheme, type ThemeMode } from "../../lib/theme"
|
||||||
|
import { useConfig } from "../../stores/preferences"
|
||||||
|
import { getBehaviorSettings, type BehaviorSetting } from "../../lib/settings/behavior-registry"
|
||||||
|
|
||||||
|
const themeModeOptions: Array<{ value: ThemeMode; icon: typeof Laptop }> = [
|
||||||
|
{ value: "system", icon: Laptop },
|
||||||
|
{ value: "light", icon: Sun },
|
||||||
|
{ value: "dark", icon: Moon },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const AppearanceSettingsSection: Component = () => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { themeMode, setThemeMode } = useTheme()
|
||||||
|
const {
|
||||||
|
preferences,
|
||||||
|
updatePreferences,
|
||||||
|
toggleShowThinkingBlocks,
|
||||||
|
toggleKeyboardShortcutHints,
|
||||||
|
toggleShowTimelineTools,
|
||||||
|
toggleUsageMetrics,
|
||||||
|
toggleAutoCleanupBlankSessions,
|
||||||
|
togglePromptSubmitOnEnter,
|
||||||
|
setDiffViewMode,
|
||||||
|
setToolOutputExpansion,
|
||||||
|
setDiagnosticsExpansion,
|
||||||
|
setThinkingBlocksExpansion,
|
||||||
|
setToolInputsVisibility,
|
||||||
|
} = useConfig()
|
||||||
|
|
||||||
|
const behaviorSettings = createMemo(() =>
|
||||||
|
getBehaviorSettings({
|
||||||
|
preferences,
|
||||||
|
updatePreferences,
|
||||||
|
toggleShowThinkingBlocks,
|
||||||
|
toggleKeyboardShortcutHints,
|
||||||
|
toggleShowTimelineTools,
|
||||||
|
toggleUsageMetrics,
|
||||||
|
toggleAutoCleanupBlankSessions,
|
||||||
|
togglePromptSubmitOnEnter,
|
||||||
|
setDiffViewMode,
|
||||||
|
setToolOutputExpansion,
|
||||||
|
setDiagnosticsExpansion,
|
||||||
|
setThinkingBlocksExpansion,
|
||||||
|
setToolInputsVisibility,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const [overrides, setOverrides] = createSignal<Map<string, unknown>>(new Map())
|
||||||
|
|
||||||
|
const setOverride = (id: string, value: unknown) => {
|
||||||
|
setOverrides((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.set(id, value)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const current = overrides()
|
||||||
|
if (current.size === 0) return
|
||||||
|
|
||||||
|
const prefs = preferences()
|
||||||
|
const settings = behaviorSettings()
|
||||||
|
|
||||||
|
let changed = false
|
||||||
|
const next = new Map(current)
|
||||||
|
for (const setting of settings) {
|
||||||
|
if (!next.has(setting.id)) continue
|
||||||
|
const overrideValue = next.get(setting.id)
|
||||||
|
const actualValue = setting.get(prefs)
|
||||||
|
if (Object.is(actualValue, overrideValue)) {
|
||||||
|
next.delete(setting.id)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
setOverrides(next)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const readSettingValue = (setting: BehaviorSetting) => {
|
||||||
|
const current = overrides()
|
||||||
|
if (current.has(setting.id)) return current.get(setting.id)
|
||||||
|
return setting.get(preferences())
|
||||||
|
}
|
||||||
|
|
||||||
|
type SelectOption = { value: string; label: string }
|
||||||
|
|
||||||
|
const BehaviorRow: Component<{ setting: BehaviorSetting }> = (props) => {
|
||||||
|
const setting = props.setting
|
||||||
|
const disabled = createMemo(() => (setting.disabled ? Boolean(setting.disabled()) : false))
|
||||||
|
|
||||||
|
if (setting.kind === "toggle") {
|
||||||
|
const options = createMemo<SelectOption[]>(() => [
|
||||||
|
{ value: "true", label: t("settings.common.enabled") },
|
||||||
|
{ value: "false", label: t("settings.common.disabled") },
|
||||||
|
])
|
||||||
|
const currentValue = createMemo(() => String(Boolean(readSettingValue(setting))))
|
||||||
|
const selectedOption = createMemo(() => options().find((opt) => opt.value === currentValue()))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={`settings-toggle-row ${disabled() ? "opacity-60" : ""}`}>
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{t(setting.titleKey)}</div>
|
||||||
|
<div class="settings-toggle-caption">{t(setting.subtitleKey)}</div>
|
||||||
|
</div>
|
||||||
|
<Select<SelectOption>
|
||||||
|
value={selectedOption()}
|
||||||
|
onChange={(opt) => {
|
||||||
|
if (!opt) return
|
||||||
|
const next = opt.value === "true"
|
||||||
|
setOverride(setting.id, next)
|
||||||
|
setting.set(next)
|
||||||
|
}}
|
||||||
|
options={options()}
|
||||||
|
optionValue="value"
|
||||||
|
optionTextValue="label"
|
||||||
|
disabled={disabled()}
|
||||||
|
itemComponent={(itemProps) => (
|
||||||
|
<Select.Item item={itemProps.item} class="selector-option">
|
||||||
|
<Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Select.ItemLabel>
|
||||||
|
</Select.Item>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="selector-trigger" aria-label={t(setting.titleKey)}>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<Select.Value<SelectOption>>
|
||||||
|
{(state) => (
|
||||||
|
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||||
|
{state.selectedOption()?.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Select.Value>
|
||||||
|
</div>
|
||||||
|
<Select.Icon class="selector-trigger-icon">
|
||||||
|
<ChevronDown class="w-3 h-3" />
|
||||||
|
</Select.Icon>
|
||||||
|
</Select.Trigger>
|
||||||
|
|
||||||
|
<Select.Portal>
|
||||||
|
<Select.Content class="selector-popover">
|
||||||
|
<Select.Listbox class="selector-listbox" />
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Portal>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const enumSetting = setting as Extract<BehaviorSetting, { kind: "enum" }>
|
||||||
|
const options = createMemo<SelectOption[]>(() =>
|
||||||
|
enumSetting.options.map((opt: { value: string; labelKey: string }) => ({
|
||||||
|
value: String(opt.value),
|
||||||
|
label: t(opt.labelKey),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const currentValue = createMemo(() => String(readSettingValue(setting) ?? ""))
|
||||||
|
const selectedOption = createMemo(() => options().find((opt) => opt.value === currentValue()))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={`settings-toggle-row ${disabled() ? "opacity-60" : ""}`}>
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{t(setting.titleKey)}</div>
|
||||||
|
<div class="settings-toggle-caption">{t(setting.subtitleKey)}</div>
|
||||||
|
</div>
|
||||||
|
<Select<SelectOption>
|
||||||
|
value={selectedOption()}
|
||||||
|
onChange={(opt) => {
|
||||||
|
if (!opt) return
|
||||||
|
setOverride(setting.id, opt.value)
|
||||||
|
enumSetting.set(opt.value as any)
|
||||||
|
}}
|
||||||
|
options={options()}
|
||||||
|
optionValue="value"
|
||||||
|
optionTextValue="label"
|
||||||
|
disabled={disabled()}
|
||||||
|
itemComponent={(itemProps) => (
|
||||||
|
<Select.Item item={itemProps.item} class="selector-option">
|
||||||
|
<Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Select.ItemLabel>
|
||||||
|
</Select.Item>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="selector-trigger" aria-label={t(setting.titleKey)}>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<Select.Value<SelectOption>>
|
||||||
|
{(state) => (
|
||||||
|
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||||
|
{state.selectedOption()?.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Select.Value>
|
||||||
|
</div>
|
||||||
|
<Select.Icon class="selector-trigger-icon">
|
||||||
|
<ChevronDown class="w-3 h-3" />
|
||||||
|
</Select.Icon>
|
||||||
|
</Select.Trigger>
|
||||||
|
|
||||||
|
<Select.Portal>
|
||||||
|
<Select.Content class="selector-popover">
|
||||||
|
<Select.Listbox class="selector-listbox" />
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Portal>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const modeLabel = (mode: ThemeMode) => {
|
||||||
|
if (mode === "system") return t("theme.mode.system")
|
||||||
|
if (mode === "light") return t("theme.mode.light")
|
||||||
|
return t("theme.mode.dark")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="settings-section-stack">
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="settings-card-title">{t("settings.appearance.theme.title")}</h3>
|
||||||
|
<p class="settings-card-subtitle">{t("settings.appearance.theme.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<span class="settings-scope-badge">{t("settings.scope.device")}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-choice-grid">
|
||||||
|
{themeModeOptions.map((option) => {
|
||||||
|
const Icon = option.icon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="settings-choice"
|
||||||
|
data-selected={themeMode() === option.value ? "true" : "false"}
|
||||||
|
onClick={() => setThemeMode(option.value)}
|
||||||
|
>
|
||||||
|
<span class="settings-choice-icon-wrap">
|
||||||
|
<Icon class="settings-choice-icon" />
|
||||||
|
</span>
|
||||||
|
<span class="settings-choice-copy">
|
||||||
|
<span class="settings-choice-label">{modeLabel(option.value)}</span>
|
||||||
|
<span class="settings-choice-description">{t(`settings.appearance.theme.option.${option.value}`)}</span>
|
||||||
|
</span>
|
||||||
|
<span class="settings-choice-check" aria-hidden="true">
|
||||||
|
<Check class="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="settings-card-title">{t("settings.appearance.behavior.title")}</h3>
|
||||||
|
<p class="settings-card-subtitle">{t("settings.appearance.behavior.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<span class="settings-scope-badge">{t("settings.scope.device")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-stack">
|
||||||
|
<For each={behaviorSettings()}>{(setting) => <BehaviorRow setting={setting} />}</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { Show, createEffect, createResource, type Component } from "solid-js"
|
||||||
|
import { Bell } from "lucide-solid"
|
||||||
|
import { showToastNotification } from "../../lib/notifications"
|
||||||
|
import {
|
||||||
|
getOsNotificationCapability,
|
||||||
|
requestOsNotificationPermission,
|
||||||
|
type OsNotificationPermission,
|
||||||
|
} from "../../lib/os-notifications"
|
||||||
|
import { useConfig } from "../../stores/preferences"
|
||||||
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
|
||||||
|
function formatPermissionLabel(permission: OsNotificationPermission, t: ReturnType<typeof useI18n>["t"]): string {
|
||||||
|
switch (permission) {
|
||||||
|
case "granted":
|
||||||
|
return t("settings.notifications.permission.granted")
|
||||||
|
case "denied":
|
||||||
|
return t("settings.notifications.permission.denied")
|
||||||
|
case "default":
|
||||||
|
return t("settings.notifications.permission.default")
|
||||||
|
case "unsupported":
|
||||||
|
return t("settings.notifications.permission.unsupported")
|
||||||
|
default:
|
||||||
|
return String(permission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NotificationsSettingsSection: Component = () => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { preferences, updatePreferences } = useConfig()
|
||||||
|
const [capability, { refetch }] = createResource(() => getOsNotificationCapability())
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
void refetch()
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleEnableToggle = async (enabled: boolean) => {
|
||||||
|
if (!enabled) {
|
||||||
|
updatePreferences({ osNotificationsEnabled: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const cap = capability()
|
||||||
|
if (cap && !cap.supported) {
|
||||||
|
showToastNotification({
|
||||||
|
title: t("settings.section.notifications.title"),
|
||||||
|
message: cap.info ?? t("settings.notifications.messages.unsupportedEnvironment"),
|
||||||
|
variant: "warning",
|
||||||
|
})
|
||||||
|
updatePreferences({ osNotificationsEnabled: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await requestOsNotificationPermission()
|
||||||
|
if (permission !== "granted") {
|
||||||
|
showToastNotification({
|
||||||
|
title: t("settings.section.notifications.title"),
|
||||||
|
message:
|
||||||
|
permission === "denied"
|
||||||
|
? t("settings.notifications.messages.permissionDenied")
|
||||||
|
: t("settings.notifications.messages.permissionNotGranted"),
|
||||||
|
variant: "warning",
|
||||||
|
})
|
||||||
|
updatePreferences({ osNotificationsEnabled: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePreferences({ osNotificationsEnabled: true })
|
||||||
|
void refetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRequestPermission = async () => {
|
||||||
|
const cap = capability()
|
||||||
|
if (cap && !cap.supported) {
|
||||||
|
showToastNotification({
|
||||||
|
title: t("settings.section.notifications.title"),
|
||||||
|
message: cap.info ?? t("settings.notifications.messages.unsupportedGeneral"),
|
||||||
|
variant: "warning",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await requestOsNotificationPermission()
|
||||||
|
if (permission === "granted") {
|
||||||
|
showToastNotification({
|
||||||
|
title: t("settings.section.notifications.title"),
|
||||||
|
message: t("settings.notifications.messages.permissionGranted"),
|
||||||
|
variant: "success",
|
||||||
|
duration: 6000,
|
||||||
|
})
|
||||||
|
void refetch()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showToastNotification({
|
||||||
|
title: t("settings.section.notifications.title"),
|
||||||
|
message:
|
||||||
|
permission === "denied"
|
||||||
|
? t("settings.notifications.messages.permissionRequestDenied")
|
||||||
|
: t("settings.notifications.messages.permissionNotGranted"),
|
||||||
|
variant: "warning",
|
||||||
|
})
|
||||||
|
void refetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
const supported = () => capability()?.supported ?? false
|
||||||
|
const permissionLabel = () => formatPermissionLabel(capability()?.permission ?? "unsupported", t)
|
||||||
|
const infoMessage = () => capability()?.info
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="settings-section-stack">
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div class="settings-card-heading-with-icon">
|
||||||
|
<Bell class="settings-card-heading-icon" />
|
||||||
|
<div>
|
||||||
|
<h3 class="settings-card-title">{t("settings.notifications.sessionStatus.title")}</h3>
|
||||||
|
<p class="settings-card-subtitle">{t("settings.notifications.sessionStatus.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="settings-scope-badge">{t("settings.scope.device")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-stack">
|
||||||
|
<div class="settings-toggle-row">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{t("settings.notifications.enable.title")}</div>
|
||||||
|
<div class="settings-toggle-caption">
|
||||||
|
{t("settings.notifications.enable.permission", { permission: permissionLabel() })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="settings-checkbox-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(preferences().osNotificationsEnabled)}
|
||||||
|
disabled={!supported() && capability.state === "ready"}
|
||||||
|
onChange={(event) => void handleEnableToggle(event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
<span>{t("settings.common.enabled")}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={supported() && (capability()?.permission ?? "unsupported") !== "granted"}>
|
||||||
|
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{t("settings.notifications.requestPermission.title")}</div>
|
||||||
|
<div class="settings-toggle-caption">{t("settings.notifications.requestPermission.subtitle")}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-secondary w-auto whitespace-nowrap"
|
||||||
|
onClick={() => void handleRequestPermission()}
|
||||||
|
>
|
||||||
|
{t("settings.notifications.requestPermission.action")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="settings-toggle-row">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{t("settings.notifications.allowVisible.title")}</div>
|
||||||
|
<div class="settings-toggle-caption">{t("settings.notifications.allowVisible.subtitle")}</div>
|
||||||
|
</div>
|
||||||
|
<label class="settings-checkbox-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(preferences().osNotificationsAllowWhenVisible)}
|
||||||
|
disabled={!preferences().osNotificationsEnabled}
|
||||||
|
onChange={(event) => updatePreferences({ osNotificationsAllowWhenVisible: event.currentTarget.checked })}
|
||||||
|
/>
|
||||||
|
<span>{t("settings.common.enabled")}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={Boolean(infoMessage())}>
|
||||||
|
<div class="settings-inline-note">{infoMessage()}</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!supported() && capability.state === "ready"}>
|
||||||
|
<div class="settings-inline-note">{t("settings.notifications.unsupportedNote")}</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="settings-card-title">{t("settings.notifications.events.title")}</h3>
|
||||||
|
<p class="settings-card-subtitle">{t("settings.notifications.events.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<span class="settings-scope-badge">{t("settings.scope.device")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-stack">
|
||||||
|
<div class="settings-toggle-row">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{t("settings.notifications.events.needsInput")}</div>
|
||||||
|
</div>
|
||||||
|
<label class="settings-checkbox-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(preferences().notifyOnNeedsInput)}
|
||||||
|
disabled={!preferences().osNotificationsEnabled}
|
||||||
|
onChange={(event) => updatePreferences({ notifyOnNeedsInput: event.currentTarget.checked })}
|
||||||
|
/>
|
||||||
|
<span>{t("settings.common.enabled")}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-toggle-row">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{t("settings.notifications.events.idle")}</div>
|
||||||
|
</div>
|
||||||
|
<label class="settings-checkbox-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(preferences().notifyOnIdle)}
|
||||||
|
disabled={!preferences().osNotificationsEnabled}
|
||||||
|
onChange={(event) => updatePreferences({ notifyOnIdle: event.currentTarget.checked })}
|
||||||
|
/>
|
||||||
|
<span>{t("settings.common.enabled")}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { createEffect, createSignal, type Component } from "solid-js"
|
||||||
|
import { Terminal } from "lucide-solid"
|
||||||
|
import OpenCodeBinarySelector from "../opencode-binary-selector"
|
||||||
|
import EnvironmentVariablesEditor from "../environment-variables-editor"
|
||||||
|
import { useConfig } from "../../stores/preferences"
|
||||||
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
|
||||||
|
export const OpenCodeSettingsSection: Component = () => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { serverSettings, updateLastUsedBinary } = useConfig()
|
||||||
|
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const binary = serverSettings().opencodeBinary || "opencode"
|
||||||
|
setSelectedBinary((current) => (current === binary ? current : binary))
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleBinaryChange = (binary: string) => {
|
||||||
|
setSelectedBinary(binary)
|
||||||
|
updateLastUsedBinary(binary)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="settings-section-stack">
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div class="settings-card-heading-with-icon">
|
||||||
|
<Terminal class="settings-card-heading-icon" />
|
||||||
|
<div>
|
||||||
|
<h3 class="settings-card-title">{t("settings.opencode.runtime.title")}</h3>
|
||||||
|
<p class="settings-card-subtitle">{t("settings.opencode.runtime.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OpenCodeBinarySelector selectedBinary={selectedBinary()} onBinaryChange={handleBinaryChange} isVisible />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="settings-card-title">{t("advancedSettings.environmentVariables.title")}</h3>
|
||||||
|
<p class="settings-card-subtitle">{t("advancedSettings.environmentVariables.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
||||||
|
</div>
|
||||||
|
<EnvironmentVariablesEditor />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
import { Switch } from "@kobalte/core/switch"
|
||||||
|
import { For, Show, createMemo, createSignal, type Component, onMount } from "solid-js"
|
||||||
|
import { toDataURL } from "qrcode"
|
||||||
|
import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
||||||
|
import type { NetworkAddress, ServerMeta } from "../../../../server/src/api-types"
|
||||||
|
import { serverApi } from "../../lib/api-client"
|
||||||
|
import { restartCli } from "../../lib/native/cli"
|
||||||
|
import { serverSettings, setListeningMode } from "../../stores/preferences"
|
||||||
|
import { showConfirmDialog } from "../../stores/alerts"
|
||||||
|
import { getLogger } from "../../lib/logger"
|
||||||
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
|
||||||
|
const log = getLogger("actions")
|
||||||
|
|
||||||
|
export const RemoteAccessSettingsSection: Component = () => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
|
||||||
|
const [authStatus, setAuthStatus] = createSignal<{
|
||||||
|
authenticated: boolean
|
||||||
|
username?: string
|
||||||
|
passwordUserProvided?: boolean
|
||||||
|
} | null>(null)
|
||||||
|
const [loading, setLoading] = createSignal(false)
|
||||||
|
const [applyingListeningMode, setApplyingListeningMode] = createSignal(false)
|
||||||
|
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
|
||||||
|
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
|
||||||
|
const [error, setError] = createSignal<string | null>(null)
|
||||||
|
const [passwordFormOpen, setPasswordFormOpen] = createSignal(false)
|
||||||
|
const [passwordValue, setPasswordValue] = createSignal("")
|
||||||
|
const [passwordConfirm, setPasswordConfirm] = createSignal("")
|
||||||
|
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
||||||
|
const [savingPassword, setSavingPassword] = createSignal(false)
|
||||||
|
|
||||||
|
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
||||||
|
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
||||||
|
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
||||||
|
const displayAddresses = createMemo(() => {
|
||||||
|
const list = addresses()
|
||||||
|
if (!allowExternalConnections()) return []
|
||||||
|
return list.filter((address) => address.scope !== "loopback")
|
||||||
|
})
|
||||||
|
|
||||||
|
const refreshMeta = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setPasswordError(null)
|
||||||
|
try {
|
||||||
|
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
|
||||||
|
setMeta(metaResult)
|
||||||
|
setAuthStatus(authResult)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void refreshMeta()
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleExpanded = async (url: string) => {
|
||||||
|
if (expandedUrl() === url) {
|
||||||
|
setExpandedUrl(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setExpandedUrl(url)
|
||||||
|
if (!qrCodes()[url]) {
|
||||||
|
try {
|
||||||
|
const dataUrl = await toDataURL(url, { margin: 1, scale: 4 })
|
||||||
|
setQrCodes((prev) => ({ ...prev, [url]: dataUrl }))
|
||||||
|
} catch (err) {
|
||||||
|
log.error("Failed to generate QR code", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAllowConnectionsChange = async (checked: boolean) => {
|
||||||
|
const targetMode: "local" | "all" = checked ? "all" : "local"
|
||||||
|
if (targetMode === currentMode() || applyingListeningMode()) return
|
||||||
|
|
||||||
|
const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), {
|
||||||
|
title: checked
|
||||||
|
? t("remoteAccess.listeningMode.restartConfirm.title.all")
|
||||||
|
: t("remoteAccess.listeningMode.restartConfirm.title.local"),
|
||||||
|
variant: "warning",
|
||||||
|
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
|
||||||
|
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setApplyingListeningMode(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await setListeningMode(targetMode)
|
||||||
|
const restarted = await restartCli()
|
||||||
|
if (!restarted) {
|
||||||
|
setError(t("remoteAccess.restart.errorManual"))
|
||||||
|
} else {
|
||||||
|
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
} finally {
|
||||||
|
setApplyingListeningMode(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
void refreshMeta()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenUrl = (url: string) => {
|
||||||
|
try {
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer")
|
||||||
|
} catch (err) {
|
||||||
|
log.error("Failed to open URL", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmitPassword = async () => {
|
||||||
|
setPasswordError(null)
|
||||||
|
|
||||||
|
const next = passwordValue()
|
||||||
|
const confirm = passwordConfirm()
|
||||||
|
if (next.trim().length < 8) {
|
||||||
|
setPasswordError(t("remoteAccess.password.error.tooShort"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (next !== confirm) {
|
||||||
|
setPasswordError(t("remoteAccess.password.error.mismatch"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingPassword(true)
|
||||||
|
try {
|
||||||
|
const result = await serverApi.setServerPassword(next)
|
||||||
|
setAuthStatus({
|
||||||
|
authenticated: true,
|
||||||
|
username: result.username,
|
||||||
|
passwordUserProvided: result.passwordUserProvided,
|
||||||
|
})
|
||||||
|
setPasswordValue("")
|
||||||
|
setPasswordConfirm("")
|
||||||
|
setPasswordFormOpen(false)
|
||||||
|
} catch (err) {
|
||||||
|
setPasswordError(err instanceof Error ? err.message : String(err))
|
||||||
|
} finally {
|
||||||
|
setSavingPassword(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="settings-section-stack">
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div class="settings-card-heading-with-icon">
|
||||||
|
<Shield class="settings-card-heading-icon" />
|
||||||
|
<div>
|
||||||
|
<h3 class="settings-card-title">{t("remoteAccess.sections.listeningMode.label")}</h3>
|
||||||
|
<p class="settings-card-subtitle">{t("remoteAccess.sections.listeningMode.help")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-toolbar-inline">
|
||||||
|
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
||||||
|
<button
|
||||||
|
class="selector-button selector-button-secondary w-auto"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void refreshMeta()}
|
||||||
|
disabled={loading()}
|
||||||
|
>
|
||||||
|
<RefreshCw class={`w-4 h-4 ${loading() ? "remote-spin" : ""}`} />
|
||||||
|
<span>{t("remoteAccess.refresh")}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
class="remote-toggle"
|
||||||
|
checked={allowExternalConnections()}
|
||||||
|
onChange={(nextChecked) => void handleAllowConnectionsChange(nextChecked)}
|
||||||
|
disabled={loading() || applyingListeningMode()}
|
||||||
|
>
|
||||||
|
<Switch.Input />
|
||||||
|
<Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}>
|
||||||
|
<span class="remote-toggle-state">
|
||||||
|
{allowExternalConnections() ? t("remoteAccess.toggle.on") : t("remoteAccess.toggle.off")}
|
||||||
|
</span>
|
||||||
|
<Switch.Thumb class="remote-toggle-thumb" />
|
||||||
|
</Switch.Control>
|
||||||
|
<div class="remote-toggle-copy">
|
||||||
|
<span class="remote-toggle-title">{t("remoteAccess.toggle.title")}</span>
|
||||||
|
<span class="remote-toggle-caption">
|
||||||
|
{allowExternalConnections()
|
||||||
|
? t("remoteAccess.toggle.caption.all")
|
||||||
|
: t("remoteAccess.toggle.caption.local")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Switch>
|
||||||
|
|
||||||
|
<p class="remote-toggle-note">{t("remoteAccess.toggle.note")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div class="settings-card-heading-with-icon">
|
||||||
|
<Shield class="settings-card-heading-icon" />
|
||||||
|
<div>
|
||||||
|
<h3 class="settings-card-title">{t("remoteAccess.sections.serverPassword.label")}</h3>
|
||||||
|
<p class="settings-card-subtitle">{t("remoteAccess.sections.serverPassword.help")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show
|
||||||
|
when={authStatus() && authStatus()!.authenticated}
|
||||||
|
fallback={<div class="settings-card-message">{t("remoteAccess.authStatus.unavailable")}</div>}
|
||||||
|
>
|
||||||
|
<div class="settings-card-content">
|
||||||
|
<p class="settings-help-text">{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}</p>
|
||||||
|
<p class="settings-help-text">
|
||||||
|
{authStatus()!.passwordUserProvided
|
||||||
|
? t("remoteAccess.password.status.set")
|
||||||
|
: t("remoteAccess.password.status.unset")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="settings-password-actions">
|
||||||
|
<button
|
||||||
|
class="settings-pill-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setPasswordFormOpen(!passwordFormOpen())
|
||||||
|
setPasswordError(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{passwordFormOpen()
|
||||||
|
? t("remoteAccess.password.actions.cancel")
|
||||||
|
: authStatus()!.passwordUserProvided
|
||||||
|
? t("remoteAccess.password.actions.change")
|
||||||
|
: t("remoteAccess.password.actions.set")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={passwordFormOpen()}>
|
||||||
|
<div class="settings-form-group">
|
||||||
|
<label class="settings-form-label">{t("remoteAccess.password.form.newPassword")}</label>
|
||||||
|
<input
|
||||||
|
class="selector-input w-full"
|
||||||
|
type="password"
|
||||||
|
value={passwordValue()}
|
||||||
|
onInput={(event) => setPasswordValue(event.currentTarget.value)}
|
||||||
|
placeholder={t("remoteAccess.password.form.placeholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="settings-form-group">
|
||||||
|
<label class="settings-form-label">{t("remoteAccess.password.form.confirmPassword")}</label>
|
||||||
|
<input
|
||||||
|
class="selector-input w-full"
|
||||||
|
type="password"
|
||||||
|
value={passwordConfirm()}
|
||||||
|
onInput={(event) => setPasswordConfirm(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={passwordError()}>
|
||||||
|
{(message) => <div class="settings-error-message">{message()}</div>}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="settings-password-actions">
|
||||||
|
<button class="settings-pill-button" type="button" disabled={savingPassword()} onClick={() => void handleSubmitPassword()}>
|
||||||
|
{savingPassword() ? t("remoteAccess.password.save.saving") : t("remoteAccess.password.save.label")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div class="settings-card-heading-with-icon">
|
||||||
|
<Wifi class="settings-card-heading-icon" />
|
||||||
|
<div>
|
||||||
|
<h3 class="settings-card-title">{t("remoteAccess.sections.addresses.label")}</h3>
|
||||||
|
<p class="settings-card-subtitle">{t("remoteAccess.sections.addresses.help")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
|
||||||
|
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
||||||
|
<Show
|
||||||
|
when={displayAddresses().length > 0 || meta()?.localUrl}
|
||||||
|
fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}
|
||||||
|
>
|
||||||
|
<div class="remote-address-list">
|
||||||
|
<Show when={meta()?.localUrl}>
|
||||||
|
{(url) => {
|
||||||
|
const value = () => url()
|
||||||
|
const expandedState = () => expandedUrl() === value()
|
||||||
|
const qr = () => qrCodes()[value()]
|
||||||
|
return (
|
||||||
|
<div class="remote-address">
|
||||||
|
<div class="remote-address-main">
|
||||||
|
<div>
|
||||||
|
<p class="remote-address-url">{value()}</p>
|
||||||
|
<p class="remote-address-meta">{t("remoteAccess.address.scope.loopback")}</p>
|
||||||
|
</div>
|
||||||
|
<div class="remote-actions">
|
||||||
|
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(value())}>
|
||||||
|
<ExternalLink class="remote-icon" />
|
||||||
|
{t("remoteAccess.address.open")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="remote-pill"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void toggleExpanded(value())}
|
||||||
|
aria-expanded={expandedState()}
|
||||||
|
>
|
||||||
|
<Link2 class="remote-icon" />
|
||||||
|
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={expandedState()}>
|
||||||
|
<div class="remote-qr">
|
||||||
|
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
||||||
|
{(dataUrl) => (
|
||||||
|
<img
|
||||||
|
src={dataUrl()}
|
||||||
|
alt={t("remoteAccess.address.qrAlt", { url: value() })}
|
||||||
|
class="remote-qr-img"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<For each={displayAddresses()}>
|
||||||
|
{(address) => {
|
||||||
|
const url = address.remoteUrl
|
||||||
|
const expandedState = () => expandedUrl() === url
|
||||||
|
const qr = () => qrCodes()[url]
|
||||||
|
const scopeLabel = () =>
|
||||||
|
address.scope === "external"
|
||||||
|
? t("remoteAccess.address.scope.network")
|
||||||
|
: address.scope === "loopback"
|
||||||
|
? t("remoteAccess.address.scope.loopback")
|
||||||
|
: t("remoteAccess.address.scope.internal")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="remote-address">
|
||||||
|
<div class="remote-address-main">
|
||||||
|
<div>
|
||||||
|
<p class="remote-address-url">{url}</p>
|
||||||
|
<p class="remote-address-meta">
|
||||||
|
{address.family.toUpperCase()} - {scopeLabel()} - {address.ip}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="remote-actions">
|
||||||
|
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
|
||||||
|
<ExternalLink class="remote-icon" />
|
||||||
|
{t("remoteAccess.address.open")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="remote-pill"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void toggleExpanded(url)}
|
||||||
|
aria-expanded={expandedState()}
|
||||||
|
>
|
||||||
|
<Link2 class="remote-icon" />
|
||||||
|
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={expandedState()}>
|
||||||
|
<div class="remote-qr">
|
||||||
|
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
||||||
|
{(dataUrl) => (
|
||||||
|
<img src={dataUrl()} alt={t("remoteAccess.address.qrAlt", { url })} class="remote-qr-img" />
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
import type { Accessor, JSXElement } from "solid-js"
|
import type { Accessor, JSXElement } from "solid-js"
|
||||||
import type { RenderCache } from "../../types/message"
|
import type { RenderCache } from "../../types/message"
|
||||||
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
|
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
|
||||||
import { escapeHtml } from "../../lib/markdown"
|
import { escapeHtml } from "../../lib/text-render-utils"
|
||||||
import type { AnsiRenderOptions, ToolScrollHelpers } from "./types"
|
import type { AnsiRenderOptions, ToolScrollHelpers } from "./types"
|
||||||
|
|
||||||
type AnsiRenderCache = RenderCache & { hasAnsi: boolean }
|
type AnsiRenderCache = RenderCache & { hasAnsi: boolean }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ToolState } from "@opencode-ai/sdk"
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
import { getRelativePath, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./utils"
|
import { getRelativePath, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./utils"
|
||||||
import { tGlobal } from "../../lib/i18n"
|
import { tGlobal } from "../../lib/i18n"
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,26 @@
|
|||||||
import type { Accessor, JSXElement } from "solid-js"
|
import { Suspense, lazy, onMount, type Accessor, type JSXElement } from "solid-js"
|
||||||
import type { RenderCache } from "../../types/message"
|
import type { RenderCache } from "../../types/message"
|
||||||
import type { DiffViewMode } from "../../stores/preferences"
|
import type { DiffViewMode } from "../../stores/preferences"
|
||||||
import { ToolCallDiffViewer } from "../diff-viewer"
|
|
||||||
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
|
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
|
||||||
import { getRelativePath } from "./utils"
|
import { getRelativePath } from "./utils"
|
||||||
import { getCacheEntry } from "../../lib/global-cache"
|
import { getCacheEntry } from "../../lib/global-cache"
|
||||||
|
|
||||||
|
const LazyToolCallDiffViewer = lazy(() =>
|
||||||
|
import("../diff-viewer").then((module) => ({ default: module.ToolCallDiffViewer })),
|
||||||
|
)
|
||||||
|
|
||||||
|
function CachedDiffMarkup(props: { html: string; onRendered?: () => void }) {
|
||||||
|
onMount(() => {
|
||||||
|
props.onRendered?.()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="tool-call-diff-viewer">
|
||||||
|
<div innerHTML={props.html} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type CacheHandle = {
|
type CacheHandle = {
|
||||||
get<T>(): T | undefined
|
get<T>(): T | undefined
|
||||||
params(): unknown
|
params(): unknown
|
||||||
@@ -101,15 +116,20 @@ export function createDiffContentRenderer(params: {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ToolCallDiffViewer
|
{cachedHtml ? (
|
||||||
diffText={payload.diffText}
|
<CachedDiffMarkup html={cachedHtml} onRendered={handleDiffRendered} />
|
||||||
filePath={payload.filePath}
|
) : (
|
||||||
theme={themeKey}
|
<Suspense fallback={<pre class="tool-call-diff-fallback">{payload.diffText}</pre>}>
|
||||||
mode={diffMode()}
|
<LazyToolCallDiffViewer
|
||||||
cachedHtml={cachedHtml}
|
diffText={payload.diffText}
|
||||||
cacheEntryParams={cacheEntryParams as any}
|
filePath={payload.filePath}
|
||||||
onRendered={handleDiffRendered}
|
theme={themeKey}
|
||||||
/>
|
mode={diffMode()}
|
||||||
|
cacheEntryParams={cacheEntryParams as any}
|
||||||
|
onRendered={handleDiffRendered}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Accessor, JSXElement } from "solid-js"
|
import type { Accessor, JSXElement } from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk"
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
import type { TextPart } from "../../types/message"
|
import type { TextPart } from "../../types/message"
|
||||||
import { Markdown } from "../markdown"
|
import { Markdown } from "../markdown"
|
||||||
import type { MarkdownRenderOptions, ToolScrollHelpers } from "./types"
|
import type { MarkdownRenderOptions, ToolScrollHelpers } from "./types"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createMemo, Show, For, createEffect, type Accessor } from "solid-js"
|
import { createMemo, Show, For, createEffect, type Accessor } from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk"
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||||
import { useI18n } from "../../lib/i18n"
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { For, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
import { For, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk"
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
import type { ToolRenderer } from "../types"
|
import type { ToolRenderer } from "../types"
|
||||||
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
||||||
import { resolveTitleForTool } from "../tool-title"
|
import { resolveTitleForTool } from "../tool-title"
|
||||||
@@ -178,28 +178,116 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
void loadMessages(instanceId, id)
|
void loadMessages(instanceId, id)
|
||||||
})
|
})
|
||||||
|
|
||||||
const childToolKeys = createMemo(() => {
|
const [childToolKeys, setChildToolKeys] = createSignal<string[]>([])
|
||||||
const id = childSessionId()
|
|
||||||
if (!id) return [] as string[]
|
|
||||||
if (!childSessionLoaded()) return [] as string[]
|
|
||||||
|
|
||||||
// React to session changes, but do the scan untracked to avoid
|
let indexedSessionId = ""
|
||||||
// subscribing to every message/part node in the store.
|
let indexedMessageCount = 0
|
||||||
|
let indexedMessageTail = ""
|
||||||
|
const indexedPartCounts = new Map<string, number>()
|
||||||
|
|
||||||
|
function resetChildToolIndex(nextSessionId: string) {
|
||||||
|
indexedSessionId = nextSessionId
|
||||||
|
indexedMessageCount = 0
|
||||||
|
indexedMessageTail = ""
|
||||||
|
indexedPartCounts.clear()
|
||||||
|
setChildToolKeys([])
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanMessageToolParts(messageId: string, startIndex: number) {
|
||||||
|
const record = store.getMessage(messageId)
|
||||||
|
if (!record) return [] as string[]
|
||||||
|
|
||||||
|
const partIds = record.partIds
|
||||||
|
const keys: string[] = []
|
||||||
|
for (let idx = startIndex; idx < partIds.length; idx += 1) {
|
||||||
|
const partId = partIds[idx]
|
||||||
|
const entry = record.parts?.[partId]
|
||||||
|
const data = entry?.data
|
||||||
|
if (!data || (data as any).type !== "tool") continue
|
||||||
|
keys.push(`${messageId}::${partId}`)
|
||||||
|
}
|
||||||
|
indexedPartCounts.set(messageId, partIds.length)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
function fullRescanChildTools(sessionId: string, messageIds: string[]) {
|
||||||
|
indexedSessionId = sessionId
|
||||||
|
indexedMessageCount = messageIds.length
|
||||||
|
indexedMessageTail = messageIds[messageIds.length - 1] ?? ""
|
||||||
|
indexedPartCounts.clear()
|
||||||
|
|
||||||
|
const nextKeys: string[] = []
|
||||||
|
for (const messageId of messageIds) {
|
||||||
|
nextKeys.push(...scanMessageToolParts(messageId, 0))
|
||||||
|
}
|
||||||
|
setChildToolKeys(nextKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const id = childSessionId()
|
||||||
|
const loaded = childSessionLoaded()
|
||||||
|
|
||||||
|
if (!id || !loaded) {
|
||||||
|
if (indexedSessionId) {
|
||||||
|
resetChildToolIndex("")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// We use the session revision as the reactive change point, but avoid
|
||||||
|
// rescanning the entire session on every update.
|
||||||
store.getSessionRevision(id)
|
store.getSessionRevision(id)
|
||||||
return untrack(() => {
|
|
||||||
|
untrack(() => {
|
||||||
const messageIds = store.getSessionMessageIds(id)
|
const messageIds = store.getSessionMessageIds(id)
|
||||||
const keys: string[] = []
|
|
||||||
for (const messageId of messageIds) {
|
if (!indexedSessionId || indexedSessionId !== id) {
|
||||||
const record = store.getMessage(messageId)
|
fullRescanChildTools(id, messageIds)
|
||||||
if (!record) continue
|
return
|
||||||
for (const partId of record.partIds) {
|
}
|
||||||
const entry = record.parts?.[partId]
|
|
||||||
const data = entry?.data
|
// Detect structural changes (reorder/shrink) and fall back to a full rescan.
|
||||||
if (!data || (data as any).type !== "tool") continue
|
if (messageIds.length < indexedMessageCount) {
|
||||||
keys.push(`${messageId}::${partId}`)
|
fullRescanChildTools(id, messageIds)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (indexedMessageCount > 0) {
|
||||||
|
const expectedTailIndex = indexedMessageCount - 1
|
||||||
|
if (expectedTailIndex >= 0 && messageIds[expectedTailIndex] !== indexedMessageTail) {
|
||||||
|
fullRescanChildTools(id, messageIds)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return keys
|
|
||||||
|
const appendedKeys: string[] = []
|
||||||
|
|
||||||
|
// Scan any new messages appended since last index.
|
||||||
|
for (let idx = indexedMessageCount; idx < messageIds.length; idx += 1) {
|
||||||
|
const messageId = messageIds[idx]
|
||||||
|
appendedKeys.push(...scanMessageToolParts(messageId, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan a small window of recent messages for newly appended parts.
|
||||||
|
// Deltas typically affect the most recent tool call, so this avoids
|
||||||
|
// iterating every message on every revision.
|
||||||
|
const existingCount = Math.min(indexedMessageCount, messageIds.length)
|
||||||
|
const windowStart = Math.max(0, existingCount - 3)
|
||||||
|
for (let idx = windowStart; idx < existingCount; idx += 1) {
|
||||||
|
const messageId = messageIds[idx]
|
||||||
|
const previousPartCount = indexedPartCounts.get(messageId) ?? 0
|
||||||
|
const record = store.getMessage(messageId)
|
||||||
|
const nextPartCount = record?.partIds.length ?? 0
|
||||||
|
if (nextPartCount > previousPartCount) {
|
||||||
|
appendedKeys.push(...scanMessageToolParts(messageId, previousPartCount))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
indexedMessageCount = messageIds.length
|
||||||
|
indexedMessageTail = messageIds[messageIds.length - 1] ?? ""
|
||||||
|
|
||||||
|
if (appendedKeys.length > 0) {
|
||||||
|
setChildToolKeys((prev) => [...prev, ...appendedKeys])
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const promptContent = createMemo(() => {
|
const promptContent = createMemo(() => {
|
||||||
@@ -354,7 +442,7 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
|
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="tool-call-task-summary">
|
<div class="tool-call-task-summary">
|
||||||
<For each={childToolKeys()}>
|
<For each={childToolKeys()}>
|
||||||
{(key) => (
|
{(key) => (
|
||||||
<Show when={renderToolCall}>
|
<Show when={renderToolCall}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { For, Show } from "solid-js"
|
import { For, Show } from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk"
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
import type { ToolRenderer } from "../types"
|
import type { ToolRenderer } from "../types"
|
||||||
import { readToolStatePayload } from "../utils"
|
import { readToolStatePayload } from "../utils"
|
||||||
import { useI18n, tGlobal } from "../../../lib/i18n"
|
import { useI18n, tGlobal } from "../../../lib/i18n"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ToolState } from "@opencode-ai/sdk"
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
import type { ToolRendererContext, ToolRenderer, ToolCallPart } from "./types"
|
import type { ToolRendererContext, ToolRenderer, ToolCallPart } from "./types"
|
||||||
import { getDefaultToolAction, getToolName, isToolStateCompleted, isToolStateRunning } from "./utils"
|
import { getDefaultToolAction, getToolName, isToolStateCompleted, isToolStateRunning } from "./utils"
|
||||||
import { enMessages } from "../../lib/i18n/messages/en"
|
import { enMessages } from "../../lib/i18n/messages/en"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Accessor, JSXElement } from "solid-js"
|
import type { Accessor, JSXElement } from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk"
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
import type { ClientPart } from "../../types/message"
|
import type { ClientPart } from "../../types/message"
|
||||||
|
|
||||||
export type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
export type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { isRenderableDiffText } from "../../lib/diff-utils"
|
import { isRenderableDiffText } from "../../lib/diff-utils"
|
||||||
import { getLanguageFromPath } from "../../lib/markdown"
|
import { getLanguageFromPath } from "../../lib/text-render-utils"
|
||||||
import type { ToolState } from "@opencode-ai/sdk"
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
import type { DiffPayload } from "./types"
|
import type { DiffPayload } from "./types"
|
||||||
import { getLogger } from "../../lib/logger"
|
import { getLogger } from "../../lib/logger"
|
||||||
import { tGlobal } from "../../lib/i18n"
|
import { tGlobal } from "../../lib/i18n"
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
|
||||||
export type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
|
export type ToolStateRunning = import("@opencode-ai/sdk/v2").ToolStateRunning
|
||||||
export type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
|
export type ToolStateCompleted = import("@opencode-ai/sdk/v2").ToolStateCompleted
|
||||||
export type ToolStateError = import("@opencode-ai/sdk").ToolStateError
|
export type ToolStateError = import("@opencode-ai/sdk/v2").ToolStateError
|
||||||
|
|
||||||
export const diffCapableTools = new Set(["edit", "patch"])
|
export const diffCapableTools = new Set(["edit", "patch"])
|
||||||
|
|
||||||
|
|||||||
398
packages/ui/src/components/virtual-follow-list.tsx
Normal file
398
packages/ui/src/components/virtual-follow-list.tsx
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
import { Show, createEffect, createMemo, createSignal, onCleanup, type Accessor, type JSX, on } from "solid-js"
|
||||||
|
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
|
||||||
|
|
||||||
|
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||||
|
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
||||||
|
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
||||||
|
|
||||||
|
export interface VirtualFollowListApi {
|
||||||
|
scrollToTop: (opts?: { immediate?: boolean }) => void
|
||||||
|
scrollToBottom: (opts?: { immediate?: boolean; suppressAutoAnchor?: boolean }) => void
|
||||||
|
scrollToKey: (
|
||||||
|
key: string,
|
||||||
|
opts?: { behavior?: ScrollBehavior; block?: ScrollLogicalPosition; setAutoScroll?: boolean },
|
||||||
|
) => void
|
||||||
|
notifyContentRendered: () => void
|
||||||
|
setAutoScroll: (enabled: boolean) => void
|
||||||
|
getAutoScroll: () => boolean
|
||||||
|
getScrollElement: () => HTMLDivElement | undefined
|
||||||
|
getShellElement: () => HTMLDivElement | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VirtualFollowListState {
|
||||||
|
autoScroll: Accessor<boolean>
|
||||||
|
showScrollTopButton: Accessor<boolean>
|
||||||
|
showScrollBottomButton: Accessor<boolean>
|
||||||
|
scrollButtonsCount: Accessor<number>
|
||||||
|
activeKey: Accessor<string | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VirtualFollowListProps<T> {
|
||||||
|
items: Accessor<T[]>
|
||||||
|
getKey: (item: T, index: number) => string
|
||||||
|
renderItem: (item: T, index: number) => JSX.Element
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional stable DOM id for the item wrapper.
|
||||||
|
* Defaults to the key itself.
|
||||||
|
*/
|
||||||
|
getAnchorId?: (key: string) => string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode an item key from an observed wrapper element id.
|
||||||
|
* Defaults to identity.
|
||||||
|
*/
|
||||||
|
getKeyFromAnchorId?: (anchorId: string) => string
|
||||||
|
|
||||||
|
overscanPx?: number
|
||||||
|
scrollSentinelMarginPx?: number
|
||||||
|
virtualizationEnabled?: Accessor<boolean>
|
||||||
|
suspendMeasurements?: Accessor<boolean>
|
||||||
|
loading?: Accessor<boolean>
|
||||||
|
isActive?: Accessor<boolean>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When switching back to an inactive (cached) pane, the list historically
|
||||||
|
* re-pinned to the bottom if autoScroll was enabled.
|
||||||
|
*
|
||||||
|
* Disable this to preserve the existing scroll position across pane switches.
|
||||||
|
*/
|
||||||
|
scrollToBottomOnActivate?: Accessor<boolean>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controls whether the list should scroll to bottom the first time items
|
||||||
|
* appear (default behavior for chat streams).
|
||||||
|
*
|
||||||
|
* Set to false when an outer component restores scroll from a cache.
|
||||||
|
*/
|
||||||
|
initialScrollToBottom?: Accessor<boolean>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial value for the internal autoScroll signal.
|
||||||
|
* Useful when restoring scroll state (e.g. start in non-follow mode).
|
||||||
|
*/
|
||||||
|
initialAutoScroll?: Accessor<boolean>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When this value changes, the list resets internal follow/anchor state.
|
||||||
|
* Useful when reusing the same list instance across different datasets.
|
||||||
|
*/
|
||||||
|
resetKey?: Accessor<string | number>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If this value changes and autoScroll is enabled, the list will
|
||||||
|
* anchor-scroll to the bottom (unless suppressed).
|
||||||
|
*/
|
||||||
|
followToken?: Accessor<string | number>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional hooks to render content inside the scroll container.
|
||||||
|
* Useful for empty/loading states that should scroll with the list.
|
||||||
|
*/
|
||||||
|
renderBeforeItems?: Accessor<JSX.Element>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render content inside the shell, above timeline/sidebar layers.
|
||||||
|
* (Quote popovers, etc.)
|
||||||
|
*/
|
||||||
|
renderOverlay?: Accessor<JSX.Element>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide localized labels for built-in controls.
|
||||||
|
*/
|
||||||
|
scrollToTopAriaLabel?: Accessor<string>
|
||||||
|
scrollToBottomAriaLabel?: Accessor<string>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receive element refs for external logic (selection, geometry, etc.)
|
||||||
|
*/
|
||||||
|
onScrollElementChange?: (element: HTMLDivElement | undefined) => void
|
||||||
|
onShellElementChange?: (element: HTMLDivElement | undefined) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callbacks for consumers.
|
||||||
|
*/
|
||||||
|
onScroll?: () => void
|
||||||
|
onMouseUp?: (event: MouseEvent) => void
|
||||||
|
onClick?: (event: MouseEvent) => void
|
||||||
|
onActiveKeyChange?: (key: string | null) => void
|
||||||
|
registerApi?: (api: VirtualFollowListApi) => void
|
||||||
|
registerState?: (state: VirtualFollowListState) => void
|
||||||
|
renderControls?: (state: VirtualFollowListState, api: VirtualFollowListApi) => JSX.Element
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||||
|
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
|
||||||
|
const [shellElement, setShellElement] = createSignal<HTMLDivElement | undefined>()
|
||||||
|
const [virtuaHandle, setVirtuaHandle] = createSignal<VirtualizerHandle | undefined>()
|
||||||
|
|
||||||
|
const isActive = () => (props.isActive ? props.isActive() : true)
|
||||||
|
const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true)
|
||||||
|
const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true)
|
||||||
|
const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : true)
|
||||||
|
|
||||||
|
const [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll()))
|
||||||
|
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
||||||
|
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
||||||
|
const [activeKey, setActiveKey] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
|
||||||
|
|
||||||
|
let userScrollIntentUntil = 0
|
||||||
|
let lastUserScrollIntentDirection: "up" | "down" | null = null
|
||||||
|
let detachScrollIntentListeners: (() => void) | undefined
|
||||||
|
let lastResetKey: string | number | undefined
|
||||||
|
let suppressAutoScrollOnce = false
|
||||||
|
let pendingInitialScroll = true
|
||||||
|
|
||||||
|
const state: VirtualFollowListState = {
|
||||||
|
autoScroll,
|
||||||
|
showScrollTopButton,
|
||||||
|
showScrollBottomButton,
|
||||||
|
scrollButtonsCount,
|
||||||
|
activeKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
function markUserScrollIntent(direction?: "up" | "down" | null) {
|
||||||
|
const now = performance.now()
|
||||||
|
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS
|
||||||
|
if (direction) {
|
||||||
|
lastUserScrollIntentDirection = direction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUserScrollIntent() {
|
||||||
|
return performance.now() <= userScrollIntentUntil
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
|
||||||
|
if (detachScrollIntentListeners) {
|
||||||
|
detachScrollIntentListeners()
|
||||||
|
detachScrollIntentListeners = undefined
|
||||||
|
}
|
||||||
|
if (!element) return
|
||||||
|
const handleWheelIntent = (event: WheelEvent) => {
|
||||||
|
const dir: "up" | "down" | null = event.deltaY < 0 ? "up" : event.deltaY > 0 ? "down" : null
|
||||||
|
markUserScrollIntent(dir)
|
||||||
|
}
|
||||||
|
const handlePointerIntent = () => markUserScrollIntent(null)
|
||||||
|
const handleKeyIntent = (event: KeyboardEvent) => {
|
||||||
|
if (!SCROLL_INTENT_KEYS.has(event.key)) return
|
||||||
|
const key = event.key
|
||||||
|
const dir: "up" | "down" | null =
|
||||||
|
key === "ArrowUp" || key === "PageUp" || key === "Home"
|
||||||
|
? "up"
|
||||||
|
: key === "ArrowDown" || key === "PageDown" || key === "End"
|
||||||
|
? "down"
|
||||||
|
: key === " " || key === "Spacebar"
|
||||||
|
? event.shiftKey
|
||||||
|
? "up"
|
||||||
|
: "down"
|
||||||
|
: null
|
||||||
|
markUserScrollIntent(dir)
|
||||||
|
}
|
||||||
|
element.addEventListener("wheel", handleWheelIntent, { passive: true })
|
||||||
|
element.addEventListener("pointerdown", handlePointerIntent)
|
||||||
|
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
|
||||||
|
element.addEventListener("keydown", handleKeyIntent)
|
||||||
|
detachScrollIntentListeners = () => {
|
||||||
|
element.removeEventListener("wheel", handleWheelIntent)
|
||||||
|
element.removeEventListener("pointerdown", handlePointerIntent)
|
||||||
|
element.removeEventListener("touchstart", handlePointerIntent)
|
||||||
|
element.removeEventListener("keydown", handleKeyIntent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScrollButtons() {
|
||||||
|
const handle = virtuaHandle()
|
||||||
|
const element = scrollElement()
|
||||||
|
if (!handle || !element) return
|
||||||
|
|
||||||
|
const offset = handle.scrollOffset
|
||||||
|
const scrollHeight = handle.scrollSize
|
||||||
|
const clientHeight = element.clientHeight
|
||||||
|
const atBottom = scrollHeight - (offset + clientHeight) <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX)
|
||||||
|
const atTop = offset <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX)
|
||||||
|
|
||||||
|
const hasItems = props.items().length > 0
|
||||||
|
setShowScrollBottomButton(hasItems && !atBottom)
|
||||||
|
setShowScrollTopButton(hasItems && !atTop)
|
||||||
|
|
||||||
|
// Sync autoScroll state based on scroll position if it was a user scroll
|
||||||
|
if (hasUserScrollIntent()) {
|
||||||
|
if (atBottom && !autoScroll()) {
|
||||||
|
setAutoScroll(true)
|
||||||
|
} else if (!atBottom && autoScroll()) {
|
||||||
|
setAutoScroll(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom(immediate = true, options?: { suppressAutoAnchor?: boolean }) {
|
||||||
|
const handle = virtuaHandle()
|
||||||
|
if (!handle) return
|
||||||
|
if (options?.suppressAutoAnchor ?? !immediate) {
|
||||||
|
suppressAutoScrollOnce = true
|
||||||
|
}
|
||||||
|
handle.scrollToIndex(props.items().length - 1, { align: "end", smooth: !immediate })
|
||||||
|
setAutoScroll(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToTop(immediate = true) {
|
||||||
|
const handle = virtuaHandle()
|
||||||
|
if (!handle) return
|
||||||
|
handle.scrollToIndex(0, { align: "start", smooth: !immediate })
|
||||||
|
setAutoScroll(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScroll() {
|
||||||
|
const isUserScroll = hasUserScrollIntent()
|
||||||
|
if (isUserScroll) {
|
||||||
|
if (lastUserScrollIntentDirection === "up" && autoScroll()) {
|
||||||
|
setAutoScroll(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateScrollButtons()
|
||||||
|
props.onScroll?.()
|
||||||
|
|
||||||
|
// Find active key (roughly the first visible item)
|
||||||
|
const handle = virtuaHandle()
|
||||||
|
if (handle) {
|
||||||
|
const start = handle.findItemIndex(handle.scrollOffset)
|
||||||
|
const items = props.items()
|
||||||
|
if (items[start]) {
|
||||||
|
const key = props.getKey(items[start], start)
|
||||||
|
if (key !== activeKey()) {
|
||||||
|
setActiveKey(key)
|
||||||
|
props.onActiveKeyChange?.(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const api: VirtualFollowListApi = {
|
||||||
|
scrollToTop: (opts) => scrollToTop(opts?.immediate ?? true),
|
||||||
|
scrollToBottom: (opts) => scrollToBottom(opts?.immediate ?? true, { suppressAutoAnchor: opts?.suppressAutoAnchor }),
|
||||||
|
scrollToKey: (key, opts) => {
|
||||||
|
const index = props.items().findIndex((item, i) => props.getKey(item, i) === key)
|
||||||
|
if (index === -1) return
|
||||||
|
const nextAutoScroll = opts?.setAutoScroll ?? false
|
||||||
|
setAutoScroll(nextAutoScroll)
|
||||||
|
virtuaHandle()?.scrollToIndex(index, { align: opts?.block ?? "start", smooth: opts?.behavior === "smooth" })
|
||||||
|
},
|
||||||
|
notifyContentRendered: () => {
|
||||||
|
if (autoScroll()) {
|
||||||
|
scrollToBottom(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setAutoScroll: (enabled) => setAutoScroll(Boolean(enabled)),
|
||||||
|
getAutoScroll: () => autoScroll(),
|
||||||
|
getScrollElement: () => scrollElement(),
|
||||||
|
getShellElement: () => shellElement(),
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => props.registerApi?.(api))
|
||||||
|
createEffect(() => props.registerState?.(state))
|
||||||
|
|
||||||
|
// Handle autoScroll (Follow) on items change
|
||||||
|
createEffect(on(() => props.items().length, (len, prevLen) => {
|
||||||
|
if (len > (prevLen ?? 0) && autoScroll() && !suppressAutoScrollOnce) {
|
||||||
|
requestAnimationFrame(() => scrollToBottom(true))
|
||||||
|
}
|
||||||
|
suppressAutoScrollOnce = false
|
||||||
|
}, { defer: true }))
|
||||||
|
|
||||||
|
// Handle followToken change
|
||||||
|
createEffect(on(() => props.followToken?.(), () => {
|
||||||
|
if (autoScroll()) {
|
||||||
|
scrollToBottom(true)
|
||||||
|
}
|
||||||
|
}, { defer: true }))
|
||||||
|
|
||||||
|
// Reset state on resetKey change
|
||||||
|
createEffect(on(() => props.resetKey?.(), (nextKey) => {
|
||||||
|
if (nextKey === lastResetKey) return
|
||||||
|
lastResetKey = nextKey
|
||||||
|
setAutoScroll(initialAutoScroll())
|
||||||
|
pendingInitialScroll = true
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Initial scroll and session activation
|
||||||
|
createEffect(() => {
|
||||||
|
const active = isActive()
|
||||||
|
if (!active) return
|
||||||
|
if (pendingInitialScroll && props.items().length > 0) {
|
||||||
|
pendingInitialScroll = false
|
||||||
|
if (initialScrollToBottom()) {
|
||||||
|
scrollToBottom(true)
|
||||||
|
}
|
||||||
|
} else if (autoScroll() && scrollToBottomOnActivate()) {
|
||||||
|
scrollToBottom(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="virtual-follow-list-shell" ref={shellElement => {
|
||||||
|
setShellElement(shellElement)
|
||||||
|
props.onShellElementChange?.(shellElement)
|
||||||
|
}}>
|
||||||
|
<div
|
||||||
|
class="message-stream"
|
||||||
|
ref={el => {
|
||||||
|
setScrollElement(el)
|
||||||
|
props.onScrollElementChange?.(el)
|
||||||
|
attachScrollIntentListeners(el)
|
||||||
|
}}
|
||||||
|
onMouseUp={props.onMouseUp}
|
||||||
|
onClick={props.onClick}
|
||||||
|
>
|
||||||
|
<Show when={props.renderBeforeItems}>
|
||||||
|
{props.renderBeforeItems!()}
|
||||||
|
</Show>
|
||||||
|
<Virtualizer
|
||||||
|
ref={setVirtuaHandle}
|
||||||
|
scrollRef={scrollElement()}
|
||||||
|
data={props.items()}
|
||||||
|
bufferSize={props.overscanPx ?? 400}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
>
|
||||||
|
{(item, index) => props.renderItem(item, index())}
|
||||||
|
</Virtualizer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={props.renderOverlay}>
|
||||||
|
<div class="virtual-follow-list-overlay">{props.renderOverlay!()}</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={props.renderControls}>
|
||||||
|
<div class="virtual-follow-list-controls-container">{props.renderControls!(state, api)}</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
!props.renderControls &&
|
||||||
|
(showScrollTopButton() || showScrollBottomButton()) &&
|
||||||
|
props.scrollToTopAriaLabel &&
|
||||||
|
props.scrollToBottomAriaLabel
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="message-scroll-button-wrapper">
|
||||||
|
<Show when={showScrollTopButton()}>
|
||||||
|
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label={props.scrollToTopAriaLabel!()}>
|
||||||
|
<span class="message-scroll-icon" aria-hidden="true">
|
||||||
|
↑
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<Show when={showScrollBottomButton()}>
|
||||||
|
<button type="button" class="message-scroll-button" onClick={() => scrollToBottom()} aria-label={props.scrollToBottomAriaLabel!()}>
|
||||||
|
<span class="message-scroll-icon" aria-hidden="true">
|
||||||
|
↓
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
import { JSX, Accessor, children as resolveChildren, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
|
||||||
|
|
||||||
const sizeCache = new Map<string, number>()
|
|
||||||
const DEFAULT_MARGIN_PX = 600
|
|
||||||
const MIN_PLACEHOLDER_HEIGHT = 32
|
|
||||||
const VISIBILITY_BUFFER_PX = 48
|
|
||||||
|
|
||||||
type ObserverRoot = Element | Document | null
|
|
||||||
|
|
||||||
type IntersectionCallback = (entry: IntersectionObserverEntry) => void
|
|
||||||
|
|
||||||
interface SharedObserver {
|
|
||||||
observer: IntersectionObserver
|
|
||||||
listeners: Map<Element, Set<IntersectionCallback>>
|
|
||||||
}
|
|
||||||
|
|
||||||
const NULL_ROOT_KEY = "__null__"
|
|
||||||
const rootIds = new WeakMap<Element | Document, number>()
|
|
||||||
let sharedRootId = 0
|
|
||||||
const sharedObservers = new Map<string, SharedObserver>()
|
|
||||||
|
|
||||||
function getRootKey(root: ObserverRoot, margin: number): string {
|
|
||||||
if (!root) {
|
|
||||||
return `${NULL_ROOT_KEY}:${margin}`
|
|
||||||
}
|
|
||||||
let id = rootIds.get(root)
|
|
||||||
if (id === undefined) {
|
|
||||||
id = ++sharedRootId
|
|
||||||
rootIds.set(root, id)
|
|
||||||
}
|
|
||||||
return `${id}:${margin}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSharedObserver(root: ObserverRoot, margin: number): SharedObserver {
|
|
||||||
const listeners = new Map<Element, Set<IntersectionCallback>>()
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
const callbacks = listeners.get(entry.target as Element)
|
|
||||||
if (!callbacks) return
|
|
||||||
callbacks.forEach((fn) => fn(entry))
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{
|
|
||||||
root: root ?? undefined,
|
|
||||||
rootMargin: `${margin}px 0px ${margin}px 0px`,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return { observer, listeners }
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldRenderEntry(entry: IntersectionObserverEntry) {
|
|
||||||
const rootBounds = entry.rootBounds
|
|
||||||
if (!rootBounds) {
|
|
||||||
return entry.isIntersecting
|
|
||||||
}
|
|
||||||
const distanceAbove = rootBounds.top - entry.boundingClientRect.bottom
|
|
||||||
const distanceBelow = entry.boundingClientRect.top - rootBounds.bottom
|
|
||||||
if (distanceAbove > VISIBILITY_BUFFER_PX || distanceBelow > VISIBILITY_BUFFER_PX) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function subscribeToSharedObserver(
|
|
||||||
target: Element,
|
|
||||||
root: ObserverRoot,
|
|
||||||
margin: number,
|
|
||||||
callback: IntersectionCallback,
|
|
||||||
): () => void {
|
|
||||||
if (typeof IntersectionObserver === "undefined") {
|
|
||||||
callback({ isIntersecting: true } as IntersectionObserverEntry)
|
|
||||||
return () => {}
|
|
||||||
}
|
|
||||||
const key = getRootKey(root, margin)
|
|
||||||
let shared = sharedObservers.get(key)
|
|
||||||
if (!shared) {
|
|
||||||
shared = createSharedObserver(root, margin)
|
|
||||||
sharedObservers.set(key, shared)
|
|
||||||
}
|
|
||||||
let targetCallbacks = shared.listeners.get(target)
|
|
||||||
if (!targetCallbacks) {
|
|
||||||
targetCallbacks = new Set()
|
|
||||||
shared.listeners.set(target, targetCallbacks)
|
|
||||||
shared.observer.observe(target)
|
|
||||||
}
|
|
||||||
targetCallbacks.add(callback)
|
|
||||||
return () => {
|
|
||||||
const current = shared?.listeners.get(target)
|
|
||||||
if (current) {
|
|
||||||
current.delete(callback)
|
|
||||||
if (current.size === 0) {
|
|
||||||
shared?.listeners.delete(target)
|
|
||||||
shared?.observer.unobserve(target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (shared && shared.listeners.size === 0) {
|
|
||||||
shared.observer.disconnect()
|
|
||||||
sharedObservers.delete(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VirtualItemProps {
|
|
||||||
cacheKey: string
|
|
||||||
children: JSX.Element
|
|
||||||
scrollContainer?: Accessor<HTMLElement | undefined | null>
|
|
||||||
threshold?: number
|
|
||||||
minPlaceholderHeight?: number
|
|
||||||
class?: string
|
|
||||||
contentClass?: string
|
|
||||||
placeholderClass?: string
|
|
||||||
virtualizationEnabled?: Accessor<boolean>
|
|
||||||
forceVisible?: Accessor<boolean>
|
|
||||||
suspendMeasurements?: Accessor<boolean>
|
|
||||||
onMeasured?: () => void
|
|
||||||
id?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VirtualItem(props: VirtualItemProps) {
|
|
||||||
const resolved = resolveChildren(() => props.children)
|
|
||||||
const cachedHeight = sizeCache.get(props.cacheKey)
|
|
||||||
const [isIntersecting, setIsIntersecting] = createSignal(true)
|
|
||||||
const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? 0)
|
|
||||||
const [hasMeasured, setHasMeasured] = createSignal(cachedHeight !== undefined)
|
|
||||||
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
|
|
||||||
let pendingVisibility: boolean | null = null
|
|
||||||
let visibilityFrame: number | null = null
|
|
||||||
const flushVisibility = () => {
|
|
||||||
if (visibilityFrame !== null) {
|
|
||||||
cancelAnimationFrame(visibilityFrame)
|
|
||||||
visibilityFrame = null
|
|
||||||
}
|
|
||||||
if (pendingVisibility !== null) {
|
|
||||||
setIsIntersecting(pendingVisibility)
|
|
||||||
pendingVisibility = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const queueVisibility = (nextValue: boolean) => {
|
|
||||||
pendingVisibility = nextValue
|
|
||||||
if (visibilityFrame !== null) return
|
|
||||||
visibilityFrame = requestAnimationFrame(() => {
|
|
||||||
visibilityFrame = null
|
|
||||||
if (pendingVisibility !== null) {
|
|
||||||
setIsIntersecting(pendingVisibility)
|
|
||||||
pendingVisibility = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
|
|
||||||
const shouldHideContent = createMemo(() => {
|
|
||||||
if (props.forceVisible?.()) return false
|
|
||||||
if (!virtualizationEnabled()) return false
|
|
||||||
return !isIntersecting()
|
|
||||||
})
|
|
||||||
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
|
|
||||||
|
|
||||||
let wrapperRef: HTMLDivElement | undefined
|
|
||||||
|
|
||||||
let contentRef: HTMLDivElement | undefined
|
|
||||||
|
|
||||||
let resizeObserver: ResizeObserver | undefined
|
|
||||||
let intersectionCleanup: (() => void) | undefined
|
|
||||||
|
|
||||||
function cleanupResizeObserver() {
|
|
||||||
if (resizeObserver) {
|
|
||||||
resizeObserver.disconnect()
|
|
||||||
resizeObserver = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupIntersectionObserver() {
|
|
||||||
if (intersectionCleanup) {
|
|
||||||
intersectionCleanup()
|
|
||||||
intersectionCleanup = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistMeasurement(nextHeight: number) {
|
|
||||||
if (!Number.isFinite(nextHeight) || nextHeight < 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const normalized = nextHeight
|
|
||||||
const previous = sizeCache.get(props.cacheKey) ?? measuredHeight()
|
|
||||||
const shouldKeepPrevious = previous > 0 && (normalized === 0 || (normalized > 0 && normalized < previous))
|
|
||||||
if (shouldKeepPrevious) {
|
|
||||||
if (!hasReportedMeasurement) {
|
|
||||||
hasReportedMeasurement = true
|
|
||||||
props.onMeasured?.()
|
|
||||||
}
|
|
||||||
setHasMeasured(true)
|
|
||||||
sizeCache.set(props.cacheKey, previous)
|
|
||||||
setMeasuredHeight(previous)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (normalized > 0) {
|
|
||||||
sizeCache.set(props.cacheKey, normalized)
|
|
||||||
setHasMeasured(true)
|
|
||||||
if (!hasReportedMeasurement) {
|
|
||||||
hasReportedMeasurement = true
|
|
||||||
props.onMeasured?.()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setMeasuredHeight(normalized)
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateMeasuredHeight() {
|
|
||||||
if (!contentRef || measurementsSuspended()) return
|
|
||||||
const next = contentRef.offsetHeight
|
|
||||||
if (next === measuredHeight()) return
|
|
||||||
persistMeasurement(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupResizeObserver() {
|
|
||||||
if (!contentRef || measurementsSuspended()) return
|
|
||||||
cleanupResizeObserver()
|
|
||||||
if (typeof ResizeObserver === "undefined") {
|
|
||||||
updateMeasuredHeight()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resizeObserver = new ResizeObserver(() => {
|
|
||||||
if (measurementsSuspended()) return
|
|
||||||
updateMeasuredHeight()
|
|
||||||
})
|
|
||||||
resizeObserver.observe(contentRef)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function refreshIntersectionObserver(targetRoot: Element | Document | null) {
|
|
||||||
cleanupIntersectionObserver()
|
|
||||||
if (!wrapperRef) {
|
|
||||||
setIsIntersecting(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (typeof IntersectionObserver === "undefined") {
|
|
||||||
setIsIntersecting(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const margin = props.threshold ?? DEFAULT_MARGIN_PX
|
|
||||||
intersectionCleanup = subscribeToSharedObserver(wrapperRef, targetRoot, margin, (entry) => {
|
|
||||||
const nextVisible = shouldRenderEntry(entry)
|
|
||||||
queueVisibility(nextVisible)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function setWrapperRef(element: HTMLDivElement | null) {
|
|
||||||
wrapperRef = element ?? undefined
|
|
||||||
const root = props.scrollContainer ? props.scrollContainer() : null
|
|
||||||
refreshIntersectionObserver(root ?? null)
|
|
||||||
}
|
|
||||||
|
|
||||||
function setContentRef(element: HTMLDivElement | null) {
|
|
||||||
contentRef = element ?? undefined
|
|
||||||
if (contentRef) {
|
|
||||||
queueMicrotask(() => {
|
|
||||||
if (shouldHideContent() || measurementsSuspended()) return
|
|
||||||
updateMeasuredHeight()
|
|
||||||
setupResizeObserver()
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
cleanupResizeObserver()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (shouldHideContent() || measurementsSuspended()) {
|
|
||||||
cleanupResizeObserver()
|
|
||||||
} else if (contentRef) {
|
|
||||||
queueMicrotask(() => {
|
|
||||||
updateMeasuredHeight()
|
|
||||||
setupResizeObserver()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const key = props.cacheKey
|
|
||||||
|
|
||||||
const cached = sizeCache.get(key)
|
|
||||||
if (cached !== undefined) {
|
|
||||||
setMeasuredHeight(cached)
|
|
||||||
setHasMeasured(true)
|
|
||||||
} else {
|
|
||||||
setMeasuredHeight(0)
|
|
||||||
setHasMeasured(false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
measurementsSuspended()
|
|
||||||
const root = props.scrollContainer ? props.scrollContainer() : null
|
|
||||||
refreshIntersectionObserver(root ?? null)
|
|
||||||
})
|
|
||||||
|
|
||||||
const placeholderHeight = createMemo(() => {
|
|
||||||
|
|
||||||
const seenHeight = measuredHeight()
|
|
||||||
if (seenHeight > 0) {
|
|
||||||
return seenHeight
|
|
||||||
}
|
|
||||||
return props.minPlaceholderHeight ?? MIN_PLACEHOLDER_HEIGHT
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
cleanupResizeObserver()
|
|
||||||
cleanupIntersectionObserver()
|
|
||||||
flushVisibility()
|
|
||||||
})
|
|
||||||
|
|
||||||
const wrapperClass = () => ["virtual-item-wrapper", props.class].filter(Boolean).join(" ")
|
|
||||||
const contentClass = () => {
|
|
||||||
const classes = ["virtual-item-content", props.contentClass]
|
|
||||||
if (shouldHideContent()) {
|
|
||||||
classes.push("virtual-item-content-hidden")
|
|
||||||
}
|
|
||||||
return classes.filter(Boolean).join(" ")
|
|
||||||
}
|
|
||||||
const placeholderClass = () => ["virtual-item-placeholder", props.placeholderClass].filter(Boolean).join(" ")
|
|
||||||
const lazyContent = createMemo<JSX.Element | null>(() => {
|
|
||||||
if (shouldHideContent()) return null
|
|
||||||
return resolved()
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={setWrapperRef} id={props.id} class={wrapperClass()} style={{ width: "100%" }}>
|
|
||||||
<div
|
|
||||||
class={placeholderClass()}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: shouldHideContent() ? `${placeholderHeight()}px` : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div ref={setContentRef} class={contentClass()}>
|
|
||||||
{lazyContent()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
23
packages/ui/src/lib/external-url.ts
Normal file
23
packages/ui/src/lib/external-url.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { isTauriHost } from "./runtime-env"
|
||||||
|
|
||||||
|
export async function openExternalUrl(url: string, context = "ui"): Promise<void> {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTauriHost()) {
|
||||||
|
try {
|
||||||
|
const { openUrl } = await import("@tauri-apps/plugin-opener")
|
||||||
|
await openUrl(url)
|
||||||
|
return
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[${context}] unable to open via system opener`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer")
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[${context}] unable to open external url`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
200
packages/ui/src/lib/git-diff-lowlight.ts
Normal file
200
packages/ui/src/lib/git-diff-lowlight.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { createLowlight, common } from "lowlight"
|
||||||
|
|
||||||
|
type AstNode = {
|
||||||
|
type: string
|
||||||
|
value?: string
|
||||||
|
children?: AstNode[]
|
||||||
|
startIndex?: number
|
||||||
|
endIndex?: number
|
||||||
|
lineNumber?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyntaxNodeEntry = {
|
||||||
|
node: AstNode
|
||||||
|
wrapper?: AstNode
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyntaxFileLine = {
|
||||||
|
value: string
|
||||||
|
lineNumber: number
|
||||||
|
valueLength: number
|
||||||
|
nodeList: SyntaxNodeEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type LowlightApi = ReturnType<typeof createLowlight>
|
||||||
|
|
||||||
|
export function processAST(ast: { children: AstNode[] }) {
|
||||||
|
let lineNumber = 1
|
||||||
|
const syntaxObj: Record<number, SyntaxFileLine> = {}
|
||||||
|
|
||||||
|
const loopAST = (nodes: AstNode[], wrapper?: AstNode) => {
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
if (node.type === "text") {
|
||||||
|
const textValue = node.value ?? ""
|
||||||
|
if (!textValue.includes("\n")) {
|
||||||
|
const valueLength = textValue.length
|
||||||
|
if (!syntaxObj[lineNumber]) {
|
||||||
|
node.startIndex = 0
|
||||||
|
node.endIndex = valueLength - 1
|
||||||
|
syntaxObj[lineNumber] = {
|
||||||
|
value: textValue,
|
||||||
|
lineNumber,
|
||||||
|
valueLength,
|
||||||
|
nodeList: [{ node, wrapper }],
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
node.startIndex = syntaxObj[lineNumber].valueLength
|
||||||
|
node.endIndex = node.startIndex + valueLength - 1
|
||||||
|
syntaxObj[lineNumber].value += textValue
|
||||||
|
syntaxObj[lineNumber].valueLength += valueLength
|
||||||
|
syntaxObj[lineNumber].nodeList.push({ node, wrapper })
|
||||||
|
}
|
||||||
|
node.lineNumber = lineNumber
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = textValue.split("\n")
|
||||||
|
node.children = node.children || []
|
||||||
|
for (let index = 0; index < lines.length; index++) {
|
||||||
|
const value = index === lines.length - 1 ? lines[index] : `${lines[index]}\n`
|
||||||
|
const currentLineNumber = index === 0 ? lineNumber : ++lineNumber
|
||||||
|
const valueLength = value.length
|
||||||
|
const childNode: AstNode = {
|
||||||
|
type: "text",
|
||||||
|
value,
|
||||||
|
startIndex: Infinity,
|
||||||
|
endIndex: Infinity,
|
||||||
|
lineNumber: currentLineNumber,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!syntaxObj[currentLineNumber]) {
|
||||||
|
childNode.startIndex = 0
|
||||||
|
childNode.endIndex = valueLength - 1
|
||||||
|
syntaxObj[currentLineNumber] = {
|
||||||
|
value,
|
||||||
|
lineNumber: currentLineNumber,
|
||||||
|
valueLength,
|
||||||
|
nodeList: [{ node: childNode, wrapper }],
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
childNode.startIndex = syntaxObj[currentLineNumber].valueLength
|
||||||
|
childNode.endIndex = childNode.startIndex + valueLength - 1
|
||||||
|
syntaxObj[currentLineNumber].value += value
|
||||||
|
syntaxObj[currentLineNumber].valueLength += valueLength
|
||||||
|
syntaxObj[currentLineNumber].nodeList.push({ node: childNode, wrapper })
|
||||||
|
}
|
||||||
|
|
||||||
|
node.children.push(childNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
node.lineNumber = lineNumber
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children) {
|
||||||
|
loopAST(node.children, node)
|
||||||
|
node.lineNumber = lineNumber
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
loopAST(ast.children)
|
||||||
|
return { syntaxFileObject: syntaxObj, syntaxFileLineNumber: lineNumber }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _getAST() {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowlight = createLowlight(common)
|
||||||
|
|
||||||
|
lowlight.register("vue", function hljsDefineVue(hljs: any) {
|
||||||
|
return {
|
||||||
|
subLanguage: "xml",
|
||||||
|
contains: [
|
||||||
|
hljs.COMMENT("<!--", "-->", { relevance: 10 }),
|
||||||
|
{
|
||||||
|
begin: /^(\s*)(<script>)/gm,
|
||||||
|
end: /^(\s*)(<\/script>)/gm,
|
||||||
|
subLanguage: "javascript",
|
||||||
|
excludeBegin: true,
|
||||||
|
excludeEnd: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
begin: /^(?:\s*)(?:<script\s+lang=(["'])ts\1>)/gm,
|
||||||
|
end: /^(\s*)(<\/script>)/gm,
|
||||||
|
subLanguage: "typescript",
|
||||||
|
excludeBegin: true,
|
||||||
|
excludeEnd: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
begin: /^(\s*)(<style(\s+scoped)?>)/gm,
|
||||||
|
end: /^(\s*)(<\/style>)/gm,
|
||||||
|
subLanguage: "css",
|
||||||
|
excludeBegin: true,
|
||||||
|
excludeEnd: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
begin: /^(?:\s*)(?:<style(?:\s+scoped)?\s+lang=(["'])(?:s[ca]ss)\1(?:\s+scoped)?>)/gm,
|
||||||
|
end: /^(\s*)(<\/style>)/gm,
|
||||||
|
subLanguage: "scss",
|
||||||
|
excludeBegin: true,
|
||||||
|
excludeEnd: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
begin: /^(?:\s*)(?:<style(?:\s+scoped)?\s+lang=(["'])stylus\1(?:\s+scoped)?>)/gm,
|
||||||
|
end: /^(\s*)(<\/style>)/gm,
|
||||||
|
subLanguage: "stylus",
|
||||||
|
excludeBegin: true,
|
||||||
|
excludeEnd: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let maxLineToIgnoreSyntax = 2000
|
||||||
|
const ignoreSyntaxHighlightList: (string | RegExp)[] = []
|
||||||
|
|
||||||
|
export const highlighter = {
|
||||||
|
name: "lowlight",
|
||||||
|
get maxLineToIgnoreSyntax() {
|
||||||
|
return maxLineToIgnoreSyntax
|
||||||
|
},
|
||||||
|
setMaxLineToIgnoreSyntax(value: number) {
|
||||||
|
maxLineToIgnoreSyntax = value
|
||||||
|
},
|
||||||
|
get ignoreSyntaxHighlightList() {
|
||||||
|
return ignoreSyntaxHighlightList
|
||||||
|
},
|
||||||
|
setIgnoreSyntaxHighlightList(values: (string | RegExp)[]) {
|
||||||
|
ignoreSyntaxHighlightList.length = 0
|
||||||
|
ignoreSyntaxHighlightList.push(...values)
|
||||||
|
},
|
||||||
|
getAST(raw: string, fileName?: string, lang?: string) {
|
||||||
|
const language = typeof lang === "string" ? lang.trim() : ""
|
||||||
|
if (
|
||||||
|
fileName &&
|
||||||
|
ignoreSyntaxHighlightList.some((item) => (item instanceof RegExp ? item.test(fileName) : fileName === item))
|
||||||
|
) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (language && lowlight.registered(language)) {
|
||||||
|
return lowlight.highlight(language, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lowlight.highlightAuto(raw)
|
||||||
|
},
|
||||||
|
processAST(ast: { children: AstNode[] }) {
|
||||||
|
return processAST(ast)
|
||||||
|
},
|
||||||
|
hasRegisteredCurrentLang(lang: string) {
|
||||||
|
return lowlight.registered(lang)
|
||||||
|
},
|
||||||
|
getHighlighterEngine(): LowlightApi {
|
||||||
|
return lowlight
|
||||||
|
},
|
||||||
|
type: "class" as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const versions = "local-common"
|
||||||
@@ -14,7 +14,7 @@ import { getLogger } from "../logger"
|
|||||||
import { requestData } from "../opencode-api"
|
import { requestData } from "../opencode-api"
|
||||||
import { emitSessionSidebarRequest } from "../session-sidebar-events"
|
import { emitSessionSidebarRequest } from "../session-sidebar-events"
|
||||||
import { tGlobal } from "../i18n"
|
import { tGlobal } from "../i18n"
|
||||||
import { runtimeEnv } from "../runtime-env"
|
import { registerBehaviorCommands } from "../settings/behavior-registry"
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -427,178 +427,19 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
commandRegistry.register({
|
registerBehaviorCommands((command) => commandRegistry.register(command), {
|
||||||
id: "prompt-submit-shortcut",
|
preferences: options.preferences,
|
||||||
label: () =>
|
toggleShowThinkingBlocks: options.toggleShowThinkingBlocks,
|
||||||
options.preferences().promptSubmitOnEnter
|
toggleKeyboardShortcutHints: options.toggleKeyboardShortcutHints,
|
||||||
? tGlobal("commands.promptSubmitShortcut.label.swapped")
|
toggleShowTimelineTools: options.toggleShowTimelineTools,
|
||||||
: tGlobal("commands.promptSubmitShortcut.label.default"),
|
toggleUsageMetrics: options.toggleUsageMetrics,
|
||||||
description: () => tGlobal("commands.promptSubmitShortcut.description"),
|
toggleAutoCleanupBlankSessions: options.toggleAutoCleanupBlankSessions,
|
||||||
category: "Input & Focus",
|
togglePromptSubmitOnEnter: options.togglePromptSubmitOnEnter,
|
||||||
keywords: () => splitKeywords("commands.promptSubmitShortcut.keywords"),
|
setDiffViewMode: options.setDiffViewMode,
|
||||||
action: options.togglePromptSubmitOnEnter,
|
setToolOutputExpansion: options.setToolOutputExpansion,
|
||||||
})
|
setDiagnosticsExpansion: options.setDiagnosticsExpansion,
|
||||||
|
setThinkingBlocksExpansion: options.setThinkingBlocksExpansion,
|
||||||
commandRegistry.register({
|
setToolInputsVisibility: options.setToolInputsVisibility,
|
||||||
id: "thinking",
|
|
||||||
label: () => tGlobal(options.preferences().showThinkingBlocks ? "commands.thinkingBlocks.label.hide" : "commands.thinkingBlocks.label.show"),
|
|
||||||
description: () => tGlobal("commands.thinkingBlocks.description"),
|
|
||||||
category: "System",
|
|
||||||
keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocks.keywords")],
|
|
||||||
action: options.toggleShowThinkingBlocks,
|
|
||||||
})
|
|
||||||
|
|
||||||
commandRegistry.register({
|
|
||||||
id: "timeline-tools",
|
|
||||||
label: () => tGlobal(options.preferences().showTimelineTools ? "commands.timelineToolCalls.label.hide" : "commands.timelineToolCalls.label.show"),
|
|
||||||
description: () => tGlobal("commands.timelineToolCalls.description"),
|
|
||||||
category: "System",
|
|
||||||
keywords: () => splitKeywords("commands.timelineToolCalls.keywords"),
|
|
||||||
action: options.toggleShowTimelineTools,
|
|
||||||
})
|
|
||||||
|
|
||||||
commandRegistry.register({
|
|
||||||
id: "keyboard-shortcut-hints",
|
|
||||||
label: () =>
|
|
||||||
tGlobal(
|
|
||||||
options.preferences().showKeyboardShortcutHints
|
|
||||||
? "commands.keyboardShortcutHints.label.hide"
|
|
||||||
: "commands.keyboardShortcutHints.label.show",
|
|
||||||
),
|
|
||||||
description: () =>
|
|
||||||
tGlobal(
|
|
||||||
runtimeEnv.host === "web"
|
|
||||||
? "commands.keyboardShortcutHints.description.disabledWeb"
|
|
||||||
: "commands.keyboardShortcutHints.description",
|
|
||||||
),
|
|
||||||
category: "System",
|
|
||||||
keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"),
|
|
||||||
disabled: () => runtimeEnv.host === "web",
|
|
||||||
action: options.toggleKeyboardShortcutHints,
|
|
||||||
})
|
|
||||||
|
|
||||||
commandRegistry.register({
|
|
||||||
id: "thinking-default-visibility",
|
|
||||||
label: () => {
|
|
||||||
const mode = options.preferences().thinkingBlocksExpansion ?? "expanded"
|
|
||||||
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
|
|
||||||
return tGlobal("commands.thinkingBlocksDefault.label", { state })
|
|
||||||
},
|
|
||||||
description: () => tGlobal("commands.thinkingBlocksDefault.description"),
|
|
||||||
category: "System",
|
|
||||||
keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocksDefault.keywords")],
|
|
||||||
action: () => {
|
|
||||||
const mode = options.preferences().thinkingBlocksExpansion ?? "expanded"
|
|
||||||
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
|
|
||||||
options.setThinkingBlocksExpansion(next)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
commandRegistry.register({
|
|
||||||
id: "diff-view-split",
|
|
||||||
label: () => {
|
|
||||||
const prefix = (options.preferences().diffViewMode || "split") === "split" ? "✓ " : ""
|
|
||||||
return `${prefix}${tGlobal("commands.diffViewSplit.label")}`
|
|
||||||
},
|
|
||||||
description: () => tGlobal("commands.diffViewSplit.description"),
|
|
||||||
category: "System",
|
|
||||||
keywords: () => splitKeywords("commands.diffViewSplit.keywords"),
|
|
||||||
action: () => options.setDiffViewMode("split"),
|
|
||||||
})
|
|
||||||
|
|
||||||
commandRegistry.register({
|
|
||||||
id: "diff-view-unified",
|
|
||||||
label: () => {
|
|
||||||
const prefix = (options.preferences().diffViewMode || "split") === "unified" ? "✓ " : ""
|
|
||||||
return `${prefix}${tGlobal("commands.diffViewUnified.label")}`
|
|
||||||
},
|
|
||||||
description: () => tGlobal("commands.diffViewUnified.description"),
|
|
||||||
category: "System",
|
|
||||||
keywords: () => splitKeywords("commands.diffViewUnified.keywords"),
|
|
||||||
action: () => options.setDiffViewMode("unified"),
|
|
||||||
})
|
|
||||||
|
|
||||||
commandRegistry.register({
|
|
||||||
id: "tool-output-default-visibility",
|
|
||||||
label: () => {
|
|
||||||
const mode = options.preferences().toolOutputExpansion || "expanded"
|
|
||||||
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
|
|
||||||
return tGlobal("commands.toolOutputsDefault.label", { state })
|
|
||||||
},
|
|
||||||
description: () => tGlobal("commands.toolOutputsDefault.description"),
|
|
||||||
category: "System",
|
|
||||||
keywords: () => splitKeywords("commands.toolOutputsDefault.keywords"),
|
|
||||||
action: () => {
|
|
||||||
const mode = options.preferences().toolOutputExpansion || "expanded"
|
|
||||||
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
|
|
||||||
options.setToolOutputExpansion(next)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
commandRegistry.register({
|
|
||||||
id: "diagnostics-default-visibility",
|
|
||||||
label: () => {
|
|
||||||
const mode = options.preferences().diagnosticsExpansion || "expanded"
|
|
||||||
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
|
|
||||||
return tGlobal("commands.diagnosticsDefault.label", { state })
|
|
||||||
},
|
|
||||||
description: () => tGlobal("commands.diagnosticsDefault.description"),
|
|
||||||
category: "System",
|
|
||||||
keywords: () => splitKeywords("commands.diagnosticsDefault.keywords"),
|
|
||||||
action: () => {
|
|
||||||
const mode = options.preferences().diagnosticsExpansion || "expanded"
|
|
||||||
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
|
|
||||||
options.setDiagnosticsExpansion(next)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
commandRegistry.register({
|
|
||||||
id: "tool-inputs-visibility",
|
|
||||||
label: () => {
|
|
||||||
const mode = options.preferences().toolInputsVisibility || "hidden"
|
|
||||||
const state =
|
|
||||||
mode === "expanded"
|
|
||||||
? tGlobal("commands.common.expanded")
|
|
||||||
: mode === "collapsed"
|
|
||||||
? tGlobal("commands.common.collapsed")
|
|
||||||
: tGlobal("commands.common.hidden")
|
|
||||||
return tGlobal("commands.toolInputsVisibility.label", { state })
|
|
||||||
},
|
|
||||||
description: () => tGlobal("commands.toolInputsVisibility.description"),
|
|
||||||
category: "System",
|
|
||||||
keywords: () => splitKeywords("commands.toolInputsVisibility.keywords"),
|
|
||||||
action: () => {
|
|
||||||
const mode = options.preferences().toolInputsVisibility || "hidden"
|
|
||||||
const next: ToolInputsVisibilityPreference =
|
|
||||||
mode === "hidden" ? "collapsed" : mode === "collapsed" ? "expanded" : "hidden"
|
|
||||||
options.setToolInputsVisibility(next)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
commandRegistry.register({
|
|
||||||
id: "token-usage-visibility",
|
|
||||||
label: () => {
|
|
||||||
const visible = options.preferences().showUsageMetrics ?? true
|
|
||||||
const state = visible ? tGlobal("commands.common.visible") : tGlobal("commands.common.hidden")
|
|
||||||
return tGlobal("commands.tokenUsageDisplay.label", { state })
|
|
||||||
},
|
|
||||||
description: () => tGlobal("commands.tokenUsageDisplay.description"),
|
|
||||||
category: "System",
|
|
||||||
keywords: () => splitKeywords("commands.tokenUsageDisplay.keywords"),
|
|
||||||
action: options.toggleUsageMetrics,
|
|
||||||
})
|
|
||||||
|
|
||||||
commandRegistry.register({
|
|
||||||
id: "auto-cleanup-blank-sessions",
|
|
||||||
label: () => {
|
|
||||||
const enabled = options.preferences().autoCleanupBlankSessions
|
|
||||||
const state = enabled ? tGlobal("commands.common.enabled") : tGlobal("commands.common.disabled")
|
|
||||||
return tGlobal("commands.autoCleanupBlankSessions.label", { state })
|
|
||||||
},
|
|
||||||
description: () => tGlobal("commands.autoCleanupBlankSessions.description"),
|
|
||||||
category: "System",
|
|
||||||
keywords: () => splitKeywords("commands.autoCleanupBlankSessions.keywords"),
|
|
||||||
action: options.toggleAutoCleanupBlankSessions,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
|
|||||||
158
packages/ui/src/lib/hooks/use-folder-drop.ts
Normal file
158
packages/ui/src/lib/hooks/use-folder-drop.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { Accessor, createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||||
|
import {
|
||||||
|
containsFileDrop,
|
||||||
|
extractDroppedDirectoryPaths,
|
||||||
|
listenForNativeFolderDrops,
|
||||||
|
listenForNativeFolderDropState,
|
||||||
|
normalizeDroppedDirectoryPaths,
|
||||||
|
supportsDesktopFolderDrop,
|
||||||
|
} from "../native/desktop-file-drop"
|
||||||
|
import { runtimeEnv } from "../runtime-env"
|
||||||
|
|
||||||
|
interface UseFolderDropOptions {
|
||||||
|
enabled: Accessor<boolean>
|
||||||
|
onDrop: (paths: string[]) => void | Promise<void>
|
||||||
|
onInvalidDrop?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FolderDropBindings {
|
||||||
|
onDragEnter: (event: DragEvent) => void
|
||||||
|
onDragOver: (event: DragEvent) => void
|
||||||
|
onDragLeave: (event: DragEvent) => void
|
||||||
|
onDrop: (event: DragEvent) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFolderDrop(options: UseFolderDropOptions): {
|
||||||
|
isActive: Accessor<boolean>
|
||||||
|
isSupported: boolean
|
||||||
|
bind: FolderDropBindings
|
||||||
|
} {
|
||||||
|
const [isActive, setIsActive] = createSignal(false)
|
||||||
|
const [dragDepth, setDragDepth] = createSignal(0)
|
||||||
|
const isSupported = supportsDesktopFolderDrop()
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setDragDepth(0)
|
||||||
|
setIsActive(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResolvedPaths(paths: string[]) {
|
||||||
|
reset()
|
||||||
|
if (!options.enabled()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const directoryPaths = await normalizeDroppedDirectoryPaths(paths)
|
||||||
|
if (directoryPaths.length === 0) {
|
||||||
|
options.onInvalidDrop?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await options.onDrop(directoryPaths)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!options.enabled()) {
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!isSupported) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let disposeNativeDrop = () => {}
|
||||||
|
let disposeNativeState = () => {}
|
||||||
|
|
||||||
|
void listenForNativeFolderDrops((paths) => {
|
||||||
|
if (!options.enabled()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void handleResolvedPaths(paths)
|
||||||
|
}).then((dispose) => {
|
||||||
|
disposeNativeDrop = dispose
|
||||||
|
})
|
||||||
|
|
||||||
|
void listenForNativeFolderDropState((state) => {
|
||||||
|
if (!options.enabled()) {
|
||||||
|
reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (state === "enter") {
|
||||||
|
setIsActive(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reset()
|
||||||
|
}).then((dispose) => {
|
||||||
|
disposeNativeState = dispose
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
disposeNativeDrop()
|
||||||
|
disposeNativeState()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const bind: FolderDropBindings = {
|
||||||
|
onDragEnter(event) {
|
||||||
|
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
setDragDepth((prev) => prev + 1)
|
||||||
|
setIsActive(true)
|
||||||
|
},
|
||||||
|
onDragOver(event) {
|
||||||
|
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.dropEffect = "copy"
|
||||||
|
}
|
||||||
|
setIsActive(true)
|
||||||
|
},
|
||||||
|
onDragLeave(event) {
|
||||||
|
if (!isSupported || runtimeEnv.host === "tauri" || !containsFileDrop(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
const nextDepth = Math.max(0, dragDepth() - 1)
|
||||||
|
setDragDepth(nextDepth)
|
||||||
|
if (nextDepth === 0) {
|
||||||
|
setIsActive(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDrop(event) {
|
||||||
|
if (!isSupported) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
if (!options.enabled()) {
|
||||||
|
reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtimeEnv.host === "tauri") {
|
||||||
|
reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const paths = extractDroppedDirectoryPaths(event)
|
||||||
|
if (paths.length === 0) {
|
||||||
|
reset()
|
||||||
|
options.onInvalidDrop?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleResolvedPaths(paths)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isActive,
|
||||||
|
isSupported,
|
||||||
|
bind,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
export const appMessages = {
|
export const appMessages = {
|
||||||
"app.launchError.title": "Unable to launch OpenCode",
|
"app.launchError.title": "Unable to launch OpenCode",
|
||||||
"app.launchError.description": "We couldn't start the selected OpenCode binary. Review the error output below or choose a different binary from Advanced Settings.",
|
"app.launchError.description": "We couldn't start the selected OpenCode binary. Review the error output below or choose a different binary from OpenCode settings.",
|
||||||
"app.launchError.binaryPathLabel": "Binary path",
|
"app.launchError.binaryPathLabel": "Binary path",
|
||||||
"app.launchError.errorOutputLabel": "Error output",
|
"app.launchError.errorOutputLabel": "Error output",
|
||||||
"app.launchError.openAdvancedSettings": "Open Advanced Settings",
|
"app.launchError.openAdvancedSettings": "Open OpenCode Settings",
|
||||||
"app.launchError.close": "Close",
|
"app.launchError.close": "Close",
|
||||||
"app.launchError.closeTitle": "Close (Esc)",
|
"app.launchError.closeTitle": "Close (Esc)",
|
||||||
"app.launchError.fallbackMessage": "Failed to launch workspace",
|
"app.launchError.fallbackMessage": "Failed to launch workspace",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.browse.buttonOpening": "Opening...",
|
"folderSelection.browse.buttonOpening": "Opening...",
|
||||||
|
|
||||||
"folderSelection.advancedSettings": "Advanced Settings",
|
"folderSelection.advancedSettings": "Advanced Settings",
|
||||||
|
"folderSelection.opencode": "OpenCode",
|
||||||
|
|
||||||
"folderSelection.hints.navigate": "Navigate",
|
"folderSelection.hints.navigate": "Navigate",
|
||||||
"folderSelection.hints.select": "Select",
|
"folderSelection.hints.select": "Select",
|
||||||
@@ -31,6 +32,11 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.loading.title": "Starting instance...",
|
"folderSelection.loading.title": "Starting instance...",
|
||||||
"folderSelection.loading.subtitle": "Hang tight while we prepare your workspace.",
|
"folderSelection.loading.subtitle": "Hang tight while we prepare your workspace.",
|
||||||
|
|
||||||
|
"folderSelection.drop.title": "Drop a folder to open it",
|
||||||
|
"folderSelection.drop.subtitle": "Start a new instance in the dropped folder.",
|
||||||
|
"folderSelection.drop.invalidTitle": "Couldn't open dropped item",
|
||||||
|
"folderSelection.drop.invalidMessage": "Drop a folder to start a new instance.",
|
||||||
|
|
||||||
"folderSelection.dialog.title": "Select Workspace",
|
"folderSelection.dialog.title": "Select Workspace",
|
||||||
"folderSelection.dialog.description": "Select workspace to start coding.",
|
"folderSelection.dialog.description": "Select workspace to start coding.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -114,6 +114,10 @@ export const instanceMessages = {
|
|||||||
"instanceShell.sessionChanges.filesChanged": "{count} files changed",
|
"instanceShell.sessionChanges.filesChanged": "{count} files changed",
|
||||||
"instanceShell.sessionChanges.actions.show": "Show changes",
|
"instanceShell.sessionChanges.actions.show": "Show changes",
|
||||||
|
|
||||||
|
"instanceShell.gitChanges.loading": "Loading git changes...",
|
||||||
|
"instanceShell.gitChanges.empty": "No git changes yet.",
|
||||||
|
"instanceShell.gitChanges.deleted": "Deleted",
|
||||||
|
|
||||||
"instanceShell.filesShell.fileListTitle": "File list",
|
"instanceShell.filesShell.fileListTitle": "File list",
|
||||||
"instanceShell.filesShell.mobileSelectorLabel": "Select file",
|
"instanceShell.filesShell.mobileSelectorLabel": "Select file",
|
||||||
"instanceShell.filesShell.mobileSelectorEmpty": "Select a file",
|
"instanceShell.filesShell.mobileSelectorEmpty": "Select a file",
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export const messagingMessages = {
|
|||||||
"messageSection.quote.copy": "Copy",
|
"messageSection.quote.copy": "Copy",
|
||||||
"messageSection.quote.copied": "Copied!",
|
"messageSection.quote.copied": "Copied!",
|
||||||
"messageSection.quote.copyFailed": "Copy failed",
|
"messageSection.quote.copyFailed": "Copy failed",
|
||||||
|
|
||||||
"messageTimeline.ariaLabel": "Message timeline",
|
"messageTimeline.ariaLabel": "Message timeline",
|
||||||
"messageTimeline.segment.user.label": "You",
|
"messageTimeline.segment.user.label": "You",
|
||||||
"messageTimeline.segment.assistant.label": "Asst",
|
"messageTimeline.segment.assistant.label": "Asst",
|
||||||
@@ -35,13 +34,12 @@ export const messagingMessages = {
|
|||||||
"messageTimeline.tooltip.compaction.manual": "User Compaction",
|
"messageTimeline.tooltip.compaction.manual": "User Compaction",
|
||||||
"messageTimeline.text.filePrefix": "[File] {filename}",
|
"messageTimeline.text.filePrefix": "[File] {filename}",
|
||||||
"messageTimeline.text.attachment": "Attachment",
|
"messageTimeline.text.attachment": "Attachment",
|
||||||
|
|
||||||
"messageBlock.tool.header": "Tool Call",
|
"messageBlock.tool.header": "Tool Call",
|
||||||
"messageBlock.tool.unknown": "unknown",
|
"messageBlock.tool.unknown": "unknown",
|
||||||
"messageBlock.tool.goToSession.label": "Go to Session",
|
"messageBlock.tool.goToSession.label": "Go to Session",
|
||||||
"messageBlock.tool.goToSession.title": "Go to session",
|
"messageBlock.tool.goToSession.title": "Go to session",
|
||||||
"messageBlock.tool.goToSession.unavailableTitle": "Session not available yet",
|
"messageBlock.tool.goToSession.unavailableTitle": "Session not available yet",
|
||||||
"messageBlock.tool.deletePart.label": "Delete",
|
"messageBlock.tool.deletePart.label": "Delete Part",
|
||||||
"messageBlock.tool.deletePart.deleting": "Deleting...",
|
"messageBlock.tool.deletePart.deleting": "Deleting...",
|
||||||
"messageBlock.tool.deletePart.title": "Delete this tool call output",
|
"messageBlock.tool.deletePart.title": "Delete this tool call output",
|
||||||
"messageBlock.tool.deletePart.failed.title": "Delete failed",
|
"messageBlock.tool.deletePart.failed.title": "Delete failed",
|
||||||
@@ -71,17 +69,38 @@ export const messagingMessages = {
|
|||||||
"messageItem.speaker.you": "You",
|
"messageItem.speaker.you": "You",
|
||||||
"messageItem.speaker.assistant": "Assistant",
|
"messageItem.speaker.assistant": "Assistant",
|
||||||
"messageItem.actions.revert": "Revert",
|
"messageItem.actions.revert": "Revert",
|
||||||
"messageItem.actions.revertTitle": "Revert to this message",
|
"messageItem.actions.revertTitle": "Undo changes up to here (deletes messages)",
|
||||||
"messageItem.actions.fork": "Fork",
|
"messageItem.actions.fork": "Fork",
|
||||||
"messageItem.actions.forkTitle": "Fork from this message",
|
"messageItem.actions.forkTitle": "Fork from this message",
|
||||||
"messageItem.actions.copy": "Copy",
|
"messageItem.actions.copy": "Copy",
|
||||||
"messageItem.actions.copyTitle": "Copy message",
|
"messageItem.actions.copyTitle": "Copy message",
|
||||||
"messageItem.actions.copied": "Copied!",
|
"messageItem.actions.copied": "Copied!",
|
||||||
|
"messageItem.actions.deleteMessage": "Delete message (doesn't undo changes)",
|
||||||
|
"messageItem.actions.deleteMessagesUpTo": "Delete messages up to here (doesn't undo changes)",
|
||||||
|
"messageItem.actions.deletingMessage": "Deleting...",
|
||||||
|
"messageItem.actions.deleteMessageFailedTitle": "Delete failed",
|
||||||
|
"messageItem.actions.deleteMessageFailedMessage": "Failed to delete message",
|
||||||
|
|
||||||
|
"messageItem.selection.checkboxAriaLabel": "Select message for deletion",
|
||||||
|
|
||||||
|
"messageSection.bulkDelete.toolbarAriaLabel": "Selected items ({count})",
|
||||||
|
"messageSection.bulkDelete.deleteSelectedTitle": "Delete selected items",
|
||||||
|
"messageSection.bulkDelete.selectAllTitle": "Select all messages",
|
||||||
|
"messageSection.bulkDelete.moreOptionsTitle": "More options",
|
||||||
|
"messageSection.bulkDelete.selectionModeLabel": "Selection",
|
||||||
|
"messageSection.bulkDelete.selectionModeAll": "All",
|
||||||
|
"messageSection.bulkDelete.selectionModeTools": "Tools only",
|
||||||
|
"messageSection.bulkDelete.selectionHint.toggle": "Select item",
|
||||||
|
"messageSection.bulkDelete.selectionHint.range": "Select range",
|
||||||
|
"messageSection.bulkDelete.selectionHint.clear": "Clear Selection",
|
||||||
|
"messageSection.bulkDelete.cancelTitle": "Cancel selection",
|
||||||
|
"messageSection.bulkDelete.failedTitle": "Delete failed",
|
||||||
|
"messageSection.bulkDelete.failedMessage": "Failed to delete selected items",
|
||||||
"messageItem.status.queued": "QUEUED",
|
"messageItem.status.queued": "QUEUED",
|
||||||
"messageItem.status.generating": "Generating...",
|
"messageItem.status.generating": "Generating...",
|
||||||
"messageItem.status.sending": "Sending...",
|
"messageItem.status.sending": "Sending...",
|
||||||
"messageItem.status.failedToSend": "Message failed to send",
|
"messageItem.status.failedToSend": "Message failed to send",
|
||||||
"messagePart.actions.delete": "Delete",
|
"messagePart.actions.delete": "Delete Part",
|
||||||
"messagePart.actions.deleting": "Deleting...",
|
"messagePart.actions.deleting": "Deleting...",
|
||||||
"messagePart.actions.deleteTitle": "Delete this item",
|
"messagePart.actions.deleteTitle": "Delete this item",
|
||||||
"messagePart.actions.deleteFailedTitle": "Delete failed",
|
"messagePart.actions.deleteFailedTitle": "Delete failed",
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ export const sessionMessages = {
|
|||||||
"sessionView.alerts.abortFailed.title": "Stop failed",
|
"sessionView.alerts.abortFailed.title": "Stop failed",
|
||||||
"sessionView.alerts.revertFailed.message": "Failed to revert to message",
|
"sessionView.alerts.revertFailed.message": "Failed to revert to message",
|
||||||
"sessionView.alerts.revertFailed.title": "Revert failed",
|
"sessionView.alerts.revertFailed.title": "Revert failed",
|
||||||
|
"sessionView.alerts.deleteUpToFailed.message": "Failed to delete messages",
|
||||||
|
"sessionView.alerts.deleteUpToFailed.title": "Delete failed",
|
||||||
"sessionView.alerts.forkFailed.message": "Failed to fork session",
|
"sessionView.alerts.forkFailed.message": "Failed to fork session",
|
||||||
"sessionView.alerts.forkFailed.title": "Fork failed",
|
"sessionView.alerts.forkFailed.title": "Fork failed",
|
||||||
"sessionView.attachments.expandPastedTextAriaLabel": "Expand pasted text",
|
"sessionView.attachments.expandPastedTextAriaLabel": "Expand pasted text",
|
||||||
|
|||||||
@@ -55,4 +55,88 @@ export const settingsMessages = {
|
|||||||
"contextUsagePanel.labels.used": "Used",
|
"contextUsagePanel.labels.used": "Used",
|
||||||
"contextUsagePanel.labels.available": "Avail",
|
"contextUsagePanel.labels.available": "Avail",
|
||||||
"contextUsagePanel.unavailable": "--",
|
"contextUsagePanel.unavailable": "--",
|
||||||
|
|
||||||
|
"settings.title": "Settings",
|
||||||
|
"settings.navigationAriaLabel": "Settings sections",
|
||||||
|
"settings.close": "Close settings",
|
||||||
|
"settings.content.eyebrow": "Workspace preferences",
|
||||||
|
"settings.open.title": "Open settings",
|
||||||
|
"settings.open.ariaLabel": "Open settings",
|
||||||
|
"settings.nav.appearance": "Appearance",
|
||||||
|
"settings.nav.notifications": "Notifications",
|
||||||
|
"settings.nav.remote": "Remote Access",
|
||||||
|
"settings.nav.opencode": "OpenCode",
|
||||||
|
"settings.scope.device": "This device",
|
||||||
|
"settings.scope.server": "Server setting",
|
||||||
|
"settings.common.enabled": "Enabled",
|
||||||
|
"settings.common.disabled": "Disabled",
|
||||||
|
"settings.section.appearance.title": "Appearance",
|
||||||
|
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
|
||||||
|
"settings.appearance.theme.title": "Theme",
|
||||||
|
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
|
||||||
|
"settings.appearance.theme.option.system": "Match your operating system setting",
|
||||||
|
"settings.appearance.theme.option.light": "Use the light appearance",
|
||||||
|
"settings.appearance.theme.option.dark": "Use the dark appearance",
|
||||||
|
"settings.section.notifications.title": "Notifications",
|
||||||
|
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
|
||||||
|
"settings.notifications.permission.granted": "Granted",
|
||||||
|
"settings.notifications.permission.denied": "Denied",
|
||||||
|
"settings.notifications.permission.default": "Not granted",
|
||||||
|
"settings.notifications.permission.unsupported": "Unsupported",
|
||||||
|
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
|
||||||
|
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
|
||||||
|
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
|
||||||
|
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
|
||||||
|
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
|
||||||
|
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
|
||||||
|
"settings.notifications.sessionStatus.title": "Session status notifications",
|
||||||
|
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
|
||||||
|
"settings.notifications.enable.title": "Enable notifications",
|
||||||
|
"settings.notifications.enable.permission": "Permission: {permission}",
|
||||||
|
"settings.notifications.requestPermission.title": "Request permission",
|
||||||
|
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
|
||||||
|
"settings.notifications.requestPermission.action": "Request",
|
||||||
|
"settings.notifications.allowVisible.title": "Notify when the app is focused",
|
||||||
|
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
|
||||||
|
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
|
||||||
|
"settings.notifications.events.title": "Notify me when",
|
||||||
|
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
|
||||||
|
"settings.notifications.events.needsInput": "Session needs input",
|
||||||
|
"settings.notifications.events.idle": "Session becomes idle",
|
||||||
|
"settings.notifications.status.enabled": "Notifications enabled",
|
||||||
|
"settings.notifications.status.disabled": "Notifications disabled",
|
||||||
|
"settings.notifications.status.unsupported": "Notifications unsupported",
|
||||||
|
"settings.section.remote.title": "Remote Access",
|
||||||
|
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
|
||||||
|
"settings.section.opencode.title": "OpenCode",
|
||||||
|
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
||||||
|
"settings.opencode.runtime.title": "Runtime",
|
||||||
|
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
||||||
|
|
||||||
|
"settings.appearance.behavior.title": "Interaction",
|
||||||
|
"settings.appearance.behavior.subtitle": "Message, diff, and input defaults.",
|
||||||
|
"settings.behavior.keyboardHints.title": "Keyboard shortcut hints",
|
||||||
|
"settings.behavior.keyboardHints.subtitle": "Show keyboard shortcut hints across the UI.",
|
||||||
|
"settings.behavior.thinking.title": "Thinking sections",
|
||||||
|
"settings.behavior.thinking.subtitle": "Show or hide AI thinking sections in messages.",
|
||||||
|
"settings.behavior.thinkingDefault.title": "Thinking default",
|
||||||
|
"settings.behavior.thinkingDefault.subtitle": "Choose whether thinking sections start expanded or collapsed.",
|
||||||
|
"settings.behavior.timelineTools.title": "Timeline tool calls",
|
||||||
|
"settings.behavior.timelineTools.subtitle": "Show or hide tool call entries in the message timeline.",
|
||||||
|
"settings.behavior.diffView.title": "Diff view",
|
||||||
|
"settings.behavior.diffView.subtitle": "Choose how tool-call diffs are displayed.",
|
||||||
|
"settings.behavior.diffView.option.split": "Split",
|
||||||
|
"settings.behavior.diffView.option.unified": "Unified",
|
||||||
|
"settings.behavior.toolOutputsDefault.title": "Tool outputs default",
|
||||||
|
"settings.behavior.toolOutputsDefault.subtitle": "Choose whether tool outputs start expanded or collapsed.",
|
||||||
|
"settings.behavior.diagnosticsDefault.title": "Diagnostics default",
|
||||||
|
"settings.behavior.diagnosticsDefault.subtitle": "Choose whether diagnostics output starts expanded or collapsed.",
|
||||||
|
"settings.behavior.toolInputsVisibility.title": "Tool inputs visibility",
|
||||||
|
"settings.behavior.toolInputsVisibility.subtitle": "Set default visibility for tool call input arguments.",
|
||||||
|
"settings.behavior.usageMetrics.title": "Token usage metrics",
|
||||||
|
"settings.behavior.usageMetrics.subtitle": "Show or hide token and cost stats for assistant messages.",
|
||||||
|
"settings.behavior.autoCleanup.title": "Auto-cleanup blank sessions",
|
||||||
|
"settings.behavior.autoCleanup.subtitle": "Automatically clean up blank sessions when creating new ones.",
|
||||||
|
"settings.behavior.promptSubmit.title": "Enter to submit",
|
||||||
|
"settings.behavior.promptSubmit.subtitle": "Use Enter to submit prompts; Cmd/Ctrl+Enter inserts a new line.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
export const appMessages = {
|
export const appMessages = {
|
||||||
"app.launchError.title": "No se pudo iniciar OpenCode",
|
"app.launchError.title": "No se pudo iniciar OpenCode",
|
||||||
"app.launchError.description": "No pudimos iniciar el binario de OpenCode seleccionado. Revisa la salida de error abajo o elige un binario distinto en Configuración avanzada.",
|
"app.launchError.description": "No pudimos iniciar el binario de OpenCode seleccionado. Revisa la salida de error abajo o elige un binario distinto en la configuración de OpenCode.",
|
||||||
"app.launchError.binaryPathLabel": "Ruta del binario",
|
"app.launchError.binaryPathLabel": "Ruta del binario",
|
||||||
"app.launchError.errorOutputLabel": "Salida de error",
|
"app.launchError.errorOutputLabel": "Salida de error",
|
||||||
"app.launchError.openAdvancedSettings": "Abrir Configuración avanzada",
|
"app.launchError.openAdvancedSettings": "Abrir Configuración de OpenCode",
|
||||||
"app.launchError.close": "Cerrar",
|
"app.launchError.close": "Cerrar",
|
||||||
"app.launchError.closeTitle": "Cerrar (Esc)",
|
"app.launchError.closeTitle": "Cerrar (Esc)",
|
||||||
"app.launchError.fallbackMessage": "No se pudo iniciar el workspace",
|
"app.launchError.fallbackMessage": "No se pudo iniciar el workspace",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.browse.buttonOpening": "Abriendo...",
|
"folderSelection.browse.buttonOpening": "Abriendo...",
|
||||||
|
|
||||||
"folderSelection.advancedSettings": "Configuración avanzada",
|
"folderSelection.advancedSettings": "Configuración avanzada",
|
||||||
|
"folderSelection.opencode": "OpenCode",
|
||||||
|
|
||||||
"folderSelection.hints.navigate": "Navegar",
|
"folderSelection.hints.navigate": "Navegar",
|
||||||
"folderSelection.hints.select": "Seleccionar",
|
"folderSelection.hints.select": "Seleccionar",
|
||||||
@@ -31,6 +32,11 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.loading.title": "Iniciando instancia...",
|
"folderSelection.loading.title": "Iniciando instancia...",
|
||||||
"folderSelection.loading.subtitle": "Espera un momento mientras preparamos tu workspace.",
|
"folderSelection.loading.subtitle": "Espera un momento mientras preparamos tu workspace.",
|
||||||
|
|
||||||
|
"folderSelection.drop.title": "Suelta una carpeta para abrirla",
|
||||||
|
"folderSelection.drop.subtitle": "Inicia una nueva instancia en la carpeta soltada.",
|
||||||
|
"folderSelection.drop.invalidTitle": "No se pudo abrir el elemento soltado",
|
||||||
|
"folderSelection.drop.invalidMessage": "Suelta una carpeta para iniciar una nueva instancia.",
|
||||||
|
|
||||||
"folderSelection.dialog.title": "Seleccionar workspace",
|
"folderSelection.dialog.title": "Seleccionar workspace",
|
||||||
"folderSelection.dialog.description": "Selecciona un workspace para empezar a programar.",
|
"folderSelection.dialog.description": "Selecciona un workspace para empezar a programar.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export const instanceMessages = {
|
|||||||
|
|
||||||
"instanceShell.rightPanel.title": "Panel de estado",
|
"instanceShell.rightPanel.title": "Panel de estado",
|
||||||
"instanceShell.rightPanel.tabs.changes": "Cambios",
|
"instanceShell.rightPanel.tabs.changes": "Cambios",
|
||||||
|
"instanceShell.rightPanel.tabs.gitChanges": "Cambios de Git",
|
||||||
"instanceShell.rightPanel.tabs.files": "Archivos",
|
"instanceShell.rightPanel.tabs.files": "Archivos",
|
||||||
"instanceShell.rightPanel.tabs.status": "Estado",
|
"instanceShell.rightPanel.tabs.status": "Estado",
|
||||||
"instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho",
|
"instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho",
|
||||||
@@ -112,6 +113,10 @@ export const instanceMessages = {
|
|||||||
"instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados",
|
"instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados",
|
||||||
"instanceShell.sessionChanges.actions.show": "Mostrar cambios",
|
"instanceShell.sessionChanges.actions.show": "Mostrar cambios",
|
||||||
|
|
||||||
|
"instanceShell.gitChanges.loading": "Cargando cambios de Git...",
|
||||||
|
"instanceShell.gitChanges.empty": "Aún no hay cambios de Git.",
|
||||||
|
"instanceShell.gitChanges.deleted": "Eliminado",
|
||||||
|
|
||||||
"instanceShell.filesShell.fileListTitle": "Lista de archivos",
|
"instanceShell.filesShell.fileListTitle": "Lista de archivos",
|
||||||
"instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo",
|
"instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo",
|
||||||
"instanceShell.filesShell.mobileSelectorEmpty": "Selecciona un archivo",
|
"instanceShell.filesShell.mobileSelectorEmpty": "Selecciona un archivo",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const messagingMessages = {
|
|||||||
"messageBlock.tool.goToSession.label": "Ir a sesión",
|
"messageBlock.tool.goToSession.label": "Ir a sesión",
|
||||||
"messageBlock.tool.goToSession.title": "Ir a la sesión",
|
"messageBlock.tool.goToSession.title": "Ir a la sesión",
|
||||||
"messageBlock.tool.goToSession.unavailableTitle": "La sesión aún no está disponible",
|
"messageBlock.tool.goToSession.unavailableTitle": "La sesión aún no está disponible",
|
||||||
"messageBlock.tool.deletePart.label": "Eliminar",
|
"messageBlock.tool.deletePart.label": "Eliminar parte",
|
||||||
"messageBlock.tool.deletePart.deleting": "Eliminando...",
|
"messageBlock.tool.deletePart.deleting": "Eliminando...",
|
||||||
"messageBlock.tool.deletePart.title": "Eliminar esta salida de herramienta",
|
"messageBlock.tool.deletePart.title": "Eliminar esta salida de herramienta",
|
||||||
"messageBlock.tool.deletePart.failed.title": "Error al eliminar",
|
"messageBlock.tool.deletePart.failed.title": "Error al eliminar",
|
||||||
@@ -71,17 +71,38 @@ export const messagingMessages = {
|
|||||||
"messageItem.speaker.you": "Tú",
|
"messageItem.speaker.you": "Tú",
|
||||||
"messageItem.speaker.assistant": "Asistente",
|
"messageItem.speaker.assistant": "Asistente",
|
||||||
"messageItem.actions.revert": "Revertir",
|
"messageItem.actions.revert": "Revertir",
|
||||||
"messageItem.actions.revertTitle": "Revertir a este mensaje",
|
"messageItem.actions.revertTitle": "Deshacer cambios hasta aqui (elimina mensajes)",
|
||||||
"messageItem.actions.fork": "Fork",
|
"messageItem.actions.fork": "Fork",
|
||||||
"messageItem.actions.forkTitle": "Fork desde este mensaje",
|
"messageItem.actions.forkTitle": "Fork desde este mensaje",
|
||||||
"messageItem.actions.copy": "Copiar",
|
"messageItem.actions.copy": "Copiar",
|
||||||
"messageItem.actions.copyTitle": "Copiar mensaje",
|
"messageItem.actions.copyTitle": "Copiar mensaje",
|
||||||
"messageItem.actions.copied": "¡Copiado!",
|
"messageItem.actions.copied": "¡Copiado!",
|
||||||
|
"messageItem.actions.deleteMessage": "Eliminar mensaje (no deshace cambios)",
|
||||||
|
"messageItem.actions.deleteMessagesUpTo": "Eliminar mensajes hasta aqui (no deshace cambios)",
|
||||||
|
"messageItem.actions.deletingMessage": "Eliminando...",
|
||||||
|
"messageItem.actions.deleteMessageFailedTitle": "Error al eliminar",
|
||||||
|
"messageItem.actions.deleteMessageFailedMessage": "No se pudo eliminar el mensaje",
|
||||||
|
|
||||||
|
"messageItem.selection.checkboxAriaLabel": "Seleccionar mensaje para eliminar",
|
||||||
|
|
||||||
|
"messageSection.bulkDelete.toolbarAriaLabel": "Elementos seleccionados ({count})",
|
||||||
|
"messageSection.bulkDelete.deleteSelectedTitle": "Eliminar elementos seleccionados",
|
||||||
|
"messageSection.bulkDelete.selectAllTitle": "Seleccionar todos los mensajes",
|
||||||
|
"messageSection.bulkDelete.moreOptionsTitle": "Más opciones",
|
||||||
|
"messageSection.bulkDelete.selectionModeLabel": "Selección",
|
||||||
|
"messageSection.bulkDelete.selectionModeAll": "Todo",
|
||||||
|
"messageSection.bulkDelete.selectionModeTools": "Solo herramientas",
|
||||||
|
"messageSection.bulkDelete.selectionHint.toggle": "Seleccionar elemento",
|
||||||
|
"messageSection.bulkDelete.selectionHint.range": "Seleccionar rango",
|
||||||
|
"messageSection.bulkDelete.selectionHint.clear": "Borrar selección",
|
||||||
|
"messageSection.bulkDelete.cancelTitle": "Cancelar selección",
|
||||||
|
"messageSection.bulkDelete.failedTitle": "Error al eliminar",
|
||||||
|
"messageSection.bulkDelete.failedMessage": "No se pudieron eliminar los elementos seleccionados",
|
||||||
"messageItem.status.queued": "EN COLA",
|
"messageItem.status.queued": "EN COLA",
|
||||||
"messageItem.status.generating": "Generando...",
|
"messageItem.status.generating": "Generando...",
|
||||||
"messageItem.status.sending": "Enviando...",
|
"messageItem.status.sending": "Enviando...",
|
||||||
"messageItem.status.failedToSend": "No se pudo enviar el mensaje",
|
"messageItem.status.failedToSend": "No se pudo enviar el mensaje",
|
||||||
"messagePart.actions.delete": "Eliminar",
|
"messagePart.actions.delete": "Eliminar parte",
|
||||||
"messagePart.actions.deleting": "Eliminando...",
|
"messagePart.actions.deleting": "Eliminando...",
|
||||||
"messagePart.actions.deleteTitle": "Eliminar este elemento",
|
"messagePart.actions.deleteTitle": "Eliminar este elemento",
|
||||||
"messagePart.actions.deleteFailedTitle": "Error al eliminar",
|
"messagePart.actions.deleteFailedTitle": "Error al eliminar",
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ export const sessionMessages = {
|
|||||||
"sessionView.alerts.abortFailed.title": "No se pudo detener",
|
"sessionView.alerts.abortFailed.title": "No se pudo detener",
|
||||||
"sessionView.alerts.revertFailed.message": "No se pudo revertir al mensaje",
|
"sessionView.alerts.revertFailed.message": "No se pudo revertir al mensaje",
|
||||||
"sessionView.alerts.revertFailed.title": "No se pudo revertir",
|
"sessionView.alerts.revertFailed.title": "No se pudo revertir",
|
||||||
|
"sessionView.alerts.deleteUpToFailed.message": "No se pudieron eliminar los mensajes",
|
||||||
|
"sessionView.alerts.deleteUpToFailed.title": "Error al eliminar",
|
||||||
"sessionView.alerts.forkFailed.message": "No se pudo hacer fork de la sesión",
|
"sessionView.alerts.forkFailed.message": "No se pudo hacer fork de la sesión",
|
||||||
"sessionView.alerts.forkFailed.title": "No se pudo hacer fork",
|
"sessionView.alerts.forkFailed.title": "No se pudo hacer fork",
|
||||||
"sessionView.attachments.expandPastedTextAriaLabel": "Expandir texto pegado",
|
"sessionView.attachments.expandPastedTextAriaLabel": "Expandir texto pegado",
|
||||||
|
|||||||
@@ -55,4 +55,88 @@ export const settingsMessages = {
|
|||||||
"contextUsagePanel.labels.used": "Usado",
|
"contextUsagePanel.labels.used": "Usado",
|
||||||
"contextUsagePanel.labels.available": "Disp.",
|
"contextUsagePanel.labels.available": "Disp.",
|
||||||
"contextUsagePanel.unavailable": "--",
|
"contextUsagePanel.unavailable": "--",
|
||||||
|
|
||||||
|
"settings.title": "Settings",
|
||||||
|
"settings.navigationAriaLabel": "Settings sections",
|
||||||
|
"settings.close": "Close settings",
|
||||||
|
"settings.content.eyebrow": "Workspace preferences",
|
||||||
|
"settings.open.title": "Open settings",
|
||||||
|
"settings.open.ariaLabel": "Open settings",
|
||||||
|
"settings.nav.appearance": "Appearance",
|
||||||
|
"settings.nav.notifications": "Notifications",
|
||||||
|
"settings.nav.remote": "Remote Access",
|
||||||
|
"settings.nav.opencode": "OpenCode",
|
||||||
|
"settings.scope.device": "This device",
|
||||||
|
"settings.scope.server": "Server setting",
|
||||||
|
"settings.common.enabled": "Enabled",
|
||||||
|
"settings.common.disabled": "Desactivado",
|
||||||
|
"settings.section.appearance.title": "Appearance",
|
||||||
|
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
|
||||||
|
"settings.appearance.theme.title": "Theme",
|
||||||
|
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
|
||||||
|
"settings.appearance.theme.option.system": "Match your operating system setting",
|
||||||
|
"settings.appearance.theme.option.light": "Use the light appearance",
|
||||||
|
"settings.appearance.theme.option.dark": "Use the dark appearance",
|
||||||
|
"settings.section.notifications.title": "Notifications",
|
||||||
|
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
|
||||||
|
"settings.notifications.permission.granted": "Granted",
|
||||||
|
"settings.notifications.permission.denied": "Denied",
|
||||||
|
"settings.notifications.permission.default": "Not granted",
|
||||||
|
"settings.notifications.permission.unsupported": "Unsupported",
|
||||||
|
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
|
||||||
|
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
|
||||||
|
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
|
||||||
|
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
|
||||||
|
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
|
||||||
|
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
|
||||||
|
"settings.notifications.sessionStatus.title": "Session status notifications",
|
||||||
|
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
|
||||||
|
"settings.notifications.enable.title": "Enable notifications",
|
||||||
|
"settings.notifications.enable.permission": "Permission: {permission}",
|
||||||
|
"settings.notifications.requestPermission.title": "Request permission",
|
||||||
|
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
|
||||||
|
"settings.notifications.requestPermission.action": "Request",
|
||||||
|
"settings.notifications.allowVisible.title": "Notify when the app is focused",
|
||||||
|
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
|
||||||
|
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
|
||||||
|
"settings.notifications.events.title": "Notify me when",
|
||||||
|
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
|
||||||
|
"settings.notifications.events.needsInput": "Session needs input",
|
||||||
|
"settings.notifications.events.idle": "Session becomes idle",
|
||||||
|
"settings.notifications.status.enabled": "Notifications enabled",
|
||||||
|
"settings.notifications.status.disabled": "Notifications disabled",
|
||||||
|
"settings.notifications.status.unsupported": "Notifications unsupported",
|
||||||
|
"settings.section.remote.title": "Remote Access",
|
||||||
|
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
|
||||||
|
"settings.section.opencode.title": "OpenCode",
|
||||||
|
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
||||||
|
"settings.opencode.runtime.title": "Runtime",
|
||||||
|
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
||||||
|
|
||||||
|
"settings.appearance.behavior.title": "Interaccion",
|
||||||
|
"settings.appearance.behavior.subtitle": "Valores predeterminados de mensajes, diffs y entrada.",
|
||||||
|
"settings.behavior.keyboardHints.title": "Sugerencias de atajos de teclado",
|
||||||
|
"settings.behavior.keyboardHints.subtitle": "Muestra sugerencias de atajos de teclado en toda la interfaz.",
|
||||||
|
"settings.behavior.thinking.title": "Secciones de pensamiento",
|
||||||
|
"settings.behavior.thinking.subtitle": "Muestra u oculta las secciones de pensamiento de la IA en los mensajes.",
|
||||||
|
"settings.behavior.thinkingDefault.title": "Pensamiento por defecto",
|
||||||
|
"settings.behavior.thinkingDefault.subtitle": "Elige si las secciones de pensamiento comienzan expandidas o contraidas.",
|
||||||
|
"settings.behavior.timelineTools.title": "Llamadas de herramientas en la linea de tiempo",
|
||||||
|
"settings.behavior.timelineTools.subtitle": "Muestra u oculta entradas de llamadas de herramientas en la linea de tiempo de mensajes.",
|
||||||
|
"settings.behavior.diffView.title": "Vista de diferencias",
|
||||||
|
"settings.behavior.diffView.subtitle": "Elige como se muestran los diffs de llamadas de herramientas.",
|
||||||
|
"settings.behavior.diffView.option.split": "Dividida",
|
||||||
|
"settings.behavior.diffView.option.unified": "Unificada",
|
||||||
|
"settings.behavior.toolOutputsDefault.title": "Salidas de herramientas por defecto",
|
||||||
|
"settings.behavior.toolOutputsDefault.subtitle": "Elige si las salidas de herramientas comienzan expandidas o contraidas.",
|
||||||
|
"settings.behavior.diagnosticsDefault.title": "Diagnosticos por defecto",
|
||||||
|
"settings.behavior.diagnosticsDefault.subtitle": "Elige si la salida de diagnosticos comienza expandida o contraida.",
|
||||||
|
"settings.behavior.toolInputsVisibility.title": "Visibilidad de entradas de herramientas",
|
||||||
|
"settings.behavior.toolInputsVisibility.subtitle": "Establece la visibilidad por defecto de los argumentos de entrada de las llamadas de herramientas.",
|
||||||
|
"settings.behavior.usageMetrics.title": "Metricas de uso de tokens",
|
||||||
|
"settings.behavior.usageMetrics.subtitle": "Muestra u oculta estadisticas de tokens y costo en mensajes del asistente.",
|
||||||
|
"settings.behavior.autoCleanup.title": "Limpieza automatica de sesiones en blanco",
|
||||||
|
"settings.behavior.autoCleanup.subtitle": "Limpia automaticamente las sesiones en blanco al crear nuevas.",
|
||||||
|
"settings.behavior.promptSubmit.title": "Enter para enviar",
|
||||||
|
"settings.behavior.promptSubmit.subtitle": "Usa Enter para enviar; Cmd/Ctrl+Enter inserta una nueva linea.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
export const appMessages = {
|
export const appMessages = {
|
||||||
"app.launchError.title": "Impossible de lancer OpenCode",
|
"app.launchError.title": "Impossible de lancer OpenCode",
|
||||||
"app.launchError.description": "Nous n'avons pas pu démarrer le binaire OpenCode sélectionné. Consultez la sortie d'erreur ci-dessous ou choisissez un autre binaire dans les Paramètres avancés.",
|
"app.launchError.description": "Nous n'avons pas pu démarrer le binaire OpenCode sélectionné. Consultez la sortie d'erreur ci-dessous ou choisissez un autre binaire dans les paramètres OpenCode.",
|
||||||
"app.launchError.binaryPathLabel": "Chemin du binaire",
|
"app.launchError.binaryPathLabel": "Chemin du binaire",
|
||||||
"app.launchError.errorOutputLabel": "Sortie d'erreur",
|
"app.launchError.errorOutputLabel": "Sortie d'erreur",
|
||||||
"app.launchError.openAdvancedSettings": "Ouvrir les paramètres avancés",
|
"app.launchError.openAdvancedSettings": "Ouvrir les paramètres OpenCode",
|
||||||
"app.launchError.close": "Fermer",
|
"app.launchError.close": "Fermer",
|
||||||
"app.launchError.closeTitle": "Fermer (Esc)",
|
"app.launchError.closeTitle": "Fermer (Esc)",
|
||||||
"app.launchError.fallbackMessage": "Échec du lancement de l'espace de travail",
|
"app.launchError.fallbackMessage": "Échec du lancement de l'espace de travail",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.browse.buttonOpening": "Ouverture...",
|
"folderSelection.browse.buttonOpening": "Ouverture...",
|
||||||
|
|
||||||
"folderSelection.advancedSettings": "Paramètres avancés",
|
"folderSelection.advancedSettings": "Paramètres avancés",
|
||||||
|
"folderSelection.opencode": "OpenCode",
|
||||||
|
|
||||||
"folderSelection.hints.navigate": "Naviguer",
|
"folderSelection.hints.navigate": "Naviguer",
|
||||||
"folderSelection.hints.select": "Sélectionner",
|
"folderSelection.hints.select": "Sélectionner",
|
||||||
@@ -31,6 +32,11 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.loading.title": "Démarrage de l'instance...",
|
"folderSelection.loading.title": "Démarrage de l'instance...",
|
||||||
"folderSelection.loading.subtitle": "Patientez pendant que nous préparons votre espace de travail.",
|
"folderSelection.loading.subtitle": "Patientez pendant que nous préparons votre espace de travail.",
|
||||||
|
|
||||||
|
"folderSelection.drop.title": "Déposez un dossier pour l'ouvrir",
|
||||||
|
"folderSelection.drop.subtitle": "Démarrez une nouvelle instance dans le dossier déposé.",
|
||||||
|
"folderSelection.drop.invalidTitle": "Impossible d'ouvrir l'élément déposé",
|
||||||
|
"folderSelection.drop.invalidMessage": "Déposez un dossier pour démarrer une nouvelle instance.",
|
||||||
|
|
||||||
"folderSelection.dialog.title": "Sélectionner l'espace de travail",
|
"folderSelection.dialog.title": "Sélectionner l'espace de travail",
|
||||||
"folderSelection.dialog.description": "Sélectionnez un espace de travail pour commencer à coder.",
|
"folderSelection.dialog.description": "Sélectionnez un espace de travail pour commencer à coder.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export const instanceMessages = {
|
|||||||
|
|
||||||
"instanceShell.rightPanel.title": "Panneau d'état",
|
"instanceShell.rightPanel.title": "Panneau d'état",
|
||||||
"instanceShell.rightPanel.tabs.changes": "Modifications",
|
"instanceShell.rightPanel.tabs.changes": "Modifications",
|
||||||
|
"instanceShell.rightPanel.tabs.gitChanges": "Changements Git",
|
||||||
"instanceShell.rightPanel.tabs.files": "Fichiers",
|
"instanceShell.rightPanel.tabs.files": "Fichiers",
|
||||||
"instanceShell.rightPanel.tabs.status": "Statut",
|
"instanceShell.rightPanel.tabs.status": "Statut",
|
||||||
"instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit",
|
"instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit",
|
||||||
@@ -112,6 +113,10 @@ export const instanceMessages = {
|
|||||||
"instanceShell.sessionChanges.filesChanged": "{count} fichiers modifiés",
|
"instanceShell.sessionChanges.filesChanged": "{count} fichiers modifiés",
|
||||||
"instanceShell.sessionChanges.actions.show": "Afficher les changements",
|
"instanceShell.sessionChanges.actions.show": "Afficher les changements",
|
||||||
|
|
||||||
|
"instanceShell.gitChanges.loading": "Chargement des changements Git...",
|
||||||
|
"instanceShell.gitChanges.empty": "Aucun changement Git pour l'instant.",
|
||||||
|
"instanceShell.gitChanges.deleted": "Supprimé",
|
||||||
|
|
||||||
"instanceShell.filesShell.fileListTitle": "Liste des fichiers",
|
"instanceShell.filesShell.fileListTitle": "Liste des fichiers",
|
||||||
"instanceShell.filesShell.mobileSelectorLabel": "Sélectionner un fichier",
|
"instanceShell.filesShell.mobileSelectorLabel": "Sélectionner un fichier",
|
||||||
"instanceShell.filesShell.mobileSelectorEmpty": "Sélectionnez un fichier",
|
"instanceShell.filesShell.mobileSelectorEmpty": "Sélectionnez un fichier",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const messagingMessages = {
|
|||||||
"messageBlock.tool.goToSession.label": "Aller à la session",
|
"messageBlock.tool.goToSession.label": "Aller à la session",
|
||||||
"messageBlock.tool.goToSession.title": "Aller à la session",
|
"messageBlock.tool.goToSession.title": "Aller à la session",
|
||||||
"messageBlock.tool.goToSession.unavailableTitle": "Session pas encore disponible",
|
"messageBlock.tool.goToSession.unavailableTitle": "Session pas encore disponible",
|
||||||
"messageBlock.tool.deletePart.label": "Supprimer",
|
"messageBlock.tool.deletePart.label": "Supprimer la partie",
|
||||||
"messageBlock.tool.deletePart.deleting": "Suppression...",
|
"messageBlock.tool.deletePart.deleting": "Suppression...",
|
||||||
"messageBlock.tool.deletePart.title": "Supprimer cette sortie d'outil",
|
"messageBlock.tool.deletePart.title": "Supprimer cette sortie d'outil",
|
||||||
"messageBlock.tool.deletePart.failed.title": "Échec de suppression",
|
"messageBlock.tool.deletePart.failed.title": "Échec de suppression",
|
||||||
@@ -71,17 +71,38 @@ export const messagingMessages = {
|
|||||||
"messageItem.speaker.you": "Vous",
|
"messageItem.speaker.you": "Vous",
|
||||||
"messageItem.speaker.assistant": "Assistant",
|
"messageItem.speaker.assistant": "Assistant",
|
||||||
"messageItem.actions.revert": "Revenir",
|
"messageItem.actions.revert": "Revenir",
|
||||||
"messageItem.actions.revertTitle": "Revenir à ce message",
|
"messageItem.actions.revertTitle": "Annuler les changements jusqu'ici (supprime les messages)",
|
||||||
"messageItem.actions.fork": "Fork",
|
"messageItem.actions.fork": "Fork",
|
||||||
"messageItem.actions.forkTitle": "Fork depuis ce message",
|
"messageItem.actions.forkTitle": "Fork depuis ce message",
|
||||||
"messageItem.actions.copy": "Copier",
|
"messageItem.actions.copy": "Copier",
|
||||||
"messageItem.actions.copyTitle": "Copier le message",
|
"messageItem.actions.copyTitle": "Copier le message",
|
||||||
"messageItem.actions.copied": "Copié !",
|
"messageItem.actions.copied": "Copié !",
|
||||||
|
"messageItem.actions.deleteMessage": "Supprimer le message (sans annuler les changements)",
|
||||||
|
"messageItem.actions.deleteMessagesUpTo": "Supprimer les messages jusqu'ici (sans annuler les changements)",
|
||||||
|
"messageItem.actions.deletingMessage": "Suppression...",
|
||||||
|
"messageItem.actions.deleteMessageFailedTitle": "Échec de suppression",
|
||||||
|
"messageItem.actions.deleteMessageFailedMessage": "Impossible de supprimer le message",
|
||||||
|
|
||||||
|
"messageItem.selection.checkboxAriaLabel": "Sélectionner le message pour suppression",
|
||||||
|
|
||||||
|
"messageSection.bulkDelete.toolbarAriaLabel": "Éléments sélectionnés ({count})",
|
||||||
|
"messageSection.bulkDelete.deleteSelectedTitle": "Supprimer les éléments sélectionnés",
|
||||||
|
"messageSection.bulkDelete.selectAllTitle": "Tout sélectionner",
|
||||||
|
"messageSection.bulkDelete.moreOptionsTitle": "Plus d'options",
|
||||||
|
"messageSection.bulkDelete.selectionModeLabel": "Sélection",
|
||||||
|
"messageSection.bulkDelete.selectionModeAll": "Tous",
|
||||||
|
"messageSection.bulkDelete.selectionModeTools": "Outils uniquement",
|
||||||
|
"messageSection.bulkDelete.selectionHint.toggle": "Selectionner un element",
|
||||||
|
"messageSection.bulkDelete.selectionHint.range": "Selectionner une plage",
|
||||||
|
"messageSection.bulkDelete.selectionHint.clear": "Effacer la selection",
|
||||||
|
"messageSection.bulkDelete.cancelTitle": "Annuler la sélection",
|
||||||
|
"messageSection.bulkDelete.failedTitle": "Échec de suppression",
|
||||||
|
"messageSection.bulkDelete.failedMessage": "Impossible de supprimer les éléments sélectionnés",
|
||||||
"messageItem.status.queued": "EN FILE",
|
"messageItem.status.queued": "EN FILE",
|
||||||
"messageItem.status.generating": "Génération...",
|
"messageItem.status.generating": "Génération...",
|
||||||
"messageItem.status.sending": "Envoi...",
|
"messageItem.status.sending": "Envoi...",
|
||||||
"messageItem.status.failedToSend": "Échec de l'envoi du message",
|
"messageItem.status.failedToSend": "Échec de l'envoi du message",
|
||||||
"messagePart.actions.delete": "Supprimer",
|
"messagePart.actions.delete": "Supprimer la partie",
|
||||||
"messagePart.actions.deleting": "Suppression...",
|
"messagePart.actions.deleting": "Suppression...",
|
||||||
"messagePart.actions.deleteTitle": "Supprimer cet élément",
|
"messagePart.actions.deleteTitle": "Supprimer cet élément",
|
||||||
"messagePart.actions.deleteFailedTitle": "Échec de suppression",
|
"messagePart.actions.deleteFailedTitle": "Échec de suppression",
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ export const sessionMessages = {
|
|||||||
"sessionView.alerts.abortFailed.title": "Échec de l'arrêt",
|
"sessionView.alerts.abortFailed.title": "Échec de l'arrêt",
|
||||||
"sessionView.alerts.revertFailed.message": "Impossible de revenir au message",
|
"sessionView.alerts.revertFailed.message": "Impossible de revenir au message",
|
||||||
"sessionView.alerts.revertFailed.title": "Échec du retour",
|
"sessionView.alerts.revertFailed.title": "Échec du retour",
|
||||||
|
"sessionView.alerts.deleteUpToFailed.message": "Impossible de supprimer les messages",
|
||||||
|
"sessionView.alerts.deleteUpToFailed.title": "Échec de suppression",
|
||||||
"sessionView.alerts.forkFailed.message": "Impossible de forker la session",
|
"sessionView.alerts.forkFailed.message": "Impossible de forker la session",
|
||||||
"sessionView.alerts.forkFailed.title": "Échec du fork",
|
"sessionView.alerts.forkFailed.title": "Échec du fork",
|
||||||
"sessionView.attachments.expandPastedTextAriaLabel": "Développer le texte collé",
|
"sessionView.attachments.expandPastedTextAriaLabel": "Développer le texte collé",
|
||||||
|
|||||||
@@ -55,4 +55,88 @@ export const settingsMessages = {
|
|||||||
"contextUsagePanel.labels.used": "Utilisé",
|
"contextUsagePanel.labels.used": "Utilisé",
|
||||||
"contextUsagePanel.labels.available": "Dispo",
|
"contextUsagePanel.labels.available": "Dispo",
|
||||||
"contextUsagePanel.unavailable": "--",
|
"contextUsagePanel.unavailable": "--",
|
||||||
|
|
||||||
|
"settings.title": "Settings",
|
||||||
|
"settings.navigationAriaLabel": "Settings sections",
|
||||||
|
"settings.close": "Close settings",
|
||||||
|
"settings.content.eyebrow": "Workspace preferences",
|
||||||
|
"settings.open.title": "Open settings",
|
||||||
|
"settings.open.ariaLabel": "Open settings",
|
||||||
|
"settings.nav.appearance": "Appearance",
|
||||||
|
"settings.nav.notifications": "Notifications",
|
||||||
|
"settings.nav.remote": "Remote Access",
|
||||||
|
"settings.nav.opencode": "OpenCode",
|
||||||
|
"settings.scope.device": "This device",
|
||||||
|
"settings.scope.server": "Server setting",
|
||||||
|
"settings.common.enabled": "Enabled",
|
||||||
|
"settings.common.disabled": "Desactive",
|
||||||
|
"settings.section.appearance.title": "Appearance",
|
||||||
|
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
|
||||||
|
"settings.appearance.theme.title": "Theme",
|
||||||
|
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
|
||||||
|
"settings.appearance.theme.option.system": "Match your operating system setting",
|
||||||
|
"settings.appearance.theme.option.light": "Use the light appearance",
|
||||||
|
"settings.appearance.theme.option.dark": "Use the dark appearance",
|
||||||
|
"settings.section.notifications.title": "Notifications",
|
||||||
|
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
|
||||||
|
"settings.notifications.permission.granted": "Granted",
|
||||||
|
"settings.notifications.permission.denied": "Denied",
|
||||||
|
"settings.notifications.permission.default": "Not granted",
|
||||||
|
"settings.notifications.permission.unsupported": "Unsupported",
|
||||||
|
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
|
||||||
|
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
|
||||||
|
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
|
||||||
|
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
|
||||||
|
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
|
||||||
|
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
|
||||||
|
"settings.notifications.sessionStatus.title": "Session status notifications",
|
||||||
|
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
|
||||||
|
"settings.notifications.enable.title": "Enable notifications",
|
||||||
|
"settings.notifications.enable.permission": "Permission: {permission}",
|
||||||
|
"settings.notifications.requestPermission.title": "Request permission",
|
||||||
|
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
|
||||||
|
"settings.notifications.requestPermission.action": "Request",
|
||||||
|
"settings.notifications.allowVisible.title": "Notify when the app is focused",
|
||||||
|
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
|
||||||
|
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
|
||||||
|
"settings.notifications.events.title": "Notify me when",
|
||||||
|
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
|
||||||
|
"settings.notifications.events.needsInput": "Session needs input",
|
||||||
|
"settings.notifications.events.idle": "Session becomes idle",
|
||||||
|
"settings.notifications.status.enabled": "Notifications enabled",
|
||||||
|
"settings.notifications.status.disabled": "Notifications disabled",
|
||||||
|
"settings.notifications.status.unsupported": "Notifications unsupported",
|
||||||
|
"settings.section.remote.title": "Remote Access",
|
||||||
|
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
|
||||||
|
"settings.section.opencode.title": "OpenCode",
|
||||||
|
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
||||||
|
"settings.opencode.runtime.title": "Runtime",
|
||||||
|
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
||||||
|
|
||||||
|
"settings.appearance.behavior.title": "Interaction",
|
||||||
|
"settings.appearance.behavior.subtitle": "Parametres par defaut pour les messages, les diffs et la saisie.",
|
||||||
|
"settings.behavior.keyboardHints.title": "Indications de raccourcis clavier",
|
||||||
|
"settings.behavior.keyboardHints.subtitle": "Afficher des indications de raccourcis clavier dans toute l'interface.",
|
||||||
|
"settings.behavior.thinking.title": "Sections de reflexion",
|
||||||
|
"settings.behavior.thinking.subtitle": "Afficher ou masquer les sections de reflexion de l'IA dans les messages.",
|
||||||
|
"settings.behavior.thinkingDefault.title": "Etat initial de la reflexion",
|
||||||
|
"settings.behavior.thinkingDefault.subtitle": "Choisir si les sections de reflexion commencent developpees ou reduites.",
|
||||||
|
"settings.behavior.timelineTools.title": "Appels d'outils dans la chronologie",
|
||||||
|
"settings.behavior.timelineTools.subtitle": "Afficher ou masquer les entrees d'appels d'outils dans la chronologie des messages.",
|
||||||
|
"settings.behavior.diffView.title": "Vue du diff",
|
||||||
|
"settings.behavior.diffView.subtitle": "Choisir comment les diffs des appels d'outils sont affiches.",
|
||||||
|
"settings.behavior.diffView.option.split": "Scinde",
|
||||||
|
"settings.behavior.diffView.option.unified": "Unifie",
|
||||||
|
"settings.behavior.toolOutputsDefault.title": "Etat initial des sorties d'outils",
|
||||||
|
"settings.behavior.toolOutputsDefault.subtitle": "Choisir si les sorties d'outils commencent developpees ou reduites.",
|
||||||
|
"settings.behavior.diagnosticsDefault.title": "Etat initial des diagnostics",
|
||||||
|
"settings.behavior.diagnosticsDefault.subtitle": "Choisir si la sortie des diagnostics commence developpee ou reduite.",
|
||||||
|
"settings.behavior.toolInputsVisibility.title": "Visibilite des entrees d'outils",
|
||||||
|
"settings.behavior.toolInputsVisibility.subtitle": "Definir la visibilite par defaut des arguments d'entree des appels d'outils.",
|
||||||
|
"settings.behavior.usageMetrics.title": "Metriques d'utilisation des tokens",
|
||||||
|
"settings.behavior.usageMetrics.subtitle": "Afficher ou masquer les stats de tokens et de cout pour les messages de l'assistant.",
|
||||||
|
"settings.behavior.autoCleanup.title": "Nettoyage auto des sessions vides",
|
||||||
|
"settings.behavior.autoCleanup.subtitle": "Nettoyer automatiquement les sessions vides lors de la creation de nouvelles.",
|
||||||
|
"settings.behavior.promptSubmit.title": "Entrer pour envoyer",
|
||||||
|
"settings.behavior.promptSubmit.subtitle": "Utiliser Entrer pour envoyer; Cmd/Ctrl+Entrer insere une nouvelle ligne.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
export const appMessages = {
|
export const appMessages = {
|
||||||
"app.launchError.title": "OpenCode を起動できません",
|
"app.launchError.title": "OpenCode を起動できません",
|
||||||
"app.launchError.description": "選択された OpenCode バイナリを起動できませんでした。下のエラー出力を確認するか、詳細設定から別のバイナリを選択してください。",
|
"app.launchError.description": "選択された OpenCode バイナリを起動できませんでした。下のエラー出力を確認するか、OpenCode 設定から別のバイナリを選択してください。",
|
||||||
"app.launchError.binaryPathLabel": "バイナリのパス",
|
"app.launchError.binaryPathLabel": "バイナリのパス",
|
||||||
"app.launchError.errorOutputLabel": "エラー出力",
|
"app.launchError.errorOutputLabel": "エラー出力",
|
||||||
"app.launchError.openAdvancedSettings": "詳細設定を開く",
|
"app.launchError.openAdvancedSettings": "OpenCode 設定を開く",
|
||||||
"app.launchError.close": "閉じる",
|
"app.launchError.close": "閉じる",
|
||||||
"app.launchError.closeTitle": "閉じる (Esc)",
|
"app.launchError.closeTitle": "閉じる (Esc)",
|
||||||
"app.launchError.fallbackMessage": "ワークスペースの起動に失敗しました",
|
"app.launchError.fallbackMessage": "ワークスペースの起動に失敗しました",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.browse.buttonOpening": "開いています...",
|
"folderSelection.browse.buttonOpening": "開いています...",
|
||||||
|
|
||||||
"folderSelection.advancedSettings": "詳細設定",
|
"folderSelection.advancedSettings": "詳細設定",
|
||||||
|
"folderSelection.opencode": "OpenCode",
|
||||||
|
|
||||||
"folderSelection.hints.navigate": "移動",
|
"folderSelection.hints.navigate": "移動",
|
||||||
"folderSelection.hints.select": "選択",
|
"folderSelection.hints.select": "選択",
|
||||||
@@ -31,6 +32,11 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.loading.title": "インスタンスを起動中...",
|
"folderSelection.loading.title": "インスタンスを起動中...",
|
||||||
"folderSelection.loading.subtitle": "ワークスペースを準備しています。しばらくお待ちください。",
|
"folderSelection.loading.subtitle": "ワークスペースを準備しています。しばらくお待ちください。",
|
||||||
|
|
||||||
|
"folderSelection.drop.title": "フォルダをドロップして開く",
|
||||||
|
"folderSelection.drop.subtitle": "ドロップしたフォルダで新しいインスタンスを開始します。",
|
||||||
|
"folderSelection.drop.invalidTitle": "ドロップした項目を開けませんでした",
|
||||||
|
"folderSelection.drop.invalidMessage": "新しいインスタンスを開始するにはフォルダをドロップしてください。",
|
||||||
|
|
||||||
"folderSelection.dialog.title": "ワークスペースを選択",
|
"folderSelection.dialog.title": "ワークスペースを選択",
|
||||||
"folderSelection.dialog.description": "コーディングを開始するワークスペースを選択してください。",
|
"folderSelection.dialog.description": "コーディングを開始するワークスペースを選択してください。",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export const instanceMessages = {
|
|||||||
|
|
||||||
"instanceShell.rightPanel.title": "ステータスパネル",
|
"instanceShell.rightPanel.title": "ステータスパネル",
|
||||||
"instanceShell.rightPanel.tabs.changes": "変更",
|
"instanceShell.rightPanel.tabs.changes": "変更",
|
||||||
|
"instanceShell.rightPanel.tabs.gitChanges": "Git 変更",
|
||||||
"instanceShell.rightPanel.tabs.files": "ファイル",
|
"instanceShell.rightPanel.tabs.files": "ファイル",
|
||||||
"instanceShell.rightPanel.tabs.status": "ステータス",
|
"instanceShell.rightPanel.tabs.status": "ステータス",
|
||||||
"instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ",
|
"instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ",
|
||||||
@@ -112,6 +113,10 @@ export const instanceMessages = {
|
|||||||
"instanceShell.sessionChanges.filesChanged": "{count} 個のファイルが変更されました",
|
"instanceShell.sessionChanges.filesChanged": "{count} 個のファイルが変更されました",
|
||||||
"instanceShell.sessionChanges.actions.show": "変更を表示",
|
"instanceShell.sessionChanges.actions.show": "変更を表示",
|
||||||
|
|
||||||
|
"instanceShell.gitChanges.loading": "Git の変更を読み込み中...",
|
||||||
|
"instanceShell.gitChanges.empty": "Git の変更はまだありません。",
|
||||||
|
"instanceShell.gitChanges.deleted": "削除済み",
|
||||||
|
|
||||||
"instanceShell.filesShell.fileListTitle": "ファイル一覧",
|
"instanceShell.filesShell.fileListTitle": "ファイル一覧",
|
||||||
"instanceShell.filesShell.mobileSelectorLabel": "ファイルを選択",
|
"instanceShell.filesShell.mobileSelectorLabel": "ファイルを選択",
|
||||||
"instanceShell.filesShell.mobileSelectorEmpty": "ファイルを選択してください",
|
"instanceShell.filesShell.mobileSelectorEmpty": "ファイルを選択してください",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const messagingMessages = {
|
|||||||
"messageBlock.tool.goToSession.label": "セッションへ移動",
|
"messageBlock.tool.goToSession.label": "セッションへ移動",
|
||||||
"messageBlock.tool.goToSession.title": "セッションへ移動",
|
"messageBlock.tool.goToSession.title": "セッションへ移動",
|
||||||
"messageBlock.tool.goToSession.unavailableTitle": "セッションはまだ利用できません",
|
"messageBlock.tool.goToSession.unavailableTitle": "セッションはまだ利用できません",
|
||||||
"messageBlock.tool.deletePart.label": "削除",
|
"messageBlock.tool.deletePart.label": "パートを削除",
|
||||||
"messageBlock.tool.deletePart.deleting": "削除中...",
|
"messageBlock.tool.deletePart.deleting": "削除中...",
|
||||||
"messageBlock.tool.deletePart.title": "このツール出力を削除",
|
"messageBlock.tool.deletePart.title": "このツール出力を削除",
|
||||||
"messageBlock.tool.deletePart.failed.title": "削除に失敗しました",
|
"messageBlock.tool.deletePart.failed.title": "削除に失敗しました",
|
||||||
@@ -71,17 +71,38 @@ export const messagingMessages = {
|
|||||||
"messageItem.speaker.you": "あなた",
|
"messageItem.speaker.you": "あなた",
|
||||||
"messageItem.speaker.assistant": "アシスタント",
|
"messageItem.speaker.assistant": "アシスタント",
|
||||||
"messageItem.actions.revert": "戻す",
|
"messageItem.actions.revert": "戻す",
|
||||||
"messageItem.actions.revertTitle": "このメッセージまで戻す",
|
"messageItem.actions.revertTitle": "ここまでの変更を元に戻す(メッセージを削除)",
|
||||||
"messageItem.actions.fork": "フォーク",
|
"messageItem.actions.fork": "フォーク",
|
||||||
"messageItem.actions.forkTitle": "このメッセージからフォーク",
|
"messageItem.actions.forkTitle": "このメッセージからフォーク",
|
||||||
"messageItem.actions.copy": "コピー",
|
"messageItem.actions.copy": "コピー",
|
||||||
"messageItem.actions.copyTitle": "メッセージをコピー",
|
"messageItem.actions.copyTitle": "メッセージをコピー",
|
||||||
"messageItem.actions.copied": "コピーしました!",
|
"messageItem.actions.copied": "コピーしました!",
|
||||||
|
"messageItem.actions.deleteMessage": "メッセージを削除(変更は元に戻さない)",
|
||||||
|
"messageItem.actions.deleteMessagesUpTo": "ここまでのメッセージを削除(変更は元に戻さない)",
|
||||||
|
"messageItem.actions.deletingMessage": "削除中...",
|
||||||
|
"messageItem.actions.deleteMessageFailedTitle": "削除に失敗しました",
|
||||||
|
"messageItem.actions.deleteMessageFailedMessage": "メッセージの削除に失敗しました",
|
||||||
|
|
||||||
|
"messageItem.selection.checkboxAriaLabel": "削除するメッセージを選択",
|
||||||
|
|
||||||
|
"messageSection.bulkDelete.toolbarAriaLabel": "選択した項目({count})",
|
||||||
|
"messageSection.bulkDelete.deleteSelectedTitle": "選択した項目を削除",
|
||||||
|
"messageSection.bulkDelete.selectAllTitle": "すべて選択",
|
||||||
|
"messageSection.bulkDelete.moreOptionsTitle": "その他のオプション",
|
||||||
|
"messageSection.bulkDelete.selectionModeLabel": "選択",
|
||||||
|
"messageSection.bulkDelete.selectionModeAll": "すべて",
|
||||||
|
"messageSection.bulkDelete.selectionModeTools": "ツールのみ",
|
||||||
|
"messageSection.bulkDelete.selectionHint.toggle": "項目を選択",
|
||||||
|
"messageSection.bulkDelete.selectionHint.range": "範囲を選択",
|
||||||
|
"messageSection.bulkDelete.selectionHint.clear": "選択を解除",
|
||||||
|
"messageSection.bulkDelete.cancelTitle": "選択をキャンセル",
|
||||||
|
"messageSection.bulkDelete.failedTitle": "削除に失敗しました",
|
||||||
|
"messageSection.bulkDelete.failedMessage": "選択した項目の削除に失敗しました",
|
||||||
"messageItem.status.queued": "待機中",
|
"messageItem.status.queued": "待機中",
|
||||||
"messageItem.status.generating": "生成中...",
|
"messageItem.status.generating": "生成中...",
|
||||||
"messageItem.status.sending": "送信中...",
|
"messageItem.status.sending": "送信中...",
|
||||||
"messageItem.status.failedToSend": "メッセージの送信に失敗しました",
|
"messageItem.status.failedToSend": "メッセージの送信に失敗しました",
|
||||||
"messagePart.actions.delete": "削除",
|
"messagePart.actions.delete": "パートを削除",
|
||||||
"messagePart.actions.deleting": "削除中...",
|
"messagePart.actions.deleting": "削除中...",
|
||||||
"messagePart.actions.deleteTitle": "この項目を削除",
|
"messagePart.actions.deleteTitle": "この項目を削除",
|
||||||
"messagePart.actions.deleteFailedTitle": "削除に失敗しました",
|
"messagePart.actions.deleteFailedTitle": "削除に失敗しました",
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ export const sessionMessages = {
|
|||||||
"sessionView.alerts.abortFailed.title": "停止に失敗",
|
"sessionView.alerts.abortFailed.title": "停止に失敗",
|
||||||
"sessionView.alerts.revertFailed.message": "メッセージへ戻せませんでした",
|
"sessionView.alerts.revertFailed.message": "メッセージへ戻せませんでした",
|
||||||
"sessionView.alerts.revertFailed.title": "復元に失敗",
|
"sessionView.alerts.revertFailed.title": "復元に失敗",
|
||||||
|
"sessionView.alerts.deleteUpToFailed.message": "メッセージの削除に失敗しました",
|
||||||
|
"sessionView.alerts.deleteUpToFailed.title": "削除に失敗しました",
|
||||||
"sessionView.alerts.forkFailed.message": "セッションのフォークに失敗しました",
|
"sessionView.alerts.forkFailed.message": "セッションのフォークに失敗しました",
|
||||||
"sessionView.alerts.forkFailed.title": "フォークに失敗",
|
"sessionView.alerts.forkFailed.title": "フォークに失敗",
|
||||||
"sessionView.attachments.expandPastedTextAriaLabel": "貼り付けたテキストを展開",
|
"sessionView.attachments.expandPastedTextAriaLabel": "貼り付けたテキストを展開",
|
||||||
|
|||||||
@@ -55,4 +55,88 @@ export const settingsMessages = {
|
|||||||
"contextUsagePanel.labels.used": "使用",
|
"contextUsagePanel.labels.used": "使用",
|
||||||
"contextUsagePanel.labels.available": "残り",
|
"contextUsagePanel.labels.available": "残り",
|
||||||
"contextUsagePanel.unavailable": "--",
|
"contextUsagePanel.unavailable": "--",
|
||||||
|
|
||||||
|
"settings.title": "Settings",
|
||||||
|
"settings.navigationAriaLabel": "Settings sections",
|
||||||
|
"settings.close": "Close settings",
|
||||||
|
"settings.content.eyebrow": "Workspace preferences",
|
||||||
|
"settings.open.title": "Open settings",
|
||||||
|
"settings.open.ariaLabel": "Open settings",
|
||||||
|
"settings.nav.appearance": "Appearance",
|
||||||
|
"settings.nav.notifications": "Notifications",
|
||||||
|
"settings.nav.remote": "Remote Access",
|
||||||
|
"settings.nav.opencode": "OpenCode",
|
||||||
|
"settings.scope.device": "This device",
|
||||||
|
"settings.scope.server": "Server setting",
|
||||||
|
"settings.common.enabled": "Enabled",
|
||||||
|
"settings.common.disabled": "無効",
|
||||||
|
"settings.section.appearance.title": "Appearance",
|
||||||
|
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
|
||||||
|
"settings.appearance.theme.title": "Theme",
|
||||||
|
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
|
||||||
|
"settings.appearance.theme.option.system": "Match your operating system setting",
|
||||||
|
"settings.appearance.theme.option.light": "Use the light appearance",
|
||||||
|
"settings.appearance.theme.option.dark": "Use the dark appearance",
|
||||||
|
"settings.section.notifications.title": "Notifications",
|
||||||
|
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
|
||||||
|
"settings.notifications.permission.granted": "Granted",
|
||||||
|
"settings.notifications.permission.denied": "Denied",
|
||||||
|
"settings.notifications.permission.default": "Not granted",
|
||||||
|
"settings.notifications.permission.unsupported": "Unsupported",
|
||||||
|
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
|
||||||
|
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
|
||||||
|
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
|
||||||
|
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
|
||||||
|
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
|
||||||
|
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
|
||||||
|
"settings.notifications.sessionStatus.title": "Session status notifications",
|
||||||
|
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
|
||||||
|
"settings.notifications.enable.title": "Enable notifications",
|
||||||
|
"settings.notifications.enable.permission": "Permission: {permission}",
|
||||||
|
"settings.notifications.requestPermission.title": "Request permission",
|
||||||
|
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
|
||||||
|
"settings.notifications.requestPermission.action": "Request",
|
||||||
|
"settings.notifications.allowVisible.title": "Notify when the app is focused",
|
||||||
|
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
|
||||||
|
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
|
||||||
|
"settings.notifications.events.title": "Notify me when",
|
||||||
|
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
|
||||||
|
"settings.notifications.events.needsInput": "Session needs input",
|
||||||
|
"settings.notifications.events.idle": "Session becomes idle",
|
||||||
|
"settings.notifications.status.enabled": "Notifications enabled",
|
||||||
|
"settings.notifications.status.disabled": "Notifications disabled",
|
||||||
|
"settings.notifications.status.unsupported": "Notifications unsupported",
|
||||||
|
"settings.section.remote.title": "Remote Access",
|
||||||
|
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
|
||||||
|
"settings.section.opencode.title": "OpenCode",
|
||||||
|
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
||||||
|
"settings.opencode.runtime.title": "Runtime",
|
||||||
|
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
||||||
|
|
||||||
|
"settings.appearance.behavior.title": "操作",
|
||||||
|
"settings.appearance.behavior.subtitle": "メッセージ、差分、入力の既定値。",
|
||||||
|
"settings.behavior.keyboardHints.title": "キーボードショートカットのヒント",
|
||||||
|
"settings.behavior.keyboardHints.subtitle": "UI全体でキーボードショートカットのヒントを表示します。",
|
||||||
|
"settings.behavior.thinking.title": "思考セクション",
|
||||||
|
"settings.behavior.thinking.subtitle": "メッセージ内のAIの思考セクションを表示/非表示にします。",
|
||||||
|
"settings.behavior.thinkingDefault.title": "思考の既定",
|
||||||
|
"settings.behavior.thinkingDefault.subtitle": "思考セクションを最初に展開/折りたたみのどちらで表示するかを選びます。",
|
||||||
|
"settings.behavior.timelineTools.title": "タイムラインのツール呼び出し",
|
||||||
|
"settings.behavior.timelineTools.subtitle": "メッセージタイムラインでツール呼び出しを表示/非表示にします。",
|
||||||
|
"settings.behavior.diffView.title": "差分表示",
|
||||||
|
"settings.behavior.diffView.subtitle": "ツール呼び出しの差分の表示方法を選びます。",
|
||||||
|
"settings.behavior.diffView.option.split": "分割",
|
||||||
|
"settings.behavior.diffView.option.unified": "統合",
|
||||||
|
"settings.behavior.toolOutputsDefault.title": "ツール出力の既定",
|
||||||
|
"settings.behavior.toolOutputsDefault.subtitle": "ツール出力を最初に展開/折りたたみのどちらで表示するかを選びます。",
|
||||||
|
"settings.behavior.diagnosticsDefault.title": "診断の既定",
|
||||||
|
"settings.behavior.diagnosticsDefault.subtitle": "診断出力を最初に展開/折りたたみのどちらで表示するかを選びます。",
|
||||||
|
"settings.behavior.toolInputsVisibility.title": "ツール入力の表示",
|
||||||
|
"settings.behavior.toolInputsVisibility.subtitle": "ツール呼び出しの入力引数の既定の表示状態を設定します。",
|
||||||
|
"settings.behavior.usageMetrics.title": "トークン使用量メトリクス",
|
||||||
|
"settings.behavior.usageMetrics.subtitle": "アシスタントのメッセージにトークン数とコストの統計を表示/非表示にします。",
|
||||||
|
"settings.behavior.autoCleanup.title": "空のセッションを自動クリーンアップ",
|
||||||
|
"settings.behavior.autoCleanup.subtitle": "新しいセッション作成時に空のセッションを自動的にクリーンアップします。",
|
||||||
|
"settings.behavior.promptSubmit.title": "Enterで送信",
|
||||||
|
"settings.behavior.promptSubmit.subtitle": "Enterで送信し、Cmd/Ctrl+Enterで改行します。",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
export const appMessages = {
|
export const appMessages = {
|
||||||
"app.launchError.title": "Не удалось запустить OpenCode",
|
"app.launchError.title": "Не удалось запустить OpenCode",
|
||||||
"app.launchError.description": "Не удалось запустить выбранный бинарник OpenCode. Просмотрите вывод ошибки ниже или выберите другой бинарник в расширенных настройках.",
|
"app.launchError.description": "Не удалось запустить выбранный бинарник OpenCode. Просмотрите вывод ошибки ниже или выберите другой бинарник в настройках OpenCode.",
|
||||||
"app.launchError.binaryPathLabel": "Путь к бинарнику",
|
"app.launchError.binaryPathLabel": "Путь к бинарнику",
|
||||||
"app.launchError.errorOutputLabel": "Вывод ошибки",
|
"app.launchError.errorOutputLabel": "Вывод ошибки",
|
||||||
"app.launchError.openAdvancedSettings": "Открыть расширенные настройки",
|
"app.launchError.openAdvancedSettings": "Открыть настройки OpenCode",
|
||||||
"app.launchError.close": "Закрыть",
|
"app.launchError.close": "Закрыть",
|
||||||
"app.launchError.closeTitle": "Закрыть (Esc)",
|
"app.launchError.closeTitle": "Закрыть (Esc)",
|
||||||
"app.launchError.fallbackMessage": "Не удалось запустить рабочее пространство",
|
"app.launchError.fallbackMessage": "Не удалось запустить рабочее пространство",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.browse.buttonOpening": "Открытие…",
|
"folderSelection.browse.buttonOpening": "Открытие…",
|
||||||
|
|
||||||
"folderSelection.advancedSettings": "Расширенные настройки",
|
"folderSelection.advancedSettings": "Расширенные настройки",
|
||||||
|
"folderSelection.opencode": "OpenCode",
|
||||||
|
|
||||||
"folderSelection.hints.navigate": "Навигация",
|
"folderSelection.hints.navigate": "Навигация",
|
||||||
"folderSelection.hints.select": "Выбрать",
|
"folderSelection.hints.select": "Выбрать",
|
||||||
@@ -31,6 +32,11 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.loading.title": "Запуск экземпляра…",
|
"folderSelection.loading.title": "Запуск экземпляра…",
|
||||||
"folderSelection.loading.subtitle": "Подождите, пока мы подготовим рабочее пространство.",
|
"folderSelection.loading.subtitle": "Подождите, пока мы подготовим рабочее пространство.",
|
||||||
|
|
||||||
|
"folderSelection.drop.title": "Перетащите папку, чтобы открыть ее",
|
||||||
|
"folderSelection.drop.subtitle": "Запустите новый экземпляр в перетащенной папке.",
|
||||||
|
"folderSelection.drop.invalidTitle": "Не удалось открыть перетащенный элемент",
|
||||||
|
"folderSelection.drop.invalidMessage": "Перетащите папку, чтобы запустить новый экземпляр.",
|
||||||
|
|
||||||
"folderSelection.dialog.title": "Выберите рабочее пространство",
|
"folderSelection.dialog.title": "Выберите рабочее пространство",
|
||||||
"folderSelection.dialog.description": "Выберите рабочее пространство, чтобы начать писать код.",
|
"folderSelection.dialog.description": "Выберите рабочее пространство, чтобы начать писать код.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export const instanceMessages = {
|
|||||||
|
|
||||||
"instanceShell.rightPanel.title": "Панель состояния",
|
"instanceShell.rightPanel.title": "Панель состояния",
|
||||||
"instanceShell.rightPanel.tabs.changes": "Изменения",
|
"instanceShell.rightPanel.tabs.changes": "Изменения",
|
||||||
|
"instanceShell.rightPanel.tabs.gitChanges": "Изменения Git",
|
||||||
"instanceShell.rightPanel.tabs.files": "Файлы",
|
"instanceShell.rightPanel.tabs.files": "Файлы",
|
||||||
"instanceShell.rightPanel.tabs.status": "Статус",
|
"instanceShell.rightPanel.tabs.status": "Статус",
|
||||||
"instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели",
|
"instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели",
|
||||||
@@ -112,6 +113,10 @@ export const instanceMessages = {
|
|||||||
"instanceShell.sessionChanges.filesChanged": "Изменено файлов: {count}",
|
"instanceShell.sessionChanges.filesChanged": "Изменено файлов: {count}",
|
||||||
"instanceShell.sessionChanges.actions.show": "Показать изменения",
|
"instanceShell.sessionChanges.actions.show": "Показать изменения",
|
||||||
|
|
||||||
|
"instanceShell.gitChanges.loading": "Загрузка изменений Git...",
|
||||||
|
"instanceShell.gitChanges.empty": "Изменений Git пока нет.",
|
||||||
|
"instanceShell.gitChanges.deleted": "Удалено",
|
||||||
|
|
||||||
"instanceShell.filesShell.fileListTitle": "Список файлов",
|
"instanceShell.filesShell.fileListTitle": "Список файлов",
|
||||||
"instanceShell.filesShell.mobileSelectorLabel": "Выбрать файл",
|
"instanceShell.filesShell.mobileSelectorLabel": "Выбрать файл",
|
||||||
"instanceShell.filesShell.mobileSelectorEmpty": "Выберите файл",
|
"instanceShell.filesShell.mobileSelectorEmpty": "Выберите файл",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const messagingMessages = {
|
|||||||
"messageBlock.tool.goToSession.label": "Перейти к сессии",
|
"messageBlock.tool.goToSession.label": "Перейти к сессии",
|
||||||
"messageBlock.tool.goToSession.title": "Перейти к сессии",
|
"messageBlock.tool.goToSession.title": "Перейти к сессии",
|
||||||
"messageBlock.tool.goToSession.unavailableTitle": "Сессия пока недоступна",
|
"messageBlock.tool.goToSession.unavailableTitle": "Сессия пока недоступна",
|
||||||
"messageBlock.tool.deletePart.label": "Удалить",
|
"messageBlock.tool.deletePart.label": "Удалить часть",
|
||||||
"messageBlock.tool.deletePart.deleting": "Удаление...",
|
"messageBlock.tool.deletePart.deleting": "Удаление...",
|
||||||
"messageBlock.tool.deletePart.title": "Удалить этот вывод инструмента",
|
"messageBlock.tool.deletePart.title": "Удалить этот вывод инструмента",
|
||||||
"messageBlock.tool.deletePart.failed.title": "Ошибка удаления",
|
"messageBlock.tool.deletePart.failed.title": "Ошибка удаления",
|
||||||
@@ -71,17 +71,38 @@ export const messagingMessages = {
|
|||||||
"messageItem.speaker.you": "Вы",
|
"messageItem.speaker.you": "Вы",
|
||||||
"messageItem.speaker.assistant": "Ассистент",
|
"messageItem.speaker.assistant": "Ассистент",
|
||||||
"messageItem.actions.revert": "Откатить",
|
"messageItem.actions.revert": "Откатить",
|
||||||
"messageItem.actions.revertTitle": "Откатиться к этому сообщению",
|
"messageItem.actions.revertTitle": "Отменить изменения до этого места (удалит сообщения)",
|
||||||
"messageItem.actions.fork": "Форк",
|
"messageItem.actions.fork": "Форк",
|
||||||
"messageItem.actions.forkTitle": "Форкнуть от этого сообщения",
|
"messageItem.actions.forkTitle": "Форкнуть от этого сообщения",
|
||||||
"messageItem.actions.copy": "Копировать",
|
"messageItem.actions.copy": "Копировать",
|
||||||
"messageItem.actions.copyTitle": "Копировать сообщение",
|
"messageItem.actions.copyTitle": "Копировать сообщение",
|
||||||
"messageItem.actions.copied": "Скопировано!",
|
"messageItem.actions.copied": "Скопировано!",
|
||||||
|
"messageItem.actions.deleteMessage": "Удалить сообщение (без отката изменений)",
|
||||||
|
"messageItem.actions.deleteMessagesUpTo": "Удалить сообщения до этого места (без отката изменений)",
|
||||||
|
"messageItem.actions.deletingMessage": "Удаление...",
|
||||||
|
"messageItem.actions.deleteMessageFailedTitle": "Ошибка удаления",
|
||||||
|
"messageItem.actions.deleteMessageFailedMessage": "Не удалось удалить сообщение",
|
||||||
|
|
||||||
|
"messageItem.selection.checkboxAriaLabel": "Выбрать сообщение для удаления",
|
||||||
|
|
||||||
|
"messageSection.bulkDelete.toolbarAriaLabel": "Выбранные элементы ({count})",
|
||||||
|
"messageSection.bulkDelete.deleteSelectedTitle": "Удалить выбранные элементы",
|
||||||
|
"messageSection.bulkDelete.selectAllTitle": "Выбрать все сообщения",
|
||||||
|
"messageSection.bulkDelete.moreOptionsTitle": "Больше настроек",
|
||||||
|
"messageSection.bulkDelete.selectionModeLabel": "Выбор",
|
||||||
|
"messageSection.bulkDelete.selectionModeAll": "Все",
|
||||||
|
"messageSection.bulkDelete.selectionModeTools": "Только инструменты",
|
||||||
|
"messageSection.bulkDelete.selectionHint.toggle": "Выбрать элемент",
|
||||||
|
"messageSection.bulkDelete.selectionHint.range": "Выбрать диапазон",
|
||||||
|
"messageSection.bulkDelete.selectionHint.clear": "Очистить выбор",
|
||||||
|
"messageSection.bulkDelete.cancelTitle": "Отменить выбор",
|
||||||
|
"messageSection.bulkDelete.failedTitle": "Ошибка удаления",
|
||||||
|
"messageSection.bulkDelete.failedMessage": "Не удалось удалить выбранные элементы",
|
||||||
"messageItem.status.queued": "В ОЧЕРЕДИ",
|
"messageItem.status.queued": "В ОЧЕРЕДИ",
|
||||||
"messageItem.status.generating": "Генерация…",
|
"messageItem.status.generating": "Генерация…",
|
||||||
"messageItem.status.sending": "Отправка…",
|
"messageItem.status.sending": "Отправка…",
|
||||||
"messageItem.status.failedToSend": "Не удалось отправить сообщение",
|
"messageItem.status.failedToSend": "Не удалось отправить сообщение",
|
||||||
"messagePart.actions.delete": "Удалить",
|
"messagePart.actions.delete": "Удалить часть",
|
||||||
"messagePart.actions.deleting": "Удаление...",
|
"messagePart.actions.deleting": "Удаление...",
|
||||||
"messagePart.actions.deleteTitle": "Удалить этот элемент",
|
"messagePart.actions.deleteTitle": "Удалить этот элемент",
|
||||||
"messagePart.actions.deleteFailedTitle": "Ошибка удаления",
|
"messagePart.actions.deleteFailedTitle": "Ошибка удаления",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user