Compare commits
171 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
90baefbb7e | ||
|
|
1c138f4489 | ||
|
|
d36e568ed0 | ||
|
|
d6462ef524 | ||
|
|
a06884ebce | ||
|
|
62bd88f6a4 | ||
|
|
6479561779 | ||
|
|
635237c258 | ||
|
|
33f0aa5714 | ||
|
|
7ca6285d58 | ||
|
|
14c60fef6c | ||
|
|
336de6a19e | ||
|
|
377c8e2249 | ||
|
|
697dea21f8 | ||
|
|
34d3f803d5 | ||
|
|
f824a063a5 | ||
|
|
96fe1b86dd | ||
|
|
5fabf286e8 | ||
|
|
e8947d61b1 | ||
|
|
1ccd14eae8 | ||
|
|
b162764ccb | ||
|
|
2124e540aa | ||
|
|
b5790998b7 | ||
|
|
9800afb785 | ||
|
|
3b73d9d5b9 | ||
|
|
f7ac30afe3 | ||
|
|
ce370d5100 | ||
|
|
c639e535b5 | ||
|
|
e84adebe61 | ||
|
|
d45a1ff078 | ||
|
|
b4121696bb | ||
|
|
f75c942162 | ||
|
|
127a1f628d | ||
|
|
859312ba3b | ||
|
|
4eaa711f01 | ||
|
|
c8ff858565 | ||
|
|
6de6ef5a4a | ||
|
|
4dee154490 | ||
|
|
ef388adc4f | ||
|
|
e8cfad1266 | ||
|
|
3f82dd21fe | ||
|
|
dc13d9a7d0 | ||
|
|
29557fba6d | ||
|
|
dea5079713 | ||
|
|
ddc58a2c3c | ||
|
|
eafd4d83af | ||
|
|
1a0734c6b1 | ||
|
|
e16c5752ed | ||
|
|
375f92410e | ||
|
|
53f1dd4150 | ||
|
|
b7f638f07d | ||
|
|
32113ea100 | ||
|
|
b31135f622 | ||
|
|
eb6701185b | ||
|
|
d948ad8e35 | ||
|
|
f58267dd30 | ||
|
|
95c747923c | ||
|
|
f3b9ee4e04 | ||
|
|
309a123c1f | ||
|
|
761e3d4268 | ||
|
|
265d497ef4 | ||
|
|
56a052086f | ||
|
|
9a4d205d97 | ||
|
|
ff71302969 | ||
|
|
4f6c8523c0 | ||
|
|
8c24a7daf3 | ||
|
|
682937e945 | ||
|
|
35ff359c0f | ||
|
|
5067db3dd0 | ||
|
|
c7195469bd | ||
|
|
1ef01da019 | ||
|
|
edd3ded1d8 | ||
|
|
e30ff6358d | ||
|
|
e9f281a69d | ||
|
|
36baac06b8 | ||
|
|
3678214e69 | ||
|
|
338e3d9d38 | ||
|
|
0c0f397db0 | ||
|
|
da70cc9944 | ||
|
|
ba418a8518 | ||
|
|
ffe991bbe4 | ||
|
|
3047a1e602 | ||
|
|
e6c568988a | ||
|
|
45fab91e7f | ||
|
|
d3484ec3af | ||
|
|
cb0d601b09 | ||
|
|
9ea4f6b5ef | ||
|
|
bf9ee76de5 | ||
|
|
6ed1e09180 | ||
|
|
54d4cf6604 | ||
|
|
359e89971f | ||
|
|
7f833747b0 | ||
|
|
ab3f228d85 | ||
|
|
67a530a83b | ||
|
|
612ec6af1b |
145
.github/workflows/build-and-upload.yml
vendored
145
.github/workflows/build-and-upload.yml
vendored
@@ -3,6 +3,11 @@ name: Build and Upload Binaries
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
|
ref:
|
||||||
|
description: "Git ref (branch, tag, or SHA) to build from"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
type: string
|
||||||
version:
|
version:
|
||||||
description: "Version to apply to workspace packages (release builds)"
|
description: "Version to apply to workspace packages (release builds)"
|
||||||
required: false
|
required: false
|
||||||
@@ -45,6 +50,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -54,7 +61,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
|
||||||
@@ -65,6 +86,112 @@ 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)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Prefer the workflow-provided version; fall back to package.json.
|
||||||
|
VERSION_TO_USE="${VERSION:-}"
|
||||||
|
if [ -z "$VERSION_TO_USE" ]; then
|
||||||
|
VERSION_TO_USE=$(node -p "require('./packages/electron-app/package.json').version")
|
||||||
|
fi
|
||||||
|
|
||||||
|
release_root="packages/electron-app/release"
|
||||||
|
# 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=()
|
||||||
|
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
|
||||||
|
|
||||||
|
for app in "${apps[@]}"; do
|
||||||
|
bundle_dir=$(basename "$(dirname "$app")")
|
||||||
|
arch="x64"
|
||||||
|
if [[ "$bundle_dir" == *"arm64"* ]]; then
|
||||||
|
arch="arm64"
|
||||||
|
fi
|
||||||
|
|
||||||
|
out_zip="$release_root/CodeNomad-${VERSION_TO_USE}-mac-${arch}.zip"
|
||||||
|
rm -f "$out_zip"
|
||||||
|
echo "ditto -ck: $app -> $out_zip"
|
||||||
|
ditto -ck --sequesterRsrc --keepParent "$app" "$out_zip"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Validate Electron macOS codesign (unzipped)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
shopt -s nullglob
|
||||||
|
|
||||||
|
tmp_dir=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
zips=(packages/electron-app/release/CodeNomad-*-mac-*.zip)
|
||||||
|
if [ "${#zips[@]}" -eq 0 ]; then
|
||||||
|
echo "No Electron macOS zip artifacts found to validate" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for zip in "${zips[@]}"; do
|
||||||
|
echo "Validating codesign for: $zip"
|
||||||
|
extract_dir="$tmp_dir/$(basename "$zip" .zip)"
|
||||||
|
mkdir -p "$extract_dir"
|
||||||
|
|
||||||
|
# Use ditto for extraction as well to preserve bundle metadata.
|
||||||
|
ditto -x -k "$zip" "$extract_dir"
|
||||||
|
|
||||||
|
app_path=""
|
||||||
|
for candidate in "$extract_dir"/*.app "$extract_dir"/*/*.app; do
|
||||||
|
if [ -d "$candidate" ]; then
|
||||||
|
app_path="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$app_path" ]; then
|
||||||
|
echo "No .app found after extracting $zip" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
codesign --verify --deep --strict --verbose=2 "$app_path"
|
||||||
|
done
|
||||||
|
|
||||||
- name: Upload release assets
|
- name: Upload release assets
|
||||||
if: ${{ inputs.upload && inputs.tag != '' }}
|
if: ${{ inputs.upload && inputs.tag != '' }}
|
||||||
run: |
|
run: |
|
||||||
@@ -85,6 +212,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -124,6 +253,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -164,6 +295,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -237,6 +370,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -310,6 +445,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -388,6 +525,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -490,6 +629,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup QEMU
|
- name: Setup QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
@@ -587,6 +728,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
80
.github/workflows/dev-release.yml
vendored
80
.github/workflows/dev-release.yml
vendored
@@ -1,18 +1,80 @@
|
|||||||
name: Dev CI
|
name: Develop Pre-Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
schedule:
|
||||||
branches:
|
# Nightly build of dev (only if dev has new commits)
|
||||||
- dev
|
- cron: "0 1 * * *"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
actions: read
|
||||||
|
id-token: write
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: dev-prerelease
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
dev-ci:
|
gate:
|
||||||
uses: ./.github/workflows/build-and-upload.yml
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
run: ${{ steps.gate.outputs.run }}
|
||||||
|
dev_sha: ${{ steps.gate.outputs.dev_sha }}
|
||||||
|
version_suffix: ${{ steps.gate.outputs.version_suffix }}
|
||||||
|
steps:
|
||||||
|
- name: Decide whether to run
|
||||||
|
id: gate
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
api() {
|
||||||
|
curl -sS \
|
||||||
|
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||||
|
"$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
DEV_SHA=$(api "https://api.github.com/repos/${GITHUB_REPOSITORY}/git/ref/heads/dev" | jq -r '.object.sha')
|
||||||
|
if [ -z "$DEV_SHA" ] || [ "$DEV_SHA" = "null" ]; then
|
||||||
|
echo "Failed to resolve dev head SHA" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DATE=$(date -u +%Y%m%d)
|
||||||
|
SHA8="${DEV_SHA::8}"
|
||||||
|
VERSION_SUFFIX="-dev-${DATE}-${SHA8}"
|
||||||
|
|
||||||
|
SHOULD_RUN="false"
|
||||||
|
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
|
||||||
|
SHOULD_RUN="true"
|
||||||
|
else
|
||||||
|
# Nightly: only run if dev has advanced since last successful dev-release build.
|
||||||
|
LAST_SHA=$(api "https://api.github.com/repos/${GITHUB_REPOSITORY}/actions/workflows/dev-release.yml/runs?branch=dev&status=success&per_page=1" | jq -r '.workflow_runs[0].head_sha // empty')
|
||||||
|
if [ -z "${LAST_SHA}" ]; then
|
||||||
|
SHOULD_RUN="true"
|
||||||
|
elif [ "${LAST_SHA}" != "${DEV_SHA}" ]; then
|
||||||
|
SHOULD_RUN="true"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "run=${SHOULD_RUN}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "dev_sha=${DEV_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version_suffix=${VERSION_SUFFIX}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
prerelease:
|
||||||
|
needs: gate
|
||||||
|
if: ${{ needs.gate.outputs.run == 'true' }}
|
||||||
|
uses: ./.github/workflows/reusable-release.yml
|
||||||
with:
|
with:
|
||||||
upload: false
|
ref: ${{ needs.gate.outputs.dev_sha }}
|
||||||
set_versions: false
|
version_suffix: ${{ needs.gate.outputs.version_suffix }}
|
||||||
|
npm_package_name: "@neuralnomads/codenomad-dev"
|
||||||
|
dist_tag: latest
|
||||||
|
prerelease: true
|
||||||
|
release_ui: false
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|||||||
40
.github/workflows/manual-npm-publish.yml
vendored
40
.github/workflows/manual-npm-publish.yml
vendored
@@ -12,8 +12,17 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: dev
|
default: dev
|
||||||
type: string
|
type: string
|
||||||
|
package_name:
|
||||||
|
description: "Package name to publish (e.g. @neuralnomads/codenomad-dev)"
|
||||||
|
required: false
|
||||||
|
default: "@neuralnomads/codenomad"
|
||||||
|
type: string
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
|
ref:
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
type: string
|
||||||
version:
|
version:
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
@@ -21,6 +30,13 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
default: dev
|
default: dev
|
||||||
|
package_name:
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: "@neuralnomads/codenomad"
|
||||||
|
secrets:
|
||||||
|
NPM_TOKEN:
|
||||||
|
required: false
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -34,6 +50,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -51,7 +69,7 @@ jobs:
|
|||||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||||
|
|
||||||
- name: Build server package (includes UI bundling)
|
- name: Build server package (includes UI bundling)
|
||||||
run: npm run build --workspace @neuralnomads/codenomad
|
run: npm run build --workspace packages/server
|
||||||
|
|
||||||
- name: Set publish metadata
|
- name: Set publish metadata
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -62,13 +80,31 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "VERSION=$VERSION_INPUT" >> "$GITHUB_ENV"
|
echo "VERSION=$VERSION_INPUT" >> "$GITHUB_ENV"
|
||||||
echo "DIST_TAG=${{ inputs.dist_tag || 'dev' }}" >> "$GITHUB_ENV"
|
echo "DIST_TAG=${{ inputs.dist_tag || 'dev' }}" >> "$GITHUB_ENV"
|
||||||
|
echo "PACKAGE_NAME=${{ inputs.package_name }}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Bump package version for publish
|
- name: Bump package version for publish
|
||||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Set server package name for publish
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
node -e "const fs=require('fs'); const path=require('path'); const p=path.join('packages','server','package.json'); const j=JSON.parse(fs.readFileSync(p,'utf8')); j.name=process.env.PACKAGE_NAME || j.name; fs.writeFileSync(p, JSON.stringify(j, null, 2)+'\n'); console.log('Publishing as', j.name);"
|
||||||
|
|
||||||
- name: Publish server package with provenance
|
- name: Publish server package with provenance
|
||||||
env:
|
env:
|
||||||
|
# Optional: when present, npm will use token auth.
|
||||||
|
# When empty/unset, npm trusted publishing (OIDC) may be used if configured.
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
NPM_CONFIG_PROVENANCE: true
|
NPM_CONFIG_PROVENANCE: true
|
||||||
NPM_CONFIG_REGISTRY: https://registry.npmjs.org
|
NPM_CONFIG_REGISTRY: https://registry.npmjs.org
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
npm publish --workspace @neuralnomads/codenomad --access public --tag ${DIST_TAG} --provenance
|
set -euo pipefail
|
||||||
|
if [ -z "${NODE_AUTH_TOKEN:-}" ]; then
|
||||||
|
echo "NPM_TOKEN not set; attempting npm trusted publishing (OIDC)"
|
||||||
|
unset NODE_AUTH_TOKEN
|
||||||
|
else
|
||||||
|
echo "Using NPM_TOKEN authentication"
|
||||||
|
fi
|
||||||
|
npm publish --workspace packages/server --access public --tag ${DIST_TAG} --provenance
|
||||||
|
|||||||
10
.github/workflows/release-ui.yml
vendored
10
.github/workflows/release-ui.yml
vendored
@@ -1,7 +1,13 @@
|
|||||||
name: Release UI
|
name: Release UI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_call: {}
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
ref:
|
||||||
|
description: "Git ref (branch, tag, or SHA) to build from"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
type: string
|
||||||
workflow_dispatch: {}
|
workflow_dispatch: {}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -18,6 +24,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -14,4 +14,5 @@ jobs:
|
|||||||
uses: ./.github/workflows/reusable-release.yml
|
uses: ./.github/workflows/reusable-release.yml
|
||||||
with:
|
with:
|
||||||
dist_tag: latest
|
dist_tag: latest
|
||||||
|
npm_package_name: "@neuralnomads/codenomad"
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|||||||
35
.github/workflows/reusable-release.yml
vendored
35
.github/workflows/reusable-release.yml
vendored
@@ -3,6 +3,11 @@ name: Reusable Release
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
|
ref:
|
||||||
|
description: "Git ref (branch, tag, or SHA) to build from"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
type: string
|
||||||
version_suffix:
|
version_suffix:
|
||||||
description: "Suffix appended to package.json version"
|
description: "Suffix appended to package.json version"
|
||||||
required: false
|
required: false
|
||||||
@@ -13,6 +18,21 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: dev
|
default: dev
|
||||||
type: string
|
type: string
|
||||||
|
npm_package_name:
|
||||||
|
description: "npm package name to publish (defaults to server package name)"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
type: string
|
||||||
|
prerelease:
|
||||||
|
description: "Create GitHub prerelease"
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
release_ui:
|
||||||
|
description: "Publish remote UI + manifest"
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
type: boolean
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
@@ -31,6 +51,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -53,17 +75,23 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAG: ${{ steps.versions.outputs.tag }}
|
TAG: ${{ steps.versions.outputs.tag }}
|
||||||
|
IS_PRERELEASE: ${{ inputs.prerelease }}
|
||||||
run: |
|
run: |
|
||||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||||
echo "Release $TAG already exists"
|
echo "Release $TAG already exists"
|
||||||
else
|
else
|
||||||
gh release create "$TAG" --title "$TAG" --generate-notes
|
if [ "${IS_PRERELEASE}" = "true" ]; then
|
||||||
|
gh release create "$TAG" --title "$TAG" --generate-notes --prerelease
|
||||||
|
else
|
||||||
|
gh release create "$TAG" --title "$TAG" --generate-notes
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
build-and-upload:
|
build-and-upload:
|
||||||
needs: prepare-release
|
needs: prepare-release
|
||||||
uses: ./.github/workflows/build-and-upload.yml
|
uses: ./.github/workflows/build-and-upload.yml
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
version: ${{ needs.prepare-release.outputs.version }}
|
version: ${{ needs.prepare-release.outputs.version }}
|
||||||
tag: ${{ needs.prepare-release.outputs.tag }}
|
tag: ${{ needs.prepare-release.outputs.tag }}
|
||||||
release_name: ${{ needs.prepare-release.outputs.release_name }}
|
release_name: ${{ needs.prepare-release.outputs.release_name }}
|
||||||
@@ -71,9 +99,12 @@ jobs:
|
|||||||
|
|
||||||
release-ui:
|
release-ui:
|
||||||
needs: prepare-release
|
needs: prepare-release
|
||||||
|
if: ${{ inputs.release_ui }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
uses: ./.github/workflows/release-ui.yml
|
uses: ./.github/workflows/release-ui.yml
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
publish-server:
|
publish-server:
|
||||||
@@ -82,6 +113,8 @@ jobs:
|
|||||||
- build-and-upload
|
- build-and-upload
|
||||||
uses: ./.github/workflows/manual-npm-publish.yml
|
uses: ./.github/workflows/manual-npm-publish.yml
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
version: ${{ needs.prepare-release.outputs.version }}
|
version: ${{ needs.prepare-release.outputs.version }}
|
||||||
dist_tag: ${{ inputs.dist_tag }}
|
dist_tag: ${{ inputs.dist_tag }}
|
||||||
|
package_name: ${{ inputs.npm_package_name }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -44,13 +44,21 @@ Run CodeNomad as a local server and access it via your web browser. Perfect for
|
|||||||
npx @neuralnomads/codenomad --launch
|
npx @neuralnomads/codenomad --launch
|
||||||
```
|
```
|
||||||
|
|
||||||
For dev version
|
Full server/CLI documentation (flags + env vars, TLS, auth, remote access):
|
||||||
|
- [packages/server/README.md](packages/server/README.md)
|
||||||
|
|
||||||
|
To see all available options:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx @neuralnomads/codenomad@dev --launch
|
npx @neuralnomads/codenomad --help
|
||||||
```
|
```
|
||||||
|
|
||||||
This command starts the server and opens the web client in your default browser.
|
### 🧪 Dev Releases
|
||||||
|
Bleeding-edge builds are published as GitHub pre-releases and are generated automatically from the `dev` branch.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @neuralnomads/codenomad-dev --launch
|
||||||
|
```
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
@@ -115,3 +123,6 @@ To build the Desktop App from source:
|
|||||||
1. Clone the repo.
|
1. Clone the repo.
|
||||||
2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
|
2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
|
||||||
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`.
|
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`.
|
||||||
|
|
||||||
|
[](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date)
|
||||||
|
|
||||||
|
|||||||
60
package-lock.json
generated
60
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.10.3",
|
"version": "0.12.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.10.3",
|
"version": "0.12.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
@@ -2809,9 +2809,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@opencode-ai/sdk": {
|
"node_modules/@opencode-ai/sdk": {
|
||||||
"version": "1.1.11",
|
"version": "1.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.6.tgz",
|
||||||
"integrity": "sha512-vqdNDz8Q+4bygmDdQem6oxhU31ci4JVdoND4ZJNeCs9x6OIU6MM3ybgemGpzNkgtJDlfb4xCdrPaZZ6Sr3V1IQ==",
|
"integrity": "sha512-dWMF8Aku4h7fh8sw5tQ2FtbqRLbIFT8FcsukpxTird49ax7oUXP+gzqxM/VdxHjfksQvzLBjLZyMdDStc5g7xA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@pinojs/redact": {
|
"node_modules/@pinojs/redact": {
|
||||||
@@ -3305,6 +3305,23 @@
|
|||||||
"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-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",
|
||||||
@@ -11879,6 +11896,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "2.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||||
|
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yargs": {
|
"node_modules/yargs": {
|
||||||
"version": "17.7.2",
|
"version": "17.7.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -11970,15 +12002,17 @@
|
|||||||
},
|
},
|
||||||
"packages/electron-app": {
|
"packages/electron-app": {
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.10.3",
|
"version": "0.12.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codenomad/ui": "file:../ui",
|
"@codenomad/ui": "file:../ui",
|
||||||
"@neuralnomads/codenomad": "file:../server"
|
"@neuralnomads/codenomad": "file:../server",
|
||||||
|
"yaml": "^2.4.2"
|
||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -12005,7 +12039,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.10.3",
|
"version": "0.12.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
@@ -12017,6 +12051,7 @@
|
|||||||
"node-forge": "^1.3.3",
|
"node-forge": "^1.3.3",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^6.19.8",
|
||||||
|
"yaml": "^2.4.2",
|
||||||
"yauzl": "^2.10.0",
|
"yauzl": "^2.10.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
@@ -12045,7 +12080,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tauri-app": {
|
"packages/tauri-app": {
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.10.3",
|
"version": "0.12.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
@@ -12053,12 +12088,12 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.10.3",
|
"version": "0.12.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@opencode-ai/sdk": "1.1.11",
|
"@opencode-ai/sdk": "1.2.6",
|
||||||
"@solidjs/router": "^0.13.0",
|
"@solidjs/router": "^0.13.0",
|
||||||
"@suid/icons-material": "^0.9.0",
|
"@suid/icons-material": "^0.9.0",
|
||||||
"@suid/material": "^0.19.0",
|
"@suid/material": "^0.19.0",
|
||||||
@@ -12075,7 +12110,8 @@
|
|||||||
"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"
|
"tauri-plugin-keepawake-api": "^0.1.0",
|
||||||
|
"yaml": "^2.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vite-pwa/assets-generator": "^1.0.2",
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.10.3",
|
"version": "0.12.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CodeNomad monorepo workspace",
|
"description": "CodeNomad monorepo workspace",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"minServerVersion": "0.10.3",
|
"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) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { EventEmitter } from "events"
|
|||||||
import { existsSync, readFileSync } from "fs"
|
import { existsSync, readFileSync } from "fs"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { parse as parseYaml } from "yaml"
|
||||||
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
||||||
|
|
||||||
const nodeRequire = createRequire(import.meta.url)
|
const nodeRequire = createRequire(import.meta.url)
|
||||||
@@ -39,6 +40,36 @@ interface CliEntryResolution {
|
|||||||
|
|
||||||
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||||
|
|
||||||
|
function isYamlPath(filePath: string): boolean {
|
||||||
|
const lower = filePath.toLowerCase()
|
||||||
|
return lower.endsWith(".yaml") || lower.endsWith(".yml")
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJsonPath(filePath: string): boolean {
|
||||||
|
return filePath.toLowerCase().endsWith(".json")
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConfigPaths(raw?: string): { configYamlPath: string; legacyJsonPath: string } {
|
||||||
|
const target = raw && raw.trim().length > 0 ? raw.trim() : DEFAULT_CONFIG_PATH
|
||||||
|
const resolved = resolveConfigPath(target)
|
||||||
|
|
||||||
|
if (isYamlPath(resolved)) {
|
||||||
|
const baseDir = path.dirname(resolved)
|
||||||
|
return { configYamlPath: resolved, legacyJsonPath: path.join(baseDir, "config.json") }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isJsonPath(resolved)) {
|
||||||
|
const baseDir = path.dirname(resolved)
|
||||||
|
return { configYamlPath: path.join(baseDir, "config.yaml"), legacyJsonPath: resolved }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treat as directory.
|
||||||
|
return {
|
||||||
|
configYamlPath: path.join(resolved, "config.yaml"),
|
||||||
|
legacyJsonPath: path.join(resolved, "config.json"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resolveConfigPath(configPath?: string): string {
|
function resolveConfigPath(configPath?: string): string {
|
||||||
const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH
|
const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH
|
||||||
if (target.startsWith("~/")) {
|
if (target.startsWith("~/")) {
|
||||||
@@ -53,11 +84,20 @@ function resolveHostForMode(mode: ListeningMode): string {
|
|||||||
|
|
||||||
function readListeningModeFromConfig(): ListeningMode {
|
function readListeningModeFromConfig(): ListeningMode {
|
||||||
try {
|
try {
|
||||||
const configPath = resolveConfigPath(process.env.CLI_CONFIG)
|
const { configYamlPath, legacyJsonPath } = resolveConfigPaths(process.env.CLI_CONFIG)
|
||||||
if (!existsSync(configPath)) return "local"
|
|
||||||
const content = readFileSync(configPath, "utf-8")
|
let parsed: any = null
|
||||||
const parsed = JSON.parse(content)
|
if (existsSync(configYamlPath)) {
|
||||||
const mode = parsed?.preferences?.listeningMode
|
const content = readFileSync(configYamlPath, "utf-8")
|
||||||
|
parsed = parseYaml(content)
|
||||||
|
} else if (existsSync(legacyJsonPath)) {
|
||||||
|
const content = readFileSync(legacyJsonPath, "utf-8")
|
||||||
|
parsed = JSON.parse(content)
|
||||||
|
} else {
|
||||||
|
return "local"
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = parsed?.server?.listeningMode ?? parsed?.preferences?.listeningMode
|
||||||
if (mode === "local" || mode === "all") {
|
if (mode === "local" || mode === "all") {
|
||||||
return mode
|
return mode
|
||||||
}
|
}
|
||||||
@@ -391,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.10.3",
|
"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",
|
||||||
@@ -36,11 +39,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@neuralnomads/codenomad": "file:../server",
|
"@neuralnomads/codenomad": "file:../server",
|
||||||
"@codenomad/ui": "file:../ui"
|
"@codenomad/ui": "file:../ui",
|
||||||
|
"yaml": "^2.4.2"
|
||||||
},
|
},
|
||||||
"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.1.53"
|
"@opencode-ai/plugin": "1.2.25"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
3
packages/server/.gitignore
vendored
3
packages/server/.gitignore
vendored
@@ -1 +1,4 @@
|
|||||||
public/
|
public/
|
||||||
|
|
||||||
|
# Local developer config (may contain secrets)
|
||||||
|
config-*.json
|
||||||
|
|||||||
@@ -5,18 +5,21 @@
|
|||||||
## Features & Capabilities
|
## Features & Capabilities
|
||||||
|
|
||||||
### 🌍 Deployment Freedom
|
### 🌍 Deployment Freedom
|
||||||
|
|
||||||
- **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop.
|
- **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop.
|
||||||
- **Code Anywhere**: Tunnel in via VPN or SSH to code securely from coffee shops or while traveling.
|
- **Code Anywhere**: Tunnel in via VPN or SSH to code securely from coffee shops or while traveling.
|
||||||
- **Multi-Device**: The responsive web client works on tablets and iPads, turning any screen into a dev terminal.
|
- **Multi-Device**: The responsive web client works on tablets and iPads, turning any screen into a dev terminal.
|
||||||
- **Always-On**: Run as a background service so your sessions are always ready when you connect.
|
- **Always-On**: Run as a background service so your sessions are always ready when you connect.
|
||||||
|
|
||||||
### ⚡️ Workspace Power
|
### ⚡️ Workspace Power
|
||||||
|
|
||||||
- **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs.
|
- **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs.
|
||||||
- **Long-Context Native**: Scroll through massive transcripts without hitches.
|
- **Long-Context Native**: Scroll through massive transcripts without hitches.
|
||||||
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing your flow.
|
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing your flow.
|
||||||
- **Command Palette**: A single, global palette to jump tabs, launch tools, and fire shortcuts.
|
- **Command Palette**: A single, global palette to jump tabs, launch tools, and fire shortcuts.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **OpenCode**: `opencode` must be installed and configured on your system.
|
- **OpenCode**: `opencode` must be installed and configured on your system.
|
||||||
- Node.js 18+ and npm (for running or building from source).
|
- Node.js 18+ and npm (for running or building from source).
|
||||||
- A workspace folder on disk you want to serve.
|
- A workspace folder on disk you want to serve.
|
||||||
@@ -25,18 +28,26 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Run via npx (Recommended)
|
### Run via npx (Recommended)
|
||||||
|
|
||||||
You can run CodeNomad directly without installing it:
|
You can run CodeNomad directly without installing it:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npx @neuralnomads/codenomad --launch
|
npx @neuralnomads/codenomad --launch
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To list all CLI options:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx @neuralnomads/codenomad --help
|
||||||
|
```
|
||||||
|
|
||||||
On startup, CodeNomad prints two URLs:
|
On startup, CodeNomad prints two URLs:
|
||||||
|
|
||||||
- `Local Connection URL : ...` (used by desktop shells)
|
- `Local Connection URL : ...` (used by desktop shells)
|
||||||
- `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled)
|
- `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled)
|
||||||
|
|
||||||
### Install Globally
|
### Install Globally
|
||||||
|
|
||||||
Or install it globally to use the `codenomad` command:
|
Or install it globally to use the `codenomad` command:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -44,7 +55,19 @@ npm install -g @neuralnomads/codenomad
|
|||||||
codenomad --launch
|
codenomad --launch
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Install Locally (per-project)
|
||||||
|
|
||||||
|
If you prefer to install CodeNomad into a project and run the local binary:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install @neuralnomads/codenomad
|
||||||
|
npx codenomad --launch
|
||||||
|
```
|
||||||
|
|
||||||
|
(`npx codenomad ...` will use `./node_modules/.bin/codenomad` when present.)
|
||||||
|
|
||||||
### Common Flags
|
### Common Flags
|
||||||
|
|
||||||
You can configure the server using flags or environment variables:
|
You can configure the server using flags or environment variables:
|
||||||
|
|
||||||
| Flag | Env Variable | Description |
|
| Flag | Env Variable | Description |
|
||||||
@@ -58,15 +81,36 @@ You can configure the server using flags or environment variables:
|
|||||||
| `--tls-ca <path>` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) |
|
| `--tls-ca <path>` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) |
|
||||||
| `--tlsSANs <list>` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) |
|
| `--tlsSANs <list>` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) |
|
||||||
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
|
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
|
||||||
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces |
|
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Restricts the root path where new workspaces can be opened. Git worktrees are created in `.codenomad/worktrees` inside the project folder. |
|
||||||
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
|
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
|
||||||
| `--config <path>` | `CLI_CONFIG` | Config file location |
|
| `--config <path>` | `CLI_CONFIG` | Config file location |
|
||||||
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
|
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
|
||||||
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
|
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
|
||||||
|
| `--log-destination <path>` | `CLI_LOG_DESTINATION` | Log destination file (defaults to stdout) |
|
||||||
| `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) |
|
| `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) |
|
||||||
| `--password <password>` | `CODENOMAD_SERVER_PASSWORD` | Password for CodeNomad's internal auth |
|
| `--password <password>` | `CODENOMAD_SERVER_PASSWORD` | Password for CodeNomad's internal auth |
|
||||||
| `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows |
|
| `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows |
|
||||||
| `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) |
|
| `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) |
|
||||||
|
| `--ui-dir <path>` | `CLI_UI_DIR` | Directory containing the built UI bundle |
|
||||||
|
| `--ui-dev-server <url>` | `CLI_UI_DEV_SERVER` | Proxy UI requests to a running dev server (requires `--https=false --http=true`) |
|
||||||
|
| `--ui-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates |
|
||||||
|
| `--ui-auto-update <enabled>` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (`true` |
|
||||||
|
| `--ui-manifest-url <url>` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL |
|
||||||
|
|
||||||
|
### Dev Releases (Advanced)
|
||||||
|
|
||||||
|
If you want the latest bleeding-edge builds (published as GitHub pre-releases), use the dev package:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx @neuralnomads/codenomad-dev --launch
|
||||||
|
```
|
||||||
|
|
||||||
|
These environment variables control how CodeNomad checks for dev updates:
|
||||||
|
|
||||||
|
| Env Variable | Description |
|
||||||
|
|-------------|-------------|
|
||||||
|
| `CODENOMAD_UPDATE_CHANNEL` | Update channel (use `dev` to enable dev build update checks) |
|
||||||
|
| `CODENOMAD_GITHUB_REPO` | GitHub repo used for dev release checks (default `NeuralNomadsAI/CodeNomad`) |
|
||||||
|
|
||||||
### HTTP vs HTTPS
|
### HTTP vs HTTPS
|
||||||
|
|
||||||
@@ -105,12 +149,14 @@ codenomad --tlsSANs "localhost,127.0.0.1,my-hostname,192.168.1.10"
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
- Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser.
|
- Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser.
|
||||||
- `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated.
|
- `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated.
|
||||||
Use this only when access is already protected by another layer (SSO proxy, VPN, Coder workspace auth, etc.).
|
Use this only when access is already protected by another layer (SSO proxy, VPN, Coder workspace auth, etc.).
|
||||||
If you bind to `0.0.0.0` while skipping auth, anyone who can reach the port can access the API.
|
If you bind to `0.0.0.0` while skipping auth, anyone who can reach the port can access the API.
|
||||||
|
|
||||||
### Progressive Web App (PWA)
|
### Progressive Web App (PWA)
|
||||||
|
|
||||||
When running as a server CodeNomad can also be installed as a PWA from any supported browser, giving you a native app experience just like the Electron installation but executing on the remote server instead.
|
When running as a server CodeNomad can also be installed as a PWA from any supported browser, giving you a native app experience just like the Electron installation but executing on the remote server instead.
|
||||||
|
|
||||||
1. Open the CodeNomad UI in a Chromium-based browser (Chrome, Edge, Brave, etc.).
|
1. Open the CodeNomad UI in a Chromium-based browser (Chrome, Edge, Brave, etc.).
|
||||||
@@ -122,5 +168,6 @@ When running as a server CodeNomad can also be installed as a PWA from any suppo
|
|||||||
> If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA).
|
> If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA).
|
||||||
|
|
||||||
### Data Storage
|
### Data Storage
|
||||||
|
|
||||||
- **Config**: `~/.config/codenomad/config.json`
|
- **Config**: `~/.config/codenomad/config.json`
|
||||||
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
|
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
|
||||||
|
|||||||
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.10.3",
|
"version": "0.12.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.10.3",
|
"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.10.3",
|
"version": "0.12.3",
|
||||||
"description": "CodeNomad Server",
|
"description": "CodeNomad Server",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
"node-forge": "^1.3.3",
|
"node-forge": "^1.3.3",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^6.19.8",
|
||||||
|
"yaml": "^2.4.2",
|
||||||
"yauzl": "^2.10.0",
|
"yauzl": "^2.10.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
AgentModelSelection,
|
AgentModelSelection,
|
||||||
AgentModelSelections,
|
AgentModelSelections,
|
||||||
ConfigFile,
|
|
||||||
ModelPreference,
|
ModelPreference,
|
||||||
OpenCodeBinary,
|
OpenCodeBinary,
|
||||||
Preferences,
|
Preferences,
|
||||||
@@ -183,9 +182,9 @@ export interface BinaryRecord {
|
|||||||
validationError?: string
|
validationError?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AppConfig = ConfigFile
|
export type SettingsOwner = string
|
||||||
export type AppConfigResponse = AppConfig
|
export type SettingsBucket = Record<string, unknown>
|
||||||
export type AppConfigUpdateRequest = Partial<AppConfig>
|
export type SettingsDoc = Record<string, unknown>
|
||||||
|
|
||||||
export interface BinaryListResponse {
|
export interface BinaryListResponse {
|
||||||
binaries: BinaryRecord[]
|
binaries: BinaryRecord[]
|
||||||
@@ -214,8 +213,8 @@ export type WorkspaceEventType =
|
|||||||
| "workspace.error"
|
| "workspace.error"
|
||||||
| "workspace.stopped"
|
| "workspace.stopped"
|
||||||
| "workspace.log"
|
| "workspace.log"
|
||||||
| "config.appChanged"
|
| "storage.configChanged"
|
||||||
| "config.binariesChanged"
|
| "storage.stateChanged"
|
||||||
| "instance.dataChanged"
|
| "instance.dataChanged"
|
||||||
| "instance.event"
|
| "instance.event"
|
||||||
| "instance.eventStatus"
|
| "instance.eventStatus"
|
||||||
@@ -226,8 +225,8 @@ export type WorkspaceEventPayload =
|
|||||||
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
||||||
| { type: "workspace.stopped"; workspaceId: string }
|
| { type: "workspace.stopped"; workspaceId: string }
|
||||||
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
||||||
| { type: "config.appChanged"; config: AppConfig }
|
| { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket }
|
||||||
| { type: "config.binariesChanged"; binaries: BinaryRecord[] }
|
| { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket }
|
||||||
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
||||||
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
||||||
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
||||||
@@ -286,6 +285,8 @@ export interface ServerMeta {
|
|||||||
serverVersion?: string
|
serverVersion?: string
|
||||||
ui?: UiMeta
|
ui?: UiMeta
|
||||||
support?: SupportMeta
|
support?: SupportMeta
|
||||||
|
/** Optional update info (dev channel only). */
|
||||||
|
update?: LatestReleaseInfo | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BackgroundProcessStatus = "running" | "stopped" | "error"
|
export type BackgroundProcessStatus = "running" | "stopped" | "error"
|
||||||
|
|||||||
@@ -1,192 +0,0 @@
|
|||||||
import {
|
|
||||||
BinaryCreateRequest,
|
|
||||||
BinaryRecord,
|
|
||||||
BinaryUpdateRequest,
|
|
||||||
BinaryValidationResult,
|
|
||||||
} from "../api-types"
|
|
||||||
import { spawnSync } from "child_process"
|
|
||||||
import { ConfigStore } from "./store"
|
|
||||||
import { EventBus } from "../events/bus"
|
|
||||||
import type { ConfigFile } from "./schema"
|
|
||||||
import { Logger } from "../logger"
|
|
||||||
import { buildSpawnSpec } from "../workspaces/runtime"
|
|
||||||
|
|
||||||
export class BinaryRegistry {
|
|
||||||
constructor(
|
|
||||||
private readonly configStore: ConfigStore,
|
|
||||||
private readonly eventBus: EventBus | undefined,
|
|
||||||
private readonly logger: Logger,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
list(): BinaryRecord[] {
|
|
||||||
return this.mapRecords()
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveDefault(): BinaryRecord {
|
|
||||||
const binaries = this.mapRecords()
|
|
||||||
if (binaries.length === 0) {
|
|
||||||
this.logger.warn("No configured binaries found, falling back to opencode")
|
|
||||||
return this.buildFallbackRecord("opencode")
|
|
||||||
}
|
|
||||||
return binaries.find((binary) => binary.isDefault) ?? binaries[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
create(request: BinaryCreateRequest): BinaryRecord {
|
|
||||||
this.logger.debug({ path: request.path }, "Registering OpenCode binary")
|
|
||||||
const entry = {
|
|
||||||
path: request.path,
|
|
||||||
version: undefined,
|
|
||||||
lastUsed: Date.now(),
|
|
||||||
label: request.label,
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = this.configStore.get()
|
|
||||||
const nextConfig = this.cloneConfig(config)
|
|
||||||
const deduped = nextConfig.opencodeBinaries.filter((binary) => binary.path !== request.path)
|
|
||||||
nextConfig.opencodeBinaries = [entry, ...deduped]
|
|
||||||
|
|
||||||
if (request.makeDefault) {
|
|
||||||
nextConfig.preferences.lastUsedBinary = request.path
|
|
||||||
}
|
|
||||||
|
|
||||||
this.configStore.replace(nextConfig)
|
|
||||||
const record = this.getById(request.path)
|
|
||||||
this.emitChange()
|
|
||||||
return record
|
|
||||||
}
|
|
||||||
|
|
||||||
update(id: string, updates: BinaryUpdateRequest): BinaryRecord {
|
|
||||||
this.logger.debug({ id }, "Updating OpenCode binary")
|
|
||||||
const config = this.configStore.get()
|
|
||||||
const nextConfig = this.cloneConfig(config)
|
|
||||||
nextConfig.opencodeBinaries = nextConfig.opencodeBinaries.map((binary) =>
|
|
||||||
binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (updates.makeDefault) {
|
|
||||||
nextConfig.preferences.lastUsedBinary = id
|
|
||||||
}
|
|
||||||
|
|
||||||
this.configStore.replace(nextConfig)
|
|
||||||
const record = this.getById(id)
|
|
||||||
this.emitChange()
|
|
||||||
return record
|
|
||||||
}
|
|
||||||
|
|
||||||
remove(id: string) {
|
|
||||||
this.logger.debug({ id }, "Removing OpenCode binary")
|
|
||||||
const config = this.configStore.get()
|
|
||||||
const nextConfig = this.cloneConfig(config)
|
|
||||||
const remaining = nextConfig.opencodeBinaries.filter((binary) => binary.path !== id)
|
|
||||||
nextConfig.opencodeBinaries = remaining
|
|
||||||
|
|
||||||
if (nextConfig.preferences.lastUsedBinary === id) {
|
|
||||||
nextConfig.preferences.lastUsedBinary = remaining[0]?.path
|
|
||||||
}
|
|
||||||
|
|
||||||
this.configStore.replace(nextConfig)
|
|
||||||
this.emitChange()
|
|
||||||
}
|
|
||||||
|
|
||||||
validatePath(path: string): BinaryValidationResult {
|
|
||||||
this.logger.debug({ path }, "Validating OpenCode binary path")
|
|
||||||
return this.validateRecord({
|
|
||||||
id: path,
|
|
||||||
path,
|
|
||||||
label: this.prettyLabel(path),
|
|
||||||
isDefault: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private cloneConfig(config: ConfigFile): ConfigFile {
|
|
||||||
return JSON.parse(JSON.stringify(config)) as ConfigFile
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapRecords(): BinaryRecord[] {
|
|
||||||
|
|
||||||
const config = this.configStore.get()
|
|
||||||
const configuredBinaries = config.opencodeBinaries.map<BinaryRecord>((binary) => ({
|
|
||||||
id: binary.path,
|
|
||||||
path: binary.path,
|
|
||||||
label: binary.label ?? this.prettyLabel(binary.path),
|
|
||||||
version: binary.version,
|
|
||||||
isDefault: false,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const defaultPath = config.preferences.lastUsedBinary ?? configuredBinaries[0]?.path ?? "opencode"
|
|
||||||
|
|
||||||
const annotated = configuredBinaries.map((binary) => ({
|
|
||||||
...binary,
|
|
||||||
isDefault: binary.path === defaultPath,
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (!annotated.some((binary) => binary.path === defaultPath)) {
|
|
||||||
annotated.unshift(this.buildFallbackRecord(defaultPath))
|
|
||||||
}
|
|
||||||
|
|
||||||
return annotated
|
|
||||||
}
|
|
||||||
|
|
||||||
private getById(id: string): BinaryRecord {
|
|
||||||
return this.mapRecords().find((binary) => binary.id === id) ?? this.buildFallbackRecord(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
private emitChange() {
|
|
||||||
this.logger.debug("Emitting binaries changed event")
|
|
||||||
this.eventBus?.publish({ type: "config.binariesChanged", binaries: this.mapRecords() })
|
|
||||||
}
|
|
||||||
|
|
||||||
private validateRecord(record: BinaryRecord): BinaryValidationResult {
|
|
||||||
const inputPath = record.path
|
|
||||||
if (!inputPath) {
|
|
||||||
return { valid: false, error: "Missing binary path" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const spec = buildSpawnSpec(inputPath, ["--version"])
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = spawnSync(spec.command, spec.args, {
|
|
||||||
encoding: "utf8",
|
|
||||||
windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
return { valid: false, error: result.error.message }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
const stderr = result.stderr?.trim()
|
|
||||||
const stdout = result.stdout?.trim()
|
|
||||||
const combined = stderr || stdout
|
|
||||||
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
|
|
||||||
return { valid: false, error }
|
|
||||||
}
|
|
||||||
|
|
||||||
const stdout = (result.stdout ?? "").trim()
|
|
||||||
const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0)
|
|
||||||
const normalized = firstLine?.trim()
|
|
||||||
|
|
||||||
const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
|
|
||||||
const version = versionMatch?.[1]
|
|
||||||
|
|
||||||
return { valid: true, version }
|
|
||||||
} catch (error) {
|
|
||||||
return { valid: false, error: error instanceof Error ? error.message : String(error) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildFallbackRecord(path: string): BinaryRecord {
|
|
||||||
return {
|
|
||||||
id: path,
|
|
||||||
path,
|
|
||||||
label: this.prettyLabel(path),
|
|
||||||
isDefault: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private prettyLabel(path: string) {
|
|
||||||
const parts = path.split(/[\\/]/)
|
|
||||||
const last = parts[parts.length - 1] || path
|
|
||||||
return last || path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
78
packages/server/src/config/location.ts
Normal file
78
packages/server/src/config/location.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import os from "os"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
export interface ConfigLocation {
|
||||||
|
/** Resolved absolute base directory containing all persisted server data. */
|
||||||
|
baseDir: string
|
||||||
|
/** Canonical YAML config file path (may be custom when input points to a YAML file). */
|
||||||
|
configYamlPath: string
|
||||||
|
/** Canonical YAML state file path (always in baseDir). */
|
||||||
|
stateYamlPath: string
|
||||||
|
/** Legacy JSON config file path used for migration (always in baseDir, or explicit JSON input). */
|
||||||
|
legacyJsonPath: string
|
||||||
|
/** Directory for per-instance persisted data (chat history etc.). */
|
||||||
|
instancesDir: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePath(inputPath: string): string {
|
||||||
|
if (inputPath.startsWith("~/")) {
|
||||||
|
return path.join(os.homedir(), inputPath.slice(2))
|
||||||
|
}
|
||||||
|
return path.resolve(inputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isYamlPath(filePath: string): boolean {
|
||||||
|
const lower = filePath.toLowerCase()
|
||||||
|
return lower.endsWith(".yaml") || lower.endsWith(".yml")
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJsonPath(filePath: string): boolean {
|
||||||
|
return filePath.toLowerCase().endsWith(".json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve CodeNomad's config location into a stable base directory + derived file paths.
|
||||||
|
*
|
||||||
|
* Supported inputs:
|
||||||
|
* - Directory: "~/.config/codenomad"
|
||||||
|
* - YAML file: "~/.config/codenomad/config.yaml" (or any *.yml/*.yaml)
|
||||||
|
* - Legacy JSON file: "~/.config/codenomad/config.json"
|
||||||
|
*/
|
||||||
|
export function resolveConfigLocation(raw: string): ConfigLocation {
|
||||||
|
const trimmed = (raw ?? "").trim()
|
||||||
|
const fallback = "~/.config/codenomad/config.json"
|
||||||
|
const input = trimmed.length > 0 ? trimmed : fallback
|
||||||
|
|
||||||
|
const resolvedInput = resolvePath(input)
|
||||||
|
|
||||||
|
if (isYamlPath(resolvedInput)) {
|
||||||
|
const baseDir = path.dirname(resolvedInput)
|
||||||
|
return {
|
||||||
|
baseDir,
|
||||||
|
configYamlPath: resolvedInput,
|
||||||
|
stateYamlPath: path.join(baseDir, "state.yaml"),
|
||||||
|
legacyJsonPath: path.join(baseDir, "config.json"),
|
||||||
|
instancesDir: path.join(baseDir, "instances"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isJsonPath(resolvedInput)) {
|
||||||
|
const baseDir = path.dirname(resolvedInput)
|
||||||
|
return {
|
||||||
|
baseDir,
|
||||||
|
configYamlPath: path.join(baseDir, "config.yaml"),
|
||||||
|
stateYamlPath: path.join(baseDir, "state.yaml"),
|
||||||
|
legacyJsonPath: resolvedInput,
|
||||||
|
instancesDir: path.join(baseDir, "instances"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseDir = resolvedInput
|
||||||
|
return {
|
||||||
|
baseDir,
|
||||||
|
configYamlPath: path.join(baseDir, "config.yaml"),
|
||||||
|
stateYamlPath: path.join(baseDir, "state.yaml"),
|
||||||
|
legacyJsonPath: path.join(baseDir, "config.json"),
|
||||||
|
instancesDir: path.join(baseDir, "instances"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,8 @@ const ModelPreferenceSchema = z.object({
|
|||||||
const AgentModelSelectionSchema = z.record(z.string(), ModelPreferenceSchema)
|
const AgentModelSelectionSchema = z.record(z.string(), ModelPreferenceSchema)
|
||||||
const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchema)
|
const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchema)
|
||||||
|
|
||||||
const PreferencesSchema = z.object({
|
const PreferencesSchema = z
|
||||||
|
.object({
|
||||||
showThinkingBlocks: z.boolean().default(false),
|
showThinkingBlocks: z.boolean().default(false),
|
||||||
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||||
showTimelineTools: z.boolean().default(true),
|
showTimelineTools: z.boolean().default(true),
|
||||||
@@ -31,7 +32,9 @@ const PreferencesSchema = z.object({
|
|||||||
osNotificationsAllowWhenVisible: z.boolean().default(false),
|
osNotificationsAllowWhenVisible: z.boolean().default(false),
|
||||||
notifyOnNeedsInput: z.boolean().default(true),
|
notifyOnNeedsInput: z.boolean().default(true),
|
||||||
notifyOnIdle: z.boolean().default(true),
|
notifyOnIdle: z.boolean().default(true),
|
||||||
})
|
})
|
||||||
|
// Preserve unknown preference keys so newer configs survive older binaries.
|
||||||
|
.passthrough()
|
||||||
|
|
||||||
const RecentFolderSchema = z.object({
|
const RecentFolderSchema = z.object({
|
||||||
path: z.string(),
|
path: z.string(),
|
||||||
@@ -45,14 +48,35 @@ const OpenCodeBinarySchema = z.object({
|
|||||||
label: z.string().optional(),
|
label: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const ConfigFileSchema = z.object({
|
const ConfigFileSchema = z
|
||||||
preferences: PreferencesSchema.default({}),
|
.object({
|
||||||
recentFolders: z.array(RecentFolderSchema).default([]),
|
preferences: PreferencesSchema.default({}),
|
||||||
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
|
recentFolders: z.array(RecentFolderSchema).default([]),
|
||||||
theme: z.enum(["light", "dark", "system"]).optional(),
|
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
|
||||||
})
|
theme: z.enum(["light", "dark", "system"]).optional(),
|
||||||
|
})
|
||||||
|
// Preserve unknown top-level keys so optional future features survive downgrades.
|
||||||
|
.passthrough()
|
||||||
|
|
||||||
|
// On-disk config.yaml only stores stable configuration (not volatile state like recent folders).
|
||||||
|
const ConfigYamlSchema = z
|
||||||
|
.object({
|
||||||
|
preferences: PreferencesSchema.default({}),
|
||||||
|
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
|
||||||
|
theme: z.enum(["light", "dark", "system"]).optional(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
|
||||||
|
// On-disk state.yaml stores server-scoped mutable state (per-server, not per-client).
|
||||||
|
const StateFileSchema = z
|
||||||
|
.object({
|
||||||
|
recentFolders: z.array(RecentFolderSchema).default([]),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
|
||||||
const DEFAULT_CONFIG = ConfigFileSchema.parse({})
|
const DEFAULT_CONFIG = ConfigFileSchema.parse({})
|
||||||
|
const DEFAULT_CONFIG_YAML = ConfigYamlSchema.parse({})
|
||||||
|
const DEFAULT_STATE = StateFileSchema.parse({})
|
||||||
|
|
||||||
export {
|
export {
|
||||||
ModelPreferenceSchema,
|
ModelPreferenceSchema,
|
||||||
@@ -62,7 +86,11 @@ export {
|
|||||||
RecentFolderSchema,
|
RecentFolderSchema,
|
||||||
OpenCodeBinarySchema,
|
OpenCodeBinarySchema,
|
||||||
ConfigFileSchema,
|
ConfigFileSchema,
|
||||||
|
ConfigYamlSchema,
|
||||||
|
StateFileSchema,
|
||||||
DEFAULT_CONFIG,
|
DEFAULT_CONFIG,
|
||||||
|
DEFAULT_CONFIG_YAML,
|
||||||
|
DEFAULT_STATE,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ModelPreference = z.infer<typeof ModelPreferenceSchema>
|
export type ModelPreference = z.infer<typeof ModelPreferenceSchema>
|
||||||
@@ -72,3 +100,5 @@ export type Preferences = z.infer<typeof PreferencesSchema>
|
|||||||
export type RecentFolder = z.infer<typeof RecentFolderSchema>
|
export type RecentFolder = z.infer<typeof RecentFolderSchema>
|
||||||
export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema>
|
export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema>
|
||||||
export type ConfigFile = z.infer<typeof ConfigFileSchema>
|
export type ConfigFile = z.infer<typeof ConfigFileSchema>
|
||||||
|
export type ConfigYamlFile = z.infer<typeof ConfigYamlSchema>
|
||||||
|
export type StateFile = z.infer<typeof StateFileSchema>
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
import fs from "fs"
|
|
||||||
import path from "path"
|
|
||||||
import { EventBus } from "../events/bus"
|
|
||||||
import { Logger } from "../logger"
|
|
||||||
import { ConfigFile, ConfigFileSchema, DEFAULT_CONFIG } from "./schema"
|
|
||||||
|
|
||||||
export class ConfigStore {
|
|
||||||
private cache: ConfigFile = DEFAULT_CONFIG
|
|
||||||
private loaded = false
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly configPath: string,
|
|
||||||
private readonly eventBus: EventBus | undefined,
|
|
||||||
private readonly logger: Logger,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
load(): ConfigFile {
|
|
||||||
if (this.loaded) {
|
|
||||||
return this.cache
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resolved = this.resolvePath(this.configPath)
|
|
||||||
if (fs.existsSync(resolved)) {
|
|
||||||
const content = fs.readFileSync(resolved, "utf-8")
|
|
||||||
const parsed = JSON.parse(content)
|
|
||||||
this.cache = ConfigFileSchema.parse(parsed)
|
|
||||||
this.logger.debug({ resolved }, "Loaded existing config file")
|
|
||||||
} else {
|
|
||||||
this.cache = DEFAULT_CONFIG
|
|
||||||
this.logger.debug({ resolved }, "No config file found, using defaults")
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn({ err: error }, "Failed to load config, using defaults")
|
|
||||||
this.cache = DEFAULT_CONFIG
|
|
||||||
}
|
|
||||||
|
|
||||||
this.loaded = true
|
|
||||||
return this.cache
|
|
||||||
}
|
|
||||||
|
|
||||||
get(): ConfigFile {
|
|
||||||
return this.load()
|
|
||||||
}
|
|
||||||
|
|
||||||
replace(config: ConfigFile) {
|
|
||||||
const validated = ConfigFileSchema.parse(config)
|
|
||||||
this.commit(validated)
|
|
||||||
}
|
|
||||||
|
|
||||||
private commit(next: ConfigFile) {
|
|
||||||
this.cache = next
|
|
||||||
this.loaded = true
|
|
||||||
this.persist()
|
|
||||||
const published = Boolean(this.eventBus)
|
|
||||||
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
|
|
||||||
this.logger.debug({ broadcast: published }, "Config SSE event emitted")
|
|
||||||
this.logger.trace({ config: this.cache }, "Config payload")
|
|
||||||
}
|
|
||||||
|
|
||||||
private persist() {
|
|
||||||
try {
|
|
||||||
const resolved = this.resolvePath(this.configPath)
|
|
||||||
fs.mkdirSync(path.dirname(resolved), { recursive: true })
|
|
||||||
fs.writeFileSync(resolved, JSON.stringify(this.cache, null, 2), "utf-8")
|
|
||||||
this.logger.debug({ resolved }, "Persisted config file")
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn({ err: error }, "Failed to persist config")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolvePath(filePath: string) {
|
|
||||||
if (filePath.startsWith("~/")) {
|
|
||||||
return path.join(process.env.HOME ?? "", filePath.slice(2))
|
|
||||||
}
|
|
||||||
return path.resolve(filePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,8 +24,8 @@ export class EventBus extends EventEmitter {
|
|||||||
this.on("workspace.error", handler)
|
this.on("workspace.error", handler)
|
||||||
this.on("workspace.stopped", handler)
|
this.on("workspace.stopped", handler)
|
||||||
this.on("workspace.log", handler)
|
this.on("workspace.log", handler)
|
||||||
this.on("config.appChanged", handler)
|
this.on("storage.configChanged", handler)
|
||||||
this.on("config.binariesChanged", handler)
|
this.on("storage.stateChanged", handler)
|
||||||
this.on("instance.dataChanged", handler)
|
this.on("instance.dataChanged", handler)
|
||||||
this.on("instance.event", handler)
|
this.on("instance.event", handler)
|
||||||
this.on("instance.eventStatus", handler)
|
this.on("instance.eventStatus", handler)
|
||||||
@@ -35,8 +35,8 @@ export class EventBus extends EventEmitter {
|
|||||||
this.off("workspace.error", handler)
|
this.off("workspace.error", handler)
|
||||||
this.off("workspace.stopped", handler)
|
this.off("workspace.stopped", handler)
|
||||||
this.off("workspace.log", handler)
|
this.off("workspace.log", handler)
|
||||||
this.off("config.appChanged", handler)
|
this.off("storage.configChanged", handler)
|
||||||
this.off("config.binariesChanged", handler)
|
this.off("storage.stateChanged", handler)
|
||||||
this.off("instance.dataChanged", handler)
|
this.off("instance.dataChanged", handler)
|
||||||
this.off("instance.event", handler)
|
this.off("instance.event", handler)
|
||||||
this.off("instance.eventStatus", handler)
|
this.off("instance.eventStatus", handler)
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import { fileURLToPath } from "url"
|
|||||||
import { createRequire } from "module"
|
import { createRequire } from "module"
|
||||||
import { createHttpServer } from "./server/http-server"
|
import { createHttpServer } from "./server/http-server"
|
||||||
import { WorkspaceManager } from "./workspaces/manager"
|
import { WorkspaceManager } from "./workspaces/manager"
|
||||||
import { ConfigStore } from "./config/store"
|
import { resolveConfigLocation } from "./config/location"
|
||||||
import { BinaryRegistry } from "./config/binaries"
|
import { SettingsService } from "./settings/service"
|
||||||
|
import { BinaryResolver } from "./settings/binaries"
|
||||||
import { FileSystemBrowser } from "./filesystem/browser"
|
import { FileSystemBrowser } from "./filesystem/browser"
|
||||||
import { EventBus } from "./events/bus"
|
import { EventBus } from "./events/bus"
|
||||||
import { ServerMeta } from "./api-types"
|
import { ServerMeta } from "./api-types"
|
||||||
@@ -21,6 +22,7 @@ import { resolveUi } from "./ui/remote-ui"
|
|||||||
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||||
import { resolveHttpsOptions } from "./server/tls"
|
import { resolveHttpsOptions } from "./server/tls"
|
||||||
import { resolveNetworkAddresses } from "./server/network-addresses"
|
import { resolveNetworkAddresses } from "./server/network-addresses"
|
||||||
|
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
@@ -76,7 +78,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
.addOption(new Option("--tls-ca <path>", "TLS CA chain (PEM)").env("CLI_TLS_CA"))
|
.addOption(new Option("--tls-ca <path>", "TLS CA chain (PEM)").env("CLI_TLS_CA"))
|
||||||
.addOption(new Option("--tlsSANs <list>", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS"))
|
.addOption(new Option("--tlsSANs <list>", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS"))
|
||||||
.addOption(
|
.addOption(
|
||||||
new Option("--workspace-root <path>", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
|
new Option("--workspace-root <path>", "Restricts root path where workspaces can be opened").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
|
||||||
)
|
)
|
||||||
.addOption(new Option("--root <path>").env("CLI_ROOT").hideHelp(true))
|
.addOption(new Option("--root <path>").env("CLI_ROOT").hideHelp(true))
|
||||||
.addOption(new Option("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false))
|
.addOption(new Option("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false))
|
||||||
@@ -210,13 +212,6 @@ function resolveHost(input: string | undefined): string {
|
|||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePath(filePath: string) {
|
|
||||||
if (filePath.startsWith("~/")) {
|
|
||||||
return path.join(process.env.HOME ?? "", filePath.slice(2))
|
|
||||||
}
|
|
||||||
return path.resolve(filePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
function programHasArg(argv: string[], flag: string): boolean {
|
function programHasArg(argv: string[], flag: string): boolean {
|
||||||
return argv.includes(flag)
|
return argv.includes(flag)
|
||||||
}
|
}
|
||||||
@@ -245,7 +240,8 @@ async function main() {
|
|||||||
|
|
||||||
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||||
|
|
||||||
const configDir = path.dirname(resolvePath(options.configPath))
|
const configLocation = resolveConfigLocation(options.configPath)
|
||||||
|
const configDir = configLocation.baseDir
|
||||||
|
|
||||||
if ((options.tlsKeyPath && !options.tlsCertPath) || (!options.tlsKeyPath && options.tlsCertPath)) {
|
if ((options.tlsKeyPath && !options.tlsCertPath) || (!options.tlsKeyPath && options.tlsCertPath)) {
|
||||||
throw new InvalidArgumentError("--tls-key and --tls-cert must be provided together")
|
throw new InvalidArgumentError("--tls-key and --tls-cert must be provided together")
|
||||||
@@ -266,7 +262,7 @@ async function main() {
|
|||||||
|
|
||||||
const authManager = new AuthManager(
|
const authManager = new AuthManager(
|
||||||
{
|
{
|
||||||
configPath: options.configPath,
|
configPath: configLocation.configYamlPath,
|
||||||
username: options.authUsername,
|
username: options.authUsername,
|
||||||
password: options.authPassword,
|
password: options.authPassword,
|
||||||
generateToken: options.generateToken,
|
generateToken: options.generateToken,
|
||||||
@@ -295,19 +291,19 @@ async function main() {
|
|||||||
|
|
||||||
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
|
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
|
||||||
|
|
||||||
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
|
const settings = new SettingsService(configLocation, eventBus, configLogger)
|
||||||
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
|
const binaryResolver = new BinaryResolver(settings)
|
||||||
const workspaceManager = new WorkspaceManager({
|
const workspaceManager = new WorkspaceManager({
|
||||||
rootDir: options.rootDir,
|
rootDir: options.rootDir,
|
||||||
configStore,
|
settings,
|
||||||
binaryRegistry,
|
binaryResolver,
|
||||||
eventBus,
|
eventBus,
|
||||||
logger: workspaceLogger,
|
logger: workspaceLogger,
|
||||||
getServerBaseUrl: () => serverMeta.localUrl,
|
getServerBaseUrl: () => serverMeta.localUrl,
|
||||||
nodeExtraCaCertsPath,
|
nodeExtraCaCertsPath,
|
||||||
})
|
})
|
||||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||||
const instanceStore = new InstanceStore()
|
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
||||||
const instanceEventBridge = new InstanceEventBridge({
|
const instanceEventBridge = new InstanceEventBridge({
|
||||||
workspaceManager,
|
workspaceManager,
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -344,6 +340,21 @@ async function main() {
|
|||||||
minServerVersion: uiResolution.minServerVersion,
|
minServerVersion: uiResolution.minServerVersion,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateChannel = (process.env.CODENOMAD_UPDATE_CHANNEL ?? "").trim().toLowerCase()
|
||||||
|
const githubRepo = (process.env.CODENOMAD_GITHUB_REPO ?? "NeuralNomadsAI/CodeNomad").trim()
|
||||||
|
const isDevVersion = packageJson.version.includes("-dev.") || packageJson.version.includes("-dev-")
|
||||||
|
const enableDevUpdateChecks = updateChannel === "dev" || (updateChannel === "" && isDevVersion)
|
||||||
|
const devReleaseMonitor = enableDevUpdateChecks
|
||||||
|
? startDevReleaseMonitor({
|
||||||
|
currentVersion: packageJson.version,
|
||||||
|
repo: githubRepo,
|
||||||
|
logger: logger.child({ component: "updates" }),
|
||||||
|
onUpdate: (release) => {
|
||||||
|
serverMeta.update = release
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
if (uiResolution.uiDevServerUrl && options.https) {
|
if (uiResolution.uiDevServerUrl && options.https) {
|
||||||
throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true")
|
throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true")
|
||||||
}
|
}
|
||||||
@@ -372,8 +383,7 @@ async function main() {
|
|||||||
defaultPort: options.httpPort,
|
defaultPort: options.httpPort,
|
||||||
protocol: "http",
|
protocol: "http",
|
||||||
workspaceManager,
|
workspaceManager,
|
||||||
configStore,
|
settings,
|
||||||
binaryRegistry,
|
|
||||||
fileSystemBrowser,
|
fileSystemBrowser,
|
||||||
eventBus,
|
eventBus,
|
||||||
serverMeta,
|
serverMeta,
|
||||||
@@ -393,8 +403,7 @@ async function main() {
|
|||||||
protocol: "https",
|
protocol: "https",
|
||||||
httpsOptions: tlsResolution?.httpsOptions,
|
httpsOptions: tlsResolution?.httpsOptions,
|
||||||
workspaceManager,
|
workspaceManager,
|
||||||
configStore,
|
settings,
|
||||||
binaryRegistry,
|
|
||||||
fileSystemBrowser,
|
fileSystemBrowser,
|
||||||
eventBus,
|
eventBus,
|
||||||
serverMeta,
|
serverMeta,
|
||||||
@@ -503,6 +512,8 @@ async function main() {
|
|||||||
|
|
||||||
// no-op: remote UI manifest replaces GitHub release monitor
|
// no-op: remote UI manifest replaces GitHub release monitor
|
||||||
|
|
||||||
|
devReleaseMonitor?.stop()
|
||||||
|
|
||||||
logger.info("Exiting process")
|
logger.info("Exiting process")
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|||||||
118
packages/server/src/releases/dev-release-monitor.ts
Normal file
118
packages/server/src/releases/dev-release-monitor.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { fetch } from "undici"
|
||||||
|
import type { LatestReleaseInfo } from "../api-types"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
import { compareVersionStrings, stripTagPrefix } from "./release-monitor"
|
||||||
|
|
||||||
|
interface DevReleaseMonitorOptions {
|
||||||
|
/** Current running server version (from package.json). */
|
||||||
|
currentVersion: string
|
||||||
|
/** GitHub repo in the form "owner/name". */
|
||||||
|
repo: string
|
||||||
|
logger: Logger
|
||||||
|
onUpdate: (release: LatestReleaseInfo | null) => void
|
||||||
|
pollIntervalMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GithubReleaseListItem {
|
||||||
|
tag_name?: string
|
||||||
|
name?: string
|
||||||
|
html_url?: string
|
||||||
|
body?: string
|
||||||
|
published_at?: string
|
||||||
|
created_at?: string
|
||||||
|
prerelease?: boolean
|
||||||
|
draft?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DevReleaseMonitor {
|
||||||
|
stop(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_POLL_INTERVAL_MS = 15 * 60 * 1000
|
||||||
|
|
||||||
|
export function startDevReleaseMonitor(options: DevReleaseMonitorOptions): DevReleaseMonitor {
|
||||||
|
let stopped = false
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const pollIntervalMs =
|
||||||
|
Number.isFinite(options.pollIntervalMs) && (options.pollIntervalMs ?? 0) > 0
|
||||||
|
? (options.pollIntervalMs as number)
|
||||||
|
: DEFAULT_POLL_INTERVAL_MS
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
if (stopped) return
|
||||||
|
try {
|
||||||
|
const release = await fetchLatestPrerelease({
|
||||||
|
repo: options.repo,
|
||||||
|
currentVersion: options.currentVersion,
|
||||||
|
})
|
||||||
|
options.onUpdate(release)
|
||||||
|
} catch (error) {
|
||||||
|
options.logger.debug({ err: error }, "Failed to refresh dev prerelease information")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void refresh()
|
||||||
|
timer = setInterval(() => void refresh(), pollIntervalMs)
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLatestPrerelease(args: {
|
||||||
|
repo: string
|
||||||
|
currentVersion: string
|
||||||
|
}): Promise<LatestReleaseInfo | null> {
|
||||||
|
const normalizedRepo = args.repo.trim()
|
||||||
|
if (!/^[^/\s]+\/[^/\s]+$/.test(normalizedRepo)) {
|
||||||
|
throw new Error(`Invalid GitHub repo: ${args.repo}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiUrl = `https://api.github.com/repos/${normalizedRepo}/releases?per_page=20`
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
"User-Agent": "CodeNomad-CLI",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`GitHub releases API responded with ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = (await response.json()) as GithubReleaseListItem[]
|
||||||
|
const latest = list.find((r) => r && r.prerelease === true && r.draft !== true)
|
||||||
|
if (!latest) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = latest.tag_name || latest.name
|
||||||
|
if (!tag) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedVersion = stripTagPrefix(tag)
|
||||||
|
if (!normalizedVersion) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compareVersionStrings(normalizedVersion, args.currentVersion) <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: normalizedVersion,
|
||||||
|
tag,
|
||||||
|
url: latest.html_url ?? `https://github.com/${normalizedRepo}/releases/tag/${encodeURIComponent(tag)}`,
|
||||||
|
channel: "dev",
|
||||||
|
publishedAt: latest.published_at ?? latest.created_at,
|
||||||
|
notes: latest.body,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,12 @@ export function startReleaseMonitor(options: ReleaseMonitorOptions): ReleaseMoni
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function compareVersionStrings(a: string, b: string): number {
|
||||||
|
const left = parseVersion(a)
|
||||||
|
const right = parseVersion(b)
|
||||||
|
return compareVersions(left, right)
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<LatestReleaseInfo | null> {
|
async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<LatestReleaseInfo | null> {
|
||||||
const response = await fetch(RELEASES_API_URL, {
|
const response = await fetch(RELEASES_API_URL, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -92,7 +98,7 @@ async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<Lates
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripTagPrefix(tag: string | undefined): string | null {
|
export function stripTagPrefix(tag: string | undefined): string | null {
|
||||||
if (!tag) return null
|
if (!tag) return null
|
||||||
const trimmed = tag.trim()
|
const trimmed = tag.trim()
|
||||||
if (!trimmed) return null
|
if (!trimmed) return null
|
||||||
@@ -101,7 +107,9 @@ function stripTagPrefix(tag: string | undefined): string | null {
|
|||||||
|
|
||||||
function parseVersion(value: string): NormalizedVersion {
|
function parseVersion(value: string): NormalizedVersion {
|
||||||
const normalized = stripTagPrefix(value) ?? "0.0.0"
|
const normalized = stripTagPrefix(value) ?? "0.0.0"
|
||||||
const [core, prerelease = null] = normalized.split("-", 2)
|
const dashIndex = normalized.indexOf("-")
|
||||||
|
const core = dashIndex >= 0 ? normalized.slice(0, dashIndex) : normalized
|
||||||
|
const prerelease = dashIndex >= 0 ? normalized.slice(dashIndex + 1) : null
|
||||||
const [major = 0, minor = 0, patch = 0] = core.split(".").map((segment) => {
|
const [major = 0, minor = 0, patch = 0] = core.split(".").map((segment) => {
|
||||||
const parsed = Number.parseInt(segment, 10)
|
const parsed = Number.parseInt(segment, 10)
|
||||||
return Number.isFinite(parsed) ? parsed : 0
|
return Number.isFinite(parsed) ? parsed : 0
|
||||||
|
|||||||
@@ -9,12 +9,11 @@ import type { Logger } from "../logger"
|
|||||||
import { WorkspaceManager } from "../workspaces/manager"
|
import { WorkspaceManager } from "../workspaces/manager"
|
||||||
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
|
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
|
||||||
|
|
||||||
import { ConfigStore } from "../config/store"
|
import type { SettingsService } from "../settings/service"
|
||||||
import { BinaryRegistry } from "../config/binaries"
|
|
||||||
import { FileSystemBrowser } from "../filesystem/browser"
|
import { FileSystemBrowser } from "../filesystem/browser"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import { registerWorkspaceRoutes } from "./routes/workspaces"
|
import { registerWorkspaceRoutes } from "./routes/workspaces"
|
||||||
import { registerConfigRoutes } from "./routes/config"
|
import { registerSettingsRoutes } from "./routes/settings"
|
||||||
import { registerFilesystemRoutes } from "./routes/filesystem"
|
import { registerFilesystemRoutes } from "./routes/filesystem"
|
||||||
import { registerMetaRoutes } from "./routes/meta"
|
import { registerMetaRoutes } from "./routes/meta"
|
||||||
import { registerEventRoutes } from "./routes/events"
|
import { registerEventRoutes } from "./routes/events"
|
||||||
@@ -37,8 +36,7 @@ interface HttpServerDeps {
|
|||||||
protocol: "http" | "https"
|
protocol: "http" | "https"
|
||||||
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
||||||
workspaceManager: WorkspaceManager
|
workspaceManager: WorkspaceManager
|
||||||
configStore: ConfigStore
|
settings: SettingsService
|
||||||
binaryRegistry: BinaryRegistry
|
|
||||||
fileSystemBrowser: FileSystemBrowser
|
fileSystemBrowser: FileSystemBrowser
|
||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
serverMeta: ServerMeta
|
serverMeta: ServerMeta
|
||||||
@@ -244,7 +242,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||||
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
|
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
|
||||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||||
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
||||||
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
|
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
|
||||||
@@ -369,6 +367,21 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe
|
|||||||
|
|
||||||
const INSTANCE_PROXY_HOST = "127.0.0.1"
|
const INSTANCE_PROXY_HOST = "127.0.0.1"
|
||||||
|
|
||||||
|
// Special-case OpenCode directory override.
|
||||||
|
//
|
||||||
|
// UI clients may need to scope certain requests to an arbitrary directory that is not
|
||||||
|
// part of the Git worktree list. Since the OpenCode SDK does not reliably support
|
||||||
|
// injecting per-request headers, we encode an override into the *path* and strip it
|
||||||
|
// before proxying to the instance.
|
||||||
|
//
|
||||||
|
// Example proxied request path:
|
||||||
|
// /workspaces/:id/worktrees/:slug/instance/__dir/<base64url>/session/create
|
||||||
|
//
|
||||||
|
// The server will decode <base64url> -> absolute directory, validate it, then set
|
||||||
|
// x-opencode-directory accordingly and forward the request to /session/create.
|
||||||
|
const OPENCODE_DIR_OVERRIDE_PREFIX = "__dir/"
|
||||||
|
const OPENCODE_DIR_OVERRIDE_MAX_LEN = 4096
|
||||||
|
|
||||||
async function proxyWorkspaceRequest(args: {
|
async function proxyWorkspaceRequest(args: {
|
||||||
request: FastifyRequest
|
request: FastifyRequest
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
@@ -459,19 +472,43 @@ async function proxyWorkspaceRequest(args: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const directory = await resolveWorktreeDirectory({
|
let extracted: { overrideDirectory: string | null; forwardedSuffix: string | undefined }
|
||||||
workspaceId,
|
try {
|
||||||
workspacePath: workspace.path,
|
extracted = extractOpencodeDirectoryOverride(args.pathSuffix)
|
||||||
worktreeSlug,
|
} catch (error) {
|
||||||
logger,
|
const message = error instanceof Error ? error.message : "Invalid directory override"
|
||||||
})
|
reply.code(400).send({ error: message })
|
||||||
|
|
||||||
if (!directory) {
|
|
||||||
reply.code(404).send({ error: "Worktree not found" })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let directory: string | null = null
|
||||||
|
let forwardedSuffix = extracted.forwardedSuffix
|
||||||
|
|
||||||
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
|
if (extracted.overrideDirectory) {
|
||||||
|
try {
|
||||||
|
directory = validateAndNormalizeOverrideDirectory({
|
||||||
|
overrideDirectory: extracted.overrideDirectory,
|
||||||
|
workspaceRoot: workspace.path,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Invalid directory override"
|
||||||
|
reply.code(400).send({ error: message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
directory = await resolveWorktreeDirectory({
|
||||||
|
workspaceId,
|
||||||
|
workspacePath: workspace.path,
|
||||||
|
worktreeSlug,
|
||||||
|
logger,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!directory) {
|
||||||
|
reply.code(404).send({ error: "Worktree not found" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSuffix = normalizeInstanceSuffix(forwardedSuffix)
|
||||||
const queryIndex = (request.raw.url ?? "").indexOf("?")
|
const queryIndex = (request.raw.url ?? "").indexOf("?")
|
||||||
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
|
||||||
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
|
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
|
||||||
@@ -535,6 +572,89 @@ async function proxyWorkspaceRequest(args: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): {
|
||||||
|
overrideDirectory: string | null
|
||||||
|
forwardedSuffix: string | undefined
|
||||||
|
} {
|
||||||
|
if (!pathSuffix) {
|
||||||
|
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fastify wildcard param does not include a leading slash.
|
||||||
|
const trimmed = pathSuffix.replace(/^\/+/, "")
|
||||||
|
if (!trimmed.startsWith(OPENCODE_DIR_OVERRIDE_PREFIX)) {
|
||||||
|
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rest = trimmed.slice(OPENCODE_DIR_OVERRIDE_PREFIX.length)
|
||||||
|
const slashIndex = rest.indexOf("/")
|
||||||
|
const encoded = (slashIndex >= 0 ? rest.slice(0, slashIndex) : rest).trim()
|
||||||
|
const remaining = slashIndex >= 0 ? rest.slice(slashIndex + 1) : ""
|
||||||
|
|
||||||
|
if (!encoded) {
|
||||||
|
throw new Error("Missing directory override")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (encoded.length > OPENCODE_DIR_OVERRIDE_MAX_LEN) {
|
||||||
|
throw new Error("Directory override too large")
|
||||||
|
}
|
||||||
|
|
||||||
|
let overrideDirectory = ""
|
||||||
|
try {
|
||||||
|
overrideDirectory = decodeBase64Url(encoded)
|
||||||
|
} catch {
|
||||||
|
throw new Error("Invalid directory override")
|
||||||
|
}
|
||||||
|
const forwardedSuffix = remaining
|
||||||
|
return { overrideDirectory, forwardedSuffix }
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64Url(input: string): string {
|
||||||
|
// base64url -> base64
|
||||||
|
const normalized = input.replace(/-/g, "+").replace(/_/g, "/")
|
||||||
|
const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4))
|
||||||
|
const base64 = `${normalized}${padding}`
|
||||||
|
return Buffer.from(base64, "base64").toString("utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAndNormalizeOverrideDirectory(params: { overrideDirectory: string; workspaceRoot: string }): string {
|
||||||
|
const raw = params.overrideDirectory.trim()
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error("Override directory is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!path.isAbsolute(raw)) {
|
||||||
|
throw new Error("Override directory must be an absolute path")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(raw)) {
|
||||||
|
throw new Error(`Override directory does not exist: ${raw}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = fs.statSync(raw)
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
throw new Error(`Override path is not a directory: ${raw}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedOverride = fs.realpathSync(raw)
|
||||||
|
const normalizedRoot = fs.realpathSync(params.workspaceRoot)
|
||||||
|
|
||||||
|
if (!isSubpath(normalizedOverride, normalizedRoot)) {
|
||||||
|
throw new Error("Override directory must be within the workspace root")
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedOverride
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSubpath(candidate: string, root: string): boolean {
|
||||||
|
const rel = path.relative(root, candidate)
|
||||||
|
if (rel === "") return true
|
||||||
|
if (rel === "..") return false
|
||||||
|
if (rel.startsWith(`..${path.sep}`)) return false
|
||||||
|
if (path.isAbsolute(rel)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
function normalizeInstanceSuffix(pathSuffix: string | undefined) {
|
||||||
if (!pathSuffix || pathSuffix === "/") {
|
if (!pathSuffix || pathSuffix === "/") {
|
||||||
return "/"
|
return "/"
|
||||||
|
|||||||
@@ -119,7 +119,8 @@
|
|||||||
showError(message || `Login failed (${res.status})`)
|
showError(message || `Login failed (${res.status})`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
window.location.href = "/"
|
// Replace history entry so Back doesn't return to /login.
|
||||||
|
window.location.replace("/")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(e && e.message ? e.message : String(e))
|
showError(e && e.message ? e.message : String(e))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,19 @@ function getTokenHtml(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
app.get("/login", async (_request, reply) => {
|
app.get("/login", async (request, reply) => {
|
||||||
|
// If already authenticated, don't show the login page.
|
||||||
|
const session = deps.authManager.getSessionFromRequest(request)
|
||||||
|
if (session) {
|
||||||
|
reply.redirect("/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid caching the login page (helps with bfcache/back behavior).
|
||||||
|
reply.header("Cache-Control", "no-store")
|
||||||
|
reply.header("Pragma", "no-cache")
|
||||||
|
reply.header("Expires", "0")
|
||||||
|
|
||||||
const status = deps.authManager.getStatus()
|
const status = deps.authManager.getStatus()
|
||||||
reply.type("text/html").send(getLoginHtml(status.username))
|
reply.type("text/html").send(getLoginHtml(status.username))
|
||||||
})
|
})
|
||||||
@@ -67,6 +79,11 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Avoid caching the token bootstrap page.
|
||||||
|
reply.header("Cache-Control", "no-store")
|
||||||
|
reply.header("Pragma", "no-cache")
|
||||||
|
reply.header("Expires", "0")
|
||||||
|
|
||||||
reply.type("text/html").send(getTokenHtml())
|
reply.type("text/html").send(getTokenHtml())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
|
||||||
import { z } from "zod"
|
|
||||||
import { ConfigStore } from "../../config/store"
|
|
||||||
import { BinaryRegistry } from "../../config/binaries"
|
|
||||||
import { ConfigFileSchema } from "../../config/schema"
|
|
||||||
|
|
||||||
interface RouteDeps {
|
|
||||||
configStore: ConfigStore
|
|
||||||
binaryRegistry: BinaryRegistry
|
|
||||||
}
|
|
||||||
|
|
||||||
const BinaryCreateSchema = z.object({
|
|
||||||
path: z.string(),
|
|
||||||
label: z.string().optional(),
|
|
||||||
makeDefault: z.boolean().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const BinaryUpdateSchema = z.object({
|
|
||||||
label: z.string().optional(),
|
|
||||||
makeDefault: z.boolean().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const BinaryValidateSchema = z.object({
|
|
||||||
path: z.string(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|
||||||
app.get("/api/config/app", async () => deps.configStore.get())
|
|
||||||
|
|
||||||
app.put("/api/config/app", async (request) => {
|
|
||||||
const body = ConfigFileSchema.parse(request.body ?? {})
|
|
||||||
deps.configStore.replace(body)
|
|
||||||
return deps.configStore.get()
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get("/api/config/binaries", async () => {
|
|
||||||
return { binaries: deps.binaryRegistry.list() }
|
|
||||||
})
|
|
||||||
|
|
||||||
app.post("/api/config/binaries", async (request, reply) => {
|
|
||||||
const body = BinaryCreateSchema.parse(request.body ?? {})
|
|
||||||
const binary = deps.binaryRegistry.create(body)
|
|
||||||
reply.code(201)
|
|
||||||
return { binary }
|
|
||||||
})
|
|
||||||
|
|
||||||
app.patch<{ Params: { id: string } }>("/api/config/binaries/:id", async (request) => {
|
|
||||||
const body = BinaryUpdateSchema.parse(request.body ?? {})
|
|
||||||
const binary = deps.binaryRegistry.update(request.params.id, body)
|
|
||||||
return { binary }
|
|
||||||
})
|
|
||||||
|
|
||||||
app.delete<{ Params: { id: string } }>("/api/config/binaries/:id", async (request, reply) => {
|
|
||||||
deps.binaryRegistry.remove(request.params.id)
|
|
||||||
reply.code(204)
|
|
||||||
})
|
|
||||||
|
|
||||||
app.post("/api/config/binaries/validate", async (request) => {
|
|
||||||
const body = BinaryValidateSchema.parse(request.body ?? {})
|
|
||||||
return deps.binaryRegistry.validatePath(body.path)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
80
packages/server/src/server/routes/settings.ts
Normal file
80
packages/server/src/server/routes/settings.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { probeBinaryVersion } from "../../workspaces/runtime"
|
||||||
|
import type { SettingsService } from "../../settings/service"
|
||||||
|
import type { Logger } from "../../logger"
|
||||||
|
|
||||||
|
interface RouteDeps {
|
||||||
|
settings: SettingsService
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
const ValidateBinarySchema = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
function validateBinaryPath(binaryPath: string): { valid: boolean; version?: string; error?: string } {
|
||||||
|
const result = probeBinaryVersion(binaryPath)
|
||||||
|
return { valid: result.valid, version: result.version, error: result.error }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
|
// Full-document access
|
||||||
|
app.get("/api/storage/config", async () => deps.settings.getDoc("config"))
|
||||||
|
app.patch("/api/storage/config", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return deps.settings.mergePatchDoc("config", request.body ?? {})
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request) => {
|
||||||
|
return deps.settings.getOwner("config", request.params.owner)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {})
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/storage/state", async () => deps.settings.getDoc("state"))
|
||||||
|
app.patch("/api/storage/state", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return deps.settings.mergePatchDoc("state", request.body ?? {})
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get<{ Params: { owner: string } }>("/api/storage/state/:owner", async (request) => {
|
||||||
|
return deps.settings.getOwner("state", request.params.owner)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch<{ Params: { owner: string } }>("/api/storage/state/:owner", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return deps.settings.mergePatchOwner("state", request.params.owner, request.body ?? {})
|
||||||
|
} catch (error) {
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Invalid patch" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Binary validation helper (used by UI when adding binaries)
|
||||||
|
app.post("/api/storage/binaries/validate", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const body = ValidateBinarySchema.parse(request.body ?? {})
|
||||||
|
return validateBinaryPath(body.path)
|
||||||
|
} catch (error) {
|
||||||
|
deps.logger.warn({ err: error }, "Failed to validate binary")
|
||||||
|
reply.code(400)
|
||||||
|
return { valid: false, error: error instanceof Error ? error.message : "Invalid request" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
55
packages/server/src/settings/binaries.ts
Normal file
55
packages/server/src/settings/binaries.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type { SettingsService } from "./service"
|
||||||
|
|
||||||
|
export interface OpenCodeBinaryEntry {
|
||||||
|
path: string
|
||||||
|
version?: string
|
||||||
|
lastUsed?: number
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedBinary {
|
||||||
|
path: string
|
||||||
|
label: string
|
||||||
|
version?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function prettyLabel(p: string): string {
|
||||||
|
const parts = p.split(/[\\/]/)
|
||||||
|
const last = parts[parts.length - 1] || p
|
||||||
|
return last || p
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUiBinaries(settings: SettingsService): OpenCodeBinaryEntry[] {
|
||||||
|
const ui = settings.getOwner("state", "ui")
|
||||||
|
const list = (ui as any)?.opencodeBinaries
|
||||||
|
if (!Array.isArray(list)) return []
|
||||||
|
return list.filter((item) => item && typeof item === "object" && typeof (item as any).path === "string") as any
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDefaultBinaryPath(settings: SettingsService): string | undefined {
|
||||||
|
const server = settings.getOwner("config", "server")
|
||||||
|
const value = (server as any)?.opencodeBinary
|
||||||
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BinaryResolver {
|
||||||
|
constructor(private readonly settings: SettingsService) {}
|
||||||
|
|
||||||
|
list(): OpenCodeBinaryEntry[] {
|
||||||
|
return readUiBinaries(this.settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveDefault(): ResolvedBinary {
|
||||||
|
const binaries = this.list()
|
||||||
|
const configuredDefault = readDefaultBinaryPath(this.settings)
|
||||||
|
const fallback = binaries[0]?.path
|
||||||
|
const path = configuredDefault ?? fallback ?? "opencode"
|
||||||
|
|
||||||
|
const entry = binaries.find((b) => b.path === path)
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
label: entry?.label ?? prettyLabel(path),
|
||||||
|
version: entry?.version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
packages/server/src/settings/merge-patch.ts
Normal file
39
packages/server/src/settings/merge-patch.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
type PlainObject = Record<string, unknown>
|
||||||
|
|
||||||
|
export function isPlainObject(value: unknown): value is PlainObject {
|
||||||
|
if (!value || typeof value !== "object") return false
|
||||||
|
if (Array.isArray(value)) return false
|
||||||
|
const proto = Object.getPrototypeOf(value)
|
||||||
|
return proto === Object.prototype || proto === null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 7396-ish merge patch with explicit null deletes.
|
||||||
|
* - Objects merge recursively
|
||||||
|
* - Arrays/scalars replace
|
||||||
|
* - null deletes keys
|
||||||
|
*/
|
||||||
|
export function applyMergePatch(current: unknown, patch: unknown): unknown {
|
||||||
|
if (!isPlainObject(patch)) {
|
||||||
|
return patch
|
||||||
|
}
|
||||||
|
|
||||||
|
const base: PlainObject = isPlainObject(current) ? { ...(current as PlainObject) } : {}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(patch)) {
|
||||||
|
if (value === null) {
|
||||||
|
delete base[key]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = base[key]
|
||||||
|
if (isPlainObject(value) && isPlainObject(existing)) {
|
||||||
|
base[key] = applyMergePatch(existing, value)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
base[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return base
|
||||||
|
}
|
||||||
269
packages/server/src/settings/migrate.ts
Normal file
269
packages/server/src/settings/migrate.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
import type { ConfigLocation } from "../config/location"
|
||||||
|
import { isPlainObject } from "./merge-patch"
|
||||||
|
|
||||||
|
type Doc = Record<string, unknown>
|
||||||
|
|
||||||
|
function ensureTrailingNewline(content: string): string {
|
||||||
|
if (!content) return "\n"
|
||||||
|
return content.endsWith("\n") ? content : `${content}\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeReadYaml(filePath: string, logger: Logger): unknown {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8")
|
||||||
|
return parseYaml(content)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error, filePath }, "Failed to read YAML file during migration")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeReadJson(filePath: string, logger: Logger): unknown {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8")
|
||||||
|
return JSON.parse(content)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error, filePath }, "Failed to read JSON file during migration")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeYaml(filePath: string, doc: Doc, logger: Logger) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
||||||
|
const yaml = stringifyYaml(doc as any)
|
||||||
|
fs.writeFileSync(filePath, ensureTrailingNewline(yaml), "utf-8")
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error, filePath }, "Failed to write YAML file during migration")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBackupPath(filePath: string): string {
|
||||||
|
const preferred = `${filePath}.bak`
|
||||||
|
if (!fs.existsSync(preferred)) {
|
||||||
|
return preferred
|
||||||
|
}
|
||||||
|
return `${filePath}.bak.${Date.now()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDoc(value: unknown): Doc {
|
||||||
|
return isPlainObject(value) ? (value as Doc) : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeNewOwnerDoc(value: unknown): boolean {
|
||||||
|
const doc = normalizeDoc(value)
|
||||||
|
// Heuristic: owner-bucket docs have at least one of these roots.
|
||||||
|
return Boolean(doc.ui || doc.server || doc.app || doc.legacy)
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeLegacyConfig(value: unknown): boolean {
|
||||||
|
const doc = normalizeDoc(value)
|
||||||
|
return Boolean(doc.preferences || doc.opencodeBinaries || doc.theme || doc.recentFolders)
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeLegacyState(value: unknown): boolean {
|
||||||
|
const doc = normalizeDoc(value)
|
||||||
|
return Boolean(doc.recentFolders)
|
||||||
|
}
|
||||||
|
|
||||||
|
function omitKeys(source: Doc, keys: Set<string>): Doc {
|
||||||
|
const out: Doc = {}
|
||||||
|
for (const [k, v] of Object.entries(source)) {
|
||||||
|
if (keys.has(k)) continue
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { config: Doc; state: Doc } {
|
||||||
|
const cfg = normalizeDoc(legacyConfig)
|
||||||
|
const st = normalizeDoc(legacyState)
|
||||||
|
|
||||||
|
const outConfig: Doc = {}
|
||||||
|
const outState: Doc = {}
|
||||||
|
|
||||||
|
const uiConfig: Doc = {}
|
||||||
|
const uiSettings: Doc = {}
|
||||||
|
const serverConfig: Doc = {}
|
||||||
|
const uiState: Doc = {}
|
||||||
|
|
||||||
|
// theme -> config.ui.theme
|
||||||
|
if (typeof cfg.theme === "string") {
|
||||||
|
uiConfig.theme = cfg.theme
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferences = normalizeDoc(cfg.preferences)
|
||||||
|
if (Object.keys(preferences).length > 0) {
|
||||||
|
// Server-owned stable keys
|
||||||
|
const envVars = preferences.environmentVariables
|
||||||
|
if (isPlainObject(envVars)) {
|
||||||
|
serverConfig.environmentVariables = envVars
|
||||||
|
}
|
||||||
|
const listeningMode = preferences.listeningMode
|
||||||
|
if (typeof listeningMode === "string") {
|
||||||
|
serverConfig.listeningMode = listeningMode
|
||||||
|
}
|
||||||
|
const lastUsedBinary = preferences.lastUsedBinary
|
||||||
|
if (typeof lastUsedBinary === "string") {
|
||||||
|
serverConfig.opencodeBinary = lastUsedBinary
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI-owned state keys (drop preferences)
|
||||||
|
const modelRecents = preferences.modelRecents
|
||||||
|
const modelFavorites = preferences.modelFavorites
|
||||||
|
const modelThinkingSelections = preferences.modelThinkingSelections
|
||||||
|
|
||||||
|
const models: Doc = {}
|
||||||
|
if (Array.isArray(modelRecents)) {
|
||||||
|
models.recents = modelRecents
|
||||||
|
}
|
||||||
|
if (Array.isArray(modelFavorites)) {
|
||||||
|
models.favorites = modelFavorites
|
||||||
|
}
|
||||||
|
if (isPlainObject(modelThinkingSelections)) {
|
||||||
|
models.thinkingSelections = modelThinkingSelections
|
||||||
|
}
|
||||||
|
if (Object.keys(models).length > 0) {
|
||||||
|
uiState.models = models
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining preferences are treated as stable UI settings.
|
||||||
|
const moved = new Set([
|
||||||
|
"environmentVariables",
|
||||||
|
"listeningMode",
|
||||||
|
"lastUsedBinary",
|
||||||
|
"modelRecents",
|
||||||
|
"modelFavorites",
|
||||||
|
"modelThinkingSelections",
|
||||||
|
])
|
||||||
|
Object.assign(uiSettings, omitKeys(preferences, moved))
|
||||||
|
}
|
||||||
|
|
||||||
|
// recentFolders lives in legacy state (yaml) or legacy config.json
|
||||||
|
const recentFolders = (st.recentFolders ?? cfg.recentFolders) as unknown
|
||||||
|
if (Array.isArray(recentFolders)) {
|
||||||
|
uiState.recentFolders = recentFolders
|
||||||
|
}
|
||||||
|
|
||||||
|
// opencodeBinaries -> state.ui.opencodeBinaries
|
||||||
|
if (Array.isArray(cfg.opencodeBinaries)) {
|
||||||
|
uiState.opencodeBinaries = cfg.opencodeBinaries
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(uiSettings).length > 0) {
|
||||||
|
uiConfig.settings = uiSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(uiConfig).length > 0) {
|
||||||
|
outConfig.ui = uiConfig
|
||||||
|
}
|
||||||
|
if (Object.keys(serverConfig).length > 0) {
|
||||||
|
outConfig.server = serverConfig
|
||||||
|
}
|
||||||
|
if (Object.keys(uiState).length > 0) {
|
||||||
|
outState.ui = uiState
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown top-level keys -> legacy.unknown
|
||||||
|
const knownConfigKeys = new Set(["preferences", "opencodeBinaries", "theme", "recentFolders"])
|
||||||
|
const unknownConfig = omitKeys(cfg, knownConfigKeys)
|
||||||
|
if (Object.keys(unknownConfig).length > 0) {
|
||||||
|
outConfig.legacy = { unknown: unknownConfig }
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownStateKeys = new Set(["recentFolders"])
|
||||||
|
const unknownState = omitKeys(st, knownStateKeys)
|
||||||
|
if (Object.keys(unknownState).length > 0) {
|
||||||
|
outState.legacy = { unknown: unknownState }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { config: outConfig, state: outState }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate older config/state layouts into owner-bucket YAML docs.
|
||||||
|
*
|
||||||
|
* Legacy inputs supported:
|
||||||
|
* - config.yaml with { preferences, opencodeBinaries, theme }
|
||||||
|
* - state.yaml with { recentFolders }
|
||||||
|
* - legacy config.json with full ConfigFile schema
|
||||||
|
*/
|
||||||
|
export function migrateSettingsLayout(location: ConfigLocation, logger: Logger) {
|
||||||
|
const configYamlPath = location.configYamlPath
|
||||||
|
const stateYamlPath = location.stateYamlPath
|
||||||
|
const legacyJsonPath = location.legacyJsonPath
|
||||||
|
|
||||||
|
const configExists = fs.existsSync(configYamlPath)
|
||||||
|
const stateExists = fs.existsSync(stateYamlPath)
|
||||||
|
|
||||||
|
const configDoc = configExists ? safeReadYaml(configYamlPath, logger) : null
|
||||||
|
const stateDoc = stateExists ? safeReadYaml(stateYamlPath, logger) : null
|
||||||
|
|
||||||
|
const configIsNew = configExists && looksLikeNewOwnerDoc(configDoc) && !looksLikeLegacyConfig(configDoc)
|
||||||
|
const stateIsNew = stateExists && looksLikeNewOwnerDoc(stateDoc) && !looksLikeLegacyState(stateDoc)
|
||||||
|
|
||||||
|
if (configIsNew && stateIsNew) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyJsonExists = fs.existsSync(legacyJsonPath)
|
||||||
|
|
||||||
|
const hasLegacyYaml = (configExists && looksLikeLegacyConfig(configDoc)) || (stateExists && looksLikeLegacyState(stateDoc))
|
||||||
|
const shouldMigrateFromJson = !configExists && legacyJsonExists
|
||||||
|
|
||||||
|
if (!hasLegacyYaml && !shouldMigrateFromJson) {
|
||||||
|
// Either fresh install or partially written docs; let stores create on first write.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceConfig = shouldMigrateFromJson ? safeReadJson(legacyJsonPath, logger) : configDoc
|
||||||
|
const sourceState = shouldMigrateFromJson ? sourceConfig : stateDoc
|
||||||
|
|
||||||
|
const { config, state } = mapLegacyToOwnerDocs(sourceConfig, sourceState)
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(location.baseDir, { recursive: true })
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error, baseDir: location.baseDir }, "Failed to create base directory during migration")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup legacy files before rewriting.
|
||||||
|
if (configExists) {
|
||||||
|
try {
|
||||||
|
const bak = pickBackupPath(configYamlPath)
|
||||||
|
fs.renameSync(configYamlPath, bak)
|
||||||
|
logger.info({ configYamlPath, bak }, "Backed up legacy config.yaml")
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error, configYamlPath }, "Failed to backup legacy config.yaml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateExists) {
|
||||||
|
try {
|
||||||
|
const bak = pickBackupPath(stateYamlPath)
|
||||||
|
fs.renameSync(stateYamlPath, bak)
|
||||||
|
logger.info({ stateYamlPath, bak }, "Backed up legacy state.yaml")
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error, stateYamlPath }, "Failed to backup legacy state.yaml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldMigrateFromJson) {
|
||||||
|
try {
|
||||||
|
const bak = pickBackupPath(legacyJsonPath)
|
||||||
|
fs.renameSync(legacyJsonPath, bak)
|
||||||
|
logger.info({ legacyJsonPath, bak }, "Moved legacy config.json to backup")
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error, legacyJsonPath }, "Failed to move legacy config.json to backup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeYaml(configYamlPath, config, logger)
|
||||||
|
writeYaml(stateYamlPath, state, logger)
|
||||||
|
|
||||||
|
logger.info({ configYamlPath, stateYamlPath }, "Migrated settings docs to owner-bucket layout")
|
||||||
|
}
|
||||||
55
packages/server/src/settings/service.ts
Normal file
55
packages/server/src/settings/service.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type { Logger } from "../logger"
|
||||||
|
import type { EventBus } from "../events/bus"
|
||||||
|
import type { ConfigLocation } from "../config/location"
|
||||||
|
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
|
||||||
|
import { migrateSettingsLayout } from "./migrate"
|
||||||
|
import type { WorkspaceEventPayload } from "../api-types"
|
||||||
|
|
||||||
|
export type DocKind = "config" | "state"
|
||||||
|
|
||||||
|
export class SettingsService {
|
||||||
|
private readonly configStore: YamlDocStore
|
||||||
|
private readonly stateStore: YamlDocStore
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly location: ConfigLocation,
|
||||||
|
private readonly eventBus: EventBus | undefined,
|
||||||
|
private readonly logger: Logger,
|
||||||
|
) {
|
||||||
|
migrateSettingsLayout(location, logger)
|
||||||
|
this.configStore = new YamlDocStore(location.configYamlPath, logger.child({ component: "settings-config" }))
|
||||||
|
this.stateStore = new YamlDocStore(location.stateYamlPath, logger.child({ component: "settings-state" }))
|
||||||
|
}
|
||||||
|
|
||||||
|
getDoc(kind: DocKind): SettingsDoc {
|
||||||
|
return kind === "config" ? this.configStore.get() : this.stateStore.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
|
||||||
|
const updated = kind === "config" ? this.configStore.mergePatch(patch) : this.stateStore.mergePatch(patch)
|
||||||
|
this.publish(kind, "*")
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
getOwner(kind: DocKind, owner: string): SettingsDoc {
|
||||||
|
return kind === "config" ? this.configStore.getOwner(owner) : this.stateStore.getOwner(owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
|
||||||
|
const updated =
|
||||||
|
kind === "config" ? this.configStore.mergePatchOwner(owner, patch) : this.stateStore.mergePatchOwner(owner, patch)
|
||||||
|
this.publish(kind, owner, updated)
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
private publish(kind: DocKind, owner: string, value?: SettingsDoc) {
|
||||||
|
if (!this.eventBus) return
|
||||||
|
const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged"
|
||||||
|
const payload: WorkspaceEventPayload = {
|
||||||
|
type,
|
||||||
|
owner,
|
||||||
|
value: value ?? this.getOwner(kind, owner),
|
||||||
|
} as any
|
||||||
|
this.eventBus.publish(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
110
packages/server/src/settings/yaml-doc-store.ts
Normal file
110
packages/server/src/settings/yaml-doc-store.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
|
||||||
|
import type { Logger } from "../logger"
|
||||||
|
import { applyMergePatch, isPlainObject } from "./merge-patch"
|
||||||
|
|
||||||
|
export type SettingsDoc = Record<string, unknown>
|
||||||
|
|
||||||
|
function ensureTrailingNewline(content: string): string {
|
||||||
|
if (!content) return "\n"
|
||||||
|
return content.endsWith("\n") ? content : `${content}\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDoc(input: unknown): SettingsDoc {
|
||||||
|
if (!isPlainObject(input)) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
export class YamlDocStore {
|
||||||
|
private cache: SettingsDoc = {}
|
||||||
|
private loaded = false
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly filePath: string,
|
||||||
|
private readonly logger: Logger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
load(): SettingsDoc {
|
||||||
|
if (this.loaded) {
|
||||||
|
return this.cache
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(this.filePath)) {
|
||||||
|
this.cache = {}
|
||||||
|
this.loaded = true
|
||||||
|
return this.cache
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(this.filePath, "utf-8")
|
||||||
|
const parsed = parseYaml(content)
|
||||||
|
this.cache = normalizeDoc(parsed)
|
||||||
|
this.loaded = true
|
||||||
|
return this.cache
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error, filePath: this.filePath }, "Failed to read YAML doc; using empty object")
|
||||||
|
this.cache = {}
|
||||||
|
this.loaded = true
|
||||||
|
return this.cache
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get(): SettingsDoc {
|
||||||
|
return this.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
replace(next: unknown): SettingsDoc {
|
||||||
|
const normalized = normalizeDoc(next)
|
||||||
|
this.cache = normalized
|
||||||
|
this.loaded = true
|
||||||
|
this.persist()
|
||||||
|
return this.cache
|
||||||
|
}
|
||||||
|
|
||||||
|
mergePatch(patch: unknown): SettingsDoc {
|
||||||
|
if (!isPlainObject(patch)) {
|
||||||
|
throw new Error("Patch must be a JSON object")
|
||||||
|
}
|
||||||
|
const current = this.get()
|
||||||
|
const next = applyMergePatch(current, patch)
|
||||||
|
return this.replace(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
getOwner(owner: string): SettingsDoc {
|
||||||
|
const doc = this.get()
|
||||||
|
const value = (doc as any)?.[owner]
|
||||||
|
return normalizeDoc(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceOwner(owner: string, value: unknown): SettingsDoc {
|
||||||
|
const doc = this.get()
|
||||||
|
const nextDoc: SettingsDoc = { ...doc, [owner]: normalizeDoc(value) }
|
||||||
|
this.replace(nextDoc)
|
||||||
|
return nextDoc[owner] as SettingsDoc
|
||||||
|
}
|
||||||
|
|
||||||
|
mergePatchOwner(owner: string, patch: unknown): SettingsDoc {
|
||||||
|
if (!isPlainObject(patch)) {
|
||||||
|
throw new Error("Patch must be a JSON object")
|
||||||
|
}
|
||||||
|
const doc = this.get()
|
||||||
|
const currentOwner = normalizeDoc((doc as any)?.[owner])
|
||||||
|
const nextOwner = normalizeDoc(applyMergePatch(currentOwner, patch))
|
||||||
|
const nextDoc: SettingsDoc = { ...doc, [owner]: nextOwner }
|
||||||
|
this.replace(nextDoc)
|
||||||
|
return nextOwner
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist() {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(this.filePath), { recursive: true })
|
||||||
|
const yaml = stringifyYaml(this.cache as any)
|
||||||
|
fs.writeFileSync(this.filePath, ensureTrailingNewline(yaml), "utf-8")
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn({ err: error, filePath: this.filePath }, "Failed to persist YAML doc")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@ import path from "path"
|
|||||||
import { spawnSync } from "child_process"
|
import { spawnSync } from "child_process"
|
||||||
import { connect } from "net"
|
import { connect } from "net"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import { ConfigStore } from "../config/store"
|
import type { SettingsService } from "../settings/service"
|
||||||
import { BinaryRegistry } from "../config/binaries"
|
import type { BinaryResolver } from "../settings/binaries"
|
||||||
import { FileSystemBrowser } from "../filesystem/browser"
|
import { FileSystemBrowser } from "../filesystem/browser"
|
||||||
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
||||||
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
||||||
@@ -23,8 +23,8 @@ const STARTUP_STABILITY_DELAY_MS = 1500
|
|||||||
|
|
||||||
interface WorkspaceManagerOptions {
|
interface WorkspaceManagerOptions {
|
||||||
rootDir: string
|
rootDir: string
|
||||||
configStore: ConfigStore
|
settings: SettingsService
|
||||||
binaryRegistry: BinaryRegistry
|
binaryResolver: BinaryResolver
|
||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
logger: Logger
|
logger: Logger
|
||||||
getServerBaseUrl: () => string
|
getServerBaseUrl: () => string
|
||||||
@@ -86,7 +86,7 @@ export class WorkspaceManager {
|
|||||||
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
||||||
|
|
||||||
const id = `${Date.now().toString(36)}`
|
const id = `${Date.now().toString(36)}`
|
||||||
const binary = this.options.binaryRegistry.resolveDefault()
|
const binary = this.options.binaryResolver.resolveDefault()
|
||||||
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
|
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
|
||||||
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
||||||
clearWorkspaceSearchCache(workspacePath)
|
clearWorkspaceSearchCache(workspacePath)
|
||||||
@@ -109,17 +109,14 @@ export class WorkspaceManager {
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!descriptor.binaryVersion) {
|
|
||||||
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.workspaces.set(id, descriptor)
|
this.workspaces.set(id, descriptor)
|
||||||
|
|
||||||
|
|
||||||
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
||||||
|
|
||||||
const preferences = this.options.configStore.get().preferences ?? {}
|
const serverConfig = this.options.settings.getOwner("config", "server")
|
||||||
const userEnvironment = preferences.environmentVariables ?? {}
|
const envVars = (serverConfig as any)?.environmentVariables
|
||||||
|
const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {}
|
||||||
|
|
||||||
const opencodeUsername = DEFAULT_OPENCODE_USERNAME
|
const opencodeUsername = DEFAULT_OPENCODE_USERNAME
|
||||||
const opencodePassword = generateOpencodeServerPassword()
|
const opencodePassword = generateOpencodeServerPassword()
|
||||||
@@ -148,7 +145,10 @@ export class WorkspaceManager {
|
|||||||
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
||||||
})
|
})
|
||||||
|
|
||||||
await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
|
const runtimeVersion = await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
|
||||||
|
if (runtimeVersion) {
|
||||||
|
descriptor.binaryVersion = runtimeVersion
|
||||||
|
}
|
||||||
|
|
||||||
descriptor.pid = pid
|
descriptor.pid = pid
|
||||||
descriptor.port = port
|
descriptor.port = port
|
||||||
@@ -277,42 +277,12 @@ export class WorkspaceManager {
|
|||||||
return candidates[0] ?? ""
|
return candidates[0] ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
private detectBinaryVersion(resolvedPath: string): string | undefined {
|
|
||||||
if (!resolvedPath) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = spawnSync(resolvedPath, ["--version"], { encoding: "utf8" })
|
|
||||||
if (result.status === 0 && result.stdout) {
|
|
||||||
const line = result.stdout.split(/\r?\n/).find((entry) => entry.trim().length > 0)
|
|
||||||
if (line) {
|
|
||||||
const normalized = line.trim()
|
|
||||||
const versionMatch = normalized.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
|
|
||||||
if (versionMatch) {
|
|
||||||
const version = versionMatch[1]
|
|
||||||
this.options.logger.debug({ binary: resolvedPath, version }, "Detected binary version")
|
|
||||||
return version
|
|
||||||
}
|
|
||||||
this.options.logger.debug({ binary: resolvedPath, reported: normalized }, "Binary reported version string")
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
} else if (result.error) {
|
|
||||||
this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to read binary version")
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.options.logger.warn({ binary: resolvedPath, err: error }, "Failed to detect binary version")
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
private async waitForWorkspaceReadiness(params: {
|
private async waitForWorkspaceReadiness(params: {
|
||||||
workspaceId: string
|
workspaceId: string
|
||||||
port: number
|
port: number
|
||||||
exitPromise: Promise<ProcessExitInfo>
|
exitPromise: Promise<ProcessExitInfo>
|
||||||
getLastOutput: () => string
|
getLastOutput: () => string
|
||||||
}) {
|
}): Promise<string | undefined> {
|
||||||
|
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
this.waitForPortAvailability(params.port),
|
this.waitForPortAvailability(params.port),
|
||||||
@@ -326,7 +296,7 @@ export class WorkspaceManager {
|
|||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
await this.waitForInstanceHealth(params)
|
const version = await this.waitForInstanceHealth(params)
|
||||||
|
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
this.delay(STARTUP_STABILITY_DELAY_MS),
|
this.delay(STARTUP_STABILITY_DELAY_MS),
|
||||||
@@ -339,6 +309,8 @@ export class WorkspaceManager {
|
|||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
return version
|
||||||
}
|
}
|
||||||
|
|
||||||
private async waitForInstanceHealth(params: {
|
private async waitForInstanceHealth(params: {
|
||||||
@@ -346,7 +318,7 @@ export class WorkspaceManager {
|
|||||||
port: number
|
port: number
|
||||||
exitPromise: Promise<ProcessExitInfo>
|
exitPromise: Promise<ProcessExitInfo>
|
||||||
getLastOutput: () => string
|
getLastOutput: () => string
|
||||||
}) {
|
}): Promise<string | undefined> {
|
||||||
const probeResult = await Promise.race([
|
const probeResult = await Promise.race([
|
||||||
this.probeInstance(params.workspaceId, params.port),
|
this.probeInstance(params.workspaceId, params.port),
|
||||||
params.exitPromise.then((info) => {
|
params.exitPromise.then((info) => {
|
||||||
@@ -360,7 +332,7 @@ export class WorkspaceManager {
|
|||||||
])
|
])
|
||||||
|
|
||||||
if (probeResult.ok) {
|
if (probeResult.ok) {
|
||||||
return
|
return probeResult.version
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestOutput = params.getLastOutput().trim()
|
const latestOutput = params.getLastOutput().trim()
|
||||||
@@ -371,8 +343,11 @@ export class WorkspaceManager {
|
|||||||
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`)
|
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> {
|
private async probeInstance(
|
||||||
const url = `http://127.0.0.1:${port}/project/current`
|
workspaceId: string,
|
||||||
|
port: number,
|
||||||
|
): Promise<{ ok: boolean; reason?: string; version?: string }> {
|
||||||
|
const url = `http://127.0.0.1:${port}/global/health`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {}
|
const headers: Record<string, string> = {}
|
||||||
@@ -383,11 +358,22 @@ export class WorkspaceManager {
|
|||||||
|
|
||||||
const response = await fetch(url, { headers })
|
const response = await fetch(url, { headers })
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const reason = `health probe returned HTTP ${response.status}`
|
const reason = `/global/health returned HTTP ${response.status}`
|
||||||
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
|
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
|
||||||
return { ok: false, reason }
|
return { ok: false, reason }
|
||||||
}
|
}
|
||||||
return { ok: true }
|
|
||||||
|
const payload = (await response.json().catch(() => null)) as null | { healthy?: unknown; version?: unknown }
|
||||||
|
const healthy = payload?.healthy === true
|
||||||
|
const version = typeof payload?.version === "string" ? payload.version.trim() : undefined
|
||||||
|
|
||||||
|
if (!healthy) {
|
||||||
|
const reason = "Instance reported unhealthy"
|
||||||
|
this.options.logger.debug({ workspaceId, payload }, "Health probe returned unhealthy response")
|
||||||
|
return { ok: false, reason }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, version: version || undefined }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const reason = error instanceof Error ? error.message : String(error)
|
const reason = error instanceof Error ? error.message : String(error)
|
||||||
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed")
|
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed")
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { Logger } from "../logger"
|
|||||||
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
|
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
|
||||||
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
|
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
|
||||||
|
|
||||||
|
const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/
|
||||||
|
|
||||||
export function buildSpawnSpec(binaryPath: string, args: string[]) {
|
export function buildSpawnSpec(binaryPath: string, args: string[]) {
|
||||||
if (process.platform !== "win32") {
|
if (process.platform !== "win32") {
|
||||||
return { command: binaryPath, args, options: {} as const }
|
return { command: binaryPath, args, options: {} as const }
|
||||||
@@ -40,6 +42,61 @@ export function buildSpawnSpec(binaryPath: string, args: string[]) {
|
|||||||
return { command: binaryPath, args, options: {} as const }
|
return { command: binaryPath, args, options: {} as const }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function probeBinaryVersion(binaryPath: string): {
|
||||||
|
valid: boolean
|
||||||
|
version?: string
|
||||||
|
reported?: string
|
||||||
|
error?: string
|
||||||
|
} {
|
||||||
|
if (!binaryPath) {
|
||||||
|
return { valid: false, error: "Missing binary path" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const spec = buildSpawnSpec(binaryPath, ["--version"])
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync(spec.command, spec.args, {
|
||||||
|
encoding: "utf8",
|
||||||
|
windowsVerbatimArguments: Boolean(
|
||||||
|
(spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return { valid: false, error: result.error.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
const stderr = result.stderr?.trim()
|
||||||
|
const stdout = result.stdout?.trim()
|
||||||
|
const combined = stderr || stdout
|
||||||
|
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
|
||||||
|
return { valid: false, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
const stdoutLines = String(result.stdout ?? "")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
const stderrLines = String(result.stderr ?? "")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
|
||||||
|
// Prefer stdout; fall back to stderr (some tools report version there).
|
||||||
|
const reported = stdoutLines[0] ?? stderrLines[0]
|
||||||
|
if (!reported) {
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionMatch = reported.match(VERSION_REGEX)
|
||||||
|
const version = versionMatch?.[1]
|
||||||
|
return { valid: true, version, reported }
|
||||||
|
} catch (error) {
|
||||||
|
return { valid: false, error: error instanceof Error ? error.message : String(error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
|
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
|
||||||
|
|
||||||
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
||||||
|
|||||||
20
packages/tauri-app/Cargo.lock
generated
20
packages/tauri-app/Cargo.lock
generated
@@ -636,6 +636,7 @@ dependencies = [
|
|||||||
"regex",
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_yaml",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
@@ -3894,6 +3895,19 @@ dependencies = [
|
|||||||
"syn 2.0.110",
|
"syn 2.0.110",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_yaml"
|
||||||
|
version = "0.9.34+deprecated"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap 2.12.1",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
"unsafe-libyaml",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serialize-to-javascript"
|
name = "serialize-to-javascript"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -5015,6 +5029,12 @@ version = "1.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unsafe-libyaml"
|
||||||
|
version = "0.2.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.7"
|
version = "2.5.7"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.10.3",
|
"version": "0.12.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ tauri-build = { version = "2.5.2", features = [] }
|
|||||||
tauri = { version = "2.5.2", features = [ "devtools"] }
|
tauri = { version = "2.5.2", features = [ "devtools"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
serde_yaml = "0.9"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
once_cell = "1"
|
once_cell = "1"
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
|
|||||||
@@ -141,16 +141,44 @@ struct PreferencesConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct AppConfig {
|
struct ServerConfig {
|
||||||
preferences: Option<PreferencesConfig>,
|
#[serde(rename = "listeningMode")]
|
||||||
|
listening_mode: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_config_path() -> PathBuf {
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AppConfig {
|
||||||
|
preferences: Option<PreferencesConfig>,
|
||||||
|
server: Option<ServerConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_config_locations() -> (PathBuf, PathBuf) {
|
||||||
let raw = env::var("CLI_CONFIG")
|
let raw = env::var("CLI_CONFIG")
|
||||||
.ok()
|
.ok()
|
||||||
.filter(|value| !value.trim().is_empty())
|
.filter(|value| !value.trim().is_empty())
|
||||||
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
|
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
|
||||||
expand_home(&raw)
|
|
||||||
|
let expanded = expand_home(&raw);
|
||||||
|
let lower = raw.trim().to_lowercase();
|
||||||
|
|
||||||
|
if lower.ends_with(".yaml") || lower.ends_with(".yml") {
|
||||||
|
let base = expanded
|
||||||
|
.parent()
|
||||||
|
.map(|p| p.to_path_buf())
|
||||||
|
.unwrap_or_else(|| expanded.clone());
|
||||||
|
return (expanded, base.join("config.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if lower.ends_with(".json") {
|
||||||
|
let base = expanded
|
||||||
|
.parent()
|
||||||
|
.map(|p| p.to_path_buf())
|
||||||
|
.unwrap_or_else(|| expanded.clone());
|
||||||
|
return (base.join("config.yaml"), expanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treat as directory.
|
||||||
|
(expanded.join("config.yaml"), expanded.join("config.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn expand_home(path: &str) -> PathBuf {
|
fn expand_home(path: &str) -> PathBuf {
|
||||||
@@ -163,14 +191,46 @@ fn expand_home(path: &str) -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_listening_mode() -> String {
|
fn resolve_listening_mode() -> String {
|
||||||
let path = resolve_config_path();
|
let (yaml_path, json_path) = resolve_config_locations();
|
||||||
if let Ok(content) = fs::read_to_string(path) {
|
|
||||||
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
|
if let Ok(content) = fs::read_to_string(&yaml_path) {
|
||||||
if let Some(mode) = config
|
if let Ok(config) = serde_yaml::from_str::<AppConfig>(&content) {
|
||||||
.preferences
|
let mode = config
|
||||||
|
.server
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|prefs| prefs.listening_mode.as_ref())
|
.and_then(|srv| srv.listening_mode.as_ref())
|
||||||
{
|
.or_else(|| {
|
||||||
|
config
|
||||||
|
.preferences
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|prefs| prefs.listening_mode.as_ref())
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(mode) = mode {
|
||||||
|
if mode == "local" {
|
||||||
|
return "local".to_string();
|
||||||
|
}
|
||||||
|
if mode == "all" {
|
||||||
|
return "all".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy fallback.
|
||||||
|
if let Ok(content) = fs::read_to_string(&json_path) {
|
||||||
|
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
|
||||||
|
let mode = config
|
||||||
|
.server
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|srv| srv.listening_mode.as_ref())
|
||||||
|
.or_else(|| {
|
||||||
|
config
|
||||||
|
.preferences
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|prefs| prefs.listening_mode.as_ref())
|
||||||
|
});
|
||||||
|
if let Some(mode) = mode {
|
||||||
if mode == "local" {
|
if mode == "local" {
|
||||||
return "local".to_string();
|
return "local".to_string();
|
||||||
}
|
}
|
||||||
@@ -260,7 +320,14 @@ impl CliProcessManager {
|
|||||||
let ready_flag = self.ready.clone();
|
let ready_flag = self.ready.clone();
|
||||||
let token_arc = self.bootstrap_token.clone();
|
let token_arc = self.bootstrap_token.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, token_arc, dev) {
|
if let Err(err) = Self::spawn_cli(
|
||||||
|
app.clone(),
|
||||||
|
status_arc.clone(),
|
||||||
|
child_arc,
|
||||||
|
ready_flag,
|
||||||
|
token_arc,
|
||||||
|
dev,
|
||||||
|
) {
|
||||||
log_line(&format!("cli spawn failed: {err}"));
|
log_line(&format!("cli spawn failed: {err}"));
|
||||||
let mut locked = status_arc.lock();
|
let mut locked = status_arc.lock();
|
||||||
locked.state = CliState::Error;
|
locked.state = CliState::Error;
|
||||||
@@ -369,7 +436,9 @@ impl CliProcessManager {
|
|||||||
|
|
||||||
if !supports_user_shell() {
|
if !supports_user_shell() {
|
||||||
if which::which(&resolution.node_binary).is_err() {
|
if which::which(&resolution.node_binary).is_err() {
|
||||||
return Err(anyhow::anyhow!("Node binary not found. Make sure Node.js is installed."));
|
return Err(anyhow::anyhow!(
|
||||||
|
"Node binary not found. Make sure Node.js is installed."
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,7 +489,6 @@ impl CliProcessManager {
|
|||||||
let token_clone = bootstrap_token.clone();
|
let token_clone = bootstrap_token.clone();
|
||||||
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
|
|
||||||
let stdout = child_clone
|
let stdout = child_clone
|
||||||
.lock()
|
.lock()
|
||||||
.as_mut()
|
.as_mut()
|
||||||
@@ -433,10 +501,24 @@ impl CliProcessManager {
|
|||||||
.map(BufReader::new);
|
.map(BufReader::new);
|
||||||
|
|
||||||
if let Some(reader) = stdout {
|
if let Some(reader) = stdout {
|
||||||
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone, &token_clone);
|
Self::process_stream(
|
||||||
|
reader,
|
||||||
|
"stdout",
|
||||||
|
&app_clone,
|
||||||
|
&status_clone,
|
||||||
|
&ready_clone,
|
||||||
|
&token_clone,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if let Some(reader) = stderr {
|
if let Some(reader) = stderr {
|
||||||
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone, &token_clone);
|
Self::process_stream(
|
||||||
|
reader,
|
||||||
|
"stderr",
|
||||||
|
&app_clone,
|
||||||
|
&status_clone,
|
||||||
|
&ready_clone,
|
||||||
|
&token_clone,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -509,8 +591,14 @@ impl CliProcessManager {
|
|||||||
if locked.error.is_none() {
|
if locked.error.is_none() {
|
||||||
locked.error = err_msg.clone();
|
locked.error = err_msg.clone();
|
||||||
}
|
}
|
||||||
log_line(&format!("cli process exited before ready: {:?}", locked.error));
|
log_line(&format!(
|
||||||
let _ = app_clone.emit("cli:error", json!({"message": locked.error.clone().unwrap_or_default()}));
|
"cli process exited before ready: {:?}",
|
||||||
|
locked.error
|
||||||
|
));
|
||||||
|
let _ = app_clone.emit(
|
||||||
|
"cli:error",
|
||||||
|
json!({"message": locked.error.clone().unwrap_or_default()}),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
locked.state = CliState::Stopped;
|
locked.state = CliState::Stopped;
|
||||||
log_line("cli process stopped cleanly");
|
log_line("cli process stopped cleanly");
|
||||||
@@ -574,13 +662,25 @@ impl CliProcessManager {
|
|||||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||||
.and_then(|m| m.as_str().parse::<u16>().ok())
|
.and_then(|m| m.as_str().parse::<u16>().ok())
|
||||||
{
|
{
|
||||||
Self::mark_ready(app, status, ready, bootstrap_token, format!("http://localhost:{port}"));
|
Self::mark_ready(
|
||||||
|
app,
|
||||||
|
status,
|
||||||
|
ready,
|
||||||
|
bootstrap_token,
|
||||||
|
format!("http://localhost:{port}"),
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
|
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
|
||||||
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
|
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
|
||||||
Self::mark_ready(app, status, ready, bootstrap_token, format!("http://localhost:{}", port));
|
Self::mark_ready(
|
||||||
|
app,
|
||||||
|
status,
|
||||||
|
ready,
|
||||||
|
bootstrap_token,
|
||||||
|
format!("http://localhost:{}", port),
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -719,18 +819,40 @@ impl CliEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
|
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
|
||||||
let mut args = vec!["serve".to_string(), "--host".to_string(), host.to_string(), "--generate-token".to_string()];
|
let mut args = vec![
|
||||||
|
"serve".to_string(),
|
||||||
|
"--host".to_string(),
|
||||||
|
host.to_string(),
|
||||||
|
"--generate-token".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
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());
|
||||||
@@ -761,9 +883,10 @@ fn resolve_tsx(_app: &AppHandle) -> Option<String> {
|
|||||||
std::env::current_dir()
|
std::env::current_dir()
|
||||||
.ok()
|
.ok()
|
||||||
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
||||||
std::env::current_exe()
|
std::env::current_exe().ok().and_then(|ex| {
|
||||||
.ok()
|
ex.parent()
|
||||||
.and_then(|ex| ex.parent().map(|p| p.join("../node_modules/tsx/dist/cli.js"))),
|
.map(|p| p.join("../node_modules/tsx/dist/cli.js"))
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
first_existing(candidates)
|
first_existing(candidates)
|
||||||
@@ -786,13 +909,19 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
|||||||
let base = workspace_root();
|
let base = workspace_root();
|
||||||
let mut candidates: Vec<Option<PathBuf>> = vec![
|
let mut candidates: Vec<Option<PathBuf>> = vec![
|
||||||
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")),
|
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")),
|
||||||
base.as_ref().map(|p| p.join("packages/server/dist/index.js")),
|
base.as_ref()
|
||||||
|
.map(|p| p.join("packages/server/dist/index.js")),
|
||||||
base.as_ref().map(|p| p.join("server/dist/bin.js")),
|
base.as_ref().map(|p| p.join("server/dist/bin.js")),
|
||||||
base.as_ref().map(|p| p.join("server/dist/index.js")),
|
base.as_ref().map(|p| p.join("server/dist/index.js")),
|
||||||
];
|
];
|
||||||
|
|
||||||
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")));
|
||||||
@@ -801,7 +930,9 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
|||||||
candidates.push(Some(resources.join("resources/server/dist/bin.js")));
|
candidates.push(Some(resources.join("resources/server/dist/bin.js")));
|
||||||
candidates.push(Some(resources.join("resources/server/dist/index.js")));
|
candidates.push(Some(resources.join("resources/server/dist/index.js")));
|
||||||
candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
|
candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
|
||||||
candidates.push(Some(resources.join("resources/server/dist/server/index.js")));
|
candidates.push(Some(
|
||||||
|
resources.join("resources/server/dist/server/index.js"),
|
||||||
|
));
|
||||||
|
|
||||||
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
|
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
|
||||||
for root in linux_resource_roots {
|
for root in linux_resource_roots {
|
||||||
@@ -820,8 +951,10 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
|||||||
first_existing(candidates)
|
first_existing(candidates)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_shell_command_string(entry: &CliEntry, cli_args: &[String]) -> anyhow::Result<ShellCommand> {
|
fn build_shell_command_string(
|
||||||
|
entry: &CliEntry,
|
||||||
|
cli_args: &[String],
|
||||||
|
) -> anyhow::Result<ShellCommand> {
|
||||||
let shell = default_shell();
|
let shell = default_shell();
|
||||||
let mut quoted: Vec<String> = Vec::new();
|
let mut quoted: Vec<String> = Vec::new();
|
||||||
quoted.push(shell_escape(&entry.node_binary));
|
quoted.push(shell_escape(&entry.node_binary));
|
||||||
@@ -852,7 +985,7 @@ fn shell_escape(input: &str) -> String {
|
|||||||
"''".to_string()
|
"''".to_string()
|
||||||
} else if !input
|
} else if !input
|
||||||
.chars()
|
.chars()
|
||||||
.any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!' ))
|
.any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!'))
|
||||||
{
|
{
|
||||||
input.to_string()
|
input.to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -884,9 +1017,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
|
|||||||
Ok(state.manager.status())
|
Ok(state.manager.status())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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 +45,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 +68,39 @@ 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 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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))
|
||||||
@@ -187,6 +222,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 +290,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 +322,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 +335,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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.10.3",
|
"version": "0.12.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@opencode-ai/sdk": "1.1.11",
|
"@opencode-ai/sdk": "1.2.6",
|
||||||
"@solidjs/router": "^0.13.0",
|
"@solidjs/router": "^0.13.0",
|
||||||
"@suid/icons-material": "^0.9.0",
|
"@suid/icons-material": "^0.9.0",
|
||||||
"@suid/material": "^0.19.0",
|
"@suid/material": "^0.19.0",
|
||||||
@@ -30,7 +30,8 @@
|
|||||||
"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"
|
"tauri-plugin-keepawake-api": "^0.1.0",
|
||||||
|
"yaml": "^2.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vite-pwa/assets-generator": "^1.0.2",
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
||||||
import { Dialog } from "@kobalte/core/dialog"
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
import { Toaster } from "solid-toast"
|
import { Toaster } from "solid-toast"
|
||||||
|
import useMediaQuery from "@suid/material/useMediaQuery"
|
||||||
|
import { Minimize2 } from "lucide-solid"
|
||||||
import AlertDialog from "./components/alert-dialog"
|
import AlertDialog from "./components/alert-dialog"
|
||||||
import FolderSelectionView from "./components/folder-selection-view"
|
import FolderSelectionView from "./components/folder-selection-view"
|
||||||
import { showConfirmDialog } from "./stores/alerts"
|
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 { initMarkdown } from "./lib/markdown"
|
||||||
import { initGithubStars } from "./stores/github-stars"
|
import { initGithubStars } from "./stores/github-stars"
|
||||||
@@ -16,6 +18,8 @@ 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"
|
||||||
|
import { launchError, showLaunchError, clearLaunchError } from "./stores/launch-errors"
|
||||||
|
import { formatLaunchErrorMessage, isMissingBinaryMessage } from "./lib/launch-errors"
|
||||||
import { initReleaseNotifications } from "./stores/releases"
|
import { initReleaseNotifications } from "./stores/releases"
|
||||||
import { runtimeEnv } from "./lib/runtime-env"
|
import { runtimeEnv } from "./lib/runtime-env"
|
||||||
import { useI18n } from "./lib/i18n"
|
import { useI18n } from "./lib/i18n"
|
||||||
@@ -50,6 +54,7 @@ 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")
|
||||||
|
|
||||||
@@ -58,8 +63,10 @@ const App: Component = () => {
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const {
|
const {
|
||||||
preferences,
|
preferences,
|
||||||
|
serverSettings,
|
||||||
recordWorkspaceLaunch,
|
recordWorkspaceLaunch,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
|
toggleKeyboardShortcutHints,
|
||||||
toggleShowTimelineTools,
|
toggleShowTimelineTools,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
@@ -68,24 +75,114 @@ const App: Component = () => {
|
|||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
setThinkingBlocksExpansion,
|
setThinkingBlocksExpansion,
|
||||||
|
setToolInputsVisibility,
|
||||||
} = useConfig()
|
} = useConfig()
|
||||||
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
||||||
interface LaunchErrorState {
|
|
||||||
message: string
|
|
||||||
binaryPath: string
|
|
||||||
missingBinary: boolean
|
|
||||||
}
|
|
||||||
const [launchError, setLaunchError] = createSignal<LaunchErrorState | null>(null)
|
|
||||||
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 isPhoneLayout = createMemo(() => phoneQuery())
|
||||||
|
|
||||||
|
// In-memory only: hides chrome on phone; may also request browser fullscreen.
|
||||||
|
const [mobileFullscreenMode, setMobileFullscreenMode] = createSignal(false)
|
||||||
|
const [browserFullscreenActive, setBrowserFullscreenActive] = createSignal(false)
|
||||||
|
|
||||||
|
const fullscreenSupported = () => {
|
||||||
|
if (typeof document === "undefined") return false
|
||||||
|
const el = document.documentElement as any
|
||||||
|
return Boolean(document.fullscreenEnabled) && typeof el?.requestFullscreen === "function"
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncBrowserFullscreenState = () => {
|
||||||
|
if (typeof document === "undefined") return
|
||||||
|
setBrowserFullscreenActive(Boolean(document.fullscreenElement))
|
||||||
|
}
|
||||||
|
|
||||||
|
const enterMobileFullscreen = async () => {
|
||||||
|
if (!isPhoneLayout()) return
|
||||||
|
setMobileFullscreenMode(true)
|
||||||
|
if (!fullscreenSupported()) return
|
||||||
|
try {
|
||||||
|
await document.documentElement.requestFullscreen()
|
||||||
|
} catch {
|
||||||
|
// Ignore: immersive mode still works without browser fullscreen.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitMobileFullscreen = async () => {
|
||||||
|
if (typeof document !== "undefined" && document.fullscreenElement && typeof document.exitFullscreen === "function") {
|
||||||
|
try {
|
||||||
|
await document.exitFullscreen()
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMobileFullscreenMode(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (typeof document === "undefined") return
|
||||||
|
const shouldShow =
|
||||||
|
runtimeEnv.host !== "web" && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
|
||||||
|
document.documentElement.dataset.keyboardHints = shouldShow ? "show" : "hide"
|
||||||
|
})
|
||||||
|
|
||||||
const updateInstanceTabBarHeight = () => {
|
const updateInstanceTabBarHeight = () => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
|
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
|
||||||
setInstanceTabBarHeight(element?.offsetHeight ?? 0)
|
setInstanceTabBarHeight(element?.offsetHeight ?? 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (typeof document === "undefined") return
|
||||||
|
syncBrowserFullscreenState()
|
||||||
|
document.addEventListener("fullscreenchange", syncBrowserFullscreenState)
|
||||||
|
onCleanup(() => document.removeEventListener("fullscreenchange", syncBrowserFullscreenState))
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
const vv = window.visualViewport
|
||||||
|
if (!vv) return
|
||||||
|
|
||||||
|
const updateKeyboardOffset = () => {
|
||||||
|
// visualViewport shrinks when the OSK is visible. Use the delta as a bottom inset.
|
||||||
|
const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop)
|
||||||
|
document.documentElement.style.setProperty("--keyboard-offset", `${Math.floor(inset)}px`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedule = () => requestAnimationFrame(updateKeyboardOffset)
|
||||||
|
schedule()
|
||||||
|
vv.addEventListener("resize", schedule)
|
||||||
|
vv.addEventListener("scroll", schedule)
|
||||||
|
window.addEventListener("orientationchange", schedule)
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
vv.removeEventListener("resize", schedule)
|
||||||
|
vv.removeEventListener("scroll", schedule)
|
||||||
|
window.removeEventListener("orientationchange", schedule)
|
||||||
|
document.documentElement.style.removeProperty("--keyboard-offset")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// If the user exits browser fullscreen via browser UI, restore chrome.
|
||||||
|
let lastBrowserFullscreen = false
|
||||||
|
createEffect(() => {
|
||||||
|
const active = browserFullscreenActive()
|
||||||
|
const mode = mobileFullscreenMode()
|
||||||
|
if (mode && lastBrowserFullscreen && !active) {
|
||||||
|
setMobileFullscreenMode(false)
|
||||||
|
}
|
||||||
|
lastBrowserFullscreen = active
|
||||||
|
})
|
||||||
|
|
||||||
|
// If we leave phone layout (rotation / resize), restore chrome.
|
||||||
|
createEffect(() => {
|
||||||
|
if (!isPhoneLayout() && mobileFullscreenMode()) {
|
||||||
|
void exitMobileFullscreen()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
|
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
|
||||||
})
|
})
|
||||||
@@ -143,60 +240,26 @@ const App: Component = () => {
|
|||||||
|
|
||||||
const launchErrorMessage = () => launchError()?.message ?? ""
|
const launchErrorMessage = () => launchError()?.message ?? ""
|
||||||
|
|
||||||
const formatLaunchErrorMessage = (error: unknown): string => {
|
|
||||||
if (!error) {
|
|
||||||
return t("app.launchError.fallbackMessage")
|
|
||||||
}
|
|
||||||
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw)
|
|
||||||
if (parsed && typeof parsed.error === "string") {
|
|
||||||
return parsed.error
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore JSON parse errors
|
|
||||||
}
|
|
||||||
return raw
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMissingBinaryMessage = (message: string): boolean => {
|
|
||||||
const normalized = message.toLowerCase()
|
|
||||||
return (
|
|
||||||
normalized.includes("opencode binary not found") ||
|
|
||||||
normalized.includes("binary not found") ||
|
|
||||||
normalized.includes("no such file or directory") ||
|
|
||||||
normalized.includes("binary is not executable") ||
|
|
||||||
normalized.includes("enoent")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearLaunchError = () => setLaunchError(null)
|
|
||||||
|
|
||||||
async function handleSelectFolder(folderPath: string, binaryPath?: string) {
|
async function handleSelectFolder(folderPath: string, binaryPath?: string) {
|
||||||
if (!folderPath) {
|
if (!folderPath) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setIsSelectingFolder(true)
|
setIsSelectingFolder(true)
|
||||||
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
|
const selectedBinary = binaryPath || serverSettings().opencodeBinary || "opencode"
|
||||||
try {
|
try {
|
||||||
recordWorkspaceLaunch(folderPath, selectedBinary)
|
recordWorkspaceLaunch(folderPath, selectedBinary)
|
||||||
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,
|
||||||
port: instances().get(instanceId)?.port,
|
port: instances().get(instanceId)?.port,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = formatLaunchErrorMessage(error)
|
const message = formatLaunchErrorMessage(error, t("app.launchError.fallbackMessage"))
|
||||||
const missingBinary = isMissingBinaryMessage(message)
|
const missingBinary = isMissingBinaryMessage(message)
|
||||||
setLaunchError({
|
showLaunchError({ source: "create", message, binaryPath: selectedBinary, missingBinary })
|
||||||
message,
|
|
||||||
binaryPath: selectedBinary,
|
|
||||||
missingBinary,
|
|
||||||
})
|
|
||||||
log.error("Failed to create instance", error)
|
log.error("Failed to create instance", error)
|
||||||
} finally {
|
} finally {
|
||||||
setIsSelectingFolder(false)
|
setIsSelectingFolder(false)
|
||||||
@@ -209,7 +272,7 @@ const App: Component = () => {
|
|||||||
|
|
||||||
function handleLaunchErrorAdvanced() {
|
function handleLaunchErrorAdvanced() {
|
||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
setIsAdvancedSettingsOpen(true)
|
openSettings("opencode")
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNewInstanceRequest() {
|
function handleNewInstanceRequest() {
|
||||||
@@ -293,6 +356,7 @@ const App: Component = () => {
|
|||||||
preferences,
|
preferences,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
|
toggleKeyboardShortcutHints,
|
||||||
toggleShowTimelineTools,
|
toggleShowTimelineTools,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
togglePromptSubmitOnEnter,
|
togglePromptSubmitOnEnter,
|
||||||
@@ -300,6 +364,7 @@ const App: Component = () => {
|
|||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
setThinkingBlocksExpansion,
|
setThinkingBlocksExpansion,
|
||||||
|
setToolInputsVisibility,
|
||||||
handleNewInstanceRequest,
|
handleNewInstanceRequest,
|
||||||
handleCloseInstance,
|
handleCloseInstance,
|
||||||
handleNewSession,
|
handleNewSession,
|
||||||
@@ -395,37 +460,61 @@ const App: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Dialog.Portal>
|
</Dialog.Portal>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<div class="h-screen w-screen flex flex-col">
|
<div class="h-screen w-screen flex flex-col" style={{ height: "100dvh", "padding-bottom": "var(--keyboard-offset, 0px)" }}>
|
||||||
|
<Show when={isPhoneLayout() && mobileFullscreenMode()}>
|
||||||
|
<div class="mobile-fullscreen-exit-wrapper">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message-scroll-button mobile-fullscreen-exit-button"
|
||||||
|
onClick={() => void exitMobileFullscreen()}
|
||||||
|
aria-label={t("instanceShell.fullscreen.exit")}
|
||||||
|
title={t("instanceShell.fullscreen.exit")}
|
||||||
|
>
|
||||||
|
<Minimize2 class="h-5 w-5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
when={!hasInstances()}
|
when={!hasInstances()}
|
||||||
fallback={
|
fallback={
|
||||||
<>
|
<>
|
||||||
<InstanceTabs
|
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}>
|
||||||
instances={instances()}
|
<InstanceTabs
|
||||||
activeInstanceId={activeInstanceId()}
|
instances={instances()}
|
||||||
onSelect={setActiveInstanceId}
|
activeInstanceId={activeInstanceId()}
|
||||||
onClose={handleCloseInstance}
|
onSelect={setActiveInstanceId}
|
||||||
onNew={handleNewInstanceRequest}
|
onClose={handleCloseInstance}
|
||||||
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
onNew={handleNewInstanceRequest}
|
||||||
/>
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<For each={Array.from(instances().values())}>
|
<For each={Array.from(instances().values())}>
|
||||||
{(instance) => {
|
{(instance) => {
|
||||||
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()}
|
||||||
tabBarOffset={instanceTabBarHeight()}
|
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()}
|
||||||
|
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
|
||||||
|
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
|
||||||
|
onExitMobileFullscreen={() => void exitMobileFullscreen()}
|
||||||
/>
|
/>
|
||||||
</InstanceMetadataProvider>
|
</InstanceMetadataProvider>
|
||||||
|
|
||||||
@@ -441,41 +530,25 @@ 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>
|
||||||
|
|
||||||
<Show when={showFolderSelection()}>
|
<Show when={showFolderSelection()}>
|
||||||
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
|
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
|
||||||
<div class="w-full h-full relative">
|
<div class="w-full h-full relative">
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowFolderSelection(false)
|
|
||||||
setIsAdvancedSettingsOpen(false)
|
|
||||||
clearLaunchError()
|
|
||||||
}}
|
|
||||||
class="absolute top-4 right-4 z-10 p-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
||||||
title={t("app.launchError.closeTitle")}
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<FolderSelectionView
|
<FolderSelectionView
|
||||||
onSelectFolder={handleSelectFolder}
|
onSelectFolder={handleSelectFolder}
|
||||||
isLoading={isSelectingFolder()}
|
isLoading={isSelectingFolder()}
|
||||||
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
onClose={() => {
|
||||||
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
setShowFolderSelection(false)
|
||||||
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
clearLaunchError()
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} />
|
<SettingsScreen />
|
||||||
|
|
||||||
<AlertDialog />
|
<AlertDialog />
|
||||||
|
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
|||||||
const availableAgents = createMemo(() => {
|
const availableAgents = createMemo(() => {
|
||||||
const allAgents = instanceAgents()
|
const allAgents = instanceAgents()
|
||||||
if (isChildSession()) {
|
if (isChildSession()) {
|
||||||
return allAgents
|
return allAgents.filter((agent) => !agent.hidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = allAgents.filter((agent) => agent.mode !== "subagent")
|
const filtered = allAgents.filter((agent) => !agent.hidden && agent.mode !== "subagent")
|
||||||
|
|
||||||
const currentAgent = allAgents.find((a) => a.name === props.currentAgent)
|
const currentAgent = allAgents.find((a) => a.name === props.currentAgent)
|
||||||
if (currentAgent && !filtered.find((a) => a.name === props.currentAgent)) {
|
if (currentAgent && !filtered.find((a) => a.name === props.currentAgent)) {
|
||||||
@@ -103,10 +103,10 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
|||||||
>
|
>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<Select.Value<Agent>>
|
<Select.Value<Agent>>
|
||||||
{(state) => (
|
{() => (
|
||||||
<div class="selector-trigger-label selector-trigger-label--stacked">
|
<div class="selector-trigger-label selector-trigger-label--stacked">
|
||||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||||
{t("agentSelector.trigger.primary", { agent: state.selectedOption()?.name ?? t("agentSelector.none") })}
|
{t("agentSelector.trigger.primary", { agent: props.currentAgent || t("agentSelector.none") })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -115,28 +115,28 @@ const AlertDialog: Component = () => {
|
|||||||
>
|
>
|
||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Overlay class="modal-overlay" />
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<Dialog.Content class="modal-surface w-full max-w-sm p-6 border border-base shadow-2xl" tabIndex={-1}>
|
<Dialog.Content class="modal-surface w-full max-w-sm p-6 border border-base shadow-2xl" tabIndex={-1}>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<div
|
<div
|
||||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
|
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
|
||||||
style={{
|
style={{
|
||||||
"background-color": accent.badgeBg,
|
"background-color": accent.badgeBg,
|
||||||
"border-color": accent.badgeBorder,
|
"border-color": accent.badgeBorder,
|
||||||
color: accent.badgeText,
|
color: accent.badgeText,
|
||||||
}}
|
}}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
{accent.symbol}
|
{accent.symbol}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
|
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
|
||||||
<Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line break-words">
|
<Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line break-words">
|
||||||
{payload.message}
|
{payload.message}
|
||||||
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>}
|
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>}
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={isPrompt}>
|
<Show when={isPrompt}>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
@@ -185,14 +185,14 @@ const AlertDialog: Component = () => {
|
|||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Portal>
|
</Dialog.Portal>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AlertDialog
|
export default AlertDialog
|
||||||
|
|||||||
@@ -112,6 +112,10 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
|
|
||||||
const groupedCommandList = () => processedCommands().groups
|
const groupedCommandList = () => processedCommands().groups
|
||||||
const orderedCommands = () => processedCommands().ordered
|
const orderedCommands = () => processedCommands().ordered
|
||||||
|
|
||||||
|
const isCommandDisabled = (command: Command) => {
|
||||||
|
return command.disabled ? Boolean(resolveResolvable(command.disabled)) : false
|
||||||
|
}
|
||||||
const selectedIndex = createMemo(() => {
|
const selectedIndex = createMemo(() => {
|
||||||
const ordered = orderedCommands()
|
const ordered = orderedCommands()
|
||||||
if (ordered.length === 0) return -1
|
if (ordered.length === 0) return -1
|
||||||
@@ -138,10 +142,11 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentId = selectedCommandId()
|
const currentId = selectedCommandId()
|
||||||
if (!currentId || !ordered.some((cmd) => cmd.id === currentId)) {
|
if (!currentId || !ordered.some((cmd) => cmd.id === currentId)) {
|
||||||
setSelectedCommandId(ordered[0].id)
|
const firstEnabled = ordered.find((cmd) => !isCommandDisabled(cmd))
|
||||||
|
setSelectedCommandId((firstEnabled || ordered[0])?.id ?? null)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -195,12 +200,14 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
if (index < 0 || index >= ordered.length) return
|
if (index < 0 || index >= ordered.length) return
|
||||||
const command = ordered[index]
|
const command = ordered[index]
|
||||||
if (!command) return
|
if (!command) return
|
||||||
|
if (isCommandDisabled(command)) return
|
||||||
props.onExecute(command)
|
props.onExecute(command)
|
||||||
props.onClose()
|
props.onClose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCommandClick(command: Command) {
|
function handleCommandClick(command: Command) {
|
||||||
|
if (isCommandDisabled(command)) return
|
||||||
props.onExecute(command)
|
props.onExecute(command)
|
||||||
props.onClose()
|
props.onClose()
|
||||||
}
|
}
|
||||||
@@ -265,11 +272,13 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
<For each={group.commands}>
|
<For each={group.commands}>
|
||||||
{(command, localIndex) => {
|
{(command, localIndex) => {
|
||||||
const commandIndex = group.startIndex + localIndex()
|
const commandIndex = group.startIndex + localIndex()
|
||||||
|
const disabled = isCommandDisabled(command)
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-command-index={commandIndex}
|
data-command-index={commandIndex}
|
||||||
onClick={() => handleCommandClick(command)}
|
onClick={() => handleCommandClick(command)}
|
||||||
|
disabled={disabled}
|
||||||
class={`modal-item ${selectedCommandId() === command.id ? "modal-item-highlight" : ""}`}
|
class={`modal-item ${selectedCommandId() === command.id ? "modal-item-highlight" : ""}`}
|
||||||
onPointerMove={(event) => {
|
onPointerMove={(event) => {
|
||||||
if (event.movementX === 0 && event.movementY === 0) return
|
if (event.movementX === 0 && event.movementY === 0) return
|
||||||
|
|||||||
123
packages/ui/src/components/context-meter.tsx
Normal file
123
packages/ui/src/components/context-meter.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import type { Component } from "solid-js"
|
||||||
|
|
||||||
|
interface ContextMeterProps {
|
||||||
|
usedTokens: number
|
||||||
|
availableTokens: number | null
|
||||||
|
formatTokens: (value: number) => string
|
||||||
|
usedLabel: string
|
||||||
|
availableLabel: string
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted"
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(Math.max(value, min), max)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFillColor(percent: number): string {
|
||||||
|
if (percent >= 0.8) return "var(--status-error)"
|
||||||
|
if (percent >= 0.6) return "var(--status-warning)"
|
||||||
|
return "var(--status-success)"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContextMeter: Component<ContextMeterProps> = (props) => {
|
||||||
|
const hasAvailable = () => typeof props.availableTokens === "number" && props.availableTokens > 0
|
||||||
|
const used = () => (typeof props.usedTokens === "number" && props.usedTokens > 0 ? props.usedTokens : 0)
|
||||||
|
const available = () => (hasAvailable() ? (props.availableTokens as number) : null)
|
||||||
|
|
||||||
|
const percent = () => {
|
||||||
|
const usedValue = used()
|
||||||
|
const availableValue = available()
|
||||||
|
if (availableValue === null || availableValue <= 0) return null
|
||||||
|
|
||||||
|
// Heuristic: if available >= used, treat it like a capacity/limit.
|
||||||
|
// Otherwise treat it like remaining tokens.
|
||||||
|
const ratio = availableValue >= usedValue ? usedValue / availableValue : usedValue / (usedValue + availableValue)
|
||||||
|
return clamp(ratio, 0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fillColor = () => {
|
||||||
|
const value = percent()
|
||||||
|
if (value === null) return "var(--border-base)"
|
||||||
|
return resolveFillColor(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const percentLabel = () => {
|
||||||
|
const value = percent()
|
||||||
|
if (value === null) return "--"
|
||||||
|
return `${Math.round(value * 100)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerClass =
|
||||||
|
`inline-flex items-center gap-2 rounded-full border border-base px-2 py-0.5 text-xs text-primary ${props.class ?? ""}`
|
||||||
|
|
||||||
|
function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number) {
|
||||||
|
const rad = (angleDeg * Math.PI) / 180
|
||||||
|
return {
|
||||||
|
x: cx + r * Math.cos(rad),
|
||||||
|
y: cy + r * Math.sin(rad),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeSectorPath(cx: number, cy: number, r: number, startAngle: number, endAngle: number) {
|
||||||
|
const start = polarToCartesian(cx, cy, r, startAngle)
|
||||||
|
const end = polarToCartesian(cx, cy, r, endAngle)
|
||||||
|
const delta = ((endAngle - startAngle) % 360 + 360) % 360
|
||||||
|
const largeArc = delta > 180 ? 1 : 0
|
||||||
|
|
||||||
|
return `M ${cx} ${cy} L ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y} Z`
|
||||||
|
}
|
||||||
|
|
||||||
|
const circle = () => {
|
||||||
|
const value = percent()
|
||||||
|
const size = 22
|
||||||
|
const r = 9
|
||||||
|
const cx = 11
|
||||||
|
const cy = 11
|
||||||
|
const progress = value === null ? 0 : value
|
||||||
|
const startAngle = -90
|
||||||
|
const endAngle = startAngle + progress * 360
|
||||||
|
const isFull = progress >= 0.999
|
||||||
|
const hasFill = progress > 0.001
|
||||||
|
|
||||||
|
const sectorPath = hasFill && !isFull ? describeSectorPath(cx, cy, r, startAngle, endAngle) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 22 22"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ flex: "0 0 auto" }}
|
||||||
|
>
|
||||||
|
<circle cx={String(cx)} cy={String(cy)} r={String(r)} fill="var(--surface-secondary)" />
|
||||||
|
<circle cx={String(cx)} cy={String(cy)} r={String(r)} fill="none" stroke="var(--border-base)" stroke-width="1" />
|
||||||
|
{isFull ? (
|
||||||
|
<circle cx={String(cx)} cy={String(cy)} r={String(r)} fill={fillColor()} opacity="0.95" />
|
||||||
|
) : sectorPath ? (
|
||||||
|
<path d={sectorPath} fill={fillColor()} opacity="0.95" />
|
||||||
|
) : null}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltipText = () => `Context Used: ${percentLabel()}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="inline-flex items-center gap-2" title={tooltipText()}>
|
||||||
|
{circle()}
|
||||||
|
<div class={containerClass}>
|
||||||
|
<span class={LABEL_CLASS}>{props.usedLabel}</span>
|
||||||
|
<span class="font-semibold text-primary tabular-nums">{props.formatTokens(used())}</span>
|
||||||
|
<span class="text-muted">/</span>
|
||||||
|
<span class={LABEL_CLASS}>{props.availableLabel}</span>
|
||||||
|
<span class="font-semibold text-primary tabular-nums">
|
||||||
|
{available() !== null ? props.formatTokens(available() as number) : "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ContextMeter
|
||||||
@@ -10,12 +10,12 @@ interface EnvironmentVariablesEditorProps {
|
|||||||
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
|
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const {
|
const {
|
||||||
preferences,
|
serverSettings,
|
||||||
addEnvironmentVariable,
|
addEnvironmentVariable,
|
||||||
removeEnvironmentVariable,
|
removeEnvironmentVariable,
|
||||||
updateEnvironmentVariables,
|
updateEnvironmentVariables,
|
||||||
} = useConfig()
|
} = useConfig()
|
||||||
const [envVars, setEnvVars] = createSignal<Record<string, string>>(preferences().environmentVariables || {})
|
const [envVars, setEnvVars] = createSignal<Record<string, string>>(serverSettings().environmentVariables || {})
|
||||||
const [newKey, setNewKey] = createSignal("")
|
const [newKey, setNewKey] = createSignal("")
|
||||||
const [newValue, setNewValue] = createSignal("")
|
const [newValue, setNewValue] = createSignal("")
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface MonacoDiffViewerProps {
|
|||||||
after: string
|
after: string
|
||||||
viewMode?: "split" | "unified"
|
viewMode?: "split" | "unified"
|
||||||
contextMode?: "expanded" | "collapsed"
|
contextMode?: "expanded" | "collapsed"
|
||||||
|
wordWrap?: "on" | "off"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||||
@@ -54,12 +55,17 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
scrollBeyondLastLine: false,
|
scrollBeyondLastLine: false,
|
||||||
renderWhitespace: "selection",
|
renderWhitespace: "selection",
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
wordWrap: "off",
|
wordWrap: props.wordWrap === "on" ? "on" : "off",
|
||||||
glyphMargin: false,
|
glyphMargin: false,
|
||||||
folding: false,
|
folding: false,
|
||||||
// Keep enough gutter space so unified diffs don't overlap `+`/`-` markers.
|
// Keep enough gutter space so unified diffs don't overlap `+`/`-` markers.
|
||||||
lineNumbersMinChars: 4,
|
lineNumbersMinChars: 4,
|
||||||
lineDecorationsWidth: 12,
|
lineDecorationsWidth: 12,
|
||||||
|
// Use legacy diff algorithm for better performance with large files
|
||||||
|
// See: https://github.com/microsoft/vscode/issues/184037
|
||||||
|
diffAlgorithm: "legacy",
|
||||||
|
// Limit computation time to avoid freezing on large files
|
||||||
|
maxComputationTime: 10000,
|
||||||
})
|
})
|
||||||
|
|
||||||
setReady(true)
|
setReady(true)
|
||||||
@@ -81,6 +87,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
if (!ready() || !monaco || !diffEditor) return
|
if (!ready() || !monaco || !diffEditor) return
|
||||||
const viewMode = props.viewMode === "unified" ? "unified" : "split"
|
const viewMode = props.viewMode === "unified" ? "unified" : "split"
|
||||||
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
|
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
|
||||||
|
const wordWrap = props.wordWrap === "on" ? "on" : "off"
|
||||||
|
|
||||||
diffEditor.updateOptions({
|
diffEditor.updateOptions({
|
||||||
renderSideBySide: viewMode === "split",
|
renderSideBySide: viewMode === "split",
|
||||||
@@ -89,7 +96,20 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
contextMode === "collapsed"
|
contextMode === "collapsed"
|
||||||
? { enabled: true }
|
? { enabled: true }
|
||||||
: { enabled: false },
|
: { enabled: false },
|
||||||
|
wordWrap,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
diffEditor.getOriginalEditor?.()?.updateOptions?.({ wordWrap })
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
diffEditor.getModifiedEditor?.()?.updateOptions?.({ wordWrap })
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|||||||
@@ -431,7 +431,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-footer">
|
<div class="panel-footer keyboard-hints">
|
||||||
<div class="panel-footer-hints">
|
<div class="panel-footer-hints">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<kbd class="kbd">↑</kbd>
|
<kbd class="kbd">↑</kbd>
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { Select } from "@kobalte/core/select"
|
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 } 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"
|
||||||
|
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
|
|
||||||
@@ -19,18 +20,15 @@ const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).h
|
|||||||
interface FolderSelectionViewProps {
|
interface FolderSelectionViewProps {
|
||||||
onSelectFolder: (folder: string, binaryPath?: string) => void
|
onSelectFolder: (folder: string, binaryPath?: string) => void
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
advancedSettingsOpen?: boolean
|
onClose?: () => void
|
||||||
onAdvancedSettingsOpen?: () => void
|
|
||||||
onAdvancedSettingsClose?: () => void
|
|
||||||
onOpenRemoteAccess?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||||
const { recentFolders, removeRecentFolder, preferences, updatePreferences } = 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")
|
||||||
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
|
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
||||||
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
||||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||||
let recentListRef: HTMLDivElement | undefined
|
let recentListRef: HTMLDivElement | undefined
|
||||||
@@ -53,7 +51,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
// Update selected binary when preferences change
|
// Update selected binary when preferences change
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const lastUsed = preferences().lastUsedBinary
|
const lastUsed = serverSettings().opencodeBinary
|
||||||
if (!lastUsed) return
|
if (!lastUsed) return
|
||||||
setSelectedBinary((current) => (current === lastUsed ? current : lastUsed))
|
setSelectedBinary((current) => (current === lastUsed ? current : lastUsed))
|
||||||
})
|
})
|
||||||
@@ -192,6 +190,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)
|
||||||
@@ -236,11 +259,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()
|
||||||
@@ -316,6 +334,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"
|
||||||
@@ -366,14 +388,33 @@ 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"
|
||||||
|
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||||
|
onClick={() => openSettings("appearance")}
|
||||||
|
aria-label={t("settings.open.title")}
|
||||||
|
title={t("settings.open.title")}
|
||||||
|
>
|
||||||
|
<Settings class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<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}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
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"
|
||||||
onClick={() => props.onOpenRemoteAccess?.()}
|
onClick={() => props.onClose?.()}
|
||||||
|
aria-label={t("app.launchError.close")}
|
||||||
|
title={t("app.launchError.closeTitle")}
|
||||||
>
|
>
|
||||||
<MonitorUp class="w-4 h-4" />
|
<X class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -548,16 +589,16 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
: t("folderSelection.browse.button")}
|
: t("folderSelection.browse.button")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Kbd shortcut="cmd+n" class="ml-2" />
|
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
|
||||||
</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>
|
||||||
@@ -573,7 +614,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel panel-footer shrink-0 hidden sm:block">
|
<div class="panel panel-footer shrink-0 hidden sm:block keyboard-hints">
|
||||||
<div class="panel-footer-hints">
|
<div class="panel-footer-hints">
|
||||||
<Show when={folders().length > 0}>
|
<Show when={folders().length > 0}>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
@@ -591,7 +632,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<Kbd shortcut="cmd+n" />
|
<Kbd shortcut="cmd+n" class="kbd-hint" />
|
||||||
<span>{t("folderSelection.hints.browse")}</span>
|
<span>{t("folderSelection.hints.browse")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -607,16 +648,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")}
|
||||||
|
|||||||
@@ -3,10 +3,15 @@ import { Component, JSX } from "solid-js"
|
|||||||
interface HintRowProps {
|
interface HintRowProps {
|
||||||
children: JSX.Element
|
children: JSX.Element
|
||||||
class?: string
|
class?: string
|
||||||
|
ariaHidden?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const HintRow: Component<HintRowProps> = (props) => {
|
const HintRow: Component<HintRowProps> = (props) => {
|
||||||
return <span class={`text-xs text-muted ${props.class || ""}`}>{props.children}</span>
|
return (
|
||||||
|
<span aria-hidden={props.ariaHidden} class={`keyboard-hints text-xs text-muted ${props.class || ""}`}>
|
||||||
|
{props.children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default HintRow
|
export default HintRow
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
|
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
|
||||||
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
|
import { getInstanceLogs, instances, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
|
||||||
import { ChevronDown } from "lucide-solid"
|
import { ChevronDown } from "lucide-solid"
|
||||||
import InstanceInfo from "./instance-info"
|
import InstanceInfo from "./instance-info"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
@@ -86,8 +86,8 @@ const InfoView: Component<InfoViewProps> = (props) => {
|
|||||||
return (
|
return (
|
||||||
<div class="log-container">
|
<div class="log-container">
|
||||||
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-hidden">
|
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-hidden">
|
||||||
<div class="lg:w-80 flex-shrink-0 overflow-y-auto">
|
<div class="lg:w-80 flex-shrink-0 min-h-0 overflow-y-auto max-h-[40vh] lg:max-h-none">
|
||||||
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} />}</Show>
|
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} showDisposeButton />}</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel flex-1 flex flex-col min-h-0 overflow-hidden">
|
<div class="panel flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import { Component, For, Show, createMemo } from "solid-js"
|
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
||||||
import type { Instance } from "../types/instance"
|
import type { Instance } from "../types/instance"
|
||||||
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
|
||||||
import InstanceServiceStatus from "./instance-service-status"
|
import InstanceServiceStatus from "./instance-service-status"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
|
import { showConfirmDialog } from "../stores/alerts"
|
||||||
|
import { disposeInstance } from "../stores/instances"
|
||||||
|
import { showToastNotification } from "../lib/notifications"
|
||||||
|
import { getLogger } from "../lib/logger"
|
||||||
|
|
||||||
interface InstanceInfoProps {
|
interface InstanceInfoProps {
|
||||||
instance: Instance
|
instance: Instance
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
|
showDisposeButton?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const log = getLogger("actions")
|
||||||
|
|
||||||
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const metadataContext = useOptionalInstanceMetadataContext()
|
const metadataContext = useOptionalInstanceMetadataContext()
|
||||||
@@ -16,6 +23,8 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
|
const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
|
||||||
const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata)
|
const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata)
|
||||||
|
|
||||||
|
const [isDisposing, setIsDisposing] = createSignal(false)
|
||||||
|
|
||||||
const currentInstance = () => instanceAccessor()
|
const currentInstance = () => instanceAccessor()
|
||||||
const metadata = () => metadataAccessor()
|
const metadata = () => metadataAccessor()
|
||||||
const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version
|
const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version
|
||||||
@@ -25,6 +34,46 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
return env ? Object.entries(env) : []
|
return env ? Object.entries(env) : []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const disposeEnabled = createMemo(() => Boolean(currentInstance()?.client) && !isDisposing())
|
||||||
|
|
||||||
|
const handleDisposeInstance = async () => {
|
||||||
|
if (!disposeEnabled()) return
|
||||||
|
|
||||||
|
const confirmed = await showConfirmDialog(t("infoView.dispose.confirm.message"), {
|
||||||
|
title: t("infoView.dispose.confirm.title"),
|
||||||
|
variant: "warning",
|
||||||
|
confirmLabel: t("infoView.dispose.confirm.confirmLabel"),
|
||||||
|
cancelLabel: t("infoView.dispose.confirm.cancelLabel"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setIsDisposing(true)
|
||||||
|
try {
|
||||||
|
const ok = await disposeInstance(currentInstance().id)
|
||||||
|
if (ok) {
|
||||||
|
showToastNotification({
|
||||||
|
message: t("infoView.dispose.toast.success"),
|
||||||
|
variant: "success",
|
||||||
|
duration: 8000,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
showToastNotification({
|
||||||
|
message: t("infoView.dispose.toast.error"),
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to dispose instance", error)
|
||||||
|
showToastNotification({
|
||||||
|
message: t("infoView.dispose.toast.error"),
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsDisposing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
@@ -156,6 +205,19 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Show when={props.showDisposeButton}>
|
||||||
|
<div class="pt-3 border-t border-base">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button-danger button-small w-full"
|
||||||
|
onClick={handleDisposeInstance}
|
||||||
|
disabled={!disposeEnabled()}
|
||||||
|
>
|
||||||
|
{isDisposing() ? t("infoView.dispose.actions.disposing") : t("infoView.dispose.actions.dispose")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,20 +11,11 @@ interface InstanceTabProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFolderName(path: string, instances: Instance[], currentInstance: Instance): string {
|
function getPathBasename(path: string): string {
|
||||||
const name = path.split("/").pop() || path
|
// Instance folders can be POSIX-like (/Users/...) on macOS/Linux or Windows-like (C:\Users\...).
|
||||||
|
// Normalize by trimming trailing separators and then splitting on both '/' and '\\'.
|
||||||
const duplicates = instances.filter((i) => {
|
const normalized = path.replace(/[\\/]+$/, "")
|
||||||
const iName = i.folder.split("/").pop() || i.folder
|
return normalized.split(/[\\/]/).pop() || path
|
||||||
return iName === name
|
|
||||||
})
|
|
||||||
|
|
||||||
if (duplicates.length > 1) {
|
|
||||||
const index = duplicates.findIndex((i) => i.id === currentInstance.id)
|
|
||||||
return `~/${name} (${index + 1})`
|
|
||||||
}
|
|
||||||
|
|
||||||
return `~/${name}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const InstanceTab: Component<InstanceTabProps> = (props) => {
|
const InstanceTab: Component<InstanceTabProps> = (props) => {
|
||||||
@@ -58,7 +49,7 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<FolderOpen class="w-4 h-4 flex-shrink-0" />
|
<FolderOpen class="w-4 h-4 flex-shrink-0" />
|
||||||
<span class="tab-label">
|
<span class="tab-label">
|
||||||
{props.instance.folder.split("/").pop() || props.instance.folder}
|
{getPathBasename(props.instance.folder)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
class={`status-indicator session-status ml-auto ${statusClassName()}`}
|
class={`status-indicator session-status ml-auto ${statusClassName()}`}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -502,7 +502,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
)}
|
)}
|
||||||
<span>{t("instanceWelcome.new.createButton")}</span>
|
<span>{t("instanceWelcome.new.createButton")}</span>
|
||||||
</div>
|
</div>
|
||||||
<Kbd shortcut={newSessionShortcutString()} class="ml-2" />
|
<Kbd shortcut={newSessionShortcutString()} class="ml-2 kbd-hint" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -539,7 +539,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="panel-footer hidden sm:block">
|
<div class="panel-footer hidden sm:block keyboard-hints">
|
||||||
|
|
||||||
<div class="panel-footer-hints">
|
<div class="panel-footer-hints">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import PermissionNotificationBanner from "../permission-notification-banner"
|
|||||||
import PermissionApprovalModal from "../permission-approval-modal"
|
import PermissionApprovalModal from "../permission-approval-modal"
|
||||||
import SessionView from "../session/session-view"
|
import SessionView from "../session/session-view"
|
||||||
import { formatTokenTotal } from "../../lib/formatters"
|
import { formatTokenTotal } from "../../lib/formatters"
|
||||||
|
import ContextMeter from "../context-meter"
|
||||||
import { sseManager } from "../../lib/sse-manager"
|
import { sseManager } from "../../lib/sse-manager"
|
||||||
import { getLogger } from "../../lib/logger"
|
import { getLogger } from "../../lib/logger"
|
||||||
import { serverApi } from "../../lib/api-client"
|
import { serverApi } from "../../lib/api-client"
|
||||||
@@ -41,7 +42,7 @@ import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests"
|
|||||||
import RightPanel from "./shell/right-panel/RightPanel"
|
import RightPanel from "./shell/right-panel/RightPanel"
|
||||||
import { useDrawerChrome } from "./shell/useDrawerChrome"
|
import { useDrawerChrome } from "./shell/useDrawerChrome"
|
||||||
import { getSessionStatus } from "../../stores/session-status"
|
import { getSessionStatus } from "../../stores/session-status"
|
||||||
import { ShieldAlert } from "lucide-solid"
|
import { Maximize2, ShieldAlert } from "lucide-solid"
|
||||||
|
|
||||||
import type { LayoutMode } from "./shell/types"
|
import type { LayoutMode } from "./shell/types"
|
||||||
import {
|
import {
|
||||||
@@ -61,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
|
||||||
@@ -69,6 +73,11 @@ interface InstanceShellProps {
|
|||||||
handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
|
handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
|
||||||
onExecuteCommand: (command: Command) => void
|
onExecuteCommand: (command: Command) => void
|
||||||
tabBarOffset: number
|
tabBarOffset: number
|
||||||
|
|
||||||
|
// In-memory only: mobile immersive/fullscreen mode.
|
||||||
|
mobileFullscreenMode: boolean
|
||||||
|
onEnterMobileFullscreen: () => void
|
||||||
|
onExitMobileFullscreen: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||||
@@ -109,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"
|
||||||
@@ -117,6 +127,9 @@ 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 compactPromptLayout = createMemo(() => layoutMode() !== "desktop")
|
||||||
const leftPinningSupported = createMemo(() => layoutMode() !== "phone")
|
const leftPinningSupported = createMemo(() => layoutMode() !== "phone")
|
||||||
const rightPinningSupported = createMemo(() => layoutMode() !== "phone")
|
const rightPinningSupported = createMemo(() => layoutMode() !== "phone")
|
||||||
|
|
||||||
@@ -349,16 +362,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
measureDrawerHost,
|
measureDrawerHost,
|
||||||
})
|
})
|
||||||
|
|
||||||
const formattedUsedTokens = () => formatTokenTotal(tokenStats().used)
|
|
||||||
|
|
||||||
|
|
||||||
const formattedAvailableTokens = () => {
|
|
||||||
const avail = tokenStats().avail
|
|
||||||
if (typeof avail === "number") {
|
|
||||||
return formatTokenTotal(avail)
|
|
||||||
}
|
|
||||||
return "--"
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderLeftPanel = () => {
|
const renderLeftPanel = () => {
|
||||||
if (leftPinned()) {
|
if (leftPinned()) {
|
||||||
@@ -594,13 +597,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
{renderLeftPanel()}
|
{renderLeftPanel()}
|
||||||
|
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", flex: 1, minWidth: 0, minHeight: 0, overflowX: "hidden" }}>
|
<Box sx={{ display: "flex", flexDirection: "column", flex: 1, minWidth: 0, minHeight: 0, overflowX: "hidden" }}>
|
||||||
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
|
<Show when={!mobileFullscreen()}>
|
||||||
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
|
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
|
||||||
<Show
|
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
|
||||||
when={!isPhoneLayout()}
|
<Show
|
||||||
fallback={
|
when={!compactHeaderLayout()}
|
||||||
<div class="flex flex-col w-full gap-1.5">
|
fallback={
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
|
<div class="flex flex-col w-full gap-1.5">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
|
||||||
<Show when={leftDrawerState() === "floating-closed"}>
|
<Show when={leftDrawerState() === "floating-closed"}>
|
||||||
<IconButton
|
<IconButton
|
||||||
ref={setLeftToggleButtonEl}
|
ref={setLeftToggleButtonEl}
|
||||||
@@ -626,17 +630,17 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
<div class="flex flex-wrap items-center justify-center gap-1">
|
<div class="flex flex-wrap items-center justify-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="connection-status-button px-2 py-0.5 text-xs"
|
class="connection-status-button command-palette-button"
|
||||||
onClick={handleCommandPaletteClick}
|
onClick={handleCommandPaletteClick}
|
||||||
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
|
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
|
||||||
style={{ flex: "0 0 auto", width: "auto" }}
|
style={{ flex: "0 0 auto", width: "auto" }}
|
||||||
>
|
>
|
||||||
{t("instanceShell.commandPalette.button")}
|
{t("instanceShell.commandPalette.button")}
|
||||||
</button>
|
</button>
|
||||||
<span class="connection-status-shortcut-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
|
||||||
@@ -647,6 +651,18 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Show when={isPhoneLayout() && !props.mobileFullscreenMode}>
|
||||||
|
<IconButton
|
||||||
|
color="inherit"
|
||||||
|
onClick={props.onEnterMobileFullscreen}
|
||||||
|
aria-label={t("instanceShell.fullscreen.enter")}
|
||||||
|
title={t("instanceShell.fullscreen.enter")}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<Maximize2 class="w-5 h-5" aria-hidden="true" />
|
||||||
|
</IconButton>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Show when={rightDrawerState() === "floating-closed"}>
|
<Show when={rightDrawerState() === "floating-closed"}>
|
||||||
<IconButton
|
<IconButton
|
||||||
ref={setRightToggleButtonEl}
|
ref={setRightToggleButtonEl}
|
||||||
@@ -659,22 +675,19 @@ 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">
|
||||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
<Show when={!showingInfoView()}>
|
||||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
<ContextMeter
|
||||||
{t("instanceShell.metrics.usedLabel")}
|
usedTokens={tokenStats().used}
|
||||||
</span>
|
availableTokens={tokenStats().avail}
|
||||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
formatTokens={formatTokenTotal}
|
||||||
|
usedLabel={t("instanceShell.metrics.usedLabel")}
|
||||||
|
availableLabel={t("instanceShell.metrics.availableLabel")}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
|
||||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
|
||||||
{t("instanceShell.metrics.availableLabel")}
|
|
||||||
</span>
|
|
||||||
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -693,18 +706,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={!showingInfoView()}>
|
<Show when={!showingInfoView()}>
|
||||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
<ContextMeter
|
||||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
usedTokens={tokenStats().used}
|
||||||
{t("instanceShell.metrics.usedLabel")}
|
availableTokens={tokenStats().avail}
|
||||||
</span>
|
formatTokens={formatTokenTotal}
|
||||||
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
|
usedLabel={t("instanceShell.metrics.usedLabel")}
|
||||||
</div>
|
availableLabel={t("instanceShell.metrics.availableLabel")}
|
||||||
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
/>
|
||||||
<span class="uppercase text-[10px] tracking-wide text-muted">
|
|
||||||
{t("instanceShell.metrics.availableLabel")}
|
|
||||||
</span>
|
|
||||||
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="ml-auto flex items-center session-header-hints">
|
<div class="ml-auto flex items-center session-header-hints">
|
||||||
@@ -720,7 +728,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
<div class="session-toolbar-center flex items-center justify-center gap-2 min-w-[160px]">
|
<div class="session-toolbar-center flex items-center justify-center gap-2 min-w-[160px]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="connection-status-button px-2 py-0.5 text-xs"
|
class="connection-status-button command-palette-button"
|
||||||
onClick={handleCommandPaletteClick}
|
onClick={handleCommandPaletteClick}
|
||||||
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
|
aria-label={t("instanceShell.commandPalette.openAriaLabel")}
|
||||||
style={{ flex: "0 0 auto", width: "auto" }}
|
style={{ flex: "0 0 auto", width: "auto" }}
|
||||||
@@ -730,7 +738,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="session-toolbar-right flex-1 flex items-center gap-3">
|
<div class="session-toolbar-right flex-1 flex items-center gap-3">
|
||||||
<span class="connection-status-shortcut-hint">
|
<span class="connection-status-shortcut-hint kbd-hint">
|
||||||
<Kbd shortcut="cmd+shift+p" />
|
<Kbd shortcut="cmd+shift+p" />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -769,9 +777,10 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
component="main"
|
component="main"
|
||||||
@@ -794,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
|
||||||
@@ -808,6 +819,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
instanceId={props.instance.id}
|
instanceId={props.instance.id}
|
||||||
instanceFolder={props.instance.folder}
|
instanceFolder={props.instance.folder}
|
||||||
escapeInDebounce={props.escapeInDebounce}
|
escapeInDebounce={props.escapeInDebounce}
|
||||||
|
isPhoneLayout={isPhoneLayout()}
|
||||||
|
compactPromptLayout={compactPromptLayout()}
|
||||||
showSidebarToggle={showEmbeddedSidebarToggle()}
|
showSidebarToggle={showEmbeddedSidebarToggle()}
|
||||||
onSidebarToggle={() => setLeftOpen(true)}
|
onSidebarToggle={() => setLeftOpen(true)}
|
||||||
forceCompactStatusLayout={showEmbeddedSidebarToggle()}
|
forceCompactStatusLayout={showEmbeddedSidebarToggle()}
|
||||||
@@ -833,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>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Show, type Accessor, type Component } from "solid-js"
|
import { Show, type Accessor, type Component } from "solid-js"
|
||||||
import type { SessionThread } from "../../../stores/session-state"
|
import type { SessionThread } from "../../../stores/session-state"
|
||||||
import type { Session } from "../../../types/session"
|
import type { Session } from "../../../types/session"
|
||||||
import type { KeyboardShortcut } from "../../../lib/keyboard-registry"
|
import { keyboardRegistry, type KeyboardShortcut } from "../../../lib/keyboard-registry"
|
||||||
import type { DrawerViewState } from "./types"
|
import type { DrawerViewState } from "./types"
|
||||||
|
|
||||||
import { Search } from "lucide-solid"
|
import { PlusSquare, Search } from "lucide-solid"
|
||||||
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"
|
||||||
import PushPinIcon from "@suid/icons-material/PushPin"
|
import PushPinIcon from "@suid/icons-material/PushPin"
|
||||||
@@ -13,7 +13,6 @@ import InfoOutlinedIcon from "@suid/icons-material/InfoOutlined"
|
|||||||
|
|
||||||
import SessionList from "../../session-list"
|
import SessionList from "../../session-list"
|
||||||
import KeyboardHint from "../../keyboard-hint"
|
import KeyboardHint from "../../keyboard-hint"
|
||||||
import Kbd from "../../kbd"
|
|
||||||
import WorktreeSelector from "../../worktree-selector"
|
import WorktreeSelector from "../../worktree-selector"
|
||||||
import AgentSelector from "../../agent-selector"
|
import AgentSelector from "../../agent-selector"
|
||||||
import ModelSelector from "../../model-selector"
|
import ModelSelector from "../../model-selector"
|
||||||
@@ -56,6 +55,20 @@ const SessionSidebar: Component<SessionSidebarProps> = (props) => (
|
|||||||
{props.t("instanceShell.leftPanel.sessionsTitle")}
|
{props.t("instanceShell.leftPanel.sessionsTitle")}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex items-center gap-2 text-primary">
|
<div class="flex items-center gap-2 text-primary">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="inherit"
|
||||||
|
aria-label={props.t("sessionList.actions.newSession.ariaLabel")}
|
||||||
|
title={props.t("sessionList.actions.newSession.title")}
|
||||||
|
onClick={() => {
|
||||||
|
const result = props.onNewSession()
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
void result.catch((error) => log.error("Failed to create session:", error))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusSquare class="w-5 h-5" />
|
||||||
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
color="inherit"
|
color="inherit"
|
||||||
@@ -71,7 +84,7 @@ const SessionSidebar: Component<SessionSidebarProps> = (props) => (
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Search class={props.showSearch() ? "w-4 h-4" : "w-4 h-4 opacity-70"} />
|
<Search class="w-5 h-5" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
@@ -152,11 +165,17 @@ const SessionSidebar: Component<SessionSidebarProps> = (props) => (
|
|||||||
|
|
||||||
<ThinkingSelector instanceId={props.instanceId} currentModel={activeSession().model} />
|
<ThinkingSelector instanceId={props.instanceId} currentModel={activeSession().model} />
|
||||||
|
|
||||||
<div class="session-sidebar-selector-hints" aria-hidden="true">
|
<KeyboardHint
|
||||||
<Kbd shortcut="cmd+shift+a" />
|
class="session-sidebar-selector-hints"
|
||||||
<Kbd shortcut="cmd+shift+m" />
|
ariaHidden={true}
|
||||||
<Kbd shortcut="cmd+shift+t" />
|
shortcuts={[
|
||||||
</div>
|
keyboardRegistry.get("open-agent-selector"),
|
||||||
|
keyboardRegistry.get("focus-model"),
|
||||||
|
keyboardRegistry.get("focus-variant"),
|
||||||
|
].filter((shortcut): shortcut is KeyboardShortcut => Boolean(shortcut))}
|
||||||
|
separator=" "
|
||||||
|
showDescription={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -18,7 +18,7 @@ import type { Instance } from "../../../../types/instance"
|
|||||||
import type { BackgroundProcess } from "../../../../../../server/src/api-types"
|
import type { BackgroundProcess } from "../../../../../../server/src/api-types"
|
||||||
import type { Session } from "../../../../types/session"
|
import type { Session } from "../../../../types/session"
|
||||||
import type { DrawerViewState } from "../types"
|
import type { DrawerViewState } from "../types"
|
||||||
import type { DiffContextMode, DiffViewMode, RightPanelTab } from "./types"
|
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types"
|
||||||
|
|
||||||
import ChangesTab from "./tabs/ChangesTab"
|
import ChangesTab from "./tabs/ChangesTab"
|
||||||
import FilesTab from "./tabs/FilesTab"
|
import FilesTab from "./tabs/FilesTab"
|
||||||
@@ -32,6 +32,7 @@ import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
|
|||||||
import {
|
import {
|
||||||
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
|
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
|
||||||
RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY,
|
RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY,
|
||||||
|
RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY,
|
||||||
RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY,
|
RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY,
|
||||||
RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY,
|
RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY,
|
||||||
RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY,
|
RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY,
|
||||||
@@ -102,6 +103,9 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
const [diffContextMode, setDiffContextMode] = createSignal<DiffContextMode>(
|
const [diffContextMode, setDiffContextMode] = createSignal<DiffContextMode>(
|
||||||
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed",
|
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed",
|
||||||
)
|
)
|
||||||
|
const [diffWordWrapMode, setDiffWordWrapMode] = createSignal<DiffWordWrapMode>(
|
||||||
|
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, ["on", "off"] as const) ?? "on",
|
||||||
|
)
|
||||||
|
|
||||||
const [changesSplitWidth, setChangesSplitWidth] = createSignal(320)
|
const [changesSplitWidth, setChangesSplitWidth] = createSignal(320)
|
||||||
const [filesSplitWidth, setFilesSplitWidth] = createSignal(320)
|
const [filesSplitWidth, setFilesSplitWidth] = createSignal(320)
|
||||||
@@ -195,6 +199,11 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode())
|
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, diffWordWrapMode())
|
||||||
|
})
|
||||||
|
|
||||||
const clampSplitWidth = (value: number) => {
|
const clampSplitWidth = (value: number) => {
|
||||||
const min = 200
|
const min = 200
|
||||||
const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65))
|
const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65))
|
||||||
@@ -738,8 +747,10 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
onSelectFile={handleSelectChangesFile}
|
onSelectFile={handleSelectChangesFile}
|
||||||
diffViewMode={diffViewMode}
|
diffViewMode={diffViewMode}
|
||||||
diffContextMode={diffContextMode}
|
diffContextMode={diffContextMode}
|
||||||
|
diffWordWrapMode={diffWordWrapMode}
|
||||||
onViewModeChange={setDiffViewMode}
|
onViewModeChange={setDiffViewMode}
|
||||||
onContextModeChange={setDiffContextMode}
|
onContextModeChange={setDiffContextMode}
|
||||||
|
onWordWrapModeChange={setDiffWordWrapMode}
|
||||||
listOpen={changesListOpen}
|
listOpen={changesListOpen}
|
||||||
onToggleList={toggleChangesList}
|
onToggleList={toggleChangesList}
|
||||||
splitWidth={changesSplitWidth}
|
splitWidth={changesSplitWidth}
|
||||||
@@ -765,8 +776,10 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
|||||||
scopeKey={gitScopeKey}
|
scopeKey={gitScopeKey}
|
||||||
diffViewMode={diffViewMode}
|
diffViewMode={diffViewMode}
|
||||||
diffContextMode={diffContextMode}
|
diffContextMode={diffContextMode}
|
||||||
|
diffWordWrapMode={diffWordWrapMode}
|
||||||
onViewModeChange={setDiffViewMode}
|
onViewModeChange={setDiffViewMode}
|
||||||
onContextModeChange={setDiffContextMode}
|
onContextModeChange={setDiffContextMode}
|
||||||
|
onWordWrapModeChange={setDiffWordWrapMode}
|
||||||
onOpenFile={(path) => void openGitFile(path)}
|
onOpenFile={(path) => void openGitFile(path)}
|
||||||
onRefresh={() => void refreshGitStatus()}
|
onRefresh={() => void refreshGitStatus()}
|
||||||
listOpen={gitChangesListOpen}
|
listOpen={gitChangesListOpen}
|
||||||
|
|||||||
@@ -1,50 +1,61 @@
|
|||||||
import type { Component } from "solid-js"
|
import type { Component } from "solid-js"
|
||||||
|
|
||||||
import type { DiffContextMode, DiffViewMode } from "../types"
|
import { AlignJustify, FoldVertical, Split, UnfoldVertical, WrapText } from "lucide-solid"
|
||||||
|
|
||||||
|
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||||
|
|
||||||
interface DiffToolbarProps {
|
interface DiffToolbarProps {
|
||||||
viewMode: DiffViewMode
|
viewMode: DiffViewMode
|
||||||
contextMode: DiffContextMode
|
contextMode: DiffContextMode
|
||||||
|
wordWrapMode: DiffWordWrapMode
|
||||||
onViewModeChange: (mode: DiffViewMode) => void
|
onViewModeChange: (mode: DiffViewMode) => void
|
||||||
onContextModeChange: (mode: DiffContextMode) => void
|
onContextModeChange: (mode: DiffContextMode) => void
|
||||||
|
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const DiffToolbar: Component<DiffToolbarProps> = (props) => {
|
const DiffToolbar: Component<DiffToolbarProps> = (props) => {
|
||||||
|
const nextViewMode = (): DiffViewMode => (props.viewMode === "split" ? "unified" : "split")
|
||||||
|
const nextContextMode = (): DiffContextMode => (props.contextMode === "collapsed" ? "expanded" : "collapsed")
|
||||||
|
const nextWordWrapMode = (): DiffWordWrapMode => (props.wordWrapMode === "on" ? "off" : "on")
|
||||||
|
|
||||||
|
const viewModeTitle = () => (nextViewMode() === "split" ? "Switch to split view" : "Switch to unified view")
|
||||||
|
const contextModeTitle = () =>
|
||||||
|
nextContextMode() === "collapsed" ? "Hide unchanged regions" : "Show full file"
|
||||||
|
const wordWrapTitle = () => (nextWordWrapMode() === "on" ? "Enable word wrap" : "Disable word wrap")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="file-viewer-toolbar">
|
<div class="file-viewer-toolbar">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={`file-viewer-toolbar-button${props.viewMode === "split" ? " active" : ""}`}
|
class="file-viewer-toolbar-icon-button"
|
||||||
aria-pressed={props.viewMode === "split"}
|
onClick={() => props.onViewModeChange(nextViewMode())}
|
||||||
onClick={() => props.onViewModeChange("split")}
|
aria-label={viewModeTitle()}
|
||||||
|
title={viewModeTitle()}
|
||||||
>
|
>
|
||||||
Split
|
{nextViewMode() === "split" ? <Split class="h-4 w-4" aria-hidden="true" /> : <AlignJustify class="h-4 w-4" aria-hidden="true" />}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={`file-viewer-toolbar-button${props.viewMode === "unified" ? " active" : ""}`}
|
class="file-viewer-toolbar-icon-button"
|
||||||
aria-pressed={props.viewMode === "unified"}
|
onClick={() => props.onContextModeChange(nextContextMode())}
|
||||||
onClick={() => props.onViewModeChange("unified")}
|
aria-label={contextModeTitle()}
|
||||||
|
title={contextModeTitle()}
|
||||||
>
|
>
|
||||||
Unified
|
{nextContextMode() === "collapsed" ? (
|
||||||
|
<FoldVertical class="h-4 w-4" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<UnfoldVertical class="h-4 w-4" aria-hidden="true" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={`file-viewer-toolbar-button${props.contextMode === "collapsed" ? " active" : ""}`}
|
class={`file-viewer-toolbar-icon-button${props.wordWrapMode === "on" ? " active" : ""}`}
|
||||||
aria-pressed={props.contextMode === "collapsed"}
|
onClick={() => props.onWordWrapModeChange(nextWordWrapMode())}
|
||||||
onClick={() => props.onContextModeChange("collapsed")}
|
aria-label={wordWrapTitle()}
|
||||||
title="Hide unchanged regions"
|
title={wordWrapTitle()}
|
||||||
>
|
>
|
||||||
Collapsed
|
<WrapText class="h-4 w-4" aria-hidden="true" />
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class={`file-viewer-toolbar-button${props.contextMode === "expanded" ? " active" : ""}`}
|
|
||||||
aria-pressed={props.contextMode === "expanded"}
|
|
||||||
onClick={() => props.onContextModeChange("expanded")}
|
|
||||||
title="Show full file"
|
|
||||||
>
|
|
||||||
Expanded
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
|
import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js"
|
||||||
|
|
||||||
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
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 } from "../types"
|
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||||
|
|
||||||
interface ChangesTabProps {
|
interface ChangesTabProps {
|
||||||
t: (key: string, vars?: Record<string, any>) => string
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
@@ -18,8 +18,10 @@ interface ChangesTabProps {
|
|||||||
|
|
||||||
diffViewMode: Accessor<DiffViewMode>
|
diffViewMode: Accessor<DiffViewMode>
|
||||||
diffContextMode: Accessor<DiffContextMode>
|
diffContextMode: Accessor<DiffContextMode>
|
||||||
|
diffWordWrapMode: Accessor<DiffWordWrapMode>
|
||||||
onViewModeChange: (mode: DiffViewMode) => void
|
onViewModeChange: (mode: DiffViewMode) => void
|
||||||
onContextModeChange: (mode: DiffContextMode) => void
|
onContextModeChange: (mode: DiffContextMode) => void
|
||||||
|
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
|
||||||
|
|
||||||
listOpen: Accessor<boolean>
|
listOpen: Accessor<boolean>
|
||||||
onToggleList: () => void
|
onToggleList: () => void
|
||||||
@@ -30,14 +32,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
|
||||||
@@ -45,49 +51,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-header">
|
|
||||||
<DiffToolbar
|
|
||||||
viewMode={props.diffViewMode()}
|
|
||||||
contextMode={props.diffContextMode()}
|
|
||||||
onViewModeChange={props.onViewModeChange}
|
|
||||||
onContextModeChange={props.onContextModeChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<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>
|
||||||
@@ -96,12 +114,13 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
|
|||||||
>
|
>
|
||||||
{(file) => (
|
{(file) => (
|
||||||
<MonacoDiffViewer
|
<MonacoDiffViewer
|
||||||
scopeKey={scopeKey}
|
scopeKey={scopeKey()}
|
||||||
path={String(file().file || "")}
|
path={String(file().file || "")}
|
||||||
before={String((file() as any).before || "")}
|
before={String((file() as any).before || "")}
|
||||||
after={String((file() as any).after || "")}
|
after={String((file() as any).after || "")}
|
||||||
viewMode={props.diffViewMode()}
|
viewMode={props.diffViewMode()}
|
||||||
contextMode={props.diffContextMode()}
|
contextMode={props.diffContextMode()}
|
||||||
|
wordWrap={props.diffWordWrapMode()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -114,11 +133,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())
|
||||||
}}
|
}}
|
||||||
@@ -139,11 +158,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)
|
||||||
}}
|
}}
|
||||||
@@ -164,8 +183,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={
|
||||||
@@ -176,12 +193,23 @@ 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>
|
||||||
|
|
||||||
|
<div style={{ "margin-left": "auto" }}>
|
||||||
|
<DiffToolbar
|
||||||
|
viewMode={props.diffViewMode()}
|
||||||
|
contextMode={props.diffContextMode()}
|
||||||
|
wordWrapMode={props.diffWordWrapMode()}
|
||||||
|
onViewModeChange={props.onViewModeChange}
|
||||||
|
onContextModeChange={props.onContextModeChange}
|
||||||
|
onWordWrapModeChange={props.onWordWrapModeChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
list={{ panel: renderListPanel, overlay: renderListOverlay }}
|
list={{ panel: renderListPanel, overlay: renderListOverlay }}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
|
import { For, Show, createMemo, 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"
|
||||||
@@ -7,7 +7,7 @@ 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 } from "../types"
|
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||||
|
|
||||||
interface GitChangesTabProps {
|
interface GitChangesTabProps {
|
||||||
t: (key: string, vars?: Record<string, any>) => string
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
@@ -29,8 +29,10 @@ interface GitChangesTabProps {
|
|||||||
|
|
||||||
diffViewMode: Accessor<DiffViewMode>
|
diffViewMode: Accessor<DiffViewMode>
|
||||||
diffContextMode: Accessor<DiffContextMode>
|
diffContextMode: Accessor<DiffContextMode>
|
||||||
|
diffWordWrapMode: Accessor<DiffWordWrapMode>
|
||||||
onViewModeChange: (mode: DiffViewMode) => void
|
onViewModeChange: (mode: DiffViewMode) => void
|
||||||
onContextModeChange: (mode: DiffContextMode) => void
|
onContextModeChange: (mode: DiffContextMode) => void
|
||||||
|
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
|
||||||
|
|
||||||
onOpenFile: (path: string) => void
|
onOpenFile: (path: string) => void
|
||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
@@ -44,17 +46,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
|
||||||
@@ -62,32 +65,36 @@ 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 "Select a session to view changes."
|
||||||
|
const currentEntries = entries()
|
||||||
|
if (currentEntries === null) return "Loading git changes…"
|
||||||
|
if (nonDeleted().length === 0) return "No git changes yet."
|
||||||
|
return "No file selected."
|
||||||
|
})
|
||||||
|
|
||||||
|
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">
|
||||||
<div class="file-viewer-header">
|
|
||||||
<DiffToolbar
|
|
||||||
viewMode={props.diffViewMode()}
|
|
||||||
contextMode={props.diffContextMode()}
|
|
||||||
onViewModeChange={props.onViewModeChange}
|
|
||||||
onContextModeChange={props.onContextModeChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="file-viewer-content file-viewer-content--monaco">
|
<div class="file-viewer-content file-viewer-content--monaco">
|
||||||
<Show
|
<Show
|
||||||
when={props.selectedLoading()}
|
when={props.selectedLoading()}
|
||||||
@@ -97,12 +104,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,
|
||||||
}
|
}
|
||||||
@@ -115,15 +122,16 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(file) => (
|
{(file) => (
|
||||||
<MonacoDiffViewer
|
<MonacoDiffViewer
|
||||||
scopeKey={props.scopeKey()}
|
scopeKey={props.scopeKey()}
|
||||||
path={String(file().path || "")}
|
path={String(file().path || "")}
|
||||||
before={String((file() as any).before || "")}
|
before={String((file() as any).before || "")}
|
||||||
after={String((file() as any).after || "")}
|
after={String((file() as any).after || "")}
|
||||||
viewMode={props.diffViewMode()}
|
viewMode={props.diffViewMode()}
|
||||||
contextMode={props.diffContextMode()}
|
contextMode={props.diffContextMode()}
|
||||||
/>
|
wordWrap={props.diffWordWrapMode()}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -146,8 +154,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" : ""}`}
|
||||||
@@ -178,8 +186,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" : ""}`}
|
||||||
@@ -209,19 +217,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 || "Git Changes"}>
|
||||||
<span class="file-path-text">{selectedEntry?.path || "Git Changes"}</span>
|
<span class="file-path-text">{selected?.path || "Git Changes"}</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>
|
||||||
@@ -231,14 +239,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
|
||||||
|
viewMode={props.diffViewMode()}
|
||||||
|
contextMode={props.diffContextMode()}
|
||||||
|
wordWrapMode={props.diffWordWrapMode()}
|
||||||
|
onViewModeChange={props.onViewModeChange}
|
||||||
|
onContextModeChange={props.onContextModeChange}
|
||||||
|
onWordWrapModeChange={props.onWordWrapModeChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
list={{ panel: renderListPanel, overlay: renderListOverlay }}
|
list={{ panel: renderListPanel, overlay: renderListOverlay }}
|
||||||
viewer={renderViewer()}
|
viewer={renderViewer()}
|
||||||
listOpen={props.listOpen()}
|
listOpen={props.listOpen()}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
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 { ChevronDown, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
import { ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
||||||
|
|
||||||
import type { Instance } from "../../../../../types/instance"
|
import type { Instance } from "../../../../../types/instance"
|
||||||
import type { BackgroundProcess } from "../../../../../../../server/src/api-types"
|
import type { BackgroundProcess } from "../../../../../../../server/src/api-types"
|
||||||
@@ -206,21 +207,25 @@ const StatusTab: Component<StatusTabProps> = (props) => {
|
|||||||
{
|
{
|
||||||
id: "session-changes",
|
id: "session-changes",
|
||||||
labelKey: "instanceShell.rightPanel.sections.sessionChanges",
|
labelKey: "instanceShell.rightPanel.sections.sessionChanges",
|
||||||
|
tooltipKey: "instanceShell.rightPanel.sections.sessionChanges.tooltip",
|
||||||
render: renderStatusSessionChanges,
|
render: renderStatusSessionChanges,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "plan",
|
id: "plan",
|
||||||
labelKey: "instanceShell.rightPanel.sections.plan",
|
labelKey: "instanceShell.rightPanel.sections.plan",
|
||||||
|
tooltipKey: "instanceShell.rightPanel.sections.plan.tooltip",
|
||||||
render: renderPlanSectionContent,
|
render: renderPlanSectionContent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "background-processes",
|
id: "background-processes",
|
||||||
labelKey: "instanceShell.rightPanel.sections.backgroundProcesses",
|
labelKey: "instanceShell.rightPanel.sections.backgroundProcesses",
|
||||||
|
tooltipKey: "instanceShell.rightPanel.sections.backgroundProcesses.tooltip",
|
||||||
render: renderBackgroundProcesses,
|
render: renderBackgroundProcesses,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "mcp",
|
id: "mcp",
|
||||||
labelKey: "instanceShell.rightPanel.sections.mcp",
|
labelKey: "instanceShell.rightPanel.sections.mcp",
|
||||||
|
tooltipKey: "instanceShell.rightPanel.sections.mcp.tooltip",
|
||||||
render: () => (
|
render: () => (
|
||||||
<InstanceServiceStatus
|
<InstanceServiceStatus
|
||||||
initialInstance={props.instance}
|
initialInstance={props.instance}
|
||||||
@@ -233,6 +238,7 @@ const StatusTab: Component<StatusTabProps> = (props) => {
|
|||||||
{
|
{
|
||||||
id: "lsp",
|
id: "lsp",
|
||||||
labelKey: "instanceShell.rightPanel.sections.lsp",
|
labelKey: "instanceShell.rightPanel.sections.lsp",
|
||||||
|
tooltipKey: "instanceShell.rightPanel.sections.lsp.tooltip",
|
||||||
render: () => (
|
render: () => (
|
||||||
<InstanceServiceStatus
|
<InstanceServiceStatus
|
||||||
initialInstance={props.instance}
|
initialInstance={props.instance}
|
||||||
@@ -245,6 +251,7 @@ const StatusTab: Component<StatusTabProps> = (props) => {
|
|||||||
{
|
{
|
||||||
id: "plugins",
|
id: "plugins",
|
||||||
labelKey: "instanceShell.rightPanel.sections.plugins",
|
labelKey: "instanceShell.rightPanel.sections.plugins",
|
||||||
|
tooltipKey: "instanceShell.rightPanel.sections.plugins.tooltip",
|
||||||
render: () => (
|
render: () => (
|
||||||
<InstanceServiceStatus
|
<InstanceServiceStatus
|
||||||
initialInstance={props.instance}
|
initialInstance={props.instance}
|
||||||
@@ -276,7 +283,23 @@ const StatusTab: Component<StatusTabProps> = (props) => {
|
|||||||
<Accordion.Item value={section.id} class="right-panel-accordion-item">
|
<Accordion.Item value={section.id} class="right-panel-accordion-item">
|
||||||
<Accordion.Header>
|
<Accordion.Header>
|
||||||
<Accordion.Trigger class="right-panel-accordion-trigger">
|
<Accordion.Trigger class="right-panel-accordion-trigger">
|
||||||
<span>{props.t(section.labelKey)}</span>
|
<span class="section-left">
|
||||||
|
<Tooltip openDelay={200} gutter={4} placement="top">
|
||||||
|
<Tooltip.Trigger
|
||||||
|
class="section-info-trigger"
|
||||||
|
aria-label={props.t(section.tooltipKey)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Info class="section-info-icon" />
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Portal>
|
||||||
|
<Tooltip.Content class="section-info-tooltip">
|
||||||
|
{props.t(section.tooltipKey)}
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Portal>
|
||||||
|
</Tooltip>
|
||||||
|
<span class="section-label">{props.t(section.labelKey)}</span>
|
||||||
|
</span>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`}
|
class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,3 +3,5 @@ export type RightPanelTab = "changes" | "git-changes" | "files" | "status"
|
|||||||
export type DiffViewMode = "split" | "unified"
|
export type DiffViewMode = "split" | "unified"
|
||||||
|
|
||||||
export type DiffContextMode = "expanded" | "collapsed"
|
export type DiffContextMode = "expanded" | "collapsed"
|
||||||
|
|
||||||
|
export type DiffWordWrapMode = "on" | "off"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-
|
|||||||
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1"
|
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1"
|
||||||
export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1"
|
export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1"
|
||||||
export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1"
|
export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1"
|
||||||
|
export const RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY = "opencode-session-right-panel-changes-diff-word-wrap-v1"
|
||||||
|
|
||||||
export const clampWidth = (value: number) =>
|
export const clampWidth = (value: number) =>
|
||||||
Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value))
|
Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value))
|
||||||
|
|||||||
@@ -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,4 +1,5 @@
|
|||||||
import { Component, JSX, For } from "solid-js"
|
import { Component, JSX, For } from "solid-js"
|
||||||
|
import useMediaQuery from "@suid/material/useMediaQuery"
|
||||||
import { isMac } from "../lib/keyboard-utils"
|
import { isMac } from "../lib/keyboard-utils"
|
||||||
|
|
||||||
interface KbdProps {
|
interface KbdProps {
|
||||||
@@ -27,6 +28,9 @@ const SPECIAL_KEY_LABELS: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Kbd: Component<KbdProps> = (props) => {
|
const Kbd: Component<KbdProps> = (props) => {
|
||||||
|
const desktopQuery = useMediaQuery("(min-width: 1280px)")
|
||||||
|
if (!desktopQuery()) return null
|
||||||
|
|
||||||
const parts = () => {
|
const parts = () => {
|
||||||
if (props.children) return [{ text: props.children, isModifier: false }]
|
if (props.children) return [{ text: props.children, isModifier: false }]
|
||||||
if (!props.shortcut) return []
|
if (!props.shortcut) return []
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
import { Component, For } from "solid-js"
|
import { Component, For } from "solid-js"
|
||||||
import { formatShortcut, isMac } from "../lib/keyboard-utils"
|
import useMediaQuery from "@suid/material/useMediaQuery"
|
||||||
import type { KeyboardShortcut } from "../lib/keyboard-registry"
|
import type { KeyboardShortcut } from "../lib/keyboard-registry"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import HintRow from "./hint-row"
|
import HintRow from "./hint-row"
|
||||||
|
|
||||||
const KeyboardHint: Component<{
|
const KeyboardHint: Component<{
|
||||||
shortcuts: KeyboardShortcut[]
|
shortcuts: KeyboardShortcut[]
|
||||||
separator?: string
|
separator?: string | null
|
||||||
showDescription?: boolean
|
showDescription?: boolean
|
||||||
|
class?: string
|
||||||
|
ariaHidden?: boolean
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
|
// Centralize layout gating here so call sites don't need to.
|
||||||
|
// We only show keyboard hint UI on desktop layouts.
|
||||||
|
const desktopQuery = useMediaQuery("(min-width: 1280px)")
|
||||||
|
|
||||||
function buildShortcutString(shortcut: KeyboardShortcut): string {
|
function buildShortcutString(shortcut: KeyboardShortcut): string {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
|
|
||||||
@@ -26,12 +32,14 @@ const KeyboardHint: Component<{
|
|||||||
return parts.join("+")
|
return parts.join("+")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!desktopQuery()) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HintRow>
|
<HintRow class={props.class} ariaHidden={props.ariaHidden}>
|
||||||
<For each={props.shortcuts}>
|
<For each={props.shortcuts}>
|
||||||
{(shortcut, i) => (
|
{(shortcut, i) => (
|
||||||
<>
|
<>
|
||||||
{i() > 0 && <span class="mx-1">{props.separator || "•"}</span>}
|
{i() > 0 && props.separator !== null && <span class="mx-1">{props.separator ?? "•"}</span>}
|
||||||
{props.showDescription !== false && <span class="mr-1">{shortcut.description}</span>}
|
{props.showDescription !== false && <span class="mr-1">{shortcut.description}</span>}
|
||||||
<Kbd shortcut={buildShortcutString(shortcut)} />
|
<Kbd shortcut={buildShortcutString(shortcut)} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -92,7 +92,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -100,14 +99,11 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
const commitCacheEntry = (renderedHtml: string) => {
|
const commitCacheEntry = (renderedHtml: string) => {
|
||||||
const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version }
|
const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version }
|
||||||
setHtml(renderedHtml)
|
setHtml(renderedHtml)
|
||||||
part.renderCache = cacheEntry
|
|
||||||
cacheHandle.set(cacheEntry)
|
cacheHandle.set(cacheEntry)
|
||||||
notifyRendered()
|
notifyRendered()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!highlightEnabled) {
|
if (!highlightEnabled) {
|
||||||
part.renderCache = undefined
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rendered = await renderMarkdown(text, { suppressHighlight: true })
|
const rendered = await renderMarkdown(text, { suppressHighlight: true })
|
||||||
|
|
||||||
@@ -185,7 +181,6 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
if (latestRequestedText === text) {
|
if (latestRequestedText === text) {
|
||||||
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version }
|
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version }
|
||||||
setHtml(rendered)
|
setHtml(rendered)
|
||||||
part.renderCache = cacheEntry
|
|
||||||
cacheHandle.set(cacheEntry)
|
cacheHandle.set(cacheEntry)
|
||||||
notifyRendered()
|
notifyRendered()
|
||||||
}
|
}
|
||||||
@@ -202,5 +197,15 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
|
|
||||||
const proseClass = () => "markdown-body"
|
const proseClass = () => "markdown-body"
|
||||||
|
|
||||||
return <div ref={containerRef} class={proseClass()} innerHTML={html()} />
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
class={proseClass()}
|
||||||
|
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,23 @@ 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 {
|
||||||
|
const type = (part as any)?.type
|
||||||
|
// Ignore part types the UI does not support rendering yet.
|
||||||
|
return !(typeof type === "string" && type === "patch")
|
||||||
|
}
|
||||||
|
|
||||||
|
function isContentPartType(type: unknown): boolean {
|
||||||
|
return type === "text" || type === "file"
|
||||||
}
|
}
|
||||||
|
|
||||||
function MessageContentItem(props: MessageContentItemProps) {
|
function MessageContentItem(props: MessageContentItemProps) {
|
||||||
@@ -222,15 +246,9 @@ function MessageContentItem(props: MessageContentItemProps) {
|
|||||||
const partId = ids[idx]
|
const partId = ids[idx]
|
||||||
const part = current.parts[partId]?.data
|
const part = current.parts[partId]?.data
|
||||||
if (!part) continue
|
if (!part) continue
|
||||||
if (
|
if (!isSupportedPartType(part)) continue
|
||||||
part.type === "tool" ||
|
|
||||||
part.type === "reasoning" ||
|
if (!isContentPartType((part as any).type)) break
|
||||||
part.type === "compaction" ||
|
|
||||||
part.type === "step-start" ||
|
|
||||||
part.type === "step-finish"
|
|
||||||
) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
resolved.push(part)
|
resolved.push(part)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,15 +274,9 @@ function MessageContentItem(props: MessageContentItemProps) {
|
|||||||
const partId = ids[idx]
|
const partId = ids[idx]
|
||||||
const part = current.parts[partId]?.data
|
const part = current.parts[partId]?.data
|
||||||
if (!part) continue
|
if (!part) continue
|
||||||
if (
|
if (!isSupportedPartType(part)) continue
|
||||||
part.type === "tool" ||
|
|
||||||
part.type === "reasoning" ||
|
if (!isContentPartType((part as any).type)) continue
|
||||||
part.type === "compaction" ||
|
|
||||||
part.type === "step-start" ||
|
|
||||||
part.type === "step-finish"
|
|
||||||
) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (partHasRenderableText(part)) {
|
if (partHasRenderableText(part)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -284,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}
|
||||||
/>
|
/>
|
||||||
@@ -300,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))
|
||||||
@@ -321,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 ""
|
||||||
@@ -352,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"
|
||||||
@@ -397,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>
|
||||||
|
|
||||||
@@ -420,7 +510,7 @@ function ToolCallItem(props: ToolCallItemProps) {
|
|||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
onContentRendered={props.onContentRendered}
|
onContentRendered={props.onContentRendered}
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
@@ -472,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
|
||||||
}
|
}
|
||||||
@@ -482,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()
|
||||||
@@ -549,6 +668,9 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
orderedParts.forEach((part, partIndex) => {
|
orderedParts.forEach((part, partIndex) => {
|
||||||
|
if (!isSupportedPartType(part)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (part.type === "tool") {
|
if (part.type === "tool") {
|
||||||
flushContent()
|
flushContent()
|
||||||
const partId = part.id
|
const partId = part.id
|
||||||
@@ -667,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
|
||||||
@@ -680,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}
|
||||||
/>
|
/>
|
||||||
@@ -696,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>
|
||||||
@@ -708,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"}>
|
||||||
@@ -717,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"}>
|
||||||
@@ -727,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"}>
|
||||||
@@ -737,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>
|
||||||
@@ -758,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 {
|
||||||
@@ -767,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)
|
||||||
@@ -780,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>
|
||||||
@@ -827,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)
|
||||||
@@ -871,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 = [
|
||||||
@@ -901,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>
|
||||||
@@ -938,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))
|
||||||
@@ -973,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 ""
|
||||||
@@ -1013,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"
|
||||||
@@ -1044,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>
|
||||||
|
|
||||||
@@ -1080,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>
|
||||||
|
|
||||||
@@ -1097,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,13 +1,24 @@
|
|||||||
import { For, Show, createSignal } from "solid-js"
|
import { For, Show, createEffect, createSignal, onCleanup } from "solid-js"
|
||||||
import { Copy, 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 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
|
||||||
@@ -17,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
|
||||||
@@ -45,6 +153,15 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
|
|
||||||
const messageParts = () => props.parts
|
const messageParts = () => props.parts
|
||||||
|
|
||||||
|
// User messages can temporarily include synthetic helper parts (e.g. tool traces / file reads).
|
||||||
|
// We only want to display the primary prompt text for the user message; other synthetic text
|
||||||
|
// parts should be hidden.
|
||||||
|
const primaryUserTextPartId = () => {
|
||||||
|
if (!isUser()) return null
|
||||||
|
const firstText = messageParts().find((part) => part?.type === "text") as { id?: string } | undefined
|
||||||
|
return typeof firstText?.id === "string" ? firstText.id : null
|
||||||
|
}
|
||||||
|
|
||||||
const fileAttachments = () =>
|
const fileAttachments = () =>
|
||||||
messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
|
messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
|
||||||
|
|
||||||
@@ -96,7 +213,8 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (url.startsWith("file://")) {
|
if (url.startsWith("file://")) {
|
||||||
window.open(url, "_blank", "noopener")
|
// Local filesystem URLs are not reliably downloadable from the message stream.
|
||||||
|
// We hide the download action for these chips.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,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
|
||||||
@@ -151,7 +274,8 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const info = props.messageInfo
|
const info = props.messageInfo
|
||||||
return Boolean(info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0)
|
const timeInfo = info?.time as { created: number; end?: number } | undefined
|
||||||
|
return Boolean(info && info.role === "assistant" && (timeInfo?.end === undefined || timeInfo?.end === 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRevert = () => {
|
const handleRevert = () => {
|
||||||
@@ -178,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,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 = () => {
|
||||||
@@ -266,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"
|
||||||
@@ -298,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()}>
|
||||||
@@ -319,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>
|
||||||
@@ -338,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>
|
||||||
@@ -366,15 +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}
|
||||||
onRendered={props.onContentRendered}
|
instanceId={props.instanceId}
|
||||||
/>
|
sessionId={props.sessionId}
|
||||||
)}
|
primaryUserTextPartId={primaryUserTextPartId()}
|
||||||
|
onRendered={props.onContentRendered}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
<Show when={fileAttachments().length > 0}>
|
<Show when={fileAttachments().length > 0}>
|
||||||
@@ -384,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
|
||||||
@@ -398,34 +606,19 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
<img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" />
|
<img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" />
|
||||||
</Show>
|
</Show>
|
||||||
<span class="truncate max-w-[180px]">{name}</span>
|
<span class="truncate max-w-[180px]">{name}</span>
|
||||||
<button
|
<Show when={!attachment.url?.startsWith("file://")}>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => void handleAttachmentDownload(attachment)}
|
type="button"
|
||||||
class="attachment-download"
|
onClick={() => void handleAttachmentDownload(attachment)}
|
||||||
aria-label={t("messageItem.attachment.downloadAriaLabel", { name })}
|
class="attachment-download"
|
||||||
>
|
aria-label={t("messageItem.attachment.downloadAriaLabel", { name })}
|
||||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
title={t("messageItem.attachment.downloadAriaLabel", { name })}
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
|
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
</svg>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
||||||
</button>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
|
||||||
|
</svg>
|
||||||
<button
|
</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>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -434,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")}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { Show } from "solid-js"
|
import { Show } from "solid-js"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
|
import ContextMeter from "./context-meter"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
|
|
||||||
const METRIC_CHIP_CLASS = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
|
|
||||||
const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted"
|
|
||||||
|
|
||||||
interface MessageListHeaderProps {
|
interface MessageListHeaderProps {
|
||||||
usedTokens: number
|
usedTokens: number
|
||||||
|
|
||||||
@@ -21,7 +19,6 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const hasAvailableTokens = () => typeof props.availableTokens === "number"
|
const hasAvailableTokens = () => typeof props.availableTokens === "number"
|
||||||
const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--")
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={props.forceCompactStatusLayout ? "connection-status connection-status--compact" : "connection-status"}>
|
<div class={props.forceCompactStatusLayout ? "connection-status connection-status--compact" : "connection-status"}>
|
||||||
@@ -40,14 +37,13 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
|||||||
|
|
||||||
<div class="connection-status-text connection-status-info">
|
<div class="connection-status-text connection-status-info">
|
||||||
<div class="connection-status-usage">
|
<div class="connection-status-usage">
|
||||||
<div class={METRIC_CHIP_CLASS}>
|
<ContextMeter
|
||||||
<span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.usedLabel")}</span>
|
usedTokens={props.usedTokens}
|
||||||
<span class="font-semibold text-primary">{props.formatTokens(props.usedTokens)}</span>
|
availableTokens={hasAvailableTokens() ? (props.availableTokens as number) : null}
|
||||||
</div>
|
formatTokens={props.formatTokens}
|
||||||
<div class={METRIC_CHIP_CLASS}>
|
usedLabel={t("messageListHeader.metrics.usedLabel")}
|
||||||
<span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.availableLabel")}</span>
|
availableLabel={t("messageListHeader.metrics.availableLabel")}
|
||||||
<span class="font-semibold text-primary">{hasAvailableTokens() ? availableDisplay() : "--"}</span>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -55,14 +51,14 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
|||||||
<div class="connection-status-shortcut-action">
|
<div class="connection-status-shortcut-action">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="connection-status-button"
|
class="connection-status-button command-palette-button"
|
||||||
onClick={props.onCommandPalette}
|
onClick={props.onCommandPalette}
|
||||||
aria-label={t("messageListHeader.commandPalette.ariaLabel")}
|
aria-label={t("messageListHeader.commandPalette.ariaLabel")}
|
||||||
>
|
>
|
||||||
{t("messageListHeader.commandPalette.button")}
|
{t("messageListHeader.commandPalette.button")}
|
||||||
</button>
|
</button>
|
||||||
<span class="connection-status-shortcut-hint">
|
<span class="connection-status-shortcut-hint">
|
||||||
<Kbd shortcut="cmd+shift+p" />
|
<Kbd shortcut="cmd+shift+p" class="kbd-hint" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import ToolCall from "./tool-call"
|
|||||||
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
|
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
|
||||||
import { Markdown } from "./markdown"
|
import { Markdown } from "./markdown"
|
||||||
import { useTheme } from "../lib/theme"
|
import { useTheme } from "../lib/theme"
|
||||||
import { useConfig } from "../stores/preferences"
|
|
||||||
import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/message"
|
import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/message"
|
||||||
|
|
||||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||||
@@ -13,23 +12,39 @@ interface MessagePartProps {
|
|||||||
messageType?: "user" | "assistant"
|
messageType?: "user" | "assistant"
|
||||||
instanceId: string
|
instanceId: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
|
// For user messages, keep the primary prompt text visible even when synthetic (optimistic).
|
||||||
|
// Other synthetic text parts (tool traces, read outputs, etc.) should be hidden.
|
||||||
|
primaryUserTextPartId?: string | null
|
||||||
onRendered?: () => void
|
onRendered?: () => void
|
||||||
}
|
}
|
||||||
export default function MessagePart(props: MessagePartProps) {
|
|
||||||
|
export default function MessagePart(props: MessagePartProps) {
|
||||||
|
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
const { preferences } = useConfig()
|
|
||||||
const partType = () => props.part?.type || ""
|
const partType = () => props.part?.type || ""
|
||||||
const reasoningId = () => `reasoning-${props.part?.id || ""}`
|
const reasoningId = () => `reasoning-${props.part?.id || ""}`
|
||||||
const isReasoningExpanded = () => isItemExpanded(reasoningId())
|
const isReasoningExpanded = () => isItemExpanded(reasoningId())
|
||||||
const isAssistantMessage = () => props.messageType === "assistant"
|
const isAssistantMessage = () => props.messageType === "assistant"
|
||||||
const textContainerClass = () => (isAssistantMessage() ? "message-text message-text-assistant" : "message-text")
|
const textContainerClass = () => (isAssistantMessage() ? "message-text message-text-assistant" : "message-text")
|
||||||
|
const markdownContainerClass = () => "message-text message-text-assistant"
|
||||||
|
const textContainerRole = () => props.messageType || "assistant"
|
||||||
|
|
||||||
const shouldHideTextPart = () => {
|
const shouldHideTextPart = () => {
|
||||||
const part = props.part
|
const part = props.part
|
||||||
if (!part || part.type !== "text") return false
|
if (!part || part.type !== "text") return false
|
||||||
// Keep optimistic user prompts visible; hide synthetic assistant text.
|
|
||||||
return Boolean((part as any).synthetic) && props.messageType !== "user"
|
const isSynthetic = Boolean((part as any).synthetic)
|
||||||
|
if (!isSynthetic) return false
|
||||||
|
|
||||||
|
// Keep optimistic user prompts visible; hide other synthetic user helper parts.
|
||||||
|
if (props.messageType === "user") {
|
||||||
|
const primaryId = props.primaryUserTextPartId
|
||||||
|
if (!primaryId) return false
|
||||||
|
return part.id !== primaryId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide synthetic assistant text.
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -43,6 +58,11 @@ interface MessagePartProps {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canRenderMarkdown = () => {
|
||||||
|
const id = (props.part as unknown as { id?: unknown })?.id
|
||||||
|
return typeof id === "string" && id.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
function reasoningSegmentHasText(segment: unknown): boolean {
|
function reasoningSegmentHasText(segment: unknown): boolean {
|
||||||
if (typeof segment === "string") {
|
if (typeof segment === "string") {
|
||||||
return segment.trim().length > 0
|
return segment.trim().length > 0
|
||||||
@@ -77,20 +97,28 @@ interface MessagePartProps {
|
|||||||
|
|
||||||
const createTextPartForMarkdown = (): TextPart => {
|
const createTextPartForMarkdown = (): TextPart => {
|
||||||
const part = props.part
|
const part = props.part
|
||||||
if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") {
|
if (part.type === "text" && typeof part.text === "string") {
|
||||||
|
// Pass through the original part so `renderCache` updates persist.
|
||||||
|
return part as unknown as TextPart
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.type === "reasoning" && typeof (part as any).text === "string") {
|
||||||
|
// Reasoning parts render as markdown in some views; normalize to TextPart.
|
||||||
return {
|
return {
|
||||||
id: part.id,
|
id: part.id,
|
||||||
type: "text",
|
type: "text",
|
||||||
text: part.text,
|
text: (part as any).text,
|
||||||
synthetic: part.type === "text" ? part.synthetic : false,
|
synthetic: false,
|
||||||
version: (part as { version?: number }).version
|
version: (part as { version?: number }).version,
|
||||||
|
renderCache: (part as any).renderCache,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: part.id,
|
id: part.id,
|
||||||
type: "text",
|
type: "text",
|
||||||
text: "",
|
text: "",
|
||||||
synthetic: false
|
synthetic: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,22 +131,23 @@ interface 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={textContainerClass()}>
|
<div
|
||||||
<Show
|
class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()}
|
||||||
when={isAssistantMessage()}
|
data-role={textContainerRole()}
|
||||||
fallback={<span class="text-primary">{plainTextContent()}</span>}
|
data-part-type="text"
|
||||||
>
|
data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined}
|
||||||
<Markdown
|
>
|
||||||
part={createTextPartForMarkdown()}
|
<Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}>
|
||||||
instanceId={props.instanceId}
|
<Markdown
|
||||||
sessionId={props.sessionId}
|
part={createTextPartForMarkdown()}
|
||||||
isDark={isDark()}
|
instanceId={props.instanceId}
|
||||||
size={isAssistantMessage() ? "tight" : "base"}
|
sessionId={props.sessionId}
|
||||||
onRendered={props.onRendered}
|
isDark={isDark()}
|
||||||
/>
|
size={isAssistantMessage() ? "tight" : "base"}
|
||||||
</Show>
|
onRendered={props.onRendered}
|
||||||
|
/>
|
||||||
</div>
|
</Show>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ChevronDown, Star } from "lucide-solid"
|
|||||||
import type { Model } from "../types/session"
|
import type { Model } from "../types/session"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { preferences, toggleFavoriteModelPreference } from "../stores/preferences"
|
import { uiState, toggleFavoriteModelPreference } from "../stores/preferences"
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||||||
|
|
||||||
const favoriteKeySet = createMemo(() => {
|
const favoriteKeySet = createMemo(() => {
|
||||||
const result = new Set<string>()
|
const result = new Set<string>()
|
||||||
for (const item of preferences().modelFavorites ?? []) {
|
for (const item of uiState().models.favorites ?? []) {
|
||||||
if (item.providerId && item.modelId) {
|
if (item.providerId && item.modelId) {
|
||||||
result.add(`${item.providerId}/${item.modelId}`)
|
result.add(`${item.providerId}/${item.modelId}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
opencodeBinaries,
|
opencodeBinaries,
|
||||||
addOpenCodeBinary,
|
addOpenCodeBinary,
|
||||||
removeOpenCodeBinary,
|
removeOpenCodeBinary,
|
||||||
preferences,
|
serverSettings,
|
||||||
updatePreferences,
|
updateLastUsedBinary,
|
||||||
} = useConfig()
|
} = useConfig()
|
||||||
const [customPath, setCustomPath] = createSignal("")
|
const [customPath, setCustomPath] = createSignal("")
|
||||||
const [validating, setValidating] = createSignal(false)
|
const [validating, setValidating] = createSignal(false)
|
||||||
@@ -42,7 +42,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
|
|
||||||
const binaries = () => opencodeBinaries()
|
const binaries = () => opencodeBinaries()
|
||||||
|
|
||||||
const lastUsedBinary = () => preferences().lastUsedBinary
|
const lastUsedBinary = () => serverSettings().opencodeBinary
|
||||||
|
|
||||||
const customBinaries = createMemo(() => binaries().filter((binary) => binary.path !== "opencode"))
|
const customBinaries = createMemo(() => binaries().filter((binary) => binary.path !== "opencode"))
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
if (validation.valid) {
|
if (validation.valid) {
|
||||||
addOpenCodeBinary(path, validation.version)
|
addOpenCodeBinary(path, validation.version)
|
||||||
props.onBinaryChange(path)
|
props.onBinaryChange(path)
|
||||||
updatePreferences({ lastUsedBinary: path })
|
updateLastUsedBinary(path)
|
||||||
setCustomPath("")
|
setCustomPath("")
|
||||||
setValidationError(null)
|
setValidationError(null)
|
||||||
} else {
|
} else {
|
||||||
@@ -183,7 +183,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
if (props.disabled) return
|
if (props.disabled) return
|
||||||
if (path === props.selectedBinary) return
|
if (path === props.selectedBinary) return
|
||||||
props.onBinaryChange(path)
|
props.onBinaryChange(path)
|
||||||
updatePreferences({ lastUsedBinary: path })
|
updateLastUsedBinary(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRemoveBinary(path: string, event: Event) {
|
function handleRemoveBinary(path: string, event: Event) {
|
||||||
@@ -193,7 +193,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
|
|
||||||
if (props.selectedBinary === path) {
|
if (props.selectedBinary === path) {
|
||||||
props.onBinaryChange("opencode")
|
props.onBinaryChange("opencode")
|
||||||
updatePreferences({ lastUsedBinary: "opencode" })
|
updateLastUsedBinary("opencode")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createSignal, Show, onMount, onCleanup, createEffect, on, untrack } from "solid-js"
|
import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js"
|
||||||
import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
|
import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
|
||||||
import UnifiedPicker from "./unified-picker"
|
import UnifiedPicker from "./unified-picker"
|
||||||
import ExpandButton from "./expand-button"
|
import ExpandButton from "./expand-button"
|
||||||
import { getAttachments, clearAttachments, removeAttachment } from "../stores/attachments"
|
import { clearAttachments, removeAttachment } from "../stores/attachments"
|
||||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import { getActiveInstance } from "../stores/instances"
|
import { getActiveInstance } from "../stores/instances"
|
||||||
@@ -63,6 +63,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
handleDrop,
|
handleDrop,
|
||||||
syncAttachmentCounters,
|
syncAttachmentCounters,
|
||||||
handleExpandTextAttachment,
|
handleExpandTextAttachment,
|
||||||
|
handleRemoveAttachment,
|
||||||
} = usePromptAttachments({
|
} = usePromptAttachments({
|
||||||
instanceId: () => props.instanceId,
|
instanceId: () => props.instanceId,
|
||||||
sessionId: () => props.sessionId,
|
sessionId: () => props.sessionId,
|
||||||
@@ -87,6 +88,9 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
if (!attachment) return
|
if (!attachment) return
|
||||||
handleExpandTextAttachment(attachment)
|
handleExpandTextAttachment(attachment)
|
||||||
},
|
},
|
||||||
|
removeAttachment: (attachmentId: string) => {
|
||||||
|
handleRemoveAttachment(attachmentId)
|
||||||
|
},
|
||||||
setPromptText: (text: string, opts?: { focus?: boolean }) => {
|
setPromptText: (text: string, opts?: { focus?: boolean }) => {
|
||||||
const textarea = textareaRef
|
const textarea = textareaRef
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
@@ -166,24 +170,32 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
setAtPosition(null)
|
setAtPosition(null)
|
||||||
setSearchQuery("")
|
setSearchQuery("")
|
||||||
|
|
||||||
const instanceId = props.instanceId
|
syncAttachmentCounters(prompt())
|
||||||
const sessionId = props.sessionId
|
|
||||||
const currentAttachments = untrack(() => getAttachments(instanceId, sessionId))
|
|
||||||
syncAttachmentCounters(prompt(), currentAttachments)
|
|
||||||
},
|
},
|
||||||
{ defer: true },
|
{ defer: true },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
onMount(() => {
|
const isCoarsePointer = () => {
|
||||||
|
if (typeof window === "undefined") return false
|
||||||
|
return Boolean(window.matchMedia?.("(pointer: coarse)")?.matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
// Scope global "type-to-focus" behavior to the active, visible prompt only.
|
||||||
|
if (typeof document === "undefined") return
|
||||||
|
if (isCoarsePointer()) return
|
||||||
|
if (props.isActive === false) return
|
||||||
|
if (props.disabled) return
|
||||||
|
|
||||||
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||||
const activeElement = document.activeElement as HTMLElement
|
const activeElement = document.activeElement as HTMLElement | null
|
||||||
|
|
||||||
const isInputElement =
|
const isInputElement =
|
||||||
activeElement?.tagName === "INPUT" ||
|
activeElement?.tagName === "INPUT" ||
|
||||||
activeElement?.tagName === "TEXTAREA" ||
|
activeElement?.tagName === "TEXTAREA" ||
|
||||||
activeElement?.tagName === "SELECT" ||
|
activeElement?.tagName === "SELECT" ||
|
||||||
activeElement?.isContentEditable
|
Boolean(activeElement?.isContentEditable)
|
||||||
|
|
||||||
if (isInputElement) return
|
if (isInputElement) return
|
||||||
|
|
||||||
@@ -191,16 +203,25 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
if (isModifierKey) return
|
if (isModifierKey) return
|
||||||
|
|
||||||
const isSpecialKey =
|
const isSpecialKey =
|
||||||
e.key === "Tab" || e.key === "Enter" || e.key.startsWith("Arrow") || e.key === "Backspace" || e.key === "Delete"
|
e.key === "Tab" ||
|
||||||
|
e.key === "Enter" ||
|
||||||
|
e.key.startsWith("Arrow") ||
|
||||||
|
e.key === "Backspace" ||
|
||||||
|
e.key === "Delete"
|
||||||
if (isSpecialKey) return
|
if (isSpecialKey) return
|
||||||
|
|
||||||
if (e.key.length === 1 && textareaRef && !props.disabled) {
|
const textarea = textareaRef
|
||||||
textareaRef.focus()
|
if (!textarea || textarea.disabled) return
|
||||||
|
|
||||||
|
// In session cache mode inactive panes are display:none; avoid stealing focus.
|
||||||
|
if (textarea.offsetParent === null) return
|
||||||
|
|
||||||
|
if (e.key.length === 1) {
|
||||||
|
textarea.focus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("keydown", handleGlobalKeyDown)
|
document.addEventListener("keydown", handleGlobalKeyDown)
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
document.removeEventListener("keydown", handleGlobalKeyDown)
|
document.removeEventListener("keydown", handleGlobalKeyDown)
|
||||||
})
|
})
|
||||||
@@ -238,10 +259,10 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
// Ignore attachments for slash commands, but keep them for next prompt.
|
// Ignore attachments for slash commands, but keep them for next prompt.
|
||||||
if (!isKnownSlashCommand) {
|
if (!isKnownSlashCommand) {
|
||||||
clearAttachments(props.instanceId, props.sessionId)
|
clearAttachments(props.instanceId, props.sessionId)
|
||||||
syncAttachmentCounters("", [])
|
syncAttachmentCounters("")
|
||||||
setIgnoredAtPositions(new Set<number>())
|
setIgnoredAtPositions(new Set<number>())
|
||||||
} else {
|
} else {
|
||||||
syncAttachmentCounters("", currentAttachments)
|
syncAttachmentCounters("")
|
||||||
setIgnoredAtPositions(new Set<number>())
|
setIgnoredAtPositions(new Set<number>())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,7 +351,9 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
const blockquote = lines.map((line) => `> ${line}`).join("\n")
|
const blockquote = lines.map((line) => `> ${line}`).join("\n")
|
||||||
if (!blockquote) return
|
if (!blockquote) return
|
||||||
|
|
||||||
insertBlockContent(`${blockquote}\n`)
|
// End the blockquote with a blank line so the user's next line
|
||||||
|
// doesn't get parsed as a lazy continuation of the quote.
|
||||||
|
insertBlockContent(`${blockquote}\n\n`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertCodeSelection(rawText: string) {
|
function insertCodeSelection(rawText: string) {
|
||||||
@@ -434,7 +457,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
onFocus={() => setIsFocused(true)}
|
onFocus={() => setIsFocused(true)}
|
||||||
onBlur={() => setIsFocused(false)}
|
onBlur={() => setIsFocused(false)}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
rows={expandState() === "expanded" ? 15 : 4}
|
rows={expandState() === "expanded" ? (props.compactLayout ? 10 : 15) : 3}
|
||||||
spellcheck={false}
|
spellcheck={false}
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
autoCapitalize="off"
|
autoCapitalize="off"
|
||||||
@@ -479,7 +502,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={shouldShowOverlay()}>
|
<Show when={shouldShowOverlay()}>
|
||||||
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
|
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
|
||||||
<Show
|
<Show
|
||||||
when={props.escapeInDebounce}
|
when={props.escapeInDebounce}
|
||||||
fallback={
|
fallback={
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { Attachment } from "../../types/attachment"
|
|
||||||
|
|
||||||
export function formatPastedPlaceholder(value: string | number) {
|
export function formatPastedPlaceholder(value: string | number) {
|
||||||
return `[pasted #${value}]`
|
return `[pasted #${value}]`
|
||||||
}
|
}
|
||||||
@@ -9,27 +7,27 @@ export function formatImagePlaceholder(value: string | number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createPastedPlaceholderRegex() {
|
export function createPastedPlaceholderRegex() {
|
||||||
return /\[pasted #(\d+)\]/g
|
return /\[\s*pasted\s*#\s*(\d+)\s*\]/gi
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createImagePlaceholderRegex() {
|
export function createImagePlaceholderRegex() {
|
||||||
return /\[Image #(\d+)\]/g
|
return /\[\s*Image\s*#\s*(\d+)\s*\]/gi
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMentionRegex() {
|
export function createMentionRegex() {
|
||||||
return /@(\S+)/g
|
return /@(\S+)/g
|
||||||
}
|
}
|
||||||
|
|
||||||
export const pastedDisplayCounterRegex = /pasted #(\d+)/
|
export const pastedDisplayCounterRegex = /pasted #(\d+)/i
|
||||||
export const imageDisplayCounterRegex = /Image #(\d+)/
|
export const imageDisplayCounterRegex = /Image #(\d+)/i
|
||||||
export const bracketedImageDisplayCounterRegex = /\[Image #(\d+)\]/
|
export const bracketedImageDisplayCounterRegex = /\[\s*Image\s*#\s*(\d+)\s*\]/i
|
||||||
|
|
||||||
export function parseCounter(value: string) {
|
export function parseCounter(value: string) {
|
||||||
const parsed = Number.parseInt(value, 10)
|
const parsed = Number.parseInt(value, 10)
|
||||||
return Number.isNaN(parsed) ? null : parsed
|
return Number.isNaN(parsed) ? null : parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findHighestAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) {
|
export function findHighestAttachmentCounters(currentPrompt: string) {
|
||||||
let highestPaste = 0
|
let highestPaste = 0
|
||||||
let highestImage = 0
|
let highestImage = 0
|
||||||
|
|
||||||
@@ -40,27 +38,6 @@ export function findHighestAttachmentCounters(currentPrompt: string, sessionAtta
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const attachment of sessionAttachments) {
|
|
||||||
if (attachment.source.type === "text") {
|
|
||||||
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
|
|
||||||
if (placeholderMatch) {
|
|
||||||
const parsed = parseCounter(placeholderMatch[1])
|
|
||||||
if (parsed !== null) {
|
|
||||||
highestPaste = Math.max(highestPaste, parsed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) {
|
|
||||||
const imageMatch = attachment.display.match(imageDisplayCounterRegex)
|
|
||||||
if (imageMatch) {
|
|
||||||
const parsed = parseCounter(imageMatch[1])
|
|
||||||
if (parsed !== null) {
|
|
||||||
highestImage = Math.max(highestImage, parsed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const match of currentPrompt.matchAll(createImagePlaceholderRegex())) {
|
for (const match of currentPrompt.matchAll(createImagePlaceholderRegex())) {
|
||||||
const parsed = parseCounter(match[1])
|
const parsed = parseCounter(match[1])
|
||||||
if (parsed !== null) {
|
if (parsed !== null) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type PromptInsertMode = "quote" | "code"
|
|||||||
export interface PromptInputApi {
|
export interface PromptInputApi {
|
||||||
insertSelection(text: string, mode: PromptInsertMode): void
|
insertSelection(text: string, mode: PromptInsertMode): void
|
||||||
expandTextAttachment(attachmentId: string): void
|
expandTextAttachment(attachmentId: string): void
|
||||||
|
removeAttachment(attachmentId: string): void
|
||||||
setPromptText(text: string, opts?: { focus?: boolean }): void
|
setPromptText(text: string, opts?: { focus?: boolean }): void
|
||||||
focus(): void
|
focus(): void
|
||||||
}
|
}
|
||||||
@@ -16,6 +17,12 @@ export interface PromptInputProps {
|
|||||||
instanceId: string
|
instanceId: string
|
||||||
instanceFolder: string
|
instanceFolder: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
|
|
||||||
|
// Used to scope global "type-to-focus" behavior.
|
||||||
|
isActive?: boolean
|
||||||
|
|
||||||
|
// Phone/tablet layouts should keep the expanded prompt more compact.
|
||||||
|
compactLayout?: boolean
|
||||||
onSend: (prompt: string, attachments: Attachment[]) => Promise<void>
|
onSend: (prompt: string, attachments: Attachment[]) => Promise<void>
|
||||||
onRunShell?: (command: string) => Promise<void>
|
onRunShell?: (command: string) => Promise<void>
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSignal, type Accessor } from "solid-js"
|
import { createEffect, createSignal, type Accessor } from "solid-js"
|
||||||
import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments"
|
import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments"
|
||||||
import { createFileAttachment, createTextAttachment } from "../../types/attachment"
|
import { createFileAttachment, createTextAttachment } from "../../types/attachment"
|
||||||
import type { Attachment } from "../../types/attachment"
|
import type { Attachment } from "../../types/attachment"
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
findHighestAttachmentCounters,
|
findHighestAttachmentCounters,
|
||||||
formatImagePlaceholder,
|
formatImagePlaceholder,
|
||||||
formatPastedPlaceholder,
|
formatPastedPlaceholder,
|
||||||
|
imageDisplayCounterRegex,
|
||||||
pastedDisplayCounterRegex,
|
pastedDisplayCounterRegex,
|
||||||
} from "./attachmentPlaceholders"
|
} from "./attachmentPlaceholders"
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ type PromptAttachments = {
|
|||||||
attachments: Accessor<Attachment[]>
|
attachments: Accessor<Attachment[]>
|
||||||
pasteCount: Accessor<number>
|
pasteCount: Accessor<number>
|
||||||
imageCount: Accessor<number>
|
imageCount: Accessor<number>
|
||||||
syncAttachmentCounters: (promptText: string, sessionAttachments: Attachment[]) => void
|
syncAttachmentCounters: (promptText: string) => void
|
||||||
|
|
||||||
handlePaste: (e: ClipboardEvent) => Promise<void>
|
handlePaste: (e: ClipboardEvent) => Promise<void>
|
||||||
isDragging: Accessor<boolean>
|
isDragging: Accessor<boolean>
|
||||||
@@ -41,45 +42,106 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
|
|||||||
const [pasteCount, setPasteCount] = createSignal(0)
|
const [pasteCount, setPasteCount] = createSignal(0)
|
||||||
const [imageCount, setImageCount] = createSignal(0)
|
const [imageCount, setImageCount] = createSignal(0)
|
||||||
|
|
||||||
function syncAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) {
|
function syncAttachmentCounters(currentPrompt: string) {
|
||||||
const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt, sessionAttachments)
|
const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt)
|
||||||
setPasteCount(highestPaste)
|
setPasteCount(highestPaste)
|
||||||
setImageCount(highestImage)
|
setImageCount(highestImage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||||
|
|
||||||
|
function removeTokenFromPrompt(currentPrompt: string, tokenRegex: RegExp) {
|
||||||
|
const next = currentPrompt.replace(tokenRegex, "")
|
||||||
|
if (next === currentPrompt) return currentPrompt
|
||||||
|
|
||||||
|
return next
|
||||||
|
.replace(/[ \t]{2,}/g, " ")
|
||||||
|
.replace(/[ \t]+\n/g, "\n")
|
||||||
|
.replace(/\n[ \t]+/g, "\n")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const createLooseImagePlaceholderRegex = (counter: string | number) =>
|
||||||
|
new RegExp(`\\[\\s*Image\\s*#\\s*${counter}\\s*\\]`, "i")
|
||||||
|
const createLoosePastedPlaceholderRegex = (counter: string | number) =>
|
||||||
|
new RegExp(`\\[\\s*pasted\\s*#\\s*${counter}\\s*\\]`, "i")
|
||||||
|
|
||||||
|
// Keep placeholder-backed attachments in sync with prompt text.
|
||||||
|
// If the placeholder token disappears from the prompt, the attachment should disappear too.
|
||||||
|
createEffect(() => {
|
||||||
|
const currentPrompt = options.prompt()
|
||||||
|
const currentAttachments = attachments()
|
||||||
|
|
||||||
|
const toRemove: string[] = []
|
||||||
|
|
||||||
|
for (const attachment of currentAttachments) {
|
||||||
|
if (attachment.source.type === "text") {
|
||||||
|
const match = attachment.display.match(pastedDisplayCounterRegex)
|
||||||
|
if (!match) continue
|
||||||
|
const counter = match[1]
|
||||||
|
if (!createLoosePastedPlaceholderRegex(counter).test(currentPrompt)) {
|
||||||
|
toRemove.push(attachment.id)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) {
|
||||||
|
const match =
|
||||||
|
attachment.display.match(bracketedImageDisplayCounterRegex) || attachment.display.match(imageDisplayCounterRegex)
|
||||||
|
if (!match) continue
|
||||||
|
const counter = match[1]
|
||||||
|
if (!createLooseImagePlaceholderRegex(counter).test(currentPrompt)) {
|
||||||
|
toRemove.push(attachment.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const attachmentId of toRemove) {
|
||||||
|
removeAttachment(options.instanceId(), options.sessionId(), attachmentId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function handleRemoveAttachment(attachmentId: string) {
|
function handleRemoveAttachment(attachmentId: string) {
|
||||||
const currentAttachments = attachments()
|
const currentAttachments = attachments()
|
||||||
const attachment = currentAttachments.find((a) => a.id === attachmentId)
|
const attachment = currentAttachments.find((a) => a.id === attachmentId)
|
||||||
|
|
||||||
|
// Always remove from store.
|
||||||
removeAttachment(options.instanceId(), options.sessionId(), attachmentId)
|
removeAttachment(options.instanceId(), options.sessionId(), attachmentId)
|
||||||
|
|
||||||
if (attachment) {
|
if (!attachment) return
|
||||||
const currentPrompt = options.prompt()
|
|
||||||
let newPrompt = currentPrompt
|
|
||||||
|
|
||||||
if (attachment.source.type === "file") {
|
const currentPrompt = options.prompt()
|
||||||
if (attachment.mediaType.startsWith("image/")) {
|
let nextPrompt = currentPrompt
|
||||||
const imageMatch = attachment.display.match(bracketedImageDisplayCounterRegex)
|
|
||||||
if (imageMatch) {
|
if (attachment.source.type === "file") {
|
||||||
const placeholder = formatImagePlaceholder(imageMatch[1])
|
if (attachment.mediaType.startsWith("image/")) {
|
||||||
newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim()
|
const imageMatch =
|
||||||
}
|
attachment.display.match(bracketedImageDisplayCounterRegex) || attachment.display.match(imageDisplayCounterRegex)
|
||||||
} else {
|
if (imageMatch) {
|
||||||
const filename = attachment.filename
|
nextPrompt = removeTokenFromPrompt(currentPrompt, createLooseImagePlaceholderRegex(imageMatch[1]))
|
||||||
newPrompt = currentPrompt.replace(`@${filename}`, "").replace(/\s+/g, " ").trim()
|
|
||||||
}
|
}
|
||||||
} else if (attachment.source.type === "agent") {
|
} else {
|
||||||
const agentName = attachment.filename
|
// For file mentions we insert `@<path>`, but the chip might display `@<filename>`.
|
||||||
newPrompt = currentPrompt.replace(`@${agentName}`, "").replace(/\s+/g, " ").trim()
|
const candidates = [attachment.source.path, attachment.filename]
|
||||||
} else if (attachment.source.type === "text") {
|
for (const candidate of candidates) {
|
||||||
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
|
if (!candidate) continue
|
||||||
if (placeholderMatch) {
|
const mentionRegex = new RegExp(`@${escapeRegExp(candidate)}(?=\\s|$)`, "i")
|
||||||
const placeholder = formatPastedPlaceholder(placeholderMatch[1])
|
nextPrompt = removeTokenFromPrompt(nextPrompt, mentionRegex)
|
||||||
newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (attachment.source.type === "agent") {
|
||||||
|
const agentName = attachment.filename
|
||||||
|
const mentionRegex = new RegExp(`@${escapeRegExp(agentName)}(?=\\s|$)`, "i")
|
||||||
|
nextPrompt = removeTokenFromPrompt(currentPrompt, mentionRegex)
|
||||||
|
} else if (attachment.source.type === "text") {
|
||||||
|
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
|
||||||
|
if (placeholderMatch) {
|
||||||
|
nextPrompt = removeTokenFromPrompt(currentPrompt, createLoosePastedPlaceholderRegex(placeholderMatch[1]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
options.setPrompt(newPrompt)
|
if (nextPrompt !== currentPrompt) {
|
||||||
|
options.setPrompt(nextPrompt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,13 +205,32 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
|
|||||||
const blob = item.getAsFile()
|
const blob = item.getAsFile()
|
||||||
if (!blob) continue
|
if (!blob) continue
|
||||||
|
|
||||||
const count = imageCount() + 1
|
const { highestImage } = findHighestAttachmentCounters(options.prompt())
|
||||||
|
const count = highestImage + 1
|
||||||
setImageCount(count)
|
setImageCount(count)
|
||||||
|
|
||||||
|
const placeholder = formatImagePlaceholder(count)
|
||||||
|
const textarea = options.getTextarea()
|
||||||
|
|
||||||
|
if (textarea) {
|
||||||
|
const start = textarea.selectionStart
|
||||||
|
const end = textarea.selectionEnd
|
||||||
|
const currentText = options.prompt()
|
||||||
|
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
|
||||||
|
options.setPrompt(newText)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const newCursorPos = start + placeholder.length
|
||||||
|
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||||
|
textarea.focus()
|
||||||
|
}, 0)
|
||||||
|
} else {
|
||||||
|
options.setPrompt(options.prompt() + placeholder)
|
||||||
|
}
|
||||||
|
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
const base64Data = (reader.result as string).split(",")[1]
|
const base64Data = (reader.result as string).split(",")[1]
|
||||||
const display = formatImagePlaceholder(count)
|
|
||||||
const filename = `image-${count}.png`
|
const filename = `image-${count}.png`
|
||||||
|
|
||||||
const attachment = createFileAttachment(
|
const attachment = createFileAttachment(
|
||||||
@@ -160,24 +241,8 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
|
|||||||
options.instanceFolder(),
|
options.instanceFolder(),
|
||||||
)
|
)
|
||||||
attachment.url = `data:image/png;base64,${base64Data}`
|
attachment.url = `data:image/png;base64,${base64Data}`
|
||||||
attachment.display = display
|
attachment.display = placeholder
|
||||||
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||||
|
|
||||||
const textarea = options.getTextarea()
|
|
||||||
if (textarea) {
|
|
||||||
const start = textarea.selectionStart
|
|
||||||
const end = textarea.selectionEnd
|
|
||||||
const currentText = options.prompt()
|
|
||||||
const placeholder = formatImagePlaceholder(count)
|
|
||||||
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
|
|
||||||
options.setPrompt(newText)
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const newCursorPos = start + placeholder.length
|
|
||||||
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
|
||||||
textarea.focus()
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
reader.readAsDataURL(blob)
|
reader.readAsDataURL(blob)
|
||||||
|
|
||||||
@@ -196,7 +261,8 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
|
|||||||
if (isLongPaste) {
|
if (isLongPaste) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
const count = pasteCount() + 1
|
const { highestPaste } = findHighestAttachmentCounters(options.prompt())
|
||||||
|
const count = highestPaste + 1
|
||||||
setPasteCount(count)
|
setPasteCount(count)
|
||||||
|
|
||||||
const summary = lineCount > 1 ? `${lineCount} lines` : `${charCount} chars`
|
const summary = lineCount > 1 ? `${lineCount} lines` : `${charCount} chars`
|
||||||
@@ -204,14 +270,12 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
|
|||||||
const filename = `paste-${count}.txt`
|
const filename = `paste-${count}.txt`
|
||||||
|
|
||||||
const attachment = createTextAttachment(pastedText, display, filename)
|
const attachment = createTextAttachment(pastedText, display, filename)
|
||||||
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
const placeholder = formatPastedPlaceholder(count)
|
||||||
|
|
||||||
const textarea = options.getTextarea()
|
const textarea = options.getTextarea()
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
const start = textarea.selectionStart
|
const start = textarea.selectionStart
|
||||||
const end = textarea.selectionEnd
|
const end = textarea.selectionEnd
|
||||||
const currentText = options.prompt()
|
const currentText = options.prompt()
|
||||||
const placeholder = formatPastedPlaceholder(count)
|
|
||||||
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
|
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
|
||||||
options.setPrompt(newText)
|
options.setPrompt(newText)
|
||||||
|
|
||||||
@@ -220,7 +284,11 @@ export function usePromptAttachments(options: PromptAttachmentsOptions): PromptA
|
|||||||
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||||
textarea.focus()
|
textarea.focus()
|
||||||
}, 0)
|
}, 0)
|
||||||
|
} else {
|
||||||
|
options.setPrompt(options.prompt() + placeholder)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -183,9 +183,25 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
|
|||||||
|
|
||||||
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
|
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
|
||||||
const currentAttachments = options.getAttachments()
|
const currentAttachments = options.getAttachments()
|
||||||
const attachment = currentAttachments.find(
|
const attachment = currentAttachments.find((a) => {
|
||||||
(a) => (a.source.type === "file" || a.source.type === "agent") && a.filename === name,
|
if (a.source.type === "agent") {
|
||||||
)
|
return a.filename === name
|
||||||
|
}
|
||||||
|
if (a.source.type === "file") {
|
||||||
|
// Match either by filename (basename) or by path (for full paths like @docs/file.txt)
|
||||||
|
return (
|
||||||
|
a.filename === name ||
|
||||||
|
a.source.path === name ||
|
||||||
|
a.source.path.endsWith("/" + name) ||
|
||||||
|
a.source.path === name.replace(/\/$/, "")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (a.source.type === "text") {
|
||||||
|
// For text attachments (path-only mentions), match by value
|
||||||
|
return a.source.value === name || a.source.value.endsWith("/" + name)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
if (attachment) {
|
if (attachment) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -205,6 +221,14 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
|
|||||||
textarea.setSelectionRange(mentionStart, mentionStart)
|
textarea.setSelectionRange(mentionStart, mentionStart)
|
||||||
}, 0)
|
}, 0)
|
||||||
|
|
||||||
|
// Check if there are any @ remaining in the text - if not, close the picker
|
||||||
|
if (!newText.includes("@") && options.isPickerOpen()) {
|
||||||
|
options.closePicker()
|
||||||
|
// Clear ignoredAtPositions since we deleted the entire @mention
|
||||||
|
// This ensures typing @ again will open the picker
|
||||||
|
options.setIgnoredAtPositions(new Set())
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { createSignal, type Accessor, type Setter } from "solid-js"
|
import { createSignal, type Accessor, type Setter } from "solid-js"
|
||||||
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
|
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
|
||||||
import type { Agent } from "../../types/session"
|
import type { Agent } from "../../types/session"
|
||||||
import { createAgentAttachment, createFileAttachment } from "../../types/attachment"
|
import { createAgentAttachment, createFileAttachment, createTextAttachment } from "../../types/attachment"
|
||||||
import { addAttachment, getAttachments } from "../../stores/attachments"
|
import { addAttachment, getAttachments } from "../../stores/attachments"
|
||||||
import type { PickerMode } from "./types"
|
import type { PickerMode } from "./types"
|
||||||
|
import type { PickerSelectAction } from "../unified-picker"
|
||||||
|
|
||||||
type PickerItem =
|
type PickerItem =
|
||||||
| { type: "agent"; agent: Agent }
|
| { type: "agent"; agent: Agent }
|
||||||
@@ -37,7 +38,7 @@ type PromptPickerController = {
|
|||||||
setIgnoredAtPositions: Setter<Set<number>>
|
setIgnoredAtPositions: Setter<Set<number>>
|
||||||
|
|
||||||
handleInput: (e: Event) => void
|
handleInput: (e: Event) => void
|
||||||
handlePickerSelect: (item: PickerItem) => void
|
handlePickerSelect: (item: PickerItem, action: PickerSelectAction) => void
|
||||||
handlePickerClose: () => void
|
handlePickerClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,10 +104,11 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
setAtPosition(null)
|
setAtPosition(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePickerSelect(item: PickerItem) {
|
function handlePickerSelect(item: PickerItem, action: PickerSelectAction) {
|
||||||
const textarea = options.getTextarea()
|
const textarea = options.getTextarea()
|
||||||
|
|
||||||
if (item.type === "command") {
|
if (item.type === "command") {
|
||||||
|
// For commands, Tab/Enter/Shift+Enter/click all mean "select".
|
||||||
const name = item.command.name
|
const name = item.command.name
|
||||||
const currentPrompt = options.prompt()
|
const currentPrompt = options.prompt()
|
||||||
|
|
||||||
@@ -128,6 +130,7 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
}
|
}
|
||||||
}, 0)
|
}, 0)
|
||||||
} else if (item.type === "agent") {
|
} else if (item.type === "agent") {
|
||||||
|
// For agents, Tab/Enter/Shift+Enter/click all mean "select".
|
||||||
const agentName = item.agent.name
|
const agentName = item.agent.name
|
||||||
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
||||||
const alreadyAttached = existingAttachments.some(
|
const alreadyAttached = existingAttachments.some(
|
||||||
@@ -163,76 +166,152 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
const relativePath = item.file.relativePath ?? displayPath
|
const relativePath = item.file.relativePath ?? displayPath
|
||||||
const isFolder = item.file.isDirectory ?? displayPath.endsWith("/")
|
const isFolder = item.file.isDirectory ?? displayPath.endsWith("/")
|
||||||
|
|
||||||
if (isFolder) {
|
|
||||||
const currentPrompt = options.prompt()
|
|
||||||
const pos = atPosition()
|
|
||||||
const cursorPos = textarea?.selectionStart || 0
|
|
||||||
const folderMention =
|
|
||||||
relativePath === "." || relativePath === ""
|
|
||||||
? "/"
|
|
||||||
: relativePath.replace(/\/+$/, "") + "/"
|
|
||||||
|
|
||||||
if (pos !== null) {
|
|
||||||
const before = currentPrompt.substring(0, pos + 1)
|
|
||||||
const after = currentPrompt.substring(cursorPos)
|
|
||||||
const newPrompt = before + folderMention + after
|
|
||||||
options.setPrompt(newPrompt)
|
|
||||||
setSearchQuery(folderMention)
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const nextTextarea = options.getTextarea()
|
|
||||||
if (nextTextarea) {
|
|
||||||
const newCursorPos = pos + 1 + folderMention.length
|
|
||||||
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
|
|
||||||
}
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath
|
|
||||||
const pathSegments = normalizedPath.split("/")
|
|
||||||
const filename = (() => {
|
|
||||||
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
|
|
||||||
return candidate === "." ? "/" : candidate
|
|
||||||
})()
|
|
||||||
|
|
||||||
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
|
||||||
const alreadyAttached = existingAttachments.some(
|
|
||||||
(att) => att.source.type === "file" && att.source.path === normalizedPath,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!alreadyAttached) {
|
|
||||||
const attachment = createFileAttachment(
|
|
||||||
normalizedPath,
|
|
||||||
filename,
|
|
||||||
"text/plain",
|
|
||||||
undefined,
|
|
||||||
options.instanceFolder(),
|
|
||||||
)
|
|
||||||
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentPrompt = options.prompt()
|
|
||||||
const pos = atPosition()
|
const pos = atPosition()
|
||||||
const cursorPos = textarea?.selectionStart || 0
|
const cursorPos = textarea?.selectionStart || 0
|
||||||
|
|
||||||
if (pos !== null) {
|
const replaceMentionToken = (mentionText: string, opts?: { trailingSpace?: boolean }) => {
|
||||||
|
if (pos === null) return
|
||||||
|
const currentPrompt = options.prompt()
|
||||||
const before = currentPrompt.substring(0, pos)
|
const before = currentPrompt.substring(0, pos)
|
||||||
const after = currentPrompt.substring(cursorPos)
|
const after = currentPrompt.substring(cursorPos)
|
||||||
const attachmentText = `@${normalizedPath}`
|
const suffix = opts?.trailingSpace ? " " : ""
|
||||||
const newPrompt = before + attachmentText + " " + after
|
const nextPrompt = before + mentionText + suffix + after
|
||||||
options.setPrompt(newPrompt)
|
options.setPrompt(nextPrompt)
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const nextTextarea = options.getTextarea()
|
const nextTextarea = options.getTextarea()
|
||||||
if (nextTextarea) {
|
if (!nextTextarea) return
|
||||||
const newCursorPos = pos + attachmentText.length + 1
|
const nextCursorPos = pos + mentionText.length + suffix.length
|
||||||
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
|
nextTextarea.setSelectionRange(nextCursorPos, nextCursorPos)
|
||||||
}
|
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const replaceMentionQueryAfterAt = (value: string) => {
|
||||||
|
// Replaces only the query after '@' (keeps the '@' itself). Used for directory navigation.
|
||||||
|
if (pos === null) return
|
||||||
|
const currentPrompt = options.prompt()
|
||||||
|
const before = currentPrompt.substring(0, pos + 1)
|
||||||
|
const after = currentPrompt.substring(cursorPos)
|
||||||
|
const nextPrompt = before + value + after
|
||||||
|
options.setPrompt(nextPrompt)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const nextTextarea = options.getTextarea()
|
||||||
|
if (!nextTextarea) return
|
||||||
|
const nextCursorPos = pos + 1 + value.length
|
||||||
|
nextTextarea.setSelectionRange(nextCursorPos, nextCursorPos)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderMention =
|
||||||
|
relativePath === "." || relativePath === "" || relativePath === "./"
|
||||||
|
? "./"
|
||||||
|
: (relativePath.startsWith("./") ? relativePath.replace(/\/+$/, "") + "/" : relativePath.replace(/^\.\//, "").replace(/\/+$/, "") + "/")
|
||||||
|
|
||||||
|
const normalizedFolderPath = (() => {
|
||||||
|
const trimmed = relativePath.replace(/\/+$/, "")
|
||||||
|
// If it's root "./", just return "./"
|
||||||
|
if (trimmed === "" || trimmed === ".") return "./"
|
||||||
|
// Otherwise remove any leading ./ and add ./ prefix
|
||||||
|
return "./" + trimmed.replace(/^\.\//, "")
|
||||||
|
})()
|
||||||
|
|
||||||
|
const addPathOnlyAttachment = (value: string) => {
|
||||||
|
const display = `path: ${value}`
|
||||||
|
const filename = value
|
||||||
|
const existing = getAttachments(options.instanceId(), options.sessionId())
|
||||||
|
const alreadyAttached = existing.some(
|
||||||
|
(att) => att.source.type === "text" && att.source.value === value && att.display === display,
|
||||||
|
)
|
||||||
|
if (!alreadyAttached) {
|
||||||
|
addAttachment(options.instanceId(), options.sessionId(), createTextAttachment(value, display, filename))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFolder) {
|
||||||
|
if (action === "tab") {
|
||||||
|
// TAB on directory: autocomplete directory name and show its contents.
|
||||||
|
replaceMentionQueryAfterAt(folderMention)
|
||||||
|
setSearchQuery(folderMention)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentionText = `@${folderMention}`
|
||||||
|
|
||||||
|
if (action === "shiftEnter") {
|
||||||
|
// SHIFT+ENTER on directory: keep @path in prompt, add text attachment, remove @ when sending
|
||||||
|
// Always prefix with ./ for consistency
|
||||||
|
const normalizedFolderPathWithPrefix = normalizedFolderPath.startsWith("./") ? normalizedFolderPath : "./" + normalizedFolderPath
|
||||||
|
addPathOnlyAttachment(normalizedFolderPathWithPrefix)
|
||||||
|
replaceMentionToken(mentionText, { trailingSpace: true })
|
||||||
|
} else {
|
||||||
|
// ENTER/click on directory: attach as a file part pointing at a file:// directory URL.
|
||||||
|
const dirLabel = normalizedFolderPath === "./" ? "./" : normalizedFolderPath.split("/").pop() || normalizedFolderPath
|
||||||
|
const dirFilename = dirLabel.endsWith("/") ? dirLabel : `${dirLabel}/`
|
||||||
|
|
||||||
|
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
||||||
|
const alreadyAttached = existingAttachments.some(
|
||||||
|
(att) => att.source.type === "file" && att.source.path === normalizedFolderPath && att.source.mime === "inode/directory",
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!alreadyAttached) {
|
||||||
|
const attachment = createFileAttachment(
|
||||||
|
normalizedFolderPath,
|
||||||
|
dirFilename,
|
||||||
|
"inode/directory",
|
||||||
|
undefined,
|
||||||
|
options.instanceFolder(),
|
||||||
|
)
|
||||||
|
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceMentionToken(mentionText, { trailingSpace: true })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath
|
||||||
|
|
||||||
|
if (action === "tab") {
|
||||||
|
// TAB on file: autocomplete the file path but do not attach.
|
||||||
|
replaceMentionToken(`@${normalizedPath}`)
|
||||||
|
setSearchQuery(normalizedPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "shiftEnter") {
|
||||||
|
// SHIFT+ENTER on file: keep @path in prompt, add text attachment, remove @ when sending
|
||||||
|
// Always prefix with ./ for consistency
|
||||||
|
const normalizedPathWithPrefix = normalizedPath.startsWith("./") ? normalizedPath : "./" + normalizedPath
|
||||||
|
addPathOnlyAttachment(normalizedPathWithPrefix)
|
||||||
|
replaceMentionToken(`@${normalizedPathWithPrefix}`, { trailingSpace: true })
|
||||||
|
} else {
|
||||||
|
// ENTER/click on file: attach file (existing behavior).
|
||||||
|
// Always prefix with ./ for consistency
|
||||||
|
const normalizedPathWithPrefix = normalizedPath.startsWith("./") ? normalizedPath : "./" + normalizedPath
|
||||||
|
const pathSegments = normalizedPath.split("/")
|
||||||
|
const filename = (() => {
|
||||||
|
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
|
||||||
|
return candidate === "." ? "/" : candidate
|
||||||
|
})()
|
||||||
|
|
||||||
|
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
||||||
|
const alreadyAttached = existingAttachments.some(
|
||||||
|
(att) => att.source.type === "file" && att.source.path === normalizedPathWithPrefix,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!alreadyAttached) {
|
||||||
|
const attachment = createFileAttachment(
|
||||||
|
normalizedPathWithPrefix,
|
||||||
|
filename,
|
||||||
|
"text/plain",
|
||||||
|
undefined,
|
||||||
|
options.instanceFolder(),
|
||||||
|
)
|
||||||
|
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceMentionToken(`@${normalizedPathWithPrefix}`, { trailingSpace: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowPicker(false)
|
setShowPicker(false)
|
||||||
@@ -245,6 +324,28 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
const pos = atPosition()
|
const pos = atPosition()
|
||||||
if (pickerMode() === "mention" && pos !== null) {
|
if (pickerMode() === "mention" && pos !== null) {
|
||||||
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
|
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
|
||||||
|
|
||||||
|
// Remove the partial @mention text from the textarea when ESC is pressed
|
||||||
|
const textarea = options.getTextarea()
|
||||||
|
if (textarea) {
|
||||||
|
const currentPrompt = options.prompt()
|
||||||
|
const cursorPos = textarea.selectionStart
|
||||||
|
// Remove text from @ position to cursor position
|
||||||
|
const before = currentPrompt.substring(0, pos)
|
||||||
|
const after = currentPrompt.substring(cursorPos)
|
||||||
|
options.setPrompt(before + after)
|
||||||
|
|
||||||
|
// Restore cursor position to where @ was
|
||||||
|
setTimeout(() => {
|
||||||
|
const nextTextarea = options.getTextarea()
|
||||||
|
if (nextTextarea) {
|
||||||
|
nextTextarea.setSelectionRange(pos, pos)
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
// Clear ignoredAtPositions so typing @ again will work
|
||||||
|
setIgnoredAtPositions(new Set<number>())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setShowPicker(false)
|
setShowPicker(false)
|
||||||
setAtPosition(null)
|
setAtPosition(null)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-so
|
|||||||
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
|
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
|
||||||
import { serverApi } from "../lib/api-client"
|
import { serverApi } from "../lib/api-client"
|
||||||
import { restartCli } from "../lib/native/cli"
|
import { restartCli } from "../lib/native/cli"
|
||||||
import { preferences, setListeningMode } from "../stores/preferences"
|
import { serverSettings, setListeningMode } from "../stores/preferences"
|
||||||
import { showConfirmDialog } from "../stores/alerts"
|
import { showConfirmDialog } from "../stores/alerts"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
@@ -23,6 +23,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
|
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
|
||||||
const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null)
|
const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null)
|
||||||
const [loading, setLoading] = createSignal(false)
|
const [loading, setLoading] = createSignal(false)
|
||||||
|
const [applyingListeningMode, setApplyingListeningMode] = createSignal(false)
|
||||||
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
|
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
|
||||||
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
|
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
|
||||||
const [error, setError] = createSignal<string | null>(null)
|
const [error, setError] = createSignal<string | null>(null)
|
||||||
@@ -33,7 +34,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
const [savingPassword, setSavingPassword] = createSignal(false)
|
const [savingPassword, setSavingPassword] = createSignal(false)
|
||||||
|
|
||||||
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
||||||
const currentMode = createMemo(() => meta()?.listeningMode ?? preferences().listeningMode)
|
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
||||||
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
||||||
const displayAddresses = createMemo(() => {
|
const displayAddresses = createMemo(() => {
|
||||||
const list = addresses()
|
const list = addresses()
|
||||||
@@ -88,6 +89,10 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (applyingListeningMode()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), {
|
const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), {
|
||||||
title: allow ? t("remoteAccess.listeningMode.restartConfirm.title.all") : t("remoteAccess.listeningMode.restartConfirm.title.local"),
|
title: allow ? t("remoteAccess.listeningMode.restartConfirm.title.all") : t("remoteAccess.listeningMode.restartConfirm.title.local"),
|
||||||
variant: "warning",
|
variant: "warning",
|
||||||
@@ -100,12 +105,21 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setListeningMode(targetMode)
|
setApplyingListeningMode(true)
|
||||||
const restarted = await restartCli()
|
setError(null)
|
||||||
if (!restarted) {
|
try {
|
||||||
setError(t("remoteAccess.restart.errorManual"))
|
// Important: await the config patch before restart so Electron reads the updated mode from disk.
|
||||||
} else {
|
await setListeningMode(targetMode)
|
||||||
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
|
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()
|
void refreshMeta()
|
||||||
@@ -196,6 +210,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
onChange={(nextChecked) => {
|
onChange={(nextChecked) => {
|
||||||
void handleAllowConnectionsChange(nextChecked)
|
void handleAllowConnectionsChange(nextChecked)
|
||||||
}}
|
}}
|
||||||
|
disabled={loading() || applyingListeningMode()}
|
||||||
>
|
>
|
||||||
<Switch.Input />
|
<Switch.Input />
|
||||||
<Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}>
|
<Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}>
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
|
|||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<kbd class="kbd ml-2">
|
<kbd class="kbd ml-2 kbd-hint">
|
||||||
Cmd+Enter
|
Cmd+Enter
|
||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -28,6 +29,8 @@ interface SessionViewProps {
|
|||||||
instanceId: string
|
instanceId: string
|
||||||
instanceFolder: string
|
instanceFolder: string
|
||||||
escapeInDebounce: boolean
|
escapeInDebounce: boolean
|
||||||
|
isPhoneLayout?: boolean
|
||||||
|
compactPromptLayout?: boolean
|
||||||
showSidebarToggle?: boolean
|
showSidebarToggle?: boolean
|
||||||
onSidebarToggle?: () => void
|
onSidebarToggle?: () => void
|
||||||
forceCompactStatusLayout?: boolean
|
forceCompactStatusLayout?: boolean
|
||||||
@@ -53,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(() => {
|
||||||
@@ -67,6 +80,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!props.isActive) return
|
if (!props.isActive) return
|
||||||
|
if (!shouldScrollToBottomOnActivate()) return
|
||||||
scheduleScrollToBottom()
|
scheduleScrollToBottom()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -76,6 +90,9 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
(isActive) => {
|
(isActive) => {
|
||||||
if (!isActive) return
|
if (!isActive) return
|
||||||
|
|
||||||
|
// On phones, focusing the prompt on session switch is disruptive (it raises the OSK).
|
||||||
|
if (props.isPhoneLayout) return
|
||||||
|
|
||||||
// Don't steal focus from other inputs (command palette, dialogs, selectors, etc.)
|
// Don't steal focus from other inputs (command palette, dialogs, selectors, etc.)
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
const activeEl = document.activeElement as HTMLElement | null
|
const activeEl = document.activeElement as HTMLElement | null
|
||||||
@@ -220,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")
|
||||||
@@ -278,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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
||||||
@@ -299,26 +348,34 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
<Show when={attachments().length > 0}>
|
<Show when={attachments().length > 0}>
|
||||||
<PromptAttachmentsBar
|
<PromptAttachmentsBar
|
||||||
attachments={attachments()}
|
attachments={attachments()}
|
||||||
onRemoveAttachment={(attachmentId) => removeAttachment(props.instanceId, props.sessionId, attachmentId)}
|
onRemoveAttachment={(attachmentId) => {
|
||||||
onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)}
|
if (promptInputApi) {
|
||||||
/>
|
promptInputApi.removeAttachment(attachmentId)
|
||||||
</Show>
|
return
|
||||||
|
}
|
||||||
|
removeAttachment(props.instanceId, props.sessionId, attachmentId)
|
||||||
|
}}
|
||||||
|
onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<PromptInput
|
<PromptInput
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
instanceFolder={props.instanceFolder}
|
instanceFolder={props.instanceFolder}
|
||||||
sessionId={activeSession.id}
|
sessionId={activeSession.id}
|
||||||
onSend={handleSendMessage}
|
isActive={props.isActive}
|
||||||
onRunShell={handleRunShell}
|
compactLayout={props.compactPromptLayout}
|
||||||
escapeInDebounce={props.escapeInDebounce}
|
onSend={handleSendMessage}
|
||||||
isSessionBusy={sessionBusy()}
|
onRunShell={handleRunShell}
|
||||||
disabled={sessionNeedsInput()}
|
escapeInDebounce={props.escapeInDebounce}
|
||||||
onAbortSession={handleAbortSession}
|
isSessionBusy={sessionBusy()}
|
||||||
registerPromptInputApi={registerPromptInputApi}
|
disabled={sessionNeedsInput()}
|
||||||
/>
|
onAbortSession={handleAbortSession}
|
||||||
|
registerPromptInputApi={registerPromptInputApi}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user