Compare commits
206 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 | ||
|
|
3382736f05 | ||
|
|
fd5941fb36 | ||
|
|
9b76521a90 | ||
|
|
ea92c0609d | ||
|
|
612e50808a | ||
|
|
2c24402742 | ||
|
|
d7c4bf1e45 | ||
|
|
5bfb09c73b | ||
|
|
fd499d95e6 | ||
|
|
204b2e020b | ||
|
|
d34e0163e3 | ||
|
|
a93252621a | ||
|
|
8ce7a9b4ee | ||
|
|
63ffb86ea7 | ||
|
|
bd9a8d9788 | ||
|
|
d291c2f074 | ||
|
|
16c2eeca3e | ||
|
|
d9d281af8c | ||
|
|
56a6364f99 | ||
|
|
ba20dd6f2f | ||
|
|
0d96a9f9ff | ||
|
|
ee9da95044 | ||
|
|
0511d92cbf | ||
|
|
e666ac333c | ||
|
|
8495dcd021 | ||
|
|
01ab2f2794 | ||
|
|
b59e85abda | ||
|
|
4eded9e204 | ||
|
|
90164aa507 | ||
|
|
f87c83cadd | ||
|
|
01300a81de | ||
|
|
d143faf8eb | ||
|
|
8c29741830 | ||
|
|
d360089b80 | ||
|
|
4279b25ff4 |
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
|
||||||
|
|||||||
33
.github/workflows/reusable-release.yml
vendored
33
.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
|
||||||
|
if [ "${IS_PRERELEASE}" = "true" ]; then
|
||||||
|
gh release create "$TAG" --title "$TAG" --generate-notes --prerelease
|
||||||
else
|
else
|
||||||
gh release create "$TAG" --title "$TAG" --generate-notes
|
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
|
||||||
|
|||||||
29
AGENTS.md
29
AGENTS.md
@@ -15,6 +15,35 @@
|
|||||||
- Prefer composable primitives (signals, hooks, utilities) over deep inheritance or implicit global state.
|
- Prefer composable primitives (signals, hooks, utilities) over deep inheritance or implicit global state.
|
||||||
- When adding platform integrations (SSE, IPC, SDK), isolate them in thin adapters that surface typed events/actions.
|
- When adding platform integrations (SSE, IPC, SDK), isolate them in thin adapters that surface typed events/actions.
|
||||||
|
|
||||||
|
## Multi-Language Support (i18n)
|
||||||
|
|
||||||
|
The UI uses a small custom i18n layer (no ICU/messageformat). When building features, never hardcode user-visible strings.
|
||||||
|
|
||||||
|
- **Runtime API:** use `useI18n()` in components (`const { t } = useI18n();`) and `tGlobal(...)` in stores/non-component code.
|
||||||
|
- Implementation: `packages/ui/src/lib/i18n/index.tsx`
|
||||||
|
- **Where messages live:** `packages/ui/src/lib/i18n/messages/<locale>/` as TypeScript objects (`"flat.dot.keys": "string"`).
|
||||||
|
- Each locale has an `index.ts` that merges message parts; duplicate keys throw at build time.
|
||||||
|
- Merge helper: `packages/ui/src/lib/i18n/messages/merge.ts`
|
||||||
|
- **Adding a new string:** add it to the appropriate `.../messages/en/*.ts` part file, then add the same key to each other locale’s corresponding file.
|
||||||
|
- Missing translations fall back to English (and finally to the key), so gaps can be easy to miss.
|
||||||
|
- **Interpolation:** placeholders are simple `{name}` replacements (word characters only). Avoid placeholders like `{file-name}`.
|
||||||
|
- **Pluralization:** handle manually via separate keys like `something.one` / `something.other` and choose in code.
|
||||||
|
- **Adding a new language:** add a new `messages/<locale>/` folder + `index.ts`, register it in `packages/ui/src/lib/i18n/index.tsx`, and add it to the language picker in `packages/ui/src/components/folder-selection-view.tsx`.
|
||||||
|
- **Locale persistence:** the selected locale is stored in app preferences (`locale`) and persisted via the server config (default `~/.config/codenomad/config.json`).
|
||||||
|
- **Avoid English-only paths:** do not import `enMessages` directly in feature code; always go through `t(...)` so locale changes apply.
|
||||||
|
|
||||||
|
## File Length Guidelines (Highlight Only)
|
||||||
|
|
||||||
|
We track file size as a refactoring signal. When you touch or create files, highlight oversized files so the team can plan refactors when time permits.
|
||||||
|
|
||||||
|
- Source files: warn after ~500 lines; target limit ~800 lines
|
||||||
|
- Test files: highlight after ~1000 lines
|
||||||
|
|
||||||
|
Behavior for agents:
|
||||||
|
- Do not refactor solely to satisfy these thresholds.
|
||||||
|
- When a change touches a file that exceeds the warning/limit, mention it in your final response and include the file path and approximate line count.
|
||||||
|
- When creating new files, aim to stay under the thresholds unless there's a clear reason.
|
||||||
|
|
||||||
## Tooling Preferences
|
## Tooling Preferences
|
||||||
- Use the `edit` tool for modifying existing files; prefer it over other editing methods.
|
- Use the `edit` tool for modifying existing files; prefer it over other editing methods.
|
||||||
- Use the `write` tool only when creating new files from scratch.
|
- Use the `write` tool only when creating new files from scratch.
|
||||||
|
|||||||
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)
|
||||||
|
|
||||||
|
|||||||
67
package-lock.json
generated
67
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.10.2",
|
"version": "0.12.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.10.2",
|
"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",
|
||||||
@@ -8008,6 +8025,12 @@
|
|||||||
"obliterator": "^2.0.1"
|
"obliterator": "^2.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/monaco-editor": {
|
||||||
|
"version": "0.52.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
|
||||||
|
"integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@@ -11873,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,
|
||||||
@@ -11964,15 +12002,17 @@
|
|||||||
},
|
},
|
||||||
"packages/electron-app": {
|
"packages/electron-app": {
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.10.2",
|
"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",
|
||||||
@@ -11999,7 +12039,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.10.2",
|
"version": "0.12.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
@@ -12011,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"
|
||||||
},
|
},
|
||||||
@@ -12039,7 +12080,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tauri-app": {
|
"packages/tauri-app": {
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.10.2",
|
"version": "0.12.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
@@ -12047,12 +12088,12 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.10.2",
|
"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",
|
||||||
@@ -12064,11 +12105,13 @@
|
|||||||
"github-markdown-css": "^5.8.1",
|
"github-markdown-css": "^5.8.1",
|
||||||
"lucide-solid": "^0.300.0",
|
"lucide-solid": "^0.300.0",
|
||||||
"marked": "^12.0.0",
|
"marked": "^12.0.0",
|
||||||
|
"monaco-editor": "^0.52.2",
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"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.2",
|
"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.2",
|
"minServerVersion": "0.12.3",
|
||||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineConfig, externalizeDepsPlugin } from "electron-vite"
|
import { defineConfig, externalizeDepsPlugin } from "electron-vite"
|
||||||
import solid from "vite-plugin-solid"
|
import solid from "vite-plugin-solid"
|
||||||
import { resolve } from "path"
|
import { resolve } from "path"
|
||||||
|
import { copyMonacoPublicAssets } from "../ui/scripts/monaco-public-assets.js"
|
||||||
|
|
||||||
const uiRoot = resolve(__dirname, "../ui")
|
const uiRoot = resolve(__dirname, "../ui")
|
||||||
const uiSrc = resolve(uiRoot, "src")
|
const uiSrc = resolve(uiRoot, "src")
|
||||||
@@ -8,6 +9,32 @@ const uiRendererRoot = resolve(uiRoot, "src/renderer")
|
|||||||
const uiRendererEntry = resolve(uiRendererRoot, "index.html")
|
const uiRendererEntry = resolve(uiRendererRoot, "index.html")
|
||||||
const uiRendererLoadingEntry = resolve(uiRendererRoot, "loading.html")
|
const uiRendererLoadingEntry = resolve(uiRendererRoot, "loading.html")
|
||||||
|
|
||||||
|
function prepareMonacoPublicAssets() {
|
||||||
|
return {
|
||||||
|
name: "prepare-monaco-public-assets",
|
||||||
|
configureServer(server: any) {
|
||||||
|
copyMonacoPublicAssets({
|
||||||
|
uiRendererRoot: uiRendererRoot,
|
||||||
|
warn: (msg: string) => server.config.logger.warn(msg),
|
||||||
|
sourceRoots: [
|
||||||
|
resolve(__dirname, "../../node_modules/monaco-editor/min/vs"),
|
||||||
|
resolve(uiRoot, "node_modules/monaco-editor/min/vs"),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
buildStart(this: any) {
|
||||||
|
copyMonacoPublicAssets({
|
||||||
|
uiRendererRoot: uiRendererRoot,
|
||||||
|
warn: (msg: string) => this.warn(msg),
|
||||||
|
sourceRoots: [
|
||||||
|
resolve(__dirname, "../../node_modules/monaco-editor/min/vs"),
|
||||||
|
resolve(uiRoot, "node_modules/monaco-editor/min/vs"),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
main: {
|
main: {
|
||||||
plugins: [externalizeDepsPlugin()],
|
plugins: [externalizeDepsPlugin()],
|
||||||
@@ -40,7 +67,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
renderer: {
|
renderer: {
|
||||||
root: uiRendererRoot,
|
root: uiRendererRoot,
|
||||||
plugins: [solid()],
|
plugins: [solid(), prepareMonacoPublicAssets()],
|
||||||
css: {
|
css: {
|
||||||
postcss: resolve(uiRoot, "postcss.config.js"),
|
postcss: resolve(uiRoot, "postcss.config.js"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -399,7 +399,11 @@ async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<b
|
|||||||
|
|
||||||
async function startCli() {
|
async function startCli() {
|
||||||
try {
|
try {
|
||||||
const devMode = process.env.NODE_ENV === "development"
|
// In desktop dev workflows we always want the CLI to run in dev mode so it:
|
||||||
|
// - uses plain HTTP
|
||||||
|
// - proxies UI requests to the renderer dev server
|
||||||
|
// Monaco's AMD assets are served from that dev server.
|
||||||
|
const devMode = !app.isPackaged
|
||||||
console.info("[cli] start requested (dev mode:", devMode, ")")
|
console.info("[cli] start requested (dev mode:", devMode, ")")
|
||||||
await cliManager.start({ dev: devMode })
|
await cliManager.start({ dev: devMode })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -390,7 +430,10 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (options.dev) {
|
if (options.dev) {
|
||||||
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
|
const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000"
|
||||||
|
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.2",
|
"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.2",
|
"version": "0.12.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.10.2",
|
"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.2",
|
"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),
|
||||||
@@ -32,6 +33,8 @@ const PreferencesSchema = z.object({
|
|||||||
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
|
||||||
|
.object({
|
||||||
preferences: PreferencesSchema.default({}),
|
preferences: PreferencesSchema.default({}),
|
||||||
recentFolders: z.array(RecentFolderSchema).default([]),
|
recentFolders: z.array(RecentFolderSchema).default([]),
|
||||||
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
|
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
|
||||||
theme: z.enum(["light", "dark", "system"]).optional(),
|
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,7 +472,30 @@ async function proxyWorkspaceRequest(args: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const directory = await resolveWorktreeDirectory({
|
let extracted: { overrideDirectory: string | null; forwardedSuffix: string | undefined }
|
||||||
|
try {
|
||||||
|
extracted = extractOpencodeDirectoryOverride(args.pathSuffix)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Invalid directory override"
|
||||||
|
reply.code(400).send({ error: message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let directory: string | null = null
|
||||||
|
let forwardedSuffix = extracted.forwardedSuffix
|
||||||
|
|
||||||
|
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,
|
workspaceId,
|
||||||
workspacePath: workspace.path,
|
workspacePath: workspace.path,
|
||||||
worktreeSlug,
|
worktreeSlug,
|
||||||
@@ -470,8 +506,9 @@ async function proxyWorkspaceRequest(args: {
|
|||||||
reply.code(404).send({ error: "Worktree not found" })
|
reply.code(404).send({ error: "Worktree not found" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
|
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> {
|
||||||
@@ -116,12 +173,26 @@ export class WorkspaceRuntime {
|
|||||||
folder: options.folder,
|
folder: options.folder,
|
||||||
binary: options.binaryPath,
|
binary: options.binaryPath,
|
||||||
spawnCommand: spec.command,
|
spawnCommand: spec.command,
|
||||||
spawnArgs: spec.args,
|
|
||||||
commandLine,
|
commandLine,
|
||||||
env: redactEnvironment(env),
|
|
||||||
},
|
},
|
||||||
"Launching OpenCode process",
|
"Launching OpenCode process",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
{
|
||||||
|
workspaceId: options.workspaceId,
|
||||||
|
spawnArgs: spec.args,
|
||||||
|
},
|
||||||
|
"OpenCode spawn args",
|
||||||
|
)
|
||||||
|
|
||||||
|
this.logger.trace(
|
||||||
|
{
|
||||||
|
workspaceId: options.workspaceId,
|
||||||
|
env: redactEnvironment(env),
|
||||||
|
},
|
||||||
|
"OpenCode spawn environment",
|
||||||
|
)
|
||||||
const detached = process.platform !== "win32"
|
const detached = process.platform !== "win32"
|
||||||
const child = spawn(spec.command, spec.args, {
|
const child = spawn(spec.command, spec.args, {
|
||||||
cwd: options.folder,
|
cwd: options.folder,
|
||||||
|
|||||||
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.2",
|
"version": "0.12.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
const fs = require("fs")
|
const fs = require("fs")
|
||||||
const path = require("path")
|
const path = require("path")
|
||||||
const { execSync } = require("child_process")
|
const { execSync } = require("child_process")
|
||||||
|
const { pathToFileURL } = require("url")
|
||||||
|
|
||||||
const root = path.resolve(__dirname, "..")
|
const root = path.resolve(__dirname, "..")
|
||||||
const workspaceRoot = path.resolve(root, "..", "..")
|
const workspaceRoot = path.resolve(root, "..", "..")
|
||||||
@@ -10,6 +11,20 @@ const uiRoot = path.resolve(root, "..", "ui")
|
|||||||
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
|
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
|
||||||
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
|
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
|
||||||
|
|
||||||
|
async function ensureMonacoAssets() {
|
||||||
|
const helperPath = path.join(uiRoot, "scripts", "monaco-public-assets.js")
|
||||||
|
const helperUrl = pathToFileURL(helperPath).href
|
||||||
|
const { copyMonacoPublicAssets } = await import(helperUrl)
|
||||||
|
copyMonacoPublicAssets({
|
||||||
|
uiRendererRoot: path.join(uiRoot, "src", "renderer"),
|
||||||
|
warn: (msg) => console.warn(`[dev-prep] ${msg}`),
|
||||||
|
sourceRoots: [
|
||||||
|
path.resolve(workspaceRoot, "node_modules", "monaco-editor", "min", "vs"),
|
||||||
|
path.resolve(uiRoot, "node_modules", "monaco-editor", "min", "vs"),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function ensureUiBuild() {
|
function ensureUiBuild() {
|
||||||
const loadingHtml = path.join(uiDist, "loading.html")
|
const loadingHtml = path.join(uiDist, "loading.html")
|
||||||
if (fs.existsSync(loadingHtml)) {
|
if (fs.existsSync(loadingHtml)) {
|
||||||
@@ -42,5 +57,11 @@ function copyUiLoadingAssets() {
|
|||||||
console.log(`[dev-prep] copied loader bundle from ${uiDist}`)
|
console.log(`[dev-prep] copied loader bundle from ${uiDist}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
await ensureMonacoAssets()
|
||||||
ensureUiBuild()
|
ensureUiBuild()
|
||||||
copyUiLoadingAssets()
|
copyUiLoadingAssets()
|
||||||
|
})().catch((err) => {
|
||||||
|
console.error("[dev-prep] failed:", err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
const fs = require("fs")
|
const fs = require("fs")
|
||||||
const path = require("path")
|
const path = require("path")
|
||||||
const { execSync } = require("child_process")
|
const { execSync } = require("child_process")
|
||||||
|
const { pathToFileURL } = require("url")
|
||||||
|
|
||||||
const root = path.resolve(__dirname, "..")
|
const root = path.resolve(__dirname, "..")
|
||||||
const workspaceRoot = path.resolve(root, "..", "..")
|
const workspaceRoot = path.resolve(root, "..", "..")
|
||||||
@@ -37,6 +38,20 @@ const braceExpansionPath = path.join(
|
|||||||
|
|
||||||
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
|
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
|
||||||
|
|
||||||
|
async function ensureMonacoAssets() {
|
||||||
|
const helperPath = path.join(uiRoot, "scripts", "monaco-public-assets.js")
|
||||||
|
const helperUrl = pathToFileURL(helperPath).href
|
||||||
|
const { copyMonacoPublicAssets } = await import(helperUrl)
|
||||||
|
copyMonacoPublicAssets({
|
||||||
|
uiRendererRoot: path.join(uiRoot, "src", "renderer"),
|
||||||
|
warn: (msg) => console.warn(`[prebuild] ${msg}`),
|
||||||
|
sourceRoots: [
|
||||||
|
path.resolve(workspaceRoot, "node_modules", "monaco-editor", "min", "vs"),
|
||||||
|
path.resolve(uiRoot, "node_modules", "monaco-editor", "min", "vs"),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function ensureServerBuild() {
|
function ensureServerBuild() {
|
||||||
const distPath = path.join(serverRoot, "dist")
|
const distPath = path.join(serverRoot, "dist")
|
||||||
const publicPath = path.join(serverRoot, "public")
|
const publicPath = path.join(serverRoot, "public")
|
||||||
@@ -223,8 +238,10 @@ function copyUiLoadingAssets() {
|
|||||||
console.log(`[prebuild] prepared UI loading assets from ${uiDist}`)
|
console.log(`[prebuild] prepared UI loading assets from ${uiDist}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
ensureServerDevDependencies()
|
ensureServerDevDependencies()
|
||||||
ensureUiDevDependencies()
|
ensureUiDevDependencies()
|
||||||
|
await ensureMonacoAssets()
|
||||||
ensureRollupPlatformBinary()
|
ensureRollupPlatformBinary()
|
||||||
ensureServerDependencies()
|
ensureServerDependencies()
|
||||||
ensureServerBuild()
|
ensureServerBuild()
|
||||||
@@ -232,3 +249,7 @@ ensureUiBuild()
|
|||||||
copyServerArtifacts()
|
copyServerArtifacts()
|
||||||
stripNodeModuleBins()
|
stripNodeModuleBins()
|
||||||
copyUiLoadingAssets()
|
copyUiLoadingAssets()
|
||||||
|
})().catch((err) => {
|
||||||
|
console.error("[prebuild] failed:", err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
let mode = config
|
||||||
|
.server
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|srv| srv.listening_mode.as_ref())
|
||||||
|
.or_else(|| {
|
||||||
|
config
|
||||||
.preferences
|
.preferences
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|prefs| prefs.listening_mode.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));
|
||||||
@@ -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,7 +335,10 @@ 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)?;
|
||||||
|
|||||||
1
packages/ui/.gitignore
vendored
1
packages/ui/.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.vite/
|
.vite/
|
||||||
src/renderer/public/logo.png
|
src/renderer/public/logo.png
|
||||||
|
src/renderer/public/monaco/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.10.2",
|
"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",
|
||||||
@@ -25,11 +25,13 @@
|
|||||||
"github-markdown-css": "^5.8.1",
|
"github-markdown-css": "^5.8.1",
|
||||||
"lucide-solid": "^0.300.0",
|
"lucide-solid": "^0.300.0",
|
||||||
"marked": "^12.0.0",
|
"marked": "^12.0.0",
|
||||||
|
"monaco-editor": "^0.52.2",
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"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",
|
||||||
|
|||||||
7
packages/ui/scripts/monaco-public-assets.d.ts
vendored
Normal file
7
packages/ui/scripts/monaco-public-assets.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export type CopyMonacoPublicAssetsParams = {
|
||||||
|
uiRendererRoot: string
|
||||||
|
warn?: (message: string) => void
|
||||||
|
sourceRoots?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function copyMonacoPublicAssets(params: CopyMonacoPublicAssetsParams): void
|
||||||
97
packages/ui/scripts/monaco-public-assets.js
Normal file
97
packages/ui/scripts/monaco-public-assets.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import fs from "fs"
|
||||||
|
import { resolve } from "path"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy Monaco's AMD `min/vs` assets into the UI renderer public folder.
|
||||||
|
*
|
||||||
|
* Monaco is loaded at runtime via `/monaco/vs/loader.js`. These assets are gitignored
|
||||||
|
* and generated on demand in dev/build so the repo stays clean.
|
||||||
|
*
|
||||||
|
* @param {object} params
|
||||||
|
* @param {string} params.uiRendererRoot Absolute path to `packages/ui/src/renderer`.
|
||||||
|
* @param {(message: string) => void} [params.warn] Warning logger.
|
||||||
|
* @param {string[]} [params.sourceRoots] Optional override list of `.../monaco-editor/min/vs` roots.
|
||||||
|
*/
|
||||||
|
export function copyMonacoPublicAssets(params) {
|
||||||
|
const uiRendererRoot = params?.uiRendererRoot
|
||||||
|
if (!uiRendererRoot) {
|
||||||
|
throw new Error("copyMonacoPublicAssets: uiRendererRoot is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
const warn = params?.warn ?? ((message) => console.warn(message))
|
||||||
|
const publicDir = resolve(uiRendererRoot, "public")
|
||||||
|
const destRoot = resolve(publicDir, "monaco/vs")
|
||||||
|
|
||||||
|
const candidates =
|
||||||
|
params?.sourceRoots?.length > 0
|
||||||
|
? params.sourceRoots
|
||||||
|
: [
|
||||||
|
// Workspace root hoisted deps.
|
||||||
|
resolve(process.cwd(), "node_modules/monaco-editor/min/vs"),
|
||||||
|
// UI package local deps (covers non-hoisted installs).
|
||||||
|
resolve(process.cwd(), "packages/ui/node_modules/monaco-editor/min/vs"),
|
||||||
|
]
|
||||||
|
|
||||||
|
const sourceRoot = candidates.find((p) => fs.existsSync(resolve(p, "loader.js")))
|
||||||
|
if (!sourceRoot) {
|
||||||
|
warn("Monaco source directory not found; skipping copy")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyRecursive = (src, dest) => {
|
||||||
|
const stat = fs.statSync(src)
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
fs.mkdirSync(dest, { recursive: true })
|
||||||
|
for (const entry of fs.readdirSync(src)) {
|
||||||
|
copyRecursive(resolve(src, entry), resolve(dest, entry))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fs.copyFileSync(src, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the working tree clean; these assets are generated.
|
||||||
|
try {
|
||||||
|
fs.rmSync(destRoot, { recursive: true, force: true })
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
fs.mkdirSync(destRoot, { recursive: true })
|
||||||
|
|
||||||
|
// Copy core Monaco runtime.
|
||||||
|
for (const dir of ["base", "editor", "platform"]) {
|
||||||
|
const src = resolve(sourceRoot, dir)
|
||||||
|
if (fs.existsSync(src)) {
|
||||||
|
copyRecursive(src, resolve(destRoot, dir))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loader.js is required.
|
||||||
|
copyRecursive(resolve(sourceRoot, "loader.js"), resolve(destRoot, "loader.js"))
|
||||||
|
|
||||||
|
// Copy baseline rich language packages + workers.
|
||||||
|
for (const lang of ["typescript", "html", "json", "css"]) {
|
||||||
|
const src = resolve(sourceRoot, "language", lang)
|
||||||
|
if (fs.existsSync(src)) {
|
||||||
|
copyRecursive(src, resolve(destRoot, "language", lang))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy baseline basic tokenizers.
|
||||||
|
for (const lang of ["python", "markdown", "cpp", "kotlin"]) {
|
||||||
|
const src = resolve(sourceRoot, "basic-languages", lang)
|
||||||
|
if (fs.existsSync(src)) {
|
||||||
|
copyRecursive(src, resolve(destRoot, "basic-languages", lang))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy monaco.contribution.js entrypoints (needed by some loads).
|
||||||
|
const monacoContribution = resolve(sourceRoot, "basic-languages", "monaco.contribution.js")
|
||||||
|
if (fs.existsSync(monacoContribution)) {
|
||||||
|
copyRecursive(monacoContribution, resolve(destRoot, "basic-languages", "monaco.contribution.js"))
|
||||||
|
}
|
||||||
|
const underscoreContribution = resolve(sourceRoot, "basic-languages", "_.contribution.js")
|
||||||
|
if (fs.existsSync(underscoreContribution)) {
|
||||||
|
copyRecursive(underscoreContribution, resolve(destRoot, "basic-languages", "_.contribution.js"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,29 +460,50 @@ 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={
|
||||||
<>
|
<>
|
||||||
|
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}>
|
||||||
<InstanceTabs
|
<InstanceTabs
|
||||||
instances={instances()}
|
instances={instances()}
|
||||||
activeInstanceId={activeInstanceId()}
|
activeInstanceId={activeInstanceId()}
|
||||||
onSelect={setActiveInstanceId}
|
onSelect={setActiveInstanceId}
|
||||||
onClose={handleCloseInstance}
|
onClose={handleCloseInstance}
|
||||||
onNew={handleNewInstanceRequest}
|
onNew={handleNewInstanceRequest}
|
||||||
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
|
||||||
/>
|
/>
|
||||||
|
</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
|
||||||
|
class="flex-1 min-h-0 overflow-hidden"
|
||||||
|
style={{ display: isVisible() ? "flex" : "none" }}
|
||||||
|
data-instance-id={instance.id}
|
||||||
|
data-instance-active={isActiveInstance() ? "true" : "false"}
|
||||||
|
data-instance-visible={isVisible() ? "true" : "false"}
|
||||||
|
>
|
||||||
<InstanceMetadataProvider instance={instance}>
|
<InstanceMetadataProvider instance={instance}>
|
||||||
<InstanceShell
|
<InstanceShell
|
||||||
instance={instance}
|
instance={instance}
|
||||||
|
isActiveInstance={isActiveInstance()}
|
||||||
escapeInDebounce={escapeInDebounce()}
|
escapeInDebounce={escapeInDebounce()}
|
||||||
paletteCommands={paletteCommands}
|
paletteCommands={paletteCommands}
|
||||||
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
|
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
|
||||||
@@ -425,7 +511,10 @@ const App: Component = () => {
|
|||||||
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
||||||
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
||||||
onExecuteCommand={executeCommand}
|
onExecuteCommand={executeCommand}
|
||||||
tabBarOffset={instanceTabBarHeight()}
|
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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -141,7 +145,8 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
|
|
||||||
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("")
|
||||||
|
|
||||||
|
|||||||
136
packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx
Normal file
136
packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||||
|
import { loadMonaco } from "../../lib/monaco/setup"
|
||||||
|
import { getOrCreateTextModel } from "../../lib/monaco/model-cache"
|
||||||
|
import { inferMonacoLanguageId } from "../../lib/monaco/language"
|
||||||
|
import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup"
|
||||||
|
import { useTheme } from "../../lib/theme"
|
||||||
|
|
||||||
|
interface MonacoDiffViewerProps {
|
||||||
|
scopeKey: string
|
||||||
|
path: string
|
||||||
|
before: string
|
||||||
|
after: string
|
||||||
|
viewMode?: "split" | "unified"
|
||||||
|
contextMode?: "expanded" | "collapsed"
|
||||||
|
wordWrap?: "on" | "off"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
let host: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
let diffEditor: any = null
|
||||||
|
let monaco: any = null
|
||||||
|
const [ready, setReady] = createSignal(false)
|
||||||
|
|
||||||
|
const disposeEditor = () => {
|
||||||
|
try {
|
||||||
|
diffEditor?.setModel(null as any)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
diffEditor?.dispose()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
diffEditor = null
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let cancelled = false
|
||||||
|
void (async () => {
|
||||||
|
monaco = await loadMonaco()
|
||||||
|
if (cancelled) return
|
||||||
|
if (!host || !monaco) return
|
||||||
|
|
||||||
|
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
|
||||||
|
diffEditor = monaco.editor.createDiffEditor(host, {
|
||||||
|
readOnly: true,
|
||||||
|
automaticLayout: true,
|
||||||
|
renderSideBySide: true,
|
||||||
|
renderSideBySideInlineBreakpoint: 0,
|
||||||
|
renderMarginRevertIcon: false,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
renderWhitespace: "selection",
|
||||||
|
fontSize: 13,
|
||||||
|
wordWrap: props.wordWrap === "on" ? "on" : "off",
|
||||||
|
glyphMargin: false,
|
||||||
|
folding: false,
|
||||||
|
// Keep enough gutter space so unified diffs don't overlap `+`/`-` markers.
|
||||||
|
lineNumbersMinChars: 4,
|
||||||
|
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)
|
||||||
|
})()
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
cancelled = true
|
||||||
|
setReady(false)
|
||||||
|
disposeEditor()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready() || !monaco || !diffEditor) return
|
||||||
|
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready() || !monaco || !diffEditor) return
|
||||||
|
const viewMode = props.viewMode === "unified" ? "unified" : "split"
|
||||||
|
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
|
||||||
|
const wordWrap = props.wordWrap === "on" ? "on" : "off"
|
||||||
|
|
||||||
|
diffEditor.updateOptions({
|
||||||
|
renderSideBySide: viewMode === "split",
|
||||||
|
renderSideBySideInlineBreakpoint: 0,
|
||||||
|
hideUnchangedRegions:
|
||||||
|
contextMode === "collapsed"
|
||||||
|
? { enabled: true }
|
||||||
|
: { enabled: false },
|
||||||
|
wordWrap,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
diffEditor.getOriginalEditor?.()?.updateOptions?.({ wordWrap })
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
diffEditor.getModifiedEditor?.()?.updateOptions?.({ wordWrap })
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready() || !monaco || !diffEditor) return
|
||||||
|
const languageId = inferMonacoLanguageId(monaco, props.path)
|
||||||
|
const beforeKey = `${props.scopeKey}:diff:${props.path}:before`
|
||||||
|
const afterKey = `${props.scopeKey}:diff:${props.path}:after`
|
||||||
|
|
||||||
|
const original = getOrCreateTextModel({ monaco, cacheKey: beforeKey, value: props.before, languageId })
|
||||||
|
const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: props.after, languageId })
|
||||||
|
diffEditor.setModel({ original, modified })
|
||||||
|
|
||||||
|
void ensureMonacoLanguageLoaded(languageId).then(() => {
|
||||||
|
try {
|
||||||
|
monaco.editor.setModelLanguage(original, languageId)
|
||||||
|
monaco.editor.setModelLanguage(modified, languageId)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return <div class="monaco-viewer" ref={host} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||||
|
import { loadMonaco } from "../../lib/monaco/setup"
|
||||||
|
import { getOrCreateTextModel } from "../../lib/monaco/model-cache"
|
||||||
|
import { inferMonacoLanguageId } from "../../lib/monaco/language"
|
||||||
|
import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup"
|
||||||
|
import { useTheme } from "../../lib/theme"
|
||||||
|
|
||||||
|
interface MonacoFileViewerProps {
|
||||||
|
scopeKey: string
|
||||||
|
path: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonacoFileViewer(props: MonacoFileViewerProps) {
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
let host: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
let editor: any = null
|
||||||
|
let monaco: any = null
|
||||||
|
const [ready, setReady] = createSignal(false)
|
||||||
|
|
||||||
|
const disposeEditor = () => {
|
||||||
|
try {
|
||||||
|
editor?.setModel(null)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
editor?.dispose()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
editor = null
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let cancelled = false
|
||||||
|
void (async () => {
|
||||||
|
monaco = await loadMonaco()
|
||||||
|
if (cancelled) return
|
||||||
|
if (!host || !monaco) return
|
||||||
|
|
||||||
|
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
|
||||||
|
editor = monaco.editor.create(host, {
|
||||||
|
value: "",
|
||||||
|
language: "plaintext",
|
||||||
|
readOnly: true,
|
||||||
|
automaticLayout: true,
|
||||||
|
lineNumbers: "on",
|
||||||
|
minimap: { enabled: false },
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
wordWrap: "off",
|
||||||
|
renderWhitespace: "selection",
|
||||||
|
fontSize: 13,
|
||||||
|
})
|
||||||
|
|
||||||
|
setReady(true)
|
||||||
|
})()
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
cancelled = true
|
||||||
|
setReady(false)
|
||||||
|
disposeEditor()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready() || !monaco || !editor) return
|
||||||
|
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!ready() || !monaco || !editor) return
|
||||||
|
const languageId = inferMonacoLanguageId(monaco, props.path)
|
||||||
|
const cacheKey = `${props.scopeKey}:file:${props.path}`
|
||||||
|
const model = getOrCreateTextModel({ monaco, cacheKey, value: props.content, languageId })
|
||||||
|
editor.setModel(model)
|
||||||
|
|
||||||
|
void ensureMonacoLanguageLoaded(languageId).then(() => {
|
||||||
|
try {
|
||||||
|
monaco.editor.setModelLanguage(model, languageId)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return <div class="monaco-viewer" ref={host} />
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -254,17 +272,72 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
|
|
||||||
function getDisplayPath(path: string): string {
|
function getDisplayPath(path: string): string {
|
||||||
|
if (!path) return path
|
||||||
|
|
||||||
|
// macOS: /Users/<name>/...
|
||||||
if (path.startsWith("/Users/")) {
|
if (path.startsWith("/Users/")) {
|
||||||
return path.replace(/^\/Users\/[^/]+/, "~")
|
return path.replace(/^\/Users\/[^/]+/, "~")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Linux: /home/<name>/...
|
||||||
|
if (path.startsWith("/home/")) {
|
||||||
|
return path.replace(/^\/home\/[^/]+/, "~")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows: C:\Users\<name>\... (and the forward-slash variant)
|
||||||
|
if (/^[A-Za-z]:\\Users\\/.test(path)) {
|
||||||
|
return path.replace(/^[A-Za-z]:\\Users\\[^\\]+/, "~")
|
||||||
|
}
|
||||||
|
if (/^[A-Za-z]:\/Users\//.test(path)) {
|
||||||
|
return path.replace(/^[A-Za-z]:\/Users\/[^/]+/, "~")
|
||||||
|
}
|
||||||
|
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function looksLikeWindowsPath(value: string): boolean {
|
||||||
|
if (!value) return false
|
||||||
|
// Drive letter (C:\...) or UNC (\\server\share\...)
|
||||||
|
return /^[A-Za-z]:[\\/]/.test(value) || /^\\\\[^\\]+\\[^\\]+/.test(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitFolderPath(rawPath: string): { baseName: string; dirName: string } {
|
||||||
|
if (!rawPath) return { baseName: "", dirName: "" }
|
||||||
|
|
||||||
|
const isWindows = looksLikeWindowsPath(rawPath)
|
||||||
|
const trimmed = rawPath.replace(/[\\/]+$/, "")
|
||||||
|
|
||||||
|
// Root edge-cases ("/", "C:\\", "\\\\server\\share\\")
|
||||||
|
if (!trimmed) {
|
||||||
|
return { baseName: rawPath, dirName: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWindows && /^[A-Za-z]:$/.test(trimmed)) {
|
||||||
|
return { baseName: `${trimmed}\\`, dirName: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSlash = trimmed.lastIndexOf("/")
|
||||||
|
const lastBackslash = isWindows ? trimmed.lastIndexOf("\\") : -1
|
||||||
|
const lastSep = Math.max(lastSlash, lastBackslash)
|
||||||
|
|
||||||
|
if (lastSep < 0) {
|
||||||
|
return { baseName: trimmed, dirName: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseName = trimmed.slice(lastSep + 1) || trimmed
|
||||||
|
const dirName = trimmed.slice(0, lastSep)
|
||||||
|
return { baseName, dirName }
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<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"
|
||||||
@@ -315,15 +388,34 @@ 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" />
|
|
||||||
<Show when={props.onOpenRemoteAccess}>
|
|
||||||
<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={() => 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" />
|
<MonitorUp class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
<Show when={props.onClose}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||||
|
onClick={() => props.onClose?.()}
|
||||||
|
aria-label={t("app.launchError.close")}
|
||||||
|
title={t("app.launchError.closeTitle")}
|
||||||
|
>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6 text-center shrink-0">
|
<div class="mb-6 text-center shrink-0">
|
||||||
@@ -441,14 +533,14 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<div class="flex items-center gap-2 mb-1">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||||
<span class="text-sm font-medium truncate text-primary">
|
<span class="text-sm font-medium truncate text-primary">
|
||||||
{folder.path.split("/").pop()}
|
{splitFolderPath(folder.path).baseName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs font-mono truncate pl-6 text-muted">
|
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
|
||||||
|
<span class="font-mono truncate-start flex-1 min-w-0">
|
||||||
{getDisplayPath(folder.path)}
|
{getDisplayPath(folder.path)}
|
||||||
</div>
|
</span>
|
||||||
<div class="text-xs mt-1 pl-6 text-muted">
|
<span class="flex-shrink-0">{formatRelativeTime(folder.lastAccessed)}</span>
|
||||||
{formatRelativeTime(folder.lastAccessed)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
||||||
@@ -497,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>
|
||||||
@@ -522,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">
|
||||||
@@ -540,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>
|
||||||
@@ -556,15 +648,16 @@ 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()}
|
||||||
|
|||||||
@@ -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={() => props.onOpenRemoteAccess?.()}
|
onClick={() => openSettings("remote")}
|
||||||
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">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
187
packages/ui/src/components/instance/shell/SessionSidebar.tsx
Normal file
187
packages/ui/src/components/instance/shell/SessionSidebar.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { Show, type Accessor, type Component } from "solid-js"
|
||||||
|
import type { SessionThread } from "../../../stores/session-state"
|
||||||
|
import type { Session } from "../../../types/session"
|
||||||
|
import { keyboardRegistry, type KeyboardShortcut } from "../../../lib/keyboard-registry"
|
||||||
|
import type { DrawerViewState } from "./types"
|
||||||
|
|
||||||
|
import { PlusSquare, Search } from "lucide-solid"
|
||||||
|
import IconButton from "@suid/material/IconButton"
|
||||||
|
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
|
||||||
|
import PushPinIcon from "@suid/icons-material/PushPin"
|
||||||
|
import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined"
|
||||||
|
import InfoOutlinedIcon from "@suid/icons-material/InfoOutlined"
|
||||||
|
|
||||||
|
import SessionList from "../../session-list"
|
||||||
|
import KeyboardHint from "../../keyboard-hint"
|
||||||
|
import WorktreeSelector from "../../worktree-selector"
|
||||||
|
import AgentSelector from "../../agent-selector"
|
||||||
|
import ModelSelector from "../../model-selector"
|
||||||
|
import ThinkingSelector from "../../thinking-selector"
|
||||||
|
import { getLogger } from "../../../lib/logger"
|
||||||
|
|
||||||
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
interface SessionSidebarProps {
|
||||||
|
t: (key: string) => string
|
||||||
|
instanceId: string
|
||||||
|
threads: Accessor<SessionThread[]>
|
||||||
|
activeSessionId: Accessor<string | null>
|
||||||
|
activeSession: Accessor<Session | null>
|
||||||
|
|
||||||
|
showSearch: Accessor<boolean>
|
||||||
|
onToggleSearch: () => void
|
||||||
|
|
||||||
|
keyboardShortcuts: Accessor<KeyboardShortcut[]>
|
||||||
|
isPhoneLayout: Accessor<boolean>
|
||||||
|
drawerState: Accessor<DrawerViewState>
|
||||||
|
leftPinned: Accessor<boolean>
|
||||||
|
|
||||||
|
onSelectSession: (sessionId: string) => void
|
||||||
|
onNewSession: () => Promise<void> | void
|
||||||
|
onSidebarAgentChange: (sessionId: string, agent: string) => Promise<void>
|
||||||
|
onSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
|
||||||
|
onPinLeftDrawer: () => void
|
||||||
|
onUnpinLeftDrawer: () => void
|
||||||
|
onCloseLeftDrawer: () => void
|
||||||
|
|
||||||
|
setContentEl: (el: HTMLElement | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SessionSidebar: Component<SessionSidebarProps> = (props) => (
|
||||||
|
<div class="flex flex-col h-full min-h-0" ref={props.setContentEl}>
|
||||||
|
<div class="flex flex-col gap-2 px-4 py-3 border-b border-base">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
|
||||||
|
{props.t("instanceShell.leftPanel.sessionsTitle")}
|
||||||
|
</span>
|
||||||
|
<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
|
||||||
|
size="small"
|
||||||
|
color="inherit"
|
||||||
|
aria-label={props.t("sessionList.filter.ariaLabel")}
|
||||||
|
title={props.t("sessionList.filter.ariaLabel")}
|
||||||
|
aria-pressed={props.showSearch()}
|
||||||
|
onClick={props.onToggleSearch}
|
||||||
|
sx={{
|
||||||
|
color: props.showSearch() ? "var(--text-primary)" : "inherit",
|
||||||
|
backgroundColor: props.showSearch() ? "var(--surface-hover)" : "transparent",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "var(--surface-hover)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Search class="w-5 h-5" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="inherit"
|
||||||
|
aria-label={props.t("instanceShell.leftPanel.instanceInfo")}
|
||||||
|
title={props.t("instanceShell.leftPanel.instanceInfo")}
|
||||||
|
onClick={() => props.onSelectSession("info")}
|
||||||
|
>
|
||||||
|
<InfoOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<Show when={!props.isPhoneLayout()}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="inherit"
|
||||||
|
aria-label={props.leftPinned() ? props.t("instanceShell.leftDrawer.unpin") : props.t("instanceShell.leftDrawer.pin")}
|
||||||
|
onClick={() => (props.leftPinned() ? props.onUnpinLeftDrawer() : props.onPinLeftDrawer())}
|
||||||
|
>
|
||||||
|
{props.leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.drawerState() === "floating-open"}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="inherit"
|
||||||
|
aria-label={props.t("instanceShell.leftDrawer.toggle.close")}
|
||||||
|
title={props.t("instanceShell.leftDrawer.toggle.close")}
|
||||||
|
onClick={props.onCloseLeftDrawer}
|
||||||
|
>
|
||||||
|
<MenuOpenIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="session-sidebar-shortcuts">
|
||||||
|
<Show when={props.keyboardShortcuts().length}>
|
||||||
|
<KeyboardHint shortcuts={props.keyboardShortcuts()} separator=" " showDescription={false} />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="session-sidebar flex flex-col flex-1 min-h-0">
|
||||||
|
<SessionList
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
threads={props.threads()}
|
||||||
|
activeSessionId={props.activeSessionId()}
|
||||||
|
onSelect={props.onSelectSession}
|
||||||
|
onNew={() => {
|
||||||
|
const result = props.onNewSession()
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
void result.catch((error) => log.error("Failed to create session:", error))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
enableFilterBar={props.showSearch()}
|
||||||
|
showHeader={false}
|
||||||
|
showFooter={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="session-sidebar-separator" />
|
||||||
|
<Show when={props.activeSession()}>
|
||||||
|
{(activeSession) => (
|
||||||
|
<>
|
||||||
|
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
|
||||||
|
<WorktreeSelector instanceId={props.instanceId} sessionId={activeSession().id} />
|
||||||
|
|
||||||
|
<AgentSelector
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={activeSession().id}
|
||||||
|
currentAgent={activeSession().agent}
|
||||||
|
onAgentChange={(agent) => props.onSidebarAgentChange(activeSession().id, agent)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ModelSelector
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={activeSession().id}
|
||||||
|
currentModel={activeSession().model}
|
||||||
|
onModelChange={(model) => props.onSidebarModelChange(activeSession().id, model)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ThinkingSelector instanceId={props.instanceId} currentModel={activeSession().model} />
|
||||||
|
|
||||||
|
<KeyboardHint
|
||||||
|
class="session-sidebar-selector-hints"
|
||||||
|
ariaHidden={true}
|
||||||
|
shortcuts={[
|
||||||
|
keyboardRegistry.get("open-agent-selector"),
|
||||||
|
keyboardRegistry.get("focus-model"),
|
||||||
|
keyboardRegistry.get("focus-variant"),
|
||||||
|
].filter((shortcut): shortcut is KeyboardShortcut => Boolean(shortcut))}
|
||||||
|
separator=" "
|
||||||
|
showDescription={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default SessionSidebar
|
||||||
@@ -0,0 +1,842 @@
|
|||||||
|
import {
|
||||||
|
Show,
|
||||||
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
createSignal,
|
||||||
|
onCleanup,
|
||||||
|
type Accessor,
|
||||||
|
type Component,
|
||||||
|
} from "solid-js"
|
||||||
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
|
import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import IconButton from "@suid/material/IconButton"
|
||||||
|
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
|
||||||
|
import PushPinIcon from "@suid/icons-material/PushPin"
|
||||||
|
import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined"
|
||||||
|
|
||||||
|
import type { Instance } from "../../../../types/instance"
|
||||||
|
import type { BackgroundProcess } from "../../../../../../server/src/api-types"
|
||||||
|
import type { Session } from "../../../../types/session"
|
||||||
|
import type { DrawerViewState } from "../types"
|
||||||
|
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types"
|
||||||
|
|
||||||
|
import ChangesTab from "./tabs/ChangesTab"
|
||||||
|
import FilesTab from "./tabs/FilesTab"
|
||||||
|
import GitChangesTab from "./tabs/GitChangesTab"
|
||||||
|
import StatusTab from "./tabs/StatusTab"
|
||||||
|
|
||||||
|
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees"
|
||||||
|
import { requestData } from "../../../../lib/opencode-api"
|
||||||
|
import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse"
|
||||||
|
import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
|
||||||
|
import {
|
||||||
|
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_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_PHONE_KEY,
|
||||||
|
RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY,
|
||||||
|
RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY,
|
||||||
|
RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY,
|
||||||
|
RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY,
|
||||||
|
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY,
|
||||||
|
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY,
|
||||||
|
RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY,
|
||||||
|
RIGHT_PANEL_TAB_STORAGE_KEY,
|
||||||
|
readStoredBool,
|
||||||
|
readStoredEnum,
|
||||||
|
readStoredPanelWidth,
|
||||||
|
readStoredRightPanelTab,
|
||||||
|
} from "../storage"
|
||||||
|
|
||||||
|
interface RightPanelProps {
|
||||||
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
|
|
||||||
|
instanceId: string
|
||||||
|
instance: Instance
|
||||||
|
|
||||||
|
activeSessionId: Accessor<string | null>
|
||||||
|
activeSession: Accessor<Session | null>
|
||||||
|
activeSessionDiffs: Accessor<any[] | undefined>
|
||||||
|
|
||||||
|
latestTodoState: Accessor<ToolState | null>
|
||||||
|
backgroundProcessList: Accessor<BackgroundProcess[]>
|
||||||
|
onOpenBackgroundOutput: (process: BackgroundProcess) => void
|
||||||
|
onStopBackgroundProcess: (processId: string) => Promise<void> | void
|
||||||
|
onTerminateBackgroundProcess: (processId: string) => Promise<void> | void
|
||||||
|
|
||||||
|
isPhoneLayout: Accessor<boolean>
|
||||||
|
rightDrawerWidth: Accessor<number>
|
||||||
|
rightDrawerWidthInitialized: Accessor<boolean>
|
||||||
|
rightDrawerState: Accessor<DrawerViewState>
|
||||||
|
rightPinned: Accessor<boolean>
|
||||||
|
onCloseRightDrawer: () => void
|
||||||
|
onPinRightDrawer: () => void
|
||||||
|
onUnpinRightDrawer: () => void
|
||||||
|
|
||||||
|
setContentEl: (el: HTMLElement | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const RightPanel: Component<RightPanelProps> = (props) => {
|
||||||
|
const [rightPanelTab, setRightPanelTab] = createSignal<RightPanelTab>(readStoredRightPanelTab("changes"))
|
||||||
|
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>([
|
||||||
|
"plan",
|
||||||
|
"background-processes",
|
||||||
|
"mcp",
|
||||||
|
"lsp",
|
||||||
|
"plugins",
|
||||||
|
])
|
||||||
|
const [selectedFile, setSelectedFile] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
const [browserPath, setBrowserPath] = createSignal(".")
|
||||||
|
const [browserEntries, setBrowserEntries] = createSignal<FileNode[] | null>(null)
|
||||||
|
const [browserLoading, setBrowserLoading] = createSignal(false)
|
||||||
|
const [browserError, setBrowserError] = createSignal<string | null>(null)
|
||||||
|
const [browserSelectedPath, setBrowserSelectedPath] = createSignal<string | null>(null)
|
||||||
|
const [browserSelectedContent, setBrowserSelectedContent] = createSignal<string | null>(null)
|
||||||
|
const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false)
|
||||||
|
const [browserSelectedError, setBrowserSelectedError] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
const [diffViewMode, setDiffViewMode] = createSignal<DiffViewMode>(
|
||||||
|
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified",
|
||||||
|
)
|
||||||
|
const [diffContextMode, setDiffContextMode] = createSignal<DiffContextMode>(
|
||||||
|
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 [filesSplitWidth, setFilesSplitWidth] = createSignal(320)
|
||||||
|
const [gitChangesSplitWidth, setGitChangesSplitWidth] = createSignal(320)
|
||||||
|
const [activeSplitResize, setActiveSplitResize] = createSignal<"changes" | "git-changes" | "files" | null>(null)
|
||||||
|
const [splitResizeStartX, setSplitResizeStartX] = createSignal(0)
|
||||||
|
const [splitResizeStartWidth, setSplitResizeStartWidth] = createSignal(0)
|
||||||
|
|
||||||
|
const [filesListOpen, setFilesListOpen] = createSignal(true)
|
||||||
|
const [filesListTouched, setFilesListTouched] = createSignal(false)
|
||||||
|
const [changesListOpen, setChangesListOpen] = createSignal(true)
|
||||||
|
const [changesListTouched, setChangesListTouched] = createSignal(false)
|
||||||
|
const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true)
|
||||||
|
const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false)
|
||||||
|
|
||||||
|
const listLayoutKey = createMemo(() => (props.isPhoneLayout() ? "phone" : "nonphone"))
|
||||||
|
|
||||||
|
const listOpenStorageKey = (tab: "changes" | "git-changes" | "files") => {
|
||||||
|
const layout = listLayoutKey()
|
||||||
|
if (tab === "changes") {
|
||||||
|
return layout === "phone" ? RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY
|
||||||
|
}
|
||||||
|
if (tab === "git-changes") {
|
||||||
|
return layout === "phone"
|
||||||
|
? RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY
|
||||||
|
: RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY
|
||||||
|
}
|
||||||
|
return layout === "phone" ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistListOpen = (tab: "changes" | "git-changes" | "files", value: boolean) => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
window.localStorage.setItem(listOpenStorageKey(tab), value ? "true" : "false")
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
// Refresh persisted visibility when layout changes (phone vs non-phone).
|
||||||
|
const layout = listLayoutKey()
|
||||||
|
layout
|
||||||
|
|
||||||
|
const filesPersisted = readStoredBool(listOpenStorageKey("files"))
|
||||||
|
if (filesPersisted !== null) {
|
||||||
|
setFilesListOpen(filesPersisted)
|
||||||
|
setFilesListTouched(true)
|
||||||
|
} else {
|
||||||
|
setFilesListOpen(true)
|
||||||
|
setFilesListTouched(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const changesPersisted = readStoredBool(listOpenStorageKey("changes"))
|
||||||
|
if (changesPersisted !== null) {
|
||||||
|
setChangesListOpen(changesPersisted)
|
||||||
|
setChangesListTouched(true)
|
||||||
|
} else {
|
||||||
|
setChangesListOpen(true)
|
||||||
|
setChangesListTouched(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const gitPersisted = readStoredBool(listOpenStorageKey("git-changes"))
|
||||||
|
if (gitPersisted !== null) {
|
||||||
|
setGitChangesListOpen(gitPersisted)
|
||||||
|
setGitChangesListTouched(true)
|
||||||
|
} else {
|
||||||
|
setGitChangesListOpen(true)
|
||||||
|
setGitChangesListTouched(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
// Default behavior: when nothing is selected, keep the file list open.
|
||||||
|
// Once the user explicitly toggles it, we stop auto-opening.
|
||||||
|
if (rightPanelTab() !== "files") return
|
||||||
|
if (filesListTouched()) return
|
||||||
|
if (!browserSelectedPath()) {
|
||||||
|
setFilesListOpen(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
window.localStorage.setItem(RIGHT_PANEL_TAB_STORAGE_KEY, rightPanelTab())
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, diffViewMode())
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
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 min = 200
|
||||||
|
const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65))
|
||||||
|
const max = Math.min(560, maxByDrawer)
|
||||||
|
return Math.min(max, Math.max(min, Math.floor(value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const [splitWidthsInitialized, setSplitWidthsInitialized] = createSignal(false)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (splitWidthsInitialized()) return
|
||||||
|
if (!props.rightDrawerWidthInitialized()) return
|
||||||
|
setSplitWidthsInitialized(true)
|
||||||
|
setChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY, 320)))
|
||||||
|
setFilesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY, 320)))
|
||||||
|
setGitChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY, 320)))
|
||||||
|
})
|
||||||
|
|
||||||
|
const persistSplitWidth = (mode: "changes" | "git-changes" | "files", width: number) => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
const key =
|
||||||
|
mode === "changes"
|
||||||
|
? RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY
|
||||||
|
: mode === "git-changes"
|
||||||
|
? RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY
|
||||||
|
: RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY
|
||||||
|
window.localStorage.setItem(key, String(width))
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSplitResize() {
|
||||||
|
setActiveSplitResize(null)
|
||||||
|
if (typeof document === "undefined") return
|
||||||
|
splitPointerDrag.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitMouseMove(event: MouseEvent) {
|
||||||
|
const mode = activeSplitResize()
|
||||||
|
if (!mode) return
|
||||||
|
event.preventDefault()
|
||||||
|
const delta = event.clientX - splitResizeStartX()
|
||||||
|
const next = clampSplitWidth(splitResizeStartWidth() + delta)
|
||||||
|
if (mode === "changes") setChangesSplitWidth(next)
|
||||||
|
else if (mode === "git-changes") setGitChangesSplitWidth(next)
|
||||||
|
else setFilesSplitWidth(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitMouseUp() {
|
||||||
|
const mode = activeSplitResize()
|
||||||
|
if (mode) {
|
||||||
|
const width =
|
||||||
|
mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth()
|
||||||
|
persistSplitWidth(mode, width)
|
||||||
|
}
|
||||||
|
stopSplitResize()
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTouchMove(event: TouchEvent) {
|
||||||
|
const mode = activeSplitResize()
|
||||||
|
if (!mode) return
|
||||||
|
const touch = event.touches[0]
|
||||||
|
if (!touch) return
|
||||||
|
event.preventDefault()
|
||||||
|
const delta = touch.clientX - splitResizeStartX()
|
||||||
|
const next = clampSplitWidth(splitResizeStartWidth() + delta)
|
||||||
|
if (mode === "changes") setChangesSplitWidth(next)
|
||||||
|
else if (mode === "git-changes") setGitChangesSplitWidth(next)
|
||||||
|
else setFilesSplitWidth(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTouchEnd() {
|
||||||
|
const mode = activeSplitResize()
|
||||||
|
if (mode) {
|
||||||
|
const width =
|
||||||
|
mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth()
|
||||||
|
persistSplitWidth(mode, width)
|
||||||
|
}
|
||||||
|
stopSplitResize()
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitPointerDrag = useGlobalPointerDrag({
|
||||||
|
onMouseMove: splitMouseMove,
|
||||||
|
onMouseUp: splitMouseUp,
|
||||||
|
onTouchMove: splitTouchMove,
|
||||||
|
onTouchEnd: splitTouchEnd,
|
||||||
|
})
|
||||||
|
|
||||||
|
const startSplitResize = (mode: "changes" | "git-changes" | "files", clientX: number) => {
|
||||||
|
if (typeof document === "undefined") return
|
||||||
|
setActiveSplitResize(mode)
|
||||||
|
setSplitResizeStartX(clientX)
|
||||||
|
setSplitResizeStartWidth(
|
||||||
|
mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth(),
|
||||||
|
)
|
||||||
|
splitPointerDrag.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSplitResizeMouseDown = (mode: "changes" | "git-changes" | "files") => (event: MouseEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
startSplitResize(mode, event.clientX)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSplitResizeTouchStart = (mode: "changes" | "git-changes" | "files") => (event: TouchEvent) => {
|
||||||
|
const touch = event.touches[0]
|
||||||
|
if (!touch) return
|
||||||
|
event.preventDefault()
|
||||||
|
startSplitResize(mode, touch.clientX)
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
stopSplitResize()
|
||||||
|
})
|
||||||
|
|
||||||
|
const worktreeSlugForViewer = createMemo(() => {
|
||||||
|
const sessionId = props.activeSessionId()
|
||||||
|
if (sessionId && sessionId !== "info") {
|
||||||
|
return getWorktreeSlugForSession(props.instanceId, sessionId)
|
||||||
|
}
|
||||||
|
return getDefaultWorktreeSlug(props.instanceId)
|
||||||
|
})
|
||||||
|
|
||||||
|
const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instanceId, worktreeSlugForViewer()))
|
||||||
|
|
||||||
|
const [gitStatusEntries, setGitStatusEntries] = createSignal<GitFileStatus[] | null>(null)
|
||||||
|
const [gitStatusLoading, setGitStatusLoading] = createSignal(false)
|
||||||
|
const [gitStatusError, setGitStatusError] = createSignal<string | null>(null)
|
||||||
|
const [gitSelectedPath, setGitSelectedPath] = createSignal<string | null>(null)
|
||||||
|
const [gitSelectedLoading, setGitSelectedLoading] = createSignal(false)
|
||||||
|
const [gitSelectedError, setGitSelectedError] = createSignal<string | null>(null)
|
||||||
|
const [gitSelectedBefore, setGitSelectedBefore] = createSignal<string | null>(null)
|
||||||
|
const [gitSelectedAfter, setGitSelectedAfter] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
const gitMostChangedPath = createMemo<string | null>(() => {
|
||||||
|
const entries = gitStatusEntries()
|
||||||
|
if (!Array.isArray(entries) || entries.length === 0) return null
|
||||||
|
const candidates = entries.filter((item) => item && item.status !== "deleted")
|
||||||
|
if (candidates.length === 0) return null
|
||||||
|
const best = candidates.reduce((currentBest, item) => {
|
||||||
|
const bestScore = (currentBest?.added ?? 0) + (currentBest?.removed ?? 0)
|
||||||
|
const score = (item?.added ?? 0) + (item?.removed ?? 0)
|
||||||
|
if (score > bestScore) return item
|
||||||
|
if (score < bestScore) return currentBest
|
||||||
|
return String(item.path || "").localeCompare(String(currentBest?.path || "")) < 0 ? item : currentBest
|
||||||
|
}, candidates[0])
|
||||||
|
return typeof best?.path === "string" ? best.path : null
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
// Reset tab state when worktree context changes.
|
||||||
|
worktreeSlugForViewer()
|
||||||
|
setBrowserPath(".")
|
||||||
|
setBrowserEntries(null)
|
||||||
|
setBrowserError(null)
|
||||||
|
setBrowserSelectedPath(null)
|
||||||
|
setBrowserSelectedContent(null)
|
||||||
|
setBrowserSelectedError(null)
|
||||||
|
setBrowserSelectedLoading(false)
|
||||||
|
|
||||||
|
setGitStatusEntries(null)
|
||||||
|
setGitStatusError(null)
|
||||||
|
setGitStatusLoading(false)
|
||||||
|
setGitSelectedPath(null)
|
||||||
|
setGitSelectedLoading(false)
|
||||||
|
setGitSelectedError(null)
|
||||||
|
setGitSelectedBefore(null)
|
||||||
|
setGitSelectedAfter(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadGitStatus = async (force = false) => {
|
||||||
|
if (!force && gitStatusEntries() !== null) return
|
||||||
|
setGitStatusLoading(true)
|
||||||
|
setGitStatusError(null)
|
||||||
|
try {
|
||||||
|
const list = await requestData<GitFileStatus[]>(browserClient().file.status(), "file.status")
|
||||||
|
setGitStatusEntries(Array.isArray(list) ? list : [])
|
||||||
|
} catch (error) {
|
||||||
|
setGitStatusError(error instanceof Error ? error.message : "Failed to load git status")
|
||||||
|
setGitStatusEntries([])
|
||||||
|
} finally {
|
||||||
|
setGitStatusLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openGitFile(path: string) {
|
||||||
|
setGitSelectedPath(path)
|
||||||
|
setGitSelectedLoading(true)
|
||||||
|
setGitSelectedError(null)
|
||||||
|
setGitSelectedBefore(null)
|
||||||
|
setGitSelectedAfter(null)
|
||||||
|
|
||||||
|
const list = gitStatusEntries() || []
|
||||||
|
const entry = list.find((item) => item.path === path) || null
|
||||||
|
if (entry?.status === "deleted") {
|
||||||
|
setGitSelectedError("Deleted file diff is not available yet")
|
||||||
|
setGitSelectedLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phone: treat file selection as a commit action and close the overlay.
|
||||||
|
if (props.isPhoneLayout()) {
|
||||||
|
setGitChangesListOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await requestData<FileContent>(browserClient().file.read({ path }), "file.read")
|
||||||
|
const type = (content as any)?.type
|
||||||
|
const encoding = (content as any)?.encoding
|
||||||
|
if (type && type !== "text") {
|
||||||
|
throw new Error("Binary file cannot be displayed")
|
||||||
|
}
|
||||||
|
if (encoding === "base64") {
|
||||||
|
throw new Error("Binary file cannot be displayed")
|
||||||
|
}
|
||||||
|
const afterText = typeof (content as any)?.content === "string" ? ((content as any).content as string) : null
|
||||||
|
if (afterText === null) {
|
||||||
|
throw new Error("Unsupported file type")
|
||||||
|
}
|
||||||
|
|
||||||
|
setGitSelectedAfter(afterText)
|
||||||
|
|
||||||
|
if (entry?.status === "added") {
|
||||||
|
setGitSelectedBefore("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffText =
|
||||||
|
typeof (content as any)?.diff === "string" && String((content as any).diff).trim().length > 0
|
||||||
|
? String((content as any).diff)
|
||||||
|
: (content as any)?.patch
|
||||||
|
? buildUnifiedDiffFromSdkPatch((content as any).patch)
|
||||||
|
: ""
|
||||||
|
|
||||||
|
const beforeText = tryReverseApplyUnifiedDiff(afterText, diffText)
|
||||||
|
if (beforeText === null) {
|
||||||
|
throw new Error("Unable to calculate diff for this file")
|
||||||
|
}
|
||||||
|
setGitSelectedBefore(beforeText)
|
||||||
|
} catch (error) {
|
||||||
|
setGitSelectedError(error instanceof Error ? error.message : "Failed to load file changes")
|
||||||
|
} finally {
|
||||||
|
setGitSelectedLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (rightPanelTab() !== "git-changes") return
|
||||||
|
const entries = gitStatusEntries()
|
||||||
|
if (entries === null) return
|
||||||
|
if (gitSelectedPath()) return
|
||||||
|
const next = gitMostChangedPath()
|
||||||
|
if (!next) return
|
||||||
|
void openGitFile(next)
|
||||||
|
})
|
||||||
|
|
||||||
|
const refreshGitStatus = async () => {
|
||||||
|
await loadGitStatus(true)
|
||||||
|
const selected = gitSelectedPath()
|
||||||
|
if (selected) {
|
||||||
|
void openGitFile(selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bestDiffFile = createMemo<string | null>(() => {
|
||||||
|
const diffs = props.activeSessionDiffs()
|
||||||
|
if (!Array.isArray(diffs) || diffs.length === 0) return null
|
||||||
|
const best = diffs.reduce((currentBest, item) => {
|
||||||
|
const bestAdd = typeof (currentBest as any)?.additions === "number" ? (currentBest as any).additions : 0
|
||||||
|
const bestDel = typeof (currentBest as any)?.deletions === "number" ? (currentBest as any).deletions : 0
|
||||||
|
const bestScore = bestAdd + bestDel
|
||||||
|
|
||||||
|
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 score = add + del
|
||||||
|
|
||||||
|
if (score > bestScore) return item
|
||||||
|
if (score < bestScore) return currentBest
|
||||||
|
return String(item.file || "").localeCompare(String((currentBest as any)?.file || "")) < 0 ? item : currentBest
|
||||||
|
}, diffs[0])
|
||||||
|
return typeof (best as any)?.file === "string" ? (best as any).file : null
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const next = bestDiffFile()
|
||||||
|
if (!next) return
|
||||||
|
const diffs = props.activeSessionDiffs()
|
||||||
|
if (!Array.isArray(diffs) || diffs.length === 0) return
|
||||||
|
|
||||||
|
const current = selectedFile()
|
||||||
|
if (current && diffs.some((d) => d.file === current)) return
|
||||||
|
setSelectedFile(next)
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizeBrowserPath = (input: string) => {
|
||||||
|
const raw = String(input || ".").trim()
|
||||||
|
if (!raw || raw === "./") return "."
|
||||||
|
const cleaned = raw.replace(/\\/g, "/").replace(/\/+$/, "")
|
||||||
|
return cleaned === "" ? "." : cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
const getParentPath = (path: string): string | null => {
|
||||||
|
const current = normalizeBrowserPath(path)
|
||||||
|
if (current === ".") return null
|
||||||
|
const parts = current.split("/").filter(Boolean)
|
||||||
|
parts.pop()
|
||||||
|
return parts.length ? parts.join("/") : "."
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadBrowserEntries = async (path: string) => {
|
||||||
|
const normalized = normalizeBrowserPath(path)
|
||||||
|
setBrowserLoading(true)
|
||||||
|
setBrowserError(null)
|
||||||
|
try {
|
||||||
|
const nodes = await requestData<FileNode[]>(browserClient().file.list({ path: normalized }), "file.list")
|
||||||
|
setBrowserPath(normalized)
|
||||||
|
setBrowserEntries(Array.isArray(nodes) ? nodes : [])
|
||||||
|
} catch (error) {
|
||||||
|
setBrowserError(error instanceof Error ? error.message : "Failed to load files")
|
||||||
|
setBrowserEntries([])
|
||||||
|
} finally {
|
||||||
|
setBrowserLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openBrowserFile = async (path: string) => {
|
||||||
|
setBrowserSelectedPath(path)
|
||||||
|
setBrowserSelectedLoading(true)
|
||||||
|
setBrowserSelectedError(null)
|
||||||
|
setBrowserSelectedContent(null)
|
||||||
|
|
||||||
|
// Phone: treat file selection as a commit action and close the overlay.
|
||||||
|
if (props.isPhoneLayout()) {
|
||||||
|
setFilesListOpen(false)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const content = await requestData<FileContent>(browserClient().file.read({ path }), "file.read")
|
||||||
|
const type = (content as any)?.type
|
||||||
|
const encoding = (content as any)?.encoding
|
||||||
|
if (type && type !== "text") {
|
||||||
|
throw new Error("Binary file cannot be displayed")
|
||||||
|
}
|
||||||
|
if (encoding === "base64") {
|
||||||
|
throw new Error("Binary file cannot be displayed")
|
||||||
|
}
|
||||||
|
const text = (content as any)?.content
|
||||||
|
if (typeof text !== "string") {
|
||||||
|
throw new Error("Unsupported file type")
|
||||||
|
}
|
||||||
|
setBrowserSelectedContent(text)
|
||||||
|
} catch (error) {
|
||||||
|
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
|
||||||
|
} finally {
|
||||||
|
setBrowserSelectedLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (rightPanelTab() !== "files") return
|
||||||
|
if (browserLoading()) return
|
||||||
|
if (browserEntries() !== null) return
|
||||||
|
void loadBrowserEntries(browserPath())
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (rightPanelTab() !== "git-changes") return
|
||||||
|
if (gitStatusLoading()) return
|
||||||
|
if (gitStatusEntries() !== null) return
|
||||||
|
void loadGitStatus()
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSelectChangesFile = (file: string, closeList: boolean) => {
|
||||||
|
setSelectedFile(file)
|
||||||
|
if (closeList) {
|
||||||
|
setChangesListOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleChangesList = () => {
|
||||||
|
setChangesListTouched(true)
|
||||||
|
setChangesListOpen((current) => {
|
||||||
|
const next = !current
|
||||||
|
persistListOpen("changes", next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleFilesList = () => {
|
||||||
|
setFilesListTouched(true)
|
||||||
|
setFilesListOpen((current) => {
|
||||||
|
const next = !current
|
||||||
|
persistListOpen("files", next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleGitList = () => {
|
||||||
|
setGitChangesListTouched(true)
|
||||||
|
setGitChangesListOpen((current) => {
|
||||||
|
const next = !current
|
||||||
|
persistListOpen("git-changes", next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshFilesTab = async () => {
|
||||||
|
void loadBrowserEntries(browserPath())
|
||||||
|
const selected = browserSelectedPath()
|
||||||
|
if (selected) {
|
||||||
|
// Refresh file content without altering overlay state.
|
||||||
|
setBrowserSelectedLoading(true)
|
||||||
|
setBrowserSelectedError(null)
|
||||||
|
try {
|
||||||
|
const content = await requestData<FileContent>(browserClient().file.read({ path: selected }), "file.read")
|
||||||
|
const type = (content as any)?.type
|
||||||
|
const encoding = (content as any)?.encoding
|
||||||
|
if (type && type !== "text") {
|
||||||
|
throw new Error("Binary file cannot be displayed")
|
||||||
|
}
|
||||||
|
if (encoding === "base64") {
|
||||||
|
throw new Error("Binary file cannot be displayed")
|
||||||
|
}
|
||||||
|
const text = (content as any)?.content
|
||||||
|
if (typeof text !== "string") {
|
||||||
|
throw new Error("Unsupported file type")
|
||||||
|
}
|
||||||
|
setBrowserSelectedContent(text)
|
||||||
|
} catch (error) {
|
||||||
|
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
|
||||||
|
} finally {
|
||||||
|
setBrowserSelectedLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const browserParentPath = createMemo(() => getParentPath(browserPath()))
|
||||||
|
const browserScopeKey = createMemo(() => `${props.instanceId}:${worktreeSlugForViewer()}`)
|
||||||
|
const gitScopeKey = createMemo(() => `${props.instanceId}:git:${worktreeSlugForViewer()}`)
|
||||||
|
|
||||||
|
const openChangesTabFromStatus = (file?: string) => {
|
||||||
|
if (file) {
|
||||||
|
setSelectedFile(file)
|
||||||
|
}
|
||||||
|
setRightPanelTab("changes")
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusSectionIds = ["session-changes", "plan", "background-processes", "mcp", "lsp", "plugins"]
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const currentExpanded = new Set(rightPanelExpandedItems())
|
||||||
|
if (statusSectionIds.every((id) => currentExpanded.has(id))) return
|
||||||
|
setRightPanelExpandedItems(statusSectionIds)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleAccordionChange = (values: string[]) => {
|
||||||
|
setRightPanelExpandedItems(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabClass = (tab: RightPanelTab) =>
|
||||||
|
`right-panel-tab ${rightPanelTab() === tab ? "right-panel-tab-active" : "right-panel-tab-inactive"}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col h-full" ref={props.setContentEl}>
|
||||||
|
<div class="right-panel-tab-bar">
|
||||||
|
<div class="tab-container">
|
||||||
|
<div class="tab-strip-shortcuts text-primary">
|
||||||
|
<Show when={props.rightDrawerState() === "floating-open"}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="inherit"
|
||||||
|
aria-label={props.t("instanceShell.rightDrawer.toggle.close")}
|
||||||
|
title={props.t("instanceShell.rightDrawer.toggle.close")}
|
||||||
|
onClick={props.onCloseRightDrawer}
|
||||||
|
>
|
||||||
|
<MenuOpenIcon fontSize="small" sx={{ transform: "scaleX(-1)" }} />
|
||||||
|
</IconButton>
|
||||||
|
</Show>
|
||||||
|
<Show when={!props.isPhoneLayout()}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="inherit"
|
||||||
|
aria-label={props.rightPinned() ? props.t("instanceShell.rightDrawer.unpin") : props.t("instanceShell.rightDrawer.pin")}
|
||||||
|
onClick={() => (props.rightPinned() ? props.onUnpinRightDrawer() : props.onPinRightDrawer())}
|
||||||
|
>
|
||||||
|
{props.rightPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="tab-scroll">
|
||||||
|
<div class="tab-strip">
|
||||||
|
<div class="tab-strip-tabs" role="tablist" aria-label={props.t("instanceShell.rightPanel.tabs.ariaLabel")}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class={tabClass("changes")}
|
||||||
|
aria-selected={rightPanelTab() === "changes"}
|
||||||
|
onClick={() => setRightPanelTab("changes")}
|
||||||
|
>
|
||||||
|
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.changes")}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class={tabClass("git-changes")}
|
||||||
|
aria-selected={rightPanelTab() === "git-changes"}
|
||||||
|
onClick={() => setRightPanelTab("git-changes")}
|
||||||
|
>
|
||||||
|
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.gitChanges")}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class={tabClass("files")}
|
||||||
|
aria-selected={rightPanelTab() === "files"}
|
||||||
|
onClick={() => setRightPanelTab("files")}
|
||||||
|
>
|
||||||
|
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.files")}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class={tabClass("status")}
|
||||||
|
aria-selected={rightPanelTab() === "status"}
|
||||||
|
onClick={() => setRightPanelTab("status")}
|
||||||
|
>
|
||||||
|
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.status")}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-strip-spacer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
<Show when={rightPanelTab() === "changes"}>
|
||||||
|
<ChangesTab
|
||||||
|
t={props.t}
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
activeSessionId={props.activeSessionId}
|
||||||
|
activeSessionDiffs={props.activeSessionDiffs}
|
||||||
|
selectedFile={selectedFile}
|
||||||
|
onSelectFile={handleSelectChangesFile}
|
||||||
|
diffViewMode={diffViewMode}
|
||||||
|
diffContextMode={diffContextMode}
|
||||||
|
diffWordWrapMode={diffWordWrapMode}
|
||||||
|
onViewModeChange={setDiffViewMode}
|
||||||
|
onContextModeChange={setDiffContextMode}
|
||||||
|
onWordWrapModeChange={setDiffWordWrapMode}
|
||||||
|
listOpen={changesListOpen}
|
||||||
|
onToggleList={toggleChangesList}
|
||||||
|
splitWidth={changesSplitWidth}
|
||||||
|
onResizeMouseDown={handleSplitResizeMouseDown("changes")}
|
||||||
|
onResizeTouchStart={handleSplitResizeTouchStart("changes")}
|
||||||
|
isPhoneLayout={props.isPhoneLayout}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={rightPanelTab() === "git-changes"}>
|
||||||
|
<GitChangesTab
|
||||||
|
t={props.t}
|
||||||
|
activeSessionId={props.activeSessionId}
|
||||||
|
entries={gitStatusEntries}
|
||||||
|
statusLoading={gitStatusLoading}
|
||||||
|
statusError={gitStatusError}
|
||||||
|
selectedPath={gitSelectedPath}
|
||||||
|
selectedLoading={gitSelectedLoading}
|
||||||
|
selectedError={gitSelectedError}
|
||||||
|
selectedBefore={gitSelectedBefore}
|
||||||
|
selectedAfter={gitSelectedAfter}
|
||||||
|
mostChangedPath={gitMostChangedPath}
|
||||||
|
scopeKey={gitScopeKey}
|
||||||
|
diffViewMode={diffViewMode}
|
||||||
|
diffContextMode={diffContextMode}
|
||||||
|
diffWordWrapMode={diffWordWrapMode}
|
||||||
|
onViewModeChange={setDiffViewMode}
|
||||||
|
onContextModeChange={setDiffContextMode}
|
||||||
|
onWordWrapModeChange={setDiffWordWrapMode}
|
||||||
|
onOpenFile={(path) => void openGitFile(path)}
|
||||||
|
onRefresh={() => void refreshGitStatus()}
|
||||||
|
listOpen={gitChangesListOpen}
|
||||||
|
onToggleList={toggleGitList}
|
||||||
|
splitWidth={gitChangesSplitWidth}
|
||||||
|
onResizeMouseDown={handleSplitResizeMouseDown("git-changes")}
|
||||||
|
onResizeTouchStart={handleSplitResizeTouchStart("git-changes")}
|
||||||
|
isPhoneLayout={props.isPhoneLayout}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={rightPanelTab() === "files"}>
|
||||||
|
<FilesTab
|
||||||
|
t={props.t}
|
||||||
|
browserPath={browserPath}
|
||||||
|
browserEntries={browserEntries}
|
||||||
|
browserLoading={browserLoading}
|
||||||
|
browserError={browserError}
|
||||||
|
browserSelectedPath={browserSelectedPath}
|
||||||
|
browserSelectedContent={browserSelectedContent}
|
||||||
|
browserSelectedLoading={browserSelectedLoading}
|
||||||
|
browserSelectedError={browserSelectedError}
|
||||||
|
parentPath={browserParentPath}
|
||||||
|
scopeKey={browserScopeKey}
|
||||||
|
onLoadEntries={(path) => void loadBrowserEntries(path)}
|
||||||
|
onOpenFile={(path) => void openBrowserFile(path)}
|
||||||
|
onRefresh={() => void refreshFilesTab()}
|
||||||
|
listOpen={filesListOpen}
|
||||||
|
onToggleList={toggleFilesList}
|
||||||
|
splitWidth={filesSplitWidth}
|
||||||
|
onResizeMouseDown={handleSplitResizeMouseDown("files")}
|
||||||
|
onResizeTouchStart={handleSplitResizeTouchStart("files")}
|
||||||
|
isPhoneLayout={props.isPhoneLayout}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={rightPanelTab() === "status"}>
|
||||||
|
<StatusTab
|
||||||
|
t={props.t}
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
instance={props.instance}
|
||||||
|
activeSessionId={props.activeSessionId}
|
||||||
|
activeSession={props.activeSession}
|
||||||
|
activeSessionDiffs={props.activeSessionDiffs}
|
||||||
|
latestTodoState={props.latestTodoState}
|
||||||
|
backgroundProcessList={props.backgroundProcessList}
|
||||||
|
onOpenBackgroundOutput={props.onOpenBackgroundOutput}
|
||||||
|
onStopBackgroundProcess={props.onStopBackgroundProcess}
|
||||||
|
onTerminateBackgroundProcess={props.onTerminateBackgroundProcess}
|
||||||
|
expandedItems={rightPanelExpandedItems}
|
||||||
|
onExpandedItemsChange={handleAccordionChange}
|
||||||
|
onOpenChangesTab={openChangesTabFromStatus}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RightPanel
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { Component } from "solid-js"
|
||||||
|
|
||||||
|
import { AlignJustify, FoldVertical, Split, UnfoldVertical, WrapText } from "lucide-solid"
|
||||||
|
|
||||||
|
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||||
|
|
||||||
|
interface DiffToolbarProps {
|
||||||
|
viewMode: DiffViewMode
|
||||||
|
contextMode: DiffContextMode
|
||||||
|
wordWrapMode: DiffWordWrapMode
|
||||||
|
onViewModeChange: (mode: DiffViewMode) => void
|
||||||
|
onContextModeChange: (mode: DiffContextMode) => void
|
||||||
|
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div class="file-viewer-toolbar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="file-viewer-toolbar-icon-button"
|
||||||
|
onClick={() => props.onViewModeChange(nextViewMode())}
|
||||||
|
aria-label={viewModeTitle()}
|
||||||
|
title={viewModeTitle()}
|
||||||
|
>
|
||||||
|
{nextViewMode() === "split" ? <Split class="h-4 w-4" aria-hidden="true" /> : <AlignJustify class="h-4 w-4" aria-hidden="true" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="file-viewer-toolbar-icon-button"
|
||||||
|
onClick={() => props.onContextModeChange(nextContextMode())}
|
||||||
|
aria-label={contextModeTitle()}
|
||||||
|
title={contextModeTitle()}
|
||||||
|
>
|
||||||
|
{nextContextMode() === "collapsed" ? (
|
||||||
|
<FoldVertical class="h-4 w-4" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<UnfoldVertical class="h-4 w-4" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`file-viewer-toolbar-icon-button${props.wordWrapMode === "on" ? " active" : ""}`}
|
||||||
|
onClick={() => props.onWordWrapModeChange(nextWordWrapMode())}
|
||||||
|
aria-label={wordWrapTitle()}
|
||||||
|
title={wordWrapTitle()}
|
||||||
|
>
|
||||||
|
<WrapText class="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DiffToolbar
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Component, JSX } from "solid-js"
|
||||||
|
|
||||||
|
interface OverlayListProps {
|
||||||
|
ariaLabel: string
|
||||||
|
children: JSX.Element
|
||||||
|
}
|
||||||
|
|
||||||
|
const OverlayList: Component<OverlayListProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<div class="file-list-overlay" role="dialog" aria-label={props.ariaLabel}>
|
||||||
|
<div class="file-list-scroll">{props.children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OverlayList
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Show, type Component, type JSX } from "solid-js"
|
||||||
|
|
||||||
|
import OverlayList from "./OverlayList"
|
||||||
|
|
||||||
|
type SplitFilePanelList = {
|
||||||
|
panel: () => JSX.Element
|
||||||
|
overlay: () => JSX.Element
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SplitFilePanelProps {
|
||||||
|
header: JSX.Element
|
||||||
|
list: SplitFilePanelList
|
||||||
|
viewer: JSX.Element
|
||||||
|
|
||||||
|
listOpen: boolean
|
||||||
|
onToggleList: () => void
|
||||||
|
|
||||||
|
splitWidth: number
|
||||||
|
onResizeMouseDown: (event: MouseEvent) => void
|
||||||
|
onResizeTouchStart: (event: TouchEvent) => void
|
||||||
|
|
||||||
|
isPhoneLayout: boolean
|
||||||
|
overlayAriaLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SplitFilePanel: Component<SplitFilePanelProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<div class="files-tab-container">
|
||||||
|
<div class="files-tab-header">
|
||||||
|
<div class="files-tab-header-row">
|
||||||
|
<button type="button" class="files-toggle-button" onClick={props.onToggleList}>
|
||||||
|
{props.listOpen ? "Hide files" : "Show files"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{props.header}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="files-tab-body">
|
||||||
|
<Show
|
||||||
|
when={!props.isPhoneLayout && props.listOpen}
|
||||||
|
fallback={props.viewer}
|
||||||
|
>
|
||||||
|
<div class="files-split" style={{ "--files-pane-width": `${props.splitWidth}px` }}>
|
||||||
|
<div class="file-list-panel">
|
||||||
|
<div class="file-list-scroll">{props.list.panel()}</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="file-split-handle"
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label="Resize file list"
|
||||||
|
onMouseDown={props.onResizeMouseDown}
|
||||||
|
onTouchStart={props.onResizeTouchStart}
|
||||||
|
/>
|
||||||
|
{props.viewer}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={props.isPhoneLayout}>
|
||||||
|
<Show when={props.listOpen}>
|
||||||
|
<OverlayList ariaLabel={props.overlayAriaLabel}>{props.list.overlay()}</OverlayList>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SplitFilePanel
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js"
|
||||||
|
|
||||||
|
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
||||||
|
|
||||||
|
import DiffToolbar from "../components/DiffToolbar"
|
||||||
|
import SplitFilePanel from "../components/SplitFilePanel"
|
||||||
|
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||||
|
|
||||||
|
interface ChangesTabProps {
|
||||||
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
|
|
||||||
|
instanceId: string
|
||||||
|
activeSessionId: Accessor<string | null>
|
||||||
|
activeSessionDiffs: Accessor<any[] | undefined>
|
||||||
|
|
||||||
|
selectedFile: Accessor<string | null>
|
||||||
|
onSelectFile: (file: string, closeList: boolean) => void
|
||||||
|
|
||||||
|
diffViewMode: Accessor<DiffViewMode>
|
||||||
|
diffContextMode: Accessor<DiffContextMode>
|
||||||
|
diffWordWrapMode: Accessor<DiffWordWrapMode>
|
||||||
|
onViewModeChange: (mode: DiffViewMode) => void
|
||||||
|
onContextModeChange: (mode: DiffContextMode) => void
|
||||||
|
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
|
||||||
|
|
||||||
|
listOpen: Accessor<boolean>
|
||||||
|
onToggleList: () => void
|
||||||
|
splitWidth: Accessor<number>
|
||||||
|
onResizeMouseDown: (event: MouseEvent) => void
|
||||||
|
onResizeTouchStart: (event: TouchEvent) => void
|
||||||
|
isPhoneLayout: Accessor<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChangesTab: Component<ChangesTabProps> = (props) => {
|
||||||
|
const sessionId = createMemo(() => props.activeSessionId())
|
||||||
|
const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
|
||||||
|
const diffs = createMemo(() => (hasSession() ? props.activeSessionDiffs() : null))
|
||||||
|
|
||||||
|
const sorted = createMemo<any[]>(() => {
|
||||||
|
const list = diffs()
|
||||||
|
if (!Array.isArray(list)) return []
|
||||||
|
return [...list].sort((a, b) => String(a.file || "").localeCompare(String(b.file || "")))
|
||||||
|
})
|
||||||
|
|
||||||
|
const totals = createMemo(() => {
|
||||||
|
return sorted().reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
acc.additions += typeof item.additions === "number" ? item.additions : 0
|
||||||
|
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{ additions: 0, deletions: 0 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const mostChanged = createMemo<any | null>(() => {
|
||||||
|
const items = sorted()
|
||||||
|
if (items.length === 0) return null
|
||||||
|
return items.reduce((best, item) => {
|
||||||
|
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 del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0
|
||||||
|
const score = add + del
|
||||||
|
|
||||||
|
if (score > bestScore) return item
|
||||||
|
if (score < bestScore) return best
|
||||||
|
return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best
|
||||||
|
}, items[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedFileData = createMemo<any | null>(() => {
|
||||||
|
const currentSelected = props.selectedFile()
|
||||||
|
const items = sorted()
|
||||||
|
if (currentSelected) {
|
||||||
|
const match = items.find((f) => f.file === currentSelected)
|
||||||
|
if (match) return match
|
||||||
|
}
|
||||||
|
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 = () => (
|
||||||
|
<div class="file-viewer-panel flex-1">
|
||||||
|
<div class="file-viewer-content file-viewer-content--monaco">
|
||||||
|
<Show
|
||||||
|
when={selected && hasSession() && sortedList.length > 0 ? selected : null}
|
||||||
|
fallback={
|
||||||
|
<div class="file-viewer-empty">
|
||||||
|
<span class="file-viewer-empty-text">{emptyViewerMessage()}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(file) => (
|
||||||
|
<MonacoDiffViewer
|
||||||
|
scopeKey={scopeKey()}
|
||||||
|
path={String(file().file || "")}
|
||||||
|
before={String((file() as any).before || "")}
|
||||||
|
after={String((file() as any).after || "")}
|
||||||
|
viewMode={props.diffViewMode()}
|
||||||
|
contextMode={props.diffContextMode()}
|
||||||
|
wordWrap={props.diffWordWrapMode()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderEmptyList = () => (
|
||||||
|
<div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderListPanel = () => (
|
||||||
|
<Show when={sortedList.length > 0} fallback={renderEmptyList()}>
|
||||||
|
<For each={sortedList}>
|
||||||
|
{(item) => (
|
||||||
|
<div
|
||||||
|
class={`file-list-item ${selected?.file === item.file ? "file-list-item-active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
props.onSelectFile(item.file, props.isPhoneLayout())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="file-list-item-content">
|
||||||
|
<div class="file-list-item-path" title={item.file}>
|
||||||
|
<span class="file-path-text">{item.file}</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-list-item-stats">
|
||||||
|
<span class="file-list-item-additions">+{item.additions}</span>
|
||||||
|
<span class="file-list-item-deletions">-{item.deletions}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderListOverlay = () => (
|
||||||
|
<Show when={sortedList.length > 0} fallback={renderEmptyList()}>
|
||||||
|
<For each={sortedList}>
|
||||||
|
{(item) => (
|
||||||
|
<div
|
||||||
|
class={`file-list-item ${selected?.file === item.file ? "file-list-item-active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
props.onSelectFile(item.file, true)
|
||||||
|
}}
|
||||||
|
title={item.file}
|
||||||
|
>
|
||||||
|
<div class="file-list-item-content">
|
||||||
|
<div class="file-list-item-path" title={item.file}>
|
||||||
|
<span class="file-path-text">{item.file}</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-list-item-stats">
|
||||||
|
<span class="file-list-item-additions">+{item.additions}</span>
|
||||||
|
<span class="file-list-item-deletions">-{item.deletions}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SplitFilePanel
|
||||||
|
header={
|
||||||
|
<>
|
||||||
|
<span class="files-tab-selected-path" title={headerPath()}>
|
||||||
|
<span class="file-path-text">{headerPath()}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
|
||||||
|
<span class="files-tab-stat files-tab-stat-additions">
|
||||||
|
<span class="files-tab-stat-value">+{totalsValue.additions}</span>
|
||||||
|
</span>
|
||||||
|
<span class="files-tab-stat files-tab-stat-deletions">
|
||||||
|
<span class="files-tab-stat-value">-{totalsValue.deletions}</span>
|
||||||
|
</span>
|
||||||
|
</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 }}
|
||||||
|
viewer={renderViewer()}
|
||||||
|
listOpen={props.listOpen()}
|
||||||
|
onToggleList={props.onToggleList}
|
||||||
|
splitWidth={props.splitWidth()}
|
||||||
|
onResizeMouseDown={props.onResizeMouseDown}
|
||||||
|
onResizeTouchStart={props.onResizeTouchStart}
|
||||||
|
isPhoneLayout={props.isPhoneLayout()}
|
||||||
|
overlayAriaLabel="Changes"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{renderContent()}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChangesTab
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
|
||||||
|
import type { FileNode } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
|
import { RefreshCw } from "lucide-solid"
|
||||||
|
|
||||||
|
import { MonacoFileViewer } from "../../../../file-viewer/monaco-file-viewer"
|
||||||
|
|
||||||
|
import SplitFilePanel from "../components/SplitFilePanel"
|
||||||
|
|
||||||
|
interface FilesTabProps {
|
||||||
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
|
|
||||||
|
browserPath: Accessor<string>
|
||||||
|
browserEntries: Accessor<FileNode[] | null>
|
||||||
|
browserLoading: Accessor<boolean>
|
||||||
|
browserError: Accessor<string | null>
|
||||||
|
|
||||||
|
browserSelectedPath: Accessor<string | null>
|
||||||
|
browserSelectedContent: Accessor<string | null>
|
||||||
|
browserSelectedLoading: Accessor<boolean>
|
||||||
|
browserSelectedError: Accessor<string | null>
|
||||||
|
|
||||||
|
parentPath: Accessor<string | null>
|
||||||
|
scopeKey: Accessor<string>
|
||||||
|
|
||||||
|
onLoadEntries: (path: string) => void
|
||||||
|
onOpenFile: (path: string) => void
|
||||||
|
onRefresh: () => void
|
||||||
|
|
||||||
|
listOpen: Accessor<boolean>
|
||||||
|
onToggleList: () => void
|
||||||
|
splitWidth: Accessor<number>
|
||||||
|
onResizeMouseDown: (event: MouseEvent) => void
|
||||||
|
onResizeTouchStart: (event: TouchEvent) => void
|
||||||
|
isPhoneLayout: Accessor<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilesTab: Component<FilesTabProps> = (props) => {
|
||||||
|
const renderContent = (): JSX.Element => {
|
||||||
|
const entriesValue = props.browserEntries()
|
||||||
|
const entries = entriesValue || []
|
||||||
|
const sorted = [...entries].sort((a, b) => {
|
||||||
|
const aDir = a.type === "directory" ? 0 : 1
|
||||||
|
const bDir = b.type === "directory" ? 0 : 1
|
||||||
|
if (aDir !== bDir) return aDir - bDir
|
||||||
|
return String(a.name || "").localeCompare(String(b.name || ""))
|
||||||
|
})
|
||||||
|
|
||||||
|
const parent = props.parentPath()
|
||||||
|
|
||||||
|
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
|
||||||
|
|
||||||
|
const emptyViewerMessage = () => {
|
||||||
|
if (props.browserLoading() && entriesValue === null) return "Loading files..."
|
||||||
|
return "Select a file to preview"
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderViewer = () => (
|
||||||
|
<div class="file-viewer-panel flex-1">
|
||||||
|
<div class="file-viewer-content file-viewer-content--monaco">
|
||||||
|
<Show
|
||||||
|
when={props.browserSelectedLoading()}
|
||||||
|
fallback={
|
||||||
|
<Show
|
||||||
|
when={props.browserSelectedError()}
|
||||||
|
fallback={
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
props.browserSelectedPath() && props.browserSelectedContent() !== null
|
||||||
|
? { path: props.browserSelectedPath() as string, content: props.browserSelectedContent() as string }
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
fallback={
|
||||||
|
<div class="file-viewer-empty">
|
||||||
|
<span class="file-viewer-empty-text">{emptyViewerMessage()}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(payload) => (
|
||||||
|
<MonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(err) => (
|
||||||
|
<div class="file-viewer-empty">
|
||||||
|
<span class="file-viewer-empty-text">{err()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="file-viewer-empty">
|
||||||
|
<span class="file-viewer-empty-text">Loading…</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderList = () => (
|
||||||
|
<>
|
||||||
|
<Show when={parent}>
|
||||||
|
{(p) => (
|
||||||
|
<div class="file-list-item" onClick={() => props.onLoadEntries(p())}>
|
||||||
|
<div class="file-list-item-content">
|
||||||
|
<div class="file-list-item-path" title={p()}>
|
||||||
|
<span class="file-path-text">..</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={props.browserLoading() && entriesValue === null}>
|
||||||
|
<div class="p-3 text-xs text-secondary">Loading files...</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<For each={sorted}>
|
||||||
|
{(item) => (
|
||||||
|
<div
|
||||||
|
class={`file-list-item ${props.browserSelectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (item.type === "directory") {
|
||||||
|
props.onLoadEntries(item.path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
props.onOpenFile(item.path)
|
||||||
|
}}
|
||||||
|
title={item.path}
|
||||||
|
>
|
||||||
|
<div class="file-list-item-content">
|
||||||
|
<div class="file-list-item-path" title={item.path}>
|
||||||
|
<span class="file-path-text">{item.name}</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-list-item-stats">
|
||||||
|
<span class="text-[10px] text-secondary">{item.type}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SplitFilePanel
|
||||||
|
header={
|
||||||
|
<>
|
||||||
|
<div class="files-tab-stats">
|
||||||
|
<span class="files-tab-stat">
|
||||||
|
<span class="files-tab-selected-path" title={headerDisplayedPath()}>
|
||||||
|
<span class="file-path-text">{headerDisplayedPath()}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Show when={props.browserLoading()}>
|
||||||
|
<span>Loading…</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="files-header-icon-button"
|
||||||
|
title={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||||
|
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||||
|
disabled={props.browserLoading()}
|
||||||
|
style={{ "margin-left": "auto" }}
|
||||||
|
onClick={() => props.onRefresh()}
|
||||||
|
>
|
||||||
|
<RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
list={{ panel: renderList, overlay: renderList }}
|
||||||
|
viewer={renderViewer()}
|
||||||
|
listOpen={props.listOpen()}
|
||||||
|
onToggleList={props.onToggleList}
|
||||||
|
splitWidth={props.splitWidth()}
|
||||||
|
onResizeMouseDown={props.onResizeMouseDown}
|
||||||
|
onResizeTouchStart={props.onResizeTouchStart}
|
||||||
|
isPhoneLayout={props.isPhoneLayout()}
|
||||||
|
overlayAriaLabel="Files"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{renderContent()}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FilesTab
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
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 { RefreshCw } from "lucide-solid"
|
||||||
|
|
||||||
|
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
|
||||||
|
|
||||||
|
import DiffToolbar from "../components/DiffToolbar"
|
||||||
|
import SplitFilePanel from "../components/SplitFilePanel"
|
||||||
|
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||||
|
|
||||||
|
interface GitChangesTabProps {
|
||||||
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
|
|
||||||
|
activeSessionId: Accessor<string | null>
|
||||||
|
|
||||||
|
entries: Accessor<GitFileStatus[] | null>
|
||||||
|
statusLoading: Accessor<boolean>
|
||||||
|
statusError: Accessor<string | null>
|
||||||
|
|
||||||
|
selectedPath: Accessor<string | null>
|
||||||
|
selectedLoading: Accessor<boolean>
|
||||||
|
selectedError: Accessor<string | null>
|
||||||
|
selectedBefore: Accessor<string | null>
|
||||||
|
selectedAfter: Accessor<string | null>
|
||||||
|
mostChangedPath: Accessor<string | null>
|
||||||
|
|
||||||
|
scopeKey: Accessor<string>
|
||||||
|
|
||||||
|
diffViewMode: Accessor<DiffViewMode>
|
||||||
|
diffContextMode: Accessor<DiffContextMode>
|
||||||
|
diffWordWrapMode: Accessor<DiffWordWrapMode>
|
||||||
|
onViewModeChange: (mode: DiffViewMode) => void
|
||||||
|
onContextModeChange: (mode: DiffContextMode) => void
|
||||||
|
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
|
||||||
|
|
||||||
|
onOpenFile: (path: string) => void
|
||||||
|
onRefresh: () => void
|
||||||
|
|
||||||
|
listOpen: Accessor<boolean>
|
||||||
|
onToggleList: () => void
|
||||||
|
splitWidth: Accessor<number>
|
||||||
|
onResizeMouseDown: (event: MouseEvent) => void
|
||||||
|
onResizeTouchStart: (event: TouchEvent) => void
|
||||||
|
isPhoneLayout: Accessor<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||||
|
const sessionId = createMemo(() => props.activeSessionId())
|
||||||
|
const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
|
||||||
|
const entries = createMemo(() => (hasSession() ? props.entries() : null))
|
||||||
|
|
||||||
|
const sorted = createMemo<GitFileStatus[]>(() => {
|
||||||
|
const list = entries()
|
||||||
|
if (!Array.isArray(list)) return []
|
||||||
|
return [...list].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
|
||||||
|
})
|
||||||
|
|
||||||
|
const totals = createMemo(() => {
|
||||||
|
return sorted().reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
acc.additions += typeof item.added === "number" ? item.added : 0
|
||||||
|
acc.deletions += typeof item.removed === "number" ? item.removed : 0
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{ additions: 0, deletions: 0 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const nonDeleted = createMemo(() => sorted().filter((item) => item && item.status !== "deleted"))
|
||||||
|
|
||||||
|
const selectedEntry = createMemo<GitFileStatus | null>(() => {
|
||||||
|
const list = sorted()
|
||||||
|
const selectedPath = props.selectedPath()
|
||||||
|
const fallbackPath = props.mostChangedPath()
|
||||||
|
const found =
|
||||||
|
list.find((item) => item.path === selectedPath) ||
|
||||||
|
(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 = () => (
|
||||||
|
<div class="file-viewer-panel flex-1">
|
||||||
|
<div class="file-viewer-content file-viewer-content--monaco">
|
||||||
|
<Show
|
||||||
|
when={props.selectedLoading()}
|
||||||
|
fallback={
|
||||||
|
<Show
|
||||||
|
when={props.selectedError()}
|
||||||
|
fallback={
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
selected &&
|
||||||
|
props.selectedBefore() !== null &&
|
||||||
|
props.selectedAfter() !== null &&
|
||||||
|
selected.status !== "deleted"
|
||||||
|
? {
|
||||||
|
path: selected.path,
|
||||||
|
before: props.selectedBefore() as string,
|
||||||
|
after: props.selectedAfter() as string,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
fallback={
|
||||||
|
<div class="file-viewer-empty">
|
||||||
|
<span class="file-viewer-empty-text">{emptyViewerMessage()}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(file) => (
|
||||||
|
<MonacoDiffViewer
|
||||||
|
scopeKey={props.scopeKey()}
|
||||||
|
path={String(file().path || "")}
|
||||||
|
before={String((file() as any).before || "")}
|
||||||
|
after={String((file() as any).after || "")}
|
||||||
|
viewMode={props.diffViewMode()}
|
||||||
|
contextMode={props.diffContextMode()}
|
||||||
|
wordWrap={props.diffWordWrapMode()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(err) => (
|
||||||
|
<div class="file-viewer-empty">
|
||||||
|
<span class="file-viewer-empty-text">{err()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="file-viewer-empty">
|
||||||
|
<span class="file-viewer-empty-text">Loading…</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
|
||||||
|
|
||||||
|
const renderListPanel = () => (
|
||||||
|
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
|
||||||
|
<For each={sortedList}>
|
||||||
|
{(item) => (
|
||||||
|
<div
|
||||||
|
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
props.onOpenFile(item.path)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="file-list-item-content">
|
||||||
|
<div class="file-list-item-path" title={item.path}>
|
||||||
|
<span class="file-path-text">{item.path}</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-list-item-stats">
|
||||||
|
<Show when={item.status === "deleted"}>
|
||||||
|
<span class="text-[10px] text-secondary">deleted</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={item.status !== "deleted"}>
|
||||||
|
<>
|
||||||
|
<span class="file-list-item-additions">+{item.added}</span>
|
||||||
|
<span class="file-list-item-deletions">-{item.removed}</span>
|
||||||
|
</>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderListOverlay = () => (
|
||||||
|
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
|
||||||
|
<For each={sortedList}>
|
||||||
|
{(item) => (
|
||||||
|
<div
|
||||||
|
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
|
||||||
|
onClick={() => props.onOpenFile(item.path)}
|
||||||
|
title={item.path}
|
||||||
|
>
|
||||||
|
<div class="file-list-item-content">
|
||||||
|
<div class="file-list-item-path" title={item.path}>
|
||||||
|
<span class="file-path-text">{item.path}</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-list-item-stats">
|
||||||
|
<Show when={item.status === "deleted"}>
|
||||||
|
<span class="text-[10px] text-secondary">deleted</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={item.status !== "deleted"}>
|
||||||
|
<>
|
||||||
|
<span class="file-list-item-additions">+{item.added}</span>
|
||||||
|
<span class="file-list-item-deletions">-{item.removed}</span>
|
||||||
|
</>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SplitFilePanel
|
||||||
|
header={
|
||||||
|
<>
|
||||||
|
<span class="files-tab-selected-path" title={selected?.path || "Git Changes"}>
|
||||||
|
<span class="file-path-text">{selected?.path || "Git Changes"}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
|
||||||
|
<span class="files-tab-stat files-tab-stat-additions">
|
||||||
|
<span class="files-tab-stat-value">+{totalsValue.additions}</span>
|
||||||
|
</span>
|
||||||
|
<span class="files-tab-stat files-tab-stat-deletions">
|
||||||
|
<span class="files-tab-stat-value">-{totalsValue.deletions}</span>
|
||||||
|
</span>
|
||||||
|
<Show when={props.statusError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="files-header-icon-button"
|
||||||
|
title={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||||
|
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||||
|
disabled={!hasSession() || props.statusLoading() || entries() === null}
|
||||||
|
style={{ "margin-left": "auto" }}
|
||||||
|
onClick={() => props.onRefresh()}
|
||||||
|
>
|
||||||
|
<RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} />
|
||||||
|
</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 }}
|
||||||
|
viewer={renderViewer()}
|
||||||
|
listOpen={props.listOpen()}
|
||||||
|
onToggleList={props.onToggleList}
|
||||||
|
splitWidth={props.splitWidth()}
|
||||||
|
onResizeMouseDown={props.onResizeMouseDown}
|
||||||
|
onResizeTouchStart={props.onResizeTouchStart}
|
||||||
|
isPhoneLayout={props.isPhoneLayout()}
|
||||||
|
overlayAriaLabel="Git Changes"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{renderContent()}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GitChangesTab
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import { For, Show, type Accessor, type Component } from "solid-js"
|
||||||
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
|
import { Accordion } from "@kobalte/core"
|
||||||
|
import { Tooltip } from "@kobalte/core/tooltip"
|
||||||
|
|
||||||
|
import { ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
||||||
|
|
||||||
|
import type { Instance } from "../../../../../types/instance"
|
||||||
|
import type { BackgroundProcess } from "../../../../../../../server/src/api-types"
|
||||||
|
import type { Session } from "../../../../../types/session"
|
||||||
|
|
||||||
|
import ContextUsagePanel from "../../../../session/context-usage-panel"
|
||||||
|
import { TodoListView } from "../../../../tool-call/renderers/todo"
|
||||||
|
import InstanceServiceStatus from "../../../../instance-service-status"
|
||||||
|
|
||||||
|
interface StatusTabProps {
|
||||||
|
t: (key: string, vars?: Record<string, any>) => string
|
||||||
|
|
||||||
|
instanceId: string
|
||||||
|
instance: Instance
|
||||||
|
|
||||||
|
activeSessionId: Accessor<string | null>
|
||||||
|
activeSession: Accessor<Session | null>
|
||||||
|
activeSessionDiffs: Accessor<any[] | undefined>
|
||||||
|
|
||||||
|
latestTodoState: Accessor<ToolState | null>
|
||||||
|
|
||||||
|
backgroundProcessList: Accessor<BackgroundProcess[]>
|
||||||
|
onOpenBackgroundOutput: (process: BackgroundProcess) => void
|
||||||
|
onStopBackgroundProcess: (processId: string) => Promise<void> | void
|
||||||
|
onTerminateBackgroundProcess: (processId: string) => Promise<void> | void
|
||||||
|
|
||||||
|
expandedItems: Accessor<string[]>
|
||||||
|
onExpandedItemsChange: (values: string[]) => void
|
||||||
|
|
||||||
|
onOpenChangesTab: (file?: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatusTab: Component<StatusTabProps> = (props) => {
|
||||||
|
const isSectionExpanded = (id: string) => props.expandedItems().includes(id)
|
||||||
|
|
||||||
|
const renderStatusSessionChanges = () => {
|
||||||
|
const sessionId = props.activeSessionId()
|
||||||
|
if (!sessionId || sessionId === "info") {
|
||||||
|
return (
|
||||||
|
<div class="right-panel-empty right-panel-empty--left">
|
||||||
|
<span class="text-xs">{props.t("instanceShell.sessionChanges.noSessionSelected")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffs = props.activeSessionDiffs()
|
||||||
|
if (diffs === undefined) {
|
||||||
|
return (
|
||||||
|
<div class="right-panel-empty right-panel-empty--left">
|
||||||
|
<span class="text-xs">{props.t("instanceShell.sessionChanges.loading")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(diffs) || diffs.length === 0) {
|
||||||
|
return (
|
||||||
|
<div class="right-panel-empty right-panel-empty--left">
|
||||||
|
<span class="text-xs">{props.t("instanceShell.sessionChanges.empty")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || "")))
|
||||||
|
const totals = sorted.reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
acc.additions += typeof item.additions === "number" ? item.additions : 0
|
||||||
|
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{ additions: 0, deletions: 0 },
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-3 min-h-0">
|
||||||
|
<div class="flex items-center justify-between gap-2 text-[11px] text-secondary">
|
||||||
|
<span>{props.t("instanceShell.sessionChanges.filesChanged", { count: sorted.length })}</span>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span style={{ color: "var(--session-status-idle-fg)" }}>{`+${totals.additions}`}</span>
|
||||||
|
<span style={{ color: "var(--session-status-working-fg)" }}>{`-${totals.deletions}`}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-md border border-base bg-surface-secondary p-2 max-h-[40vh] overflow-y-auto">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<For each={sorted}>
|
||||||
|
{(item) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="border-b border-base last:border-b-0 text-left hover:bg-surface-muted rounded-sm"
|
||||||
|
onClick={() => props.onOpenChangesTab(item.file)}
|
||||||
|
title={props.t("instanceShell.sessionChanges.actions.show")}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div
|
||||||
|
class="text-xs font-mono text-primary min-w-0 flex-1 overflow-hidden whitespace-nowrap"
|
||||||
|
title={item.file}
|
||||||
|
style="text-overflow: ellipsis; direction: rtl; text-align: left; unicode-bidi: plaintext;"
|
||||||
|
>
|
||||||
|
{item.file}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-[11px] flex-shrink-0">
|
||||||
|
<span style={{ color: "var(--session-status-idle-fg)" }}>{`+${item.additions}`}</span>
|
||||||
|
<span style={{ color: "var(--session-status-working-fg)" }}>{`-${item.deletions}`}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderPlanSectionContent = () => {
|
||||||
|
const sessionId = props.activeSessionId()
|
||||||
|
if (!sessionId || sessionId === "info") {
|
||||||
|
return (
|
||||||
|
<div class="right-panel-empty right-panel-empty--left">
|
||||||
|
<span class="text-xs">{props.t("instanceShell.plan.noSessionSelected")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const todoState = props.latestTodoState()
|
||||||
|
if (!todoState) {
|
||||||
|
return (
|
||||||
|
<div class="right-panel-empty right-panel-empty--left">
|
||||||
|
<span class="text-xs">{props.t("instanceShell.plan.empty")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <TodoListView state={todoState} emptyLabel={props.t("instanceShell.plan.empty")} showStatusLabel={false} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderBackgroundProcesses = () => {
|
||||||
|
const processes = props.backgroundProcessList()
|
||||||
|
if (processes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div class="right-panel-empty right-panel-empty--left">
|
||||||
|
<span class="text-xs">{props.t("instanceShell.backgroundProcesses.empty")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<For each={processes}>
|
||||||
|
{(process) => (
|
||||||
|
<div class="status-process-card">
|
||||||
|
<div class="status-process-header">
|
||||||
|
<span class="status-process-title">{process.title}</span>
|
||||||
|
<div class="status-process-meta">
|
||||||
|
<span>{props.t("instanceShell.backgroundProcesses.status", { status: process.status })}</span>
|
||||||
|
<Show when={typeof process.outputSizeBytes === "number"}>
|
||||||
|
<span>
|
||||||
|
{props.t("instanceShell.backgroundProcesses.output", {
|
||||||
|
sizeKb: Math.round((process.outputSizeBytes ?? 0) / 1024),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-process-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
||||||
|
onClick={() => props.onOpenBackgroundOutput(process)}
|
||||||
|
aria-label={props.t("instanceShell.backgroundProcesses.actions.output")}
|
||||||
|
title={props.t("instanceShell.backgroundProcesses.actions.output")}
|
||||||
|
>
|
||||||
|
<TerminalSquare class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
||||||
|
disabled={process.status !== "running"}
|
||||||
|
onClick={() => props.onStopBackgroundProcess(process.id)}
|
||||||
|
aria-label={props.t("instanceShell.backgroundProcesses.actions.stop")}
|
||||||
|
title={props.t("instanceShell.backgroundProcesses.actions.stop")}
|
||||||
|
>
|
||||||
|
<XOctagon class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
|
||||||
|
onClick={() => props.onTerminateBackgroundProcess(process.id)}
|
||||||
|
aria-label={props.t("instanceShell.backgroundProcesses.actions.terminate")}
|
||||||
|
title={props.t("instanceShell.backgroundProcesses.actions.terminate")}
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusSections = [
|
||||||
|
{
|
||||||
|
id: "session-changes",
|
||||||
|
labelKey: "instanceShell.rightPanel.sections.sessionChanges",
|
||||||
|
tooltipKey: "instanceShell.rightPanel.sections.sessionChanges.tooltip",
|
||||||
|
render: renderStatusSessionChanges,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "plan",
|
||||||
|
labelKey: "instanceShell.rightPanel.sections.plan",
|
||||||
|
tooltipKey: "instanceShell.rightPanel.sections.plan.tooltip",
|
||||||
|
render: renderPlanSectionContent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "background-processes",
|
||||||
|
labelKey: "instanceShell.rightPanel.sections.backgroundProcesses",
|
||||||
|
tooltipKey: "instanceShell.rightPanel.sections.backgroundProcesses.tooltip",
|
||||||
|
render: renderBackgroundProcesses,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mcp",
|
||||||
|
labelKey: "instanceShell.rightPanel.sections.mcp",
|
||||||
|
tooltipKey: "instanceShell.rightPanel.sections.mcp.tooltip",
|
||||||
|
render: () => (
|
||||||
|
<InstanceServiceStatus
|
||||||
|
initialInstance={props.instance}
|
||||||
|
sections={["mcp"]}
|
||||||
|
showSectionHeadings={false}
|
||||||
|
class="space-y-2"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lsp",
|
||||||
|
labelKey: "instanceShell.rightPanel.sections.lsp",
|
||||||
|
tooltipKey: "instanceShell.rightPanel.sections.lsp.tooltip",
|
||||||
|
render: () => (
|
||||||
|
<InstanceServiceStatus
|
||||||
|
initialInstance={props.instance}
|
||||||
|
sections={["lsp"]}
|
||||||
|
showSectionHeadings={false}
|
||||||
|
class="space-y-2"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "plugins",
|
||||||
|
labelKey: "instanceShell.rightPanel.sections.plugins",
|
||||||
|
tooltipKey: "instanceShell.rightPanel.sections.plugins.tooltip",
|
||||||
|
render: () => (
|
||||||
|
<InstanceServiceStatus
|
||||||
|
initialInstance={props.instance}
|
||||||
|
sections={["plugins"]}
|
||||||
|
showSectionHeadings={false}
|
||||||
|
class="space-y-2"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="status-tab-container">
|
||||||
|
<Show when={props.activeSession()}>
|
||||||
|
{(activeSession) => (
|
||||||
|
<ContextUsagePanel instanceId={props.instanceId} sessionId={activeSession().id} class="status-tab-context-panel" />
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Accordion.Root
|
||||||
|
class="right-panel-accordion"
|
||||||
|
collapsible
|
||||||
|
multiple
|
||||||
|
value={props.expandedItems()}
|
||||||
|
onChange={props.onExpandedItemsChange}
|
||||||
|
>
|
||||||
|
<For each={statusSections}>
|
||||||
|
{(section) => (
|
||||||
|
<Accordion.Item value={section.id} class="right-panel-accordion-item">
|
||||||
|
<Accordion.Header>
|
||||||
|
<Accordion.Trigger class="right-panel-accordion-trigger">
|
||||||
|
<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
|
||||||
|
class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`}
|
||||||
|
/>
|
||||||
|
</Accordion.Trigger>
|
||||||
|
</Accordion.Header>
|
||||||
|
<Accordion.Content class="right-panel-accordion-content">{section.render()}</Accordion.Content>
|
||||||
|
</Accordion.Item>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Accordion.Root>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StatusTab
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export type RightPanelTab = "changes" | "git-changes" | "files" | "status"
|
||||||
|
|
||||||
|
export type DiffViewMode = "split" | "unified"
|
||||||
|
|
||||||
|
export type DiffContextMode = "expanded" | "collapsed"
|
||||||
|
|
||||||
|
export type DiffWordWrapMode = "on" | "off"
|
||||||
93
packages/ui/src/components/instance/shell/storage.ts
Normal file
93
packages/ui/src/components/instance/shell/storage.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
export const DEFAULT_SESSION_SIDEBAR_WIDTH = 340
|
||||||
|
export const MIN_SESSION_SIDEBAR_WIDTH = 220
|
||||||
|
export const MAX_SESSION_SIDEBAR_WIDTH = 400
|
||||||
|
|
||||||
|
export const RIGHT_DRAWER_WIDTH = 260
|
||||||
|
export const MIN_RIGHT_DRAWER_WIDTH = 200
|
||||||
|
export const MAX_RIGHT_DRAWER_WIDTH = 1200
|
||||||
|
|
||||||
|
export const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8"
|
||||||
|
export const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1"
|
||||||
|
export const LEFT_PIN_STORAGE_KEY = "opencode-session-left-drawer-pinned-v1"
|
||||||
|
export const RIGHT_PIN_STORAGE_KEY = "opencode-session-right-drawer-pinned-v1"
|
||||||
|
export const RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v2"
|
||||||
|
export const LEGACY_RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v1"
|
||||||
|
export const RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-changes-split-width-v1"
|
||||||
|
export const RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-files-split-width-v1"
|
||||||
|
export const RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-git-changes-split-width-v1"
|
||||||
|
export const RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-changes-list-open-nonphone-v1"
|
||||||
|
export const RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-changes-list-open-phone-v1"
|
||||||
|
export const RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-files-list-open-nonphone-v1"
|
||||||
|
export const RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-files-list-open-phone-v1"
|
||||||
|
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-list-open-nonphone-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_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) =>
|
||||||
|
Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value))
|
||||||
|
|
||||||
|
export const clampRightWidth = (value: number) => {
|
||||||
|
const windowMax = typeof window !== "undefined" ? Math.floor(window.innerWidth * 0.7) : MAX_RIGHT_DRAWER_WIDTH
|
||||||
|
const max = Math.max(MIN_RIGHT_DRAWER_WIDTH, windowMax)
|
||||||
|
return Math.min(max, Math.max(MIN_RIGHT_DRAWER_WIDTH, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPinStorageKey = (side: "left" | "right") => (side === "left" ? LEFT_PIN_STORAGE_KEY : RIGHT_PIN_STORAGE_KEY)
|
||||||
|
|
||||||
|
export function readStoredPinState(side: "left" | "right", defaultValue: boolean) {
|
||||||
|
if (typeof window === "undefined") return defaultValue
|
||||||
|
const stored = window.localStorage.getItem(getPinStorageKey(side))
|
||||||
|
if (stored === "true") return true
|
||||||
|
if (stored === "false") return false
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistPinState(side: "left" | "right", value: boolean) {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
window.localStorage.setItem(getPinStorageKey(side), value ? "true" : "false")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStoredRightPanelTab(
|
||||||
|
defaultValue: "changes" | "git-changes" | "files" | "status",
|
||||||
|
): "changes" | "git-changes" | "files" | "status" {
|
||||||
|
if (typeof window === "undefined") return defaultValue
|
||||||
|
|
||||||
|
const stored = window.localStorage.getItem(RIGHT_PANEL_TAB_STORAGE_KEY)
|
||||||
|
if (stored === "status") return "status"
|
||||||
|
if (stored === "changes") return "changes"
|
||||||
|
if (stored === "git-changes") return "git-changes"
|
||||||
|
if (stored === "files") return "files"
|
||||||
|
|
||||||
|
// Migrate from v1 (where the stored values were the internal tab ids).
|
||||||
|
const legacy = window.localStorage.getItem(LEGACY_RIGHT_PANEL_TAB_STORAGE_KEY)
|
||||||
|
if (legacy === "status") return "status"
|
||||||
|
if (legacy === "browser") return "files"
|
||||||
|
if (legacy === "files") return "changes"
|
||||||
|
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStoredPanelWidth(key: string, fallback: number) {
|
||||||
|
if (typeof window === "undefined") return fallback
|
||||||
|
const stored = window.localStorage.getItem(key)
|
||||||
|
if (!stored) return fallback
|
||||||
|
const parsed = Number.parseInt(stored, 10)
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStoredBool(key: string): boolean | null {
|
||||||
|
if (typeof window === "undefined") return null
|
||||||
|
const stored = window.localStorage.getItem(key)
|
||||||
|
if (stored === "true") return true
|
||||||
|
if (stored === "false") return false
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStoredEnum<T extends string>(key: string, allowed: readonly T[]): T | null {
|
||||||
|
if (typeof window === "undefined") return null
|
||||||
|
const stored = window.localStorage.getItem(key)
|
||||||
|
if (!stored) return null
|
||||||
|
return (allowed as readonly string[]).includes(stored) ? (stored as T) : null
|
||||||
|
}
|
||||||
3
packages/ui/src/components/instance/shell/types.ts
Normal file
3
packages/ui/src/components/instance/shell/types.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export type LayoutMode = "desktop" | "tablet" | "phone"
|
||||||
|
|
||||||
|
export type DrawerViewState = "pinned" | "floating-open" | "floating-closed"
|
||||||
260
packages/ui/src/components/instance/shell/useDrawerChrome.ts
Normal file
260
packages/ui/src/components/instance/shell/useDrawerChrome.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import {
|
||||||
|
batch,
|
||||||
|
createComponent,
|
||||||
|
createEffect,
|
||||||
|
createMemo,
|
||||||
|
createSignal,
|
||||||
|
onCleanup,
|
||||||
|
onMount,
|
||||||
|
type Accessor,
|
||||||
|
type JSX,
|
||||||
|
type Setter,
|
||||||
|
} from "solid-js"
|
||||||
|
import MenuIcon from "@suid/icons-material/Menu"
|
||||||
|
|
||||||
|
import type { TranslateParams } from "../../../lib/i18n"
|
||||||
|
|
||||||
|
import type { DrawerViewState, LayoutMode } from "./types"
|
||||||
|
import { persistPinState, readStoredPinState } from "./storage"
|
||||||
|
|
||||||
|
export interface UseDrawerChromeOptions {
|
||||||
|
t: (key: string, params?: TranslateParams) => string
|
||||||
|
layoutMode: Accessor<LayoutMode>
|
||||||
|
leftPinningSupported: Accessor<boolean>
|
||||||
|
rightPinningSupported: Accessor<boolean>
|
||||||
|
leftDrawerContentEl: Accessor<HTMLElement | null>
|
||||||
|
rightDrawerContentEl: Accessor<HTMLElement | null>
|
||||||
|
leftToggleButtonEl: Accessor<HTMLElement | null>
|
||||||
|
rightToggleButtonEl: Accessor<HTMLElement | null>
|
||||||
|
measureDrawerHost?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DrawerChromeApi {
|
||||||
|
leftPinned: Accessor<boolean>
|
||||||
|
leftOpen: Accessor<boolean>
|
||||||
|
rightPinned: Accessor<boolean>
|
||||||
|
rightOpen: Accessor<boolean>
|
||||||
|
setLeftOpen: Setter<boolean>
|
||||||
|
setRightOpen: Setter<boolean>
|
||||||
|
leftDrawerState: Accessor<DrawerViewState>
|
||||||
|
rightDrawerState: Accessor<DrawerViewState>
|
||||||
|
pinLeft: () => void
|
||||||
|
unpinLeft: () => void
|
||||||
|
pinRight: () => void
|
||||||
|
unpinRight: () => void
|
||||||
|
closeLeft: () => void
|
||||||
|
closeRight: () => void
|
||||||
|
leftAppBarButtonLabel: Accessor<string>
|
||||||
|
rightAppBarButtonLabel: Accessor<string>
|
||||||
|
leftAppBarButtonIcon: Accessor<JSX.Element>
|
||||||
|
rightAppBarButtonIcon: Accessor<JSX.Element>
|
||||||
|
handleLeftAppBarButtonClick: () => void
|
||||||
|
handleRightAppBarButtonClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDrawerChrome(options: UseDrawerChromeOptions): DrawerChromeApi {
|
||||||
|
const [leftPinned, setLeftPinned] = createSignal(true)
|
||||||
|
const [leftOpen, setLeftOpen] = createSignal(true)
|
||||||
|
const [rightPinned, setRightPinned] = createSignal(true)
|
||||||
|
const [rightOpen, setRightOpen] = createSignal(true)
|
||||||
|
|
||||||
|
const measureDrawerHost = () => options.measureDrawerHost?.()
|
||||||
|
|
||||||
|
const focusTarget = (element: HTMLElement | null) => {
|
||||||
|
if (!element) return
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
element.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const blurIfInside = (element: HTMLElement | null) => {
|
||||||
|
if (typeof document === "undefined" || !element) return
|
||||||
|
const active = document.activeElement as HTMLElement | null
|
||||||
|
if (active && element.contains(active)) {
|
||||||
|
active.blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistPinIfSupported = (side: "left" | "right", value: boolean) => {
|
||||||
|
if (side === "left" && !options.leftPinningSupported()) return
|
||||||
|
if (side === "right" && !options.rightPinningSupported()) return
|
||||||
|
persistPinState(side, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
switch (options.layoutMode()) {
|
||||||
|
case "desktop": {
|
||||||
|
const leftSaved = readStoredPinState("left", true)
|
||||||
|
const rightSaved = readStoredPinState("right", true)
|
||||||
|
setLeftPinned(leftSaved)
|
||||||
|
setLeftOpen(leftSaved)
|
||||||
|
setRightPinned(rightSaved)
|
||||||
|
setRightOpen(rightSaved)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "tablet": {
|
||||||
|
setLeftPinned(true)
|
||||||
|
setLeftOpen(true)
|
||||||
|
setRightPinned(false)
|
||||||
|
setRightOpen(false)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
setLeftPinned(false)
|
||||||
|
setLeftOpen(false)
|
||||||
|
setRightPinned(false)
|
||||||
|
setRightOpen(false)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const leftDrawerState = createMemo<DrawerViewState>(() => {
|
||||||
|
if (leftPinned()) return "pinned"
|
||||||
|
return leftOpen() ? "floating-open" : "floating-closed"
|
||||||
|
})
|
||||||
|
|
||||||
|
const rightDrawerState = createMemo<DrawerViewState>(() => {
|
||||||
|
if (rightPinned()) return "pinned"
|
||||||
|
return rightOpen() ? "floating-open" : "floating-closed"
|
||||||
|
})
|
||||||
|
|
||||||
|
const leftAppBarButtonLabel = () => {
|
||||||
|
const state = leftDrawerState()
|
||||||
|
if (state === "pinned") return options.t("instanceShell.leftDrawer.toggle.pinned")
|
||||||
|
return options.t("instanceShell.leftDrawer.toggle.open")
|
||||||
|
}
|
||||||
|
|
||||||
|
const rightAppBarButtonLabel = () => {
|
||||||
|
const state = rightDrawerState()
|
||||||
|
if (state === "pinned") return options.t("instanceShell.rightDrawer.toggle.pinned")
|
||||||
|
return options.t("instanceShell.rightDrawer.toggle.open")
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftAppBarButtonIcon = () => {
|
||||||
|
return createComponent(MenuIcon, { fontSize: "small" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const rightAppBarButtonIcon = () => {
|
||||||
|
return createComponent(MenuIcon, { fontSize: "small", sx: { transform: "scaleX(-1)" } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinLeft = () => {
|
||||||
|
blurIfInside(options.leftDrawerContentEl())
|
||||||
|
batch(() => {
|
||||||
|
setLeftPinned(true)
|
||||||
|
setLeftOpen(true)
|
||||||
|
})
|
||||||
|
persistPinIfSupported("left", true)
|
||||||
|
measureDrawerHost()
|
||||||
|
}
|
||||||
|
|
||||||
|
const unpinLeft = () => {
|
||||||
|
blurIfInside(options.leftDrawerContentEl())
|
||||||
|
batch(() => {
|
||||||
|
setLeftPinned(false)
|
||||||
|
setLeftOpen(true)
|
||||||
|
})
|
||||||
|
persistPinIfSupported("left", false)
|
||||||
|
measureDrawerHost()
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinRight = () => {
|
||||||
|
blurIfInside(options.rightDrawerContentEl())
|
||||||
|
batch(() => {
|
||||||
|
setRightPinned(true)
|
||||||
|
setRightOpen(true)
|
||||||
|
})
|
||||||
|
persistPinIfSupported("right", true)
|
||||||
|
measureDrawerHost()
|
||||||
|
}
|
||||||
|
|
||||||
|
const unpinRight = () => {
|
||||||
|
blurIfInside(options.rightDrawerContentEl())
|
||||||
|
batch(() => {
|
||||||
|
setRightPinned(false)
|
||||||
|
setRightOpen(true)
|
||||||
|
})
|
||||||
|
persistPinIfSupported("right", false)
|
||||||
|
measureDrawerHost()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLeftAppBarButtonClick = () => {
|
||||||
|
const state = leftDrawerState()
|
||||||
|
if (state !== "floating-closed") return
|
||||||
|
setLeftOpen(true)
|
||||||
|
measureDrawerHost()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRightAppBarButtonClick = () => {
|
||||||
|
const state = rightDrawerState()
|
||||||
|
if (state !== "floating-closed") return
|
||||||
|
setRightOpen(true)
|
||||||
|
measureDrawerHost()
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeLeft = () => {
|
||||||
|
if (leftDrawerState() === "pinned") return
|
||||||
|
blurIfInside(options.leftDrawerContentEl())
|
||||||
|
setLeftOpen(false)
|
||||||
|
focusTarget(options.leftToggleButtonEl())
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeRight = () => {
|
||||||
|
if (rightDrawerState() === "pinned") return
|
||||||
|
blurIfInside(options.rightDrawerContentEl())
|
||||||
|
setRightOpen(false)
|
||||||
|
focusTarget(options.rightToggleButtonEl())
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeFloatingDrawersIfAny = () => {
|
||||||
|
let handled = false
|
||||||
|
if (!leftPinned() && leftOpen()) {
|
||||||
|
setLeftOpen(false)
|
||||||
|
blurIfInside(options.leftDrawerContentEl())
|
||||||
|
focusTarget(options.leftToggleButtonEl())
|
||||||
|
handled = true
|
||||||
|
}
|
||||||
|
if (!rightPinned() && rightOpen()) {
|
||||||
|
setRightOpen(false)
|
||||||
|
blurIfInside(options.rightDrawerContentEl())
|
||||||
|
focusTarget(options.rightToggleButtonEl())
|
||||||
|
handled = true
|
||||||
|
}
|
||||||
|
return handled
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== "Escape") return
|
||||||
|
if (!closeFloatingDrawersIfAny()) return
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", handleEscape, true)
|
||||||
|
onCleanup(() => window.removeEventListener("keydown", handleEscape, true))
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
leftPinned,
|
||||||
|
leftOpen,
|
||||||
|
rightPinned,
|
||||||
|
rightOpen,
|
||||||
|
setLeftOpen,
|
||||||
|
setRightOpen,
|
||||||
|
leftDrawerState,
|
||||||
|
rightDrawerState,
|
||||||
|
pinLeft,
|
||||||
|
unpinLeft,
|
||||||
|
pinRight,
|
||||||
|
unpinRight,
|
||||||
|
closeLeft,
|
||||||
|
closeRight,
|
||||||
|
leftAppBarButtonLabel,
|
||||||
|
rightAppBarButtonLabel,
|
||||||
|
leftAppBarButtonIcon,
|
||||||
|
rightAppBarButtonIcon,
|
||||||
|
handleLeftAppBarButtonClick,
|
||||||
|
handleRightAppBarButtonClick,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { createEffect, createSignal, type Accessor } from "solid-js"
|
||||||
|
|
||||||
|
type DrawerHostMeasure = {
|
||||||
|
setDrawerHost: (element: HTMLElement) => void
|
||||||
|
drawerContainer: () => HTMLElement | undefined
|
||||||
|
measureDrawerHost: () => void
|
||||||
|
floatingTopPx: () => string
|
||||||
|
floatingHeight: () => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDrawerHostMeasure(tabBarOffset: Accessor<number>): DrawerHostMeasure {
|
||||||
|
const [drawerHost, setDrawerHost] = createSignal<HTMLElement | null>(null)
|
||||||
|
const [floatingDrawerTop, setFloatingDrawerTop] = createSignal(0)
|
||||||
|
const [floatingDrawerHeight, setFloatingDrawerHeight] = createSignal(0)
|
||||||
|
|
||||||
|
const storeDrawerHost = (element: HTMLElement) => {
|
||||||
|
setDrawerHost(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
const measureDrawerHost = () => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
const host = drawerHost()
|
||||||
|
if (!host) return
|
||||||
|
const rect = host.getBoundingClientRect()
|
||||||
|
setFloatingDrawerTop(rect.top)
|
||||||
|
setFloatingDrawerHeight(Math.max(0, rect.height))
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
tabBarOffset()
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
requestAnimationFrame(() => measureDrawerHost())
|
||||||
|
})
|
||||||
|
|
||||||
|
const drawerContainer = () => {
|
||||||
|
const host = drawerHost()
|
||||||
|
if (host) return host
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
return document.body
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackDrawerTop = () => tabBarOffset()
|
||||||
|
const floatingTop = () => {
|
||||||
|
const measured = floatingDrawerTop()
|
||||||
|
if (measured > 0) return measured
|
||||||
|
return fallbackDrawerTop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const floatingTopPx = () => `${floatingTop()}px`
|
||||||
|
const floatingHeight = () => {
|
||||||
|
const measured = floatingDrawerHeight()
|
||||||
|
if (measured > 0) return `${measured}px`
|
||||||
|
return `calc(100% - ${floatingTop()}px)`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
setDrawerHost: storeDrawerHost,
|
||||||
|
drawerContainer,
|
||||||
|
measureDrawerHost,
|
||||||
|
floatingTopPx,
|
||||||
|
floatingHeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
113
packages/ui/src/components/instance/shell/useDrawerResize.ts
Normal file
113
packages/ui/src/components/instance/shell/useDrawerResize.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { createSignal, onCleanup, type Accessor, type Setter } from "solid-js"
|
||||||
|
|
||||||
|
import { useGlobalPointerDrag } from "./useGlobalPointerDrag"
|
||||||
|
|
||||||
|
type DrawerResizeSide = "left" | "right"
|
||||||
|
|
||||||
|
type DrawerResizeOptions = {
|
||||||
|
sessionSidebarWidth: Accessor<number>
|
||||||
|
rightDrawerWidth: Accessor<number>
|
||||||
|
setSessionSidebarWidth: Setter<number>
|
||||||
|
setRightDrawerWidth: Setter<number>
|
||||||
|
clampLeft: (width: number) => number
|
||||||
|
clampRight: (width: number) => number
|
||||||
|
measureDrawerHost: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type DrawerResizeApi = {
|
||||||
|
handleDrawerResizeMouseDown: (side: DrawerResizeSide) => (event: MouseEvent) => void
|
||||||
|
handleDrawerResizeTouchStart: (side: DrawerResizeSide) => (event: TouchEvent) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDrawerResize(options: DrawerResizeOptions): DrawerResizeApi {
|
||||||
|
const [activeResizeSide, setActiveResizeSide] = createSignal<DrawerResizeSide | null>(null)
|
||||||
|
const [resizeStartX, setResizeStartX] = createSignal(0)
|
||||||
|
const [resizeStartWidth, setResizeStartWidth] = createSignal(0)
|
||||||
|
|
||||||
|
const scheduleDrawerMeasure = () => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
options.measureDrawerHost()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => options.measureDrawerHost())
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyDrawerWidth = (side: DrawerResizeSide, width: number) => {
|
||||||
|
if (side === "left") {
|
||||||
|
options.setSessionSidebarWidth(width)
|
||||||
|
} else {
|
||||||
|
options.setRightDrawerWidth(width)
|
||||||
|
}
|
||||||
|
scheduleDrawerMeasure()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrawerPointerMove = (clientX: number) => {
|
||||||
|
const side = activeResizeSide()
|
||||||
|
if (!side) return
|
||||||
|
const startWidth = resizeStartWidth()
|
||||||
|
const clamp = side === "left" ? options.clampLeft : options.clampRight
|
||||||
|
const delta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX
|
||||||
|
const nextWidth = clamp(startWidth + delta)
|
||||||
|
applyDrawerWidth(side, nextWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawerMouseMove(event: MouseEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
handleDrawerPointerMove(event.clientX)
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawerMouseUp() {
|
||||||
|
stopDrawerResize()
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawerTouchMove(event: TouchEvent) {
|
||||||
|
const touch = event.touches[0]
|
||||||
|
if (!touch) return
|
||||||
|
event.preventDefault()
|
||||||
|
handleDrawerPointerMove(touch.clientX)
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawerTouchEnd() {
|
||||||
|
stopDrawerResize()
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawerPointerDrag = useGlobalPointerDrag({
|
||||||
|
onMouseMove: drawerMouseMove,
|
||||||
|
onMouseUp: drawerMouseUp,
|
||||||
|
onTouchMove: drawerTouchMove,
|
||||||
|
onTouchEnd: drawerTouchEnd,
|
||||||
|
})
|
||||||
|
|
||||||
|
function stopDrawerResize() {
|
||||||
|
setActiveResizeSide(null)
|
||||||
|
drawerPointerDrag.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDrawerResize = (side: DrawerResizeSide, clientX: number) => {
|
||||||
|
setActiveResizeSide(side)
|
||||||
|
setResizeStartX(clientX)
|
||||||
|
setResizeStartWidth(side === "left" ? options.sessionSidebarWidth() : options.rightDrawerWidth())
|
||||||
|
drawerPointerDrag.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrawerResizeMouseDown = (side: DrawerResizeSide) => (event: MouseEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
startDrawerResize(side, event.clientX)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrawerResizeTouchStart = (side: DrawerResizeSide) => (event: TouchEvent) => {
|
||||||
|
const touch = event.touches[0]
|
||||||
|
if (!touch) return
|
||||||
|
event.preventDefault()
|
||||||
|
startDrawerResize(side, touch.clientX)
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
stopDrawerResize()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleDrawerResizeMouseDown,
|
||||||
|
handleDrawerResizeTouchStart,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
type GlobalPointerDragHandlers = {
|
||||||
|
onMouseMove: (event: MouseEvent) => void
|
||||||
|
onMouseUp: (event: MouseEvent) => void
|
||||||
|
onTouchMove: (event: TouchEvent) => void
|
||||||
|
onTouchEnd: (event: TouchEvent) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlobalPointerDrag = {
|
||||||
|
start: () => void
|
||||||
|
stop: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGlobalPointerDrag(handlers: GlobalPointerDragHandlers): GlobalPointerDrag {
|
||||||
|
const start = () => {
|
||||||
|
document.addEventListener("mousemove", handlers.onMouseMove)
|
||||||
|
document.addEventListener("mouseup", handlers.onMouseUp)
|
||||||
|
document.addEventListener("touchmove", handlers.onTouchMove, { passive: false })
|
||||||
|
document.addEventListener("touchend", handlers.onTouchEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
document.removeEventListener("mousemove", handlers.onMouseMove)
|
||||||
|
document.removeEventListener("mouseup", handlers.onMouseUp)
|
||||||
|
document.removeEventListener("touchmove", handlers.onTouchMove)
|
||||||
|
document.removeEventListener("touchend", handlers.onTouchEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start, stop }
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { batch, createMemo, type Accessor } from "solid-js"
|
||||||
|
import type { ToolState } from "@opencode-ai/sdk/v2"
|
||||||
|
import type { Session } from "../../../types/session"
|
||||||
|
import {
|
||||||
|
activeParentSessionId,
|
||||||
|
activeSessionId as activeSessionMap,
|
||||||
|
getSessionFamily,
|
||||||
|
getSessionInfo,
|
||||||
|
getSessionThreads,
|
||||||
|
sessions,
|
||||||
|
setActiveParentSession,
|
||||||
|
setActiveSession,
|
||||||
|
} from "../../../stores/sessions"
|
||||||
|
import { messageStoreBus } from "../../../stores/message-v2/bus"
|
||||||
|
import { getBackgroundProcesses } from "../../../stores/background-processes"
|
||||||
|
import type { LatestTodoSnapshot, SessionUsageState } from "../../../stores/message-v2/types"
|
||||||
|
|
||||||
|
type InstanceSessionContextOptions = {
|
||||||
|
instanceId: Accessor<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstanceSessionContextState = {
|
||||||
|
// Session collections and selections
|
||||||
|
allInstanceSessions: Accessor<Map<string, Session>>
|
||||||
|
sessionThreads: Accessor<ReturnType<typeof getSessionThreads>>
|
||||||
|
activeSessions: Accessor<Map<string, SessionFamilyMember>>
|
||||||
|
activeSessionIdForInstance: Accessor<string | null>
|
||||||
|
parentSessionIdForInstance: Accessor<string | null>
|
||||||
|
activeSessionForInstance: Accessor<SessionFamilyMember | null>
|
||||||
|
activeSessionDiffs: Accessor<SessionFamilyMember["diff"] | undefined>
|
||||||
|
|
||||||
|
// Usage / info summaries
|
||||||
|
activeSessionUsage: Accessor<SessionUsageState | null>
|
||||||
|
activeSessionInfoDetails: Accessor<ReturnType<typeof getSessionInfo> | null>
|
||||||
|
tokenStats: Accessor<{ used: number; avail: number | null }>
|
||||||
|
|
||||||
|
// Todo state
|
||||||
|
latestTodoSnapshot: Accessor<LatestTodoSnapshot | null>
|
||||||
|
latestTodoState: Accessor<ToolState | null>
|
||||||
|
|
||||||
|
// Background processes
|
||||||
|
backgroundProcessList: Accessor<ReturnType<typeof getBackgroundProcesses>>
|
||||||
|
|
||||||
|
// Controller
|
||||||
|
handleSessionSelect: (sessionId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionFamilyMember = ReturnType<typeof getSessionFamily>[number]
|
||||||
|
|
||||||
|
export function useInstanceSessionContext(options: InstanceSessionContextOptions): InstanceSessionContextState {
|
||||||
|
const messageStore = createMemo(() => messageStoreBus.getOrCreate(options.instanceId()))
|
||||||
|
|
||||||
|
const allInstanceSessions = createMemo<Map<string, Session>>(() => {
|
||||||
|
return sessions().get(options.instanceId()) ?? new Map()
|
||||||
|
})
|
||||||
|
|
||||||
|
const sessionThreads = createMemo(() => getSessionThreads(options.instanceId()))
|
||||||
|
|
||||||
|
const activeSessions = createMemo(() => {
|
||||||
|
const parentId = activeParentSessionId().get(options.instanceId())
|
||||||
|
if (!parentId) return new Map<string, ReturnType<typeof getSessionFamily>[number]>()
|
||||||
|
const sessionFamily = getSessionFamily(options.instanceId(), parentId)
|
||||||
|
return new Map(sessionFamily.map((s) => [s.id, s]))
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeSessionIdForInstance = createMemo(() => {
|
||||||
|
return activeSessionMap().get(options.instanceId()) || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const parentSessionIdForInstance = createMemo(() => {
|
||||||
|
return activeParentSessionId().get(options.instanceId()) || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeSessionForInstance = createMemo(() => {
|
||||||
|
const sessionId = activeSessionIdForInstance()
|
||||||
|
if (!sessionId || sessionId === "info") return null
|
||||||
|
return activeSessions().get(sessionId) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeSessionDiffs = createMemo(() => {
|
||||||
|
const session = activeSessionForInstance()
|
||||||
|
return session?.diff
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeSessionUsage = createMemo(() => {
|
||||||
|
const sessionId = activeSessionIdForInstance()
|
||||||
|
if (!sessionId) return null
|
||||||
|
const store = messageStore()
|
||||||
|
return store?.getSessionUsage(sessionId) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeSessionInfoDetails = createMemo(() => {
|
||||||
|
const sessionId = activeSessionIdForInstance()
|
||||||
|
if (!sessionId) return null
|
||||||
|
return getSessionInfo(options.instanceId(), sessionId) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const tokenStats = createMemo(() => {
|
||||||
|
const usage = activeSessionUsage()
|
||||||
|
const info = activeSessionInfoDetails()
|
||||||
|
return {
|
||||||
|
used: usage?.actualUsageTokens ?? info?.actualUsageTokens ?? 0,
|
||||||
|
avail: info?.contextAvailableTokens ?? null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const latestTodoSnapshot = createMemo(() => {
|
||||||
|
const sessionId = activeSessionIdForInstance()
|
||||||
|
if (!sessionId || sessionId === "info") return null
|
||||||
|
const store = messageStore()
|
||||||
|
if (!store) return null
|
||||||
|
const snapshot = store.state.latestTodos[sessionId]
|
||||||
|
return snapshot ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const latestTodoState = createMemo<ToolState | null>(() => {
|
||||||
|
const snapshot = latestTodoSnapshot()
|
||||||
|
if (!snapshot) return null
|
||||||
|
const store = messageStore()
|
||||||
|
if (!store) return null
|
||||||
|
const message = store.getMessage(snapshot.messageId)
|
||||||
|
if (!message) return null
|
||||||
|
const partRecord = message.parts?.[snapshot.partId]
|
||||||
|
const part = partRecord?.data as { type?: string; tool?: string; state?: ToolState }
|
||||||
|
if (!part || part.type !== "tool" || part.tool !== "todowrite") return null
|
||||||
|
const state = part.state
|
||||||
|
if (!state || state.status !== "completed") return null
|
||||||
|
return state
|
||||||
|
})
|
||||||
|
|
||||||
|
const backgroundProcessList = createMemo(() => getBackgroundProcesses(options.instanceId()))
|
||||||
|
|
||||||
|
const handleSessionSelect = (sessionId: string) => {
|
||||||
|
const instanceId = options.instanceId()
|
||||||
|
if (sessionId === "info") {
|
||||||
|
setActiveSession(instanceId, sessionId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = allInstanceSessions().get(sessionId)
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
if (session.parentId === null) {
|
||||||
|
setActiveParentSession(instanceId, sessionId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentId = session.parentId
|
||||||
|
if (!parentId) return
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
setActiveParentSession(instanceId, parentId)
|
||||||
|
setActiveSession(instanceId, sessionId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allInstanceSessions,
|
||||||
|
sessionThreads,
|
||||||
|
activeSessions,
|
||||||
|
activeSessionIdForInstance,
|
||||||
|
parentSessionIdForInstance,
|
||||||
|
activeSessionForInstance,
|
||||||
|
activeSessionDiffs,
|
||||||
|
activeSessionUsage,
|
||||||
|
activeSessionInfoDetails,
|
||||||
|
tokenStats,
|
||||||
|
latestTodoSnapshot,
|
||||||
|
latestTodoState,
|
||||||
|
backgroundProcessList,
|
||||||
|
handleSessionSelect,
|
||||||
|
}
|
||||||
|
}
|
||||||
99
packages/ui/src/components/instance/shell/useSessionCache.ts
Normal file
99
packages/ui/src/components/instance/shell/useSessionCache.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { createEffect, createSignal, type Accessor } from "solid-js"
|
||||||
|
import { messageStoreBus } from "../../../stores/message-v2/bus"
|
||||||
|
import { clearSessionRenderCache } from "../../message-block"
|
||||||
|
import { getLogger } from "../../../lib/logger"
|
||||||
|
|
||||||
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
const SESSION_CACHE_LIMIT = 5
|
||||||
|
|
||||||
|
type SessionCacheOptions = {
|
||||||
|
instanceId: Accessor<string>
|
||||||
|
instanceSessions: Accessor<Map<string, unknown>>
|
||||||
|
activeSessionId: Accessor<string | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionCacheState = {
|
||||||
|
cachedSessionIds: Accessor<string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSessionCache(options: SessionCacheOptions): SessionCacheState {
|
||||||
|
const [cachedSessionIds, setCachedSessionIds] = createSignal<string[]>([])
|
||||||
|
const [pendingEvictions, setPendingEvictions] = createSignal<string[]>([])
|
||||||
|
|
||||||
|
const evictSession = (sessionId: string) => {
|
||||||
|
if (!sessionId) return
|
||||||
|
const instanceId = options.instanceId()
|
||||||
|
log.info("Evicting cached session", { instanceId, sessionId })
|
||||||
|
const store = messageStoreBus.getInstance(instanceId)
|
||||||
|
store?.clearSession(sessionId)
|
||||||
|
clearSessionRenderCache(instanceId, sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleEvictions = (ids: string[]) => {
|
||||||
|
if (!ids.length) return
|
||||||
|
setPendingEvictions((current) => {
|
||||||
|
const existing = new Set(current)
|
||||||
|
const next = [...current]
|
||||||
|
ids.forEach((id) => {
|
||||||
|
if (!existing.has(id)) {
|
||||||
|
next.push(id)
|
||||||
|
existing.add(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const pending = pendingEvictions()
|
||||||
|
if (!pending.length) return
|
||||||
|
const cached = new Set(cachedSessionIds())
|
||||||
|
const remaining: string[] = []
|
||||||
|
pending.forEach((id) => {
|
||||||
|
if (cached.has(id)) {
|
||||||
|
remaining.push(id)
|
||||||
|
} else {
|
||||||
|
evictSession(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (remaining.length !== pending.length) {
|
||||||
|
setPendingEvictions(remaining)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const instanceSessions = options.instanceSessions()
|
||||||
|
const activeId = options.activeSessionId()
|
||||||
|
|
||||||
|
setCachedSessionIds((current) => {
|
||||||
|
const next = current.filter((id) => id !== "info" && instanceSessions.has(id))
|
||||||
|
|
||||||
|
const touch = (id: string | null) => {
|
||||||
|
if (!id || id === "info") return
|
||||||
|
if (!instanceSessions.has(id)) return
|
||||||
|
|
||||||
|
const index = next.indexOf(id)
|
||||||
|
if (index !== -1) {
|
||||||
|
next.splice(index, 1)
|
||||||
|
}
|
||||||
|
next.unshift(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
touch(activeId)
|
||||||
|
|
||||||
|
const trimmed = next.length > SESSION_CACHE_LIMIT ? next.slice(0, SESSION_CACHE_LIMIT) : next
|
||||||
|
|
||||||
|
const trimmedSet = new Set(trimmed)
|
||||||
|
const removed = current.filter((id) => !trimmedSet.has(id))
|
||||||
|
if (removed.length) {
|
||||||
|
scheduleEvictions(removed)
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
cachedSessionIds,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { createEffect, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
||||||
|
import {
|
||||||
|
SESSION_SIDEBAR_EVENT,
|
||||||
|
type SessionSidebarRequestAction,
|
||||||
|
type SessionSidebarRequestDetail,
|
||||||
|
} from "../../../lib/session-sidebar-events"
|
||||||
|
|
||||||
|
interface PendingSidebarAction {
|
||||||
|
action: SessionSidebarRequestAction
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseSessionSidebarRequestsOptions {
|
||||||
|
instanceId: Accessor<string>
|
||||||
|
sidebarContentEl: Accessor<HTMLElement | null>
|
||||||
|
leftPinned: Accessor<boolean>
|
||||||
|
leftOpen: Accessor<boolean>
|
||||||
|
setLeftOpen: (next: boolean) => void
|
||||||
|
measureDrawerHost: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSessionSidebarRequests(options: UseSessionSidebarRequestsOptions) {
|
||||||
|
let sidebarActionId = 0
|
||||||
|
const [pendingSidebarAction, setPendingSidebarAction] = createSignal<PendingSidebarAction | null>(null)
|
||||||
|
|
||||||
|
const triggerKeyboardEvent = (target: HTMLElement, options: { key: string; code: string; keyCode: number }) => {
|
||||||
|
target.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", {
|
||||||
|
key: options.key,
|
||||||
|
code: options.code,
|
||||||
|
keyCode: options.keyCode,
|
||||||
|
which: options.keyCode,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusAgentSelectorControl = () => {
|
||||||
|
const agentTrigger = options.sidebarContentEl()?.querySelector("[data-agent-selector]") as HTMLElement | null
|
||||||
|
if (!agentTrigger) return false
|
||||||
|
agentTrigger.focus()
|
||||||
|
setTimeout(() => triggerKeyboardEvent(agentTrigger, { key: "Enter", code: "Enter", keyCode: 13 }), 10)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusModelSelectorControl = () => {
|
||||||
|
const input = options.sidebarContentEl()?.querySelector<HTMLInputElement>("[data-model-selector]")
|
||||||
|
if (!input) return false
|
||||||
|
input.focus()
|
||||||
|
setTimeout(() => triggerKeyboardEvent(input, { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }), 10)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusVariantSelectorControl = () => {
|
||||||
|
const input = options.sidebarContentEl()?.querySelector<HTMLInputElement>("[data-thinking-selector]")
|
||||||
|
if (!input) return false
|
||||||
|
input.focus()
|
||||||
|
setTimeout(() => triggerKeyboardEvent(input, { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }), 10)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const pending = pendingSidebarAction()
|
||||||
|
if (!pending) return
|
||||||
|
const action = pending.action
|
||||||
|
const contentReady = Boolean(options.sidebarContentEl())
|
||||||
|
if (!contentReady) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action === "show-session-list") {
|
||||||
|
setPendingSidebarAction(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const handled =
|
||||||
|
action === "focus-agent-selector"
|
||||||
|
? focusAgentSelectorControl()
|
||||||
|
: action === "focus-model-selector"
|
||||||
|
? focusModelSelectorControl()
|
||||||
|
: focusVariantSelectorControl()
|
||||||
|
if (handled) {
|
||||||
|
setPendingSidebarAction(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSidebarRequest = (action: SessionSidebarRequestAction) => {
|
||||||
|
setPendingSidebarAction({ action, id: sidebarActionId++ })
|
||||||
|
if (!options.leftPinned() && !options.leftOpen()) {
|
||||||
|
options.setLeftOpen(true)
|
||||||
|
options.measureDrawerHost()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
const handler = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<SessionSidebarRequestDetail>).detail
|
||||||
|
if (!detail || detail.instanceId !== options.instanceId()) return
|
||||||
|
handleSidebarRequest(detail.action)
|
||||||
|
}
|
||||||
|
window.addEventListener(SESSION_SIDEBAR_EVENT, handler)
|
||||||
|
onCleanup(() => window.removeEventListener(SESSION_SIDEBAR_EVENT, handler))
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleSidebarRequest,
|
||||||
|
pendingSidebarAction,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { 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>
|
||||||
|
|
||||||
|
<Show when={props.showDeleteMessage}>
|
||||||
<button
|
<button
|
||||||
class="tool-call-header-button"
|
class="tool-call-header-button"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={deleteDisabled()}
|
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
|
||||||
onClick={handleDeleteToolPart}
|
onClick={handleDeleteUpTo}
|
||||||
title={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")}
|
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })}
|
||||||
aria-label={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")}
|
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||||
|
title={t("messageItem.actions.deleteMessagesUpTo")}
|
||||||
|
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
|
||||||
>
|
>
|
||||||
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
<DeleteUpToIcon />
|
||||||
</button>
|
</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")}
|
||||||
>
|
>
|
||||||
|
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||||
|
<Show when={props.showDeleteMessage}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="tool-call-header-button absolute right-2 top-1/2 -translate-y-1/2"
|
class="tool-call-header-button"
|
||||||
disabled={!canDelete()}
|
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
|
||||||
onClick={handleDelete}
|
onClick={handleDeleteUpTo}
|
||||||
title={t("messagePart.actions.deleteTitle")}
|
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })}
|
||||||
|
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||||
|
title={t("messageItem.actions.deleteMessagesUpTo")}
|
||||||
|
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
|
||||||
>
|
>
|
||||||
{deleting() ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
<DeleteUpToIcon />
|
||||||
</button>
|
</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 ""
|
||||||
@@ -1010,29 +1353,48 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
|
|
||||||
const toggle = () => setExpanded((prev) => !prev)
|
const toggle = () => setExpanded((prev) => !prev)
|
||||||
|
|
||||||
const hasDeleteTarget = () => Boolean(props.partId)
|
const viewHideLabel = () =>
|
||||||
const canDelete = () => hasDeleteTarget() && !deleting()
|
expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")
|
||||||
|
|
||||||
const handleDelete = async (event: Event) => {
|
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
|
||||||
|
|
||||||
|
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">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="message-reasoning-toggle"
|
class="message-reasoning-toggle"
|
||||||
@@ -1040,9 +1402,82 @@ 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 class="message-reasoning-label-primary">
|
||||||
|
<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>{t("messageBlock.reasoning.thinkingLabel")}</span>
|
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
|
||||||
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="message-reasoning-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message-action-button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
toggle()
|
||||||
|
}}
|
||||||
|
aria-label={viewHideLabel()}
|
||||||
|
title={viewHideLabel()}
|
||||||
|
>
|
||||||
|
<Show when={expanded()} fallback={<ChevronsUpDown class="w-3.5 h-3.5" aria-hidden="true" />}>
|
||||||
|
<ChevronsDownUp class="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Show when={props.showDeleteMessage}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message-action-button"
|
||||||
|
onClick={handleDeleteUpTo}
|
||||||
|
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
|
||||||
|
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })}
|
||||||
|
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||||
|
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
|
||||||
|
title={t("messageItem.actions.deleteMessagesUpTo")}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<span class="message-reasoning-time">{timestamp()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={hasMeta()}>
|
||||||
|
<div class="message-reasoning-meta-row">
|
||||||
<span class="message-step-meta-inline">
|
<span class="message-step-meta-inline">
|
||||||
<Show when={agentIdentifier()}>
|
<Show when={agentIdentifier()}>
|
||||||
{(value) => (
|
{(value) => (
|
||||||
@@ -1055,34 +1490,8 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</span>
|
|
||||||
<span class="message-reasoning-meta">
|
|
||||||
<span class="message-reasoning-indicator">
|
|
||||||
{expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<Show when={hasDeleteTarget()}>
|
|
||||||
<span
|
|
||||||
class={`message-reasoning-indicator${canDelete() ? "" : " opacity-50 pointer-events-none"}`}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={handleDelete}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
handleDelete(event)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
aria-label={t("messagePart.actions.deleteTitle")}
|
|
||||||
title={t("messagePart.actions.deleteTitle")}
|
|
||||||
>
|
|
||||||
{deleting() ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<span class="message-reasoning-time">{timestamp()}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Show when={expanded()}>
|
<Show when={expanded()}>
|
||||||
<div class="message-reasoning-expanded">
|
<div class="message-reasoning-expanded">
|
||||||
|
|||||||
@@ -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,27 +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-speaker">
|
<div class="message-item-header-row message-item-header-row--top" ref={(el) => (topRowEl = el)}>
|
||||||
|
<div class="message-header-left">
|
||||||
|
<div class="message-speaker-primary" ref={(el) => (speakerPrimaryEl = el)}>
|
||||||
|
<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.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"}>
|
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
|
||||||
{speakerLabel()}
|
{speakerLabel()}
|
||||||
</span>
|
</span>
|
||||||
<Show when={agentMeta()}>{(meta) => <span class="message-agent-meta">{meta()}</span>}</Show>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="message-item-actions">
|
|
||||||
|
<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 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={handleRevert}
|
onClick={handleCopy}
|
||||||
title={t("messageItem.actions.revert")}
|
title={copyLabel()}
|
||||||
aria-label={t("messageItem.actions.revert")}
|
aria-label={copyLabel()}
|
||||||
>
|
>
|
||||||
<Undo class="w-3.5 h-3.5" aria-hidden="true" />
|
<Copy 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"
|
||||||
@@ -297,26 +453,42 @@ 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>
|
||||||
|
|
||||||
|
<Show when={props.onRevert}>
|
||||||
<button
|
<button
|
||||||
class="message-action-button"
|
class="message-action-button"
|
||||||
onClick={handleCopy}
|
onClick={handleRevert}
|
||||||
title={copyLabel()}
|
title={t("messageItem.actions.revertTitle")}
|
||||||
aria-label={copyLabel()}
|
aria-label={t("messageItem.actions.revertTitle")}
|
||||||
>
|
>
|
||||||
<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 when={deletableTextPartId()}>
|
</Show>
|
||||||
{(partId) => (
|
|
||||||
|
<Show when={props.showDeleteMessage}>
|
||||||
<button
|
<button
|
||||||
class="message-action-button"
|
class="message-action-button"
|
||||||
onClick={() => void handleDeletePart(partId())}
|
onClick={() => void handleDeleteUpTo()}
|
||||||
disabled={isDeletingPart(partId())}
|
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
|
||||||
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.record.id })}
|
||||||
aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||||
|
title={t("messageItem.actions.deleteMessagesUpTo")}
|
||||||
|
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
|
||||||
>
|
>
|
||||||
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
<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>
|
</button>
|
||||||
)}
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -331,23 +503,42 @@ 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 handleDeletePart(partId())}
|
onClick={() => void handleDeleteUpTo()}
|
||||||
disabled={isDeletingPart(partId())}
|
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
|
||||||
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.record.id })}
|
||||||
aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
|
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
|
||||||
|
title={t("messageItem.actions.deleteMessagesUpTo")}
|
||||||
|
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
|
||||||
>
|
>
|
||||||
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
|
<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>
|
</button>
|
||||||
)}
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
|
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={metaText() && !showMetaInline()}>
|
||||||
|
<div class="message-item-header-row message-item-header-row--meta">
|
||||||
|
<span class="message-agent-meta-block">{metaText()}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -369,15 +560,20 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<For each={messageParts()}>
|
<For each={messageParts()}>
|
||||||
{(part) => (
|
{(part) => {
|
||||||
|
return (
|
||||||
|
<div class="message-part-shell">
|
||||||
<MessagePart
|
<MessagePart
|
||||||
part={part}
|
part={part}
|
||||||
messageType={props.record.role}
|
messageType={props.record.role}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
|
primaryUserTextPartId={primaryUserTextPartId()}
|
||||||
onRendered={props.onContentRendered}
|
onRendered={props.onContentRendered}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
<Show when={fileAttachments().length > 0}>
|
<Show when={fileAttachments().length > 0}>
|
||||||
@@ -387,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
|
||||||
@@ -401,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>
|
||||||
|
<Show when={!attachment.url?.startsWith("file://")}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleAttachmentDownload(attachment)}
|
onClick={() => void handleAttachmentDownload(attachment)}
|
||||||
class="attachment-download"
|
class="attachment-download"
|
||||||
aria-label={t("messageItem.attachment.downloadAriaLabel", { name })}
|
aria-label={t("messageItem.attachment.downloadAriaLabel", { name })}
|
||||||
|
title={t("messageItem.attachment.downloadAriaLabel", { name })}
|
||||||
>
|
>
|
||||||
<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 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="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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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>
|
||||||
)
|
)
|
||||||
@@ -437,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,11 +131,13 @@ 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}
|
||||||
>
|
>
|
||||||
|
<Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}>
|
||||||
<Markdown
|
<Markdown
|
||||||
part={createTextPartForMarkdown()}
|
part={createTextPartForMarkdown()}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
@@ -117,7 +147,6 @@ interface MessagePartProps {
|
|||||||
onRendered={props.onRendered}
|
onRendered={props.onRendered}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
</div>
|
</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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user