mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge branch 'main' of github.com:Kilo-Org/kilocode
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# Git LFS tracking for binary/media files
|
||||
*.gif filter=lfs diff=lfs merge=lfs -text
|
||||
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# Visual regression baseline snapshots
|
||||
packages/kilo-ui/tests/**/*.png filter=lfs diff=lfs merge=lfs -text
|
||||
packages/kilo-vscode/tests/**/*.png filter=lfs diff=lfs merge=lfs -text
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
if: github.repository == 'Kilo-Org/kilocode'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -17,6 +17,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
close-stale-prs:
|
||||
if: github.repository == 'Kilo-Org/kilocode'
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404 # kilocode_change
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
|
||||
@@ -13,7 +13,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
close-non-compliant:
|
||||
if: false # kilocode_change - disabled: not needed in kilocode repo
|
||||
if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close non-compliant issues and PRs after 2 hours
|
||||
|
||||
@@ -35,4 +35,4 @@ jobs:
|
||||
KILO_ORG_ID: ${{ secrets.KILO_ORG_ID }}
|
||||
KILO_PERMISSION: '{"bash": "deny"}'
|
||||
with:
|
||||
model: kilo/kilo/auto
|
||||
model: kilo/kilo-auto/frontier
|
||||
|
||||
@@ -4,6 +4,9 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- "packages/kilo-docs/**"
|
||||
- ".github/workflows/docs-check-links.yml"
|
||||
schedule:
|
||||
# Run daily at 9am UTC
|
||||
- cron: "0 9 * * *"
|
||||
@@ -11,6 +14,7 @@ on:
|
||||
|
||||
jobs:
|
||||
link-checker:
|
||||
if: github.repository == 'Kilo-Org/kilocode'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
generate:
|
||||
if: github.repository == 'Kilo-Org/kilocode'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -16,6 +16,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
nix-eval:
|
||||
if: github.repository == 'Kilo-Org/kilocode'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
|
||||
@@ -21,6 +21,7 @@ jobs:
|
||||
# Native runners required: bun install cross-compilation flags (--os/--cpu)
|
||||
# do not produce byte-identical node_modules as native installs.
|
||||
compute-hash:
|
||||
if: github.repository == 'Kilo-Org/kilocode'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -76,7 +77,7 @@ jobs:
|
||||
|
||||
update-hashes:
|
||||
needs: compute-hash
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.repository == 'Kilo-Org/kilocode' && github.event_name != 'pull_request'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
|
||||
steps:
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
jobs:
|
||||
# kilocode_change start
|
||||
check-author:
|
||||
if: false # kilocode_change - disabled: not needed in kilocode repo
|
||||
if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo
|
||||
uses: ./.github/workflows/check-org-member.yml
|
||||
with:
|
||||
username: ${{ github.event.pull_request.user.login }}
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
|
||||
check-duplicates:
|
||||
needs: check-author
|
||||
if: false # kilocode_change - disabled: not needed in kilocode repo
|
||||
if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo
|
||||
# kilocode_change end
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
fi
|
||||
|
||||
add-contributor-label:
|
||||
if: false # kilocode_change - disabled: not needed in kilocode repo
|
||||
if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404 # kilocode_change
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
jobs:
|
||||
# kilocode_change start
|
||||
check-author:
|
||||
if: false # kilocode_change - disabled: not needed in kilocode repo
|
||||
if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo
|
||||
uses: ./.github/workflows/check-org-member.yml
|
||||
with:
|
||||
username: ${{ github.event.pull_request.user.login }}
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
|
||||
check-standards:
|
||||
needs: check-author
|
||||
if: false # kilocode_change - disabled: not needed in kilocode repo
|
||||
if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo
|
||||
# kilocode_change end
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
@@ -141,7 +141,7 @@ jobs:
|
||||
console.log('PR meets all standards');
|
||||
|
||||
check-compliance:
|
||||
if: false # kilocode_change - disabled: not needed in kilocode repo
|
||||
if: github.repository == 'Kilo-Org/kilocode' && false # kilocode_change - disabled: not needed in kilocode repo
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -11,6 +11,7 @@ env:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
if: github.repository == 'Kilo-Org/kilocode'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
@@ -6,9 +6,13 @@ on:
|
||||
- dev
|
||||
paths:
|
||||
- "packages/kilo-vscode/**"
|
||||
- "packages/ui/**"
|
||||
- "packages/kilo-ui/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- "packages/kilo-vscode/**"
|
||||
- "packages/ui/**"
|
||||
- "packages/kilo-ui/**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -30,3 +34,11 @@ jobs:
|
||||
- name: Run unit tests
|
||||
working-directory: packages/kilo-vscode
|
||||
run: bun run test:unit
|
||||
|
||||
- name: Check formatting (prettier)
|
||||
working-directory: packages/kilo-vscode
|
||||
run: bun run format:check
|
||||
|
||||
- name: Check for dead code (knip)
|
||||
working-directory: packages/kilo-vscode
|
||||
run: bun run knip
|
||||
|
||||
@@ -39,6 +39,8 @@ jobs:
|
||||
run: bun turbo test
|
||||
|
||||
e2e:
|
||||
# kilocode_change - disabled: packages/app is not actively maintained
|
||||
if: false
|
||||
name: e2e (${{ matrix.settings.name }})
|
||||
needs: unit
|
||||
strategy:
|
||||
@@ -98,12 +100,9 @@ jobs:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
needs:
|
||||
- unit
|
||||
- e2e
|
||||
if: always()
|
||||
steps:
|
||||
- name: Verify upstream test jobs passed
|
||||
run: |
|
||||
echo "unit=${{ needs.unit.result }}"
|
||||
echo "e2e=${{ needs.e2e.result }}"
|
||||
test "${{ needs.unit.result }}" = "success"
|
||||
test "${{ needs.e2e.result }}" = "success"
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
if: github.repository == 'Kilo-Org/kilocode'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
name: Visual Regression Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
jobs:
|
||||
check-paths:
|
||||
name: Check changed paths
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matched: ${{ steps.filter.outputs.matched }}
|
||||
is_fork: ${{ steps.fork-check.outputs.is_fork }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: Kilo-Org/paths-filter@master
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
matched:
|
||||
- "packages/kilo-ui/**"
|
||||
- "packages/ui/**"
|
||||
- "packages/util/**"
|
||||
- "packages/sdk/js/**"
|
||||
- "packages/kilo-vscode/webview-ui/**"
|
||||
- "packages/kilo-vscode/.storybook/**"
|
||||
- "packages/kilo-vscode/tests/visual-regression*"
|
||||
- ".github/workflows/visual-regression.yml"
|
||||
- name: Check if PR is from a fork
|
||||
id: fork-check
|
||||
run: |
|
||||
if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then
|
||||
echo "is_fork=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_fork=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
visual-regression:
|
||||
needs: check-paths
|
||||
if: needs.check-paths.outputs.matched == 'true'
|
||||
name: Visual Regression (kilo-ui)
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout (internal)
|
||||
if: needs.check-paths.outputs.is_fork != 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: true
|
||||
token: ${{ secrets.BOT_PAT }}
|
||||
ref: ${{ github.head_ref }}
|
||||
|
||||
- name: Checkout (fork)
|
||||
if: needs.check-paths.outputs.is_fork == 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
- name: Check if HEAD is a baseline update commit
|
||||
if: needs.check-paths.outputs.is_fork != 'true'
|
||||
id: check-baseline-commit
|
||||
run: |
|
||||
COMMIT_MSG=$(git log -1 --format=%s)
|
||||
if [ "$COMMIT_MSG" = "chore: update visual regression baselines" ] || [ "$COMMIT_MSG" = "chore: update kilo-vscode visual regression baselines" ]; then
|
||||
echo "is_baseline_update=true" >> "$GITHUB_OUTPUT"
|
||||
echo "HEAD commit is a baseline update commit — will not auto-commit again."
|
||||
else
|
||||
echo "is_baseline_update=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Cache Bun modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: bun-${{ hashFiles('bun.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ hashFiles('packages/kilo-ui/package.json') }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: bunx playwright install chromium
|
||||
working-directory: packages/kilo-ui
|
||||
|
||||
- name: Install Playwright system deps
|
||||
run: bunx playwright install-deps chromium
|
||||
working-directory: packages/kilo-ui
|
||||
|
||||
- name: Cache Storybook build
|
||||
id: storybook-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: packages/kilo-ui/storybook-static
|
||||
key: storybook-${{ hashFiles('packages/kilo-ui/src/**', 'packages/kilo-ui/.storybook/**', 'packages/ui/src/**', 'packages/kilo-ui/package.json') }}
|
||||
|
||||
- name: Build Storybook
|
||||
if: steps.storybook-cache.outputs.cache-hit != 'true'
|
||||
run: bun run build-storybook
|
||||
working-directory: packages/kilo-ui
|
||||
|
||||
- name: Generate baselines for new/missing stories
|
||||
run: bun run test:visual:update
|
||||
working-directory: packages/kilo-ui
|
||||
env:
|
||||
CI: true
|
||||
PLAYWRIGHT_WORKERS: "4"
|
||||
|
||||
- name: Remove stale baselines for deleted stories
|
||||
run: |
|
||||
node -e "
|
||||
const fs = require('fs'), path = require('path');
|
||||
const idx = JSON.parse(fs.readFileSync('storybook-static/index.json', 'utf8'));
|
||||
const stories = Object.values(idx.entries || idx.stories || {}).filter(s => s.id && !s.id.endsWith('--docs'));
|
||||
const expected = new Set(stories.map(s => { const [c,v] = s.id.split('--'); return c + '/' + v + '-chromium-linux.png'; }));
|
||||
const dir = 'tests/visual-regression.spec.ts-snapshots';
|
||||
if (!fs.existsSync(dir)) process.exit(0);
|
||||
for (const component of fs.readdirSync(dir)) {
|
||||
const cdir = path.join(dir, component);
|
||||
if (!fs.statSync(cdir).isDirectory()) continue;
|
||||
for (const file of fs.readdirSync(cdir)) {
|
||||
const rel = component + '/' + file;
|
||||
if (file.endsWith('.png') && !expected.has(rel)) { console.log('Removing stale baseline:', rel); fs.unlinkSync(path.join(cdir, file)); }
|
||||
}
|
||||
}
|
||||
"
|
||||
working-directory: packages/kilo-ui
|
||||
|
||||
- name: Check for baseline changes (fork PRs)
|
||||
if: needs.check-paths.outputs.is_fork == 'true'
|
||||
run: |
|
||||
git add packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/
|
||||
if git diff --cached --quiet; then
|
||||
echo "No visual regression detected."
|
||||
else
|
||||
echo "::error::Visual regression detected. Screenshot baselines have changed."
|
||||
echo "::error::Since this PR is from a fork, updated screenshots cannot be committed automatically."
|
||||
echo "::error::Please ask a Kilo developer for help updating the screenshots."
|
||||
git diff --cached --stat
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Fail if baselines still changing after auto-update
|
||||
if: needs.check-paths.outputs.is_fork != 'true' && steps.check-baseline-commit.outputs.is_baseline_update == 'true'
|
||||
run: |
|
||||
git add packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/
|
||||
if git diff --cached --quiet; then
|
||||
echo "Baselines are stable after auto-update."
|
||||
else
|
||||
echo "::error::Visual regression baselines changed again after a previous auto-update commit."
|
||||
echo "::error::This indicates non-deterministic screenshots that would cause an infinite update loop."
|
||||
echo "::error::Please investigate the flaky screenshots and add non-deterministic stories to the SKIP set."
|
||||
git diff --cached --stat
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Commit and push new baselines (if any)
|
||||
if: needs.check-paths.outputs.is_fork != 'true' && steps.check-baseline-commit.outputs.is_baseline_update != 'true'
|
||||
id: commit-baselines
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.BOT_PAT }}
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/
|
||||
if git diff --cached --quiet; then
|
||||
echo "No new baselines — nothing to commit."
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
git commit -m "chore: update visual regression baselines"
|
||||
git lfs push --all origin
|
||||
git push
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Fail if baselines changed
|
||||
if: needs.check-paths.outputs.is_fork != 'true' && steps.commit-baselines.outputs.changed == 'true'
|
||||
run: |
|
||||
echo "::error::Visual regression baselines changed. New baselines have been committed to the branch. Please pull and review."
|
||||
exit 1
|
||||
|
||||
- name: Upload test results on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: visual-regression-results
|
||||
path: packages/kilo-ui/test-results/
|
||||
retention-days: 7
|
||||
|
||||
visual-regression-vscode:
|
||||
needs: check-paths
|
||||
if: needs.check-paths.outputs.matched == 'true'
|
||||
name: Visual Regression (kilo-vscode webview)
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout (internal)
|
||||
if: needs.check-paths.outputs.is_fork != 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: true
|
||||
token: ${{ secrets.BOT_PAT }}
|
||||
ref: ${{ github.head_ref }}
|
||||
|
||||
- name: Checkout (fork)
|
||||
if: needs.check-paths.outputs.is_fork == 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
- name: Check if HEAD is a baseline update commit
|
||||
if: needs.check-paths.outputs.is_fork != 'true'
|
||||
id: check-baseline-commit-vscode
|
||||
run: |
|
||||
COMMIT_MSG=$(git log -1 --format=%s)
|
||||
if [ "$COMMIT_MSG" = "chore: update visual regression baselines" ] || [ "$COMMIT_MSG" = "chore: update kilo-vscode visual regression baselines" ]; then
|
||||
echo "is_baseline_update=true" >> "$GITHUB_OUTPUT"
|
||||
echo "HEAD commit is a baseline update commit — will not auto-commit again."
|
||||
else
|
||||
echo "is_baseline_update=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Cache Bun modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: bun-${{ hashFiles('bun.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
id: playwright-cache-vscode
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-vscode-${{ hashFiles('packages/kilo-vscode/package.json') }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: steps.playwright-cache-vscode.outputs.cache-hit != 'true'
|
||||
run: bunx playwright install chromium
|
||||
working-directory: packages/kilo-vscode
|
||||
|
||||
- name: Install Playwright system deps
|
||||
run: bunx playwright install-deps chromium
|
||||
working-directory: packages/kilo-vscode
|
||||
|
||||
- name: Cache Storybook build
|
||||
id: storybook-cache-vscode
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: packages/kilo-vscode/storybook-static
|
||||
key: storybook-vscode-${{ hashFiles('packages/kilo-vscode/webview-ui/src/**', 'packages/kilo-vscode/.storybook/**', 'packages/kilo-ui/src/**', 'packages/ui/src/**', 'packages/kilo-vscode/package.json') }}
|
||||
|
||||
- name: Build Storybook
|
||||
if: steps.storybook-cache-vscode.outputs.cache-hit != 'true'
|
||||
run: bun run build-storybook
|
||||
working-directory: packages/kilo-vscode
|
||||
|
||||
- name: Generate baselines for new/missing stories
|
||||
run: bun run test:visual:update
|
||||
working-directory: packages/kilo-vscode
|
||||
env:
|
||||
CI: true
|
||||
PLAYWRIGHT_WORKERS: "4"
|
||||
|
||||
- name: Remove stale baselines for deleted stories
|
||||
run: |
|
||||
node -e "
|
||||
const fs = require('fs'), path = require('path');
|
||||
const idx = JSON.parse(fs.readFileSync('storybook-static/index.json', 'utf8'));
|
||||
const stories = Object.values(idx.entries || idx.stories || {}).filter(s => s.id && !s.id.endsWith('--docs'));
|
||||
const expected = new Set(stories.map(s => { const [c,v] = s.id.split('--'); return c + '/' + v + '-chromium-linux.png'; }));
|
||||
const dir = 'tests/visual-regression.spec.ts-snapshots';
|
||||
if (!fs.existsSync(dir)) process.exit(0);
|
||||
for (const component of fs.readdirSync(dir)) {
|
||||
const cdir = path.join(dir, component);
|
||||
if (!fs.statSync(cdir).isDirectory()) continue;
|
||||
for (const file of fs.readdirSync(cdir)) {
|
||||
const rel = component + '/' + file;
|
||||
if (file.endsWith('.png') && !expected.has(rel)) { console.log('Removing stale baseline:', rel); fs.unlinkSync(path.join(cdir, file)); }
|
||||
}
|
||||
}
|
||||
"
|
||||
working-directory: packages/kilo-vscode
|
||||
|
||||
- name: Check for baseline changes (fork PRs)
|
||||
if: needs.check-paths.outputs.is_fork == 'true'
|
||||
run: |
|
||||
git add packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/
|
||||
if git diff --cached --quiet; then
|
||||
echo "No visual regression detected."
|
||||
else
|
||||
echo "::error::Visual regression detected. Screenshot baselines have changed."
|
||||
echo "::error::Since this PR is from a fork, updated screenshots cannot be committed automatically."
|
||||
echo "::error::Please ask a Kilo developer for help updating the screenshots."
|
||||
git diff --cached --stat
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Fail if baselines still changing after auto-update
|
||||
if: needs.check-paths.outputs.is_fork != 'true' && steps.check-baseline-commit-vscode.outputs.is_baseline_update == 'true'
|
||||
run: |
|
||||
git add packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/
|
||||
if git diff --cached --quiet; then
|
||||
echo "Baselines are stable after auto-update."
|
||||
else
|
||||
echo "::error::Visual regression baselines changed again after a previous auto-update commit."
|
||||
echo "::error::This indicates non-deterministic screenshots that would cause an infinite update loop."
|
||||
echo "::error::Please investigate the flaky screenshots and add non-deterministic stories to the SKIP set."
|
||||
git diff --cached --stat
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Commit and push new baselines (if any)
|
||||
if: needs.check-paths.outputs.is_fork != 'true' && steps.check-baseline-commit-vscode.outputs.is_baseline_update != 'true'
|
||||
id: commit-baselines-vscode
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.BOT_PAT }}
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/
|
||||
if git diff --cached --quiet; then
|
||||
echo "No new baselines — nothing to commit."
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
git commit -m "chore: update kilo-vscode visual regression baselines"
|
||||
git lfs push --all origin
|
||||
git push
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Fail if baselines changed
|
||||
if: needs.check-paths.outputs.is_fork != 'true' && steps.commit-baselines-vscode.outputs.changed == 'true'
|
||||
run: |
|
||||
echo "::error::Visual regression baselines changed. New baselines have been committed to the branch. Please pull and review."
|
||||
exit 1
|
||||
|
||||
- name: Upload test results on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: visual-regression-vscode-results
|
||||
path: packages/kilo-vscode/test-results/
|
||||
retention-days: 7
|
||||
@@ -1,3 +1,4 @@
|
||||
.husky/_
|
||||
.DS_Store
|
||||
node_modules
|
||||
.worktrees
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
---
|
||||
name: vscode-visual-regression
|
||||
description: Write Storybook stories and visual regression tests for the Kilo VS Code extension webview UI
|
||||
---
|
||||
|
||||
Use this skill when the user asks you to add visual regression tests, screenshot tests, or Storybook stories for components in `packages/kilo-vscode/`.
|
||||
|
||||
# Architecture
|
||||
|
||||
The VS Code extension uses **Storybook + Playwright** for visual regression testing:
|
||||
|
||||
1. **Storybook stories** define UI scenarios using SolidJS components with mock contexts
|
||||
2. **Playwright** auto-discovers all stories, renders each in headless Chromium, and compares screenshots against baseline PNGs using `toHaveScreenshot()`
|
||||
3. **Baselines** are Linux-only Chromium PNGs stored in `tests/visual-regression.spec.ts-snapshots/` (tracked via Git LFS)
|
||||
|
||||
The test runner at `tests/visual-regression.spec.ts` is fully automatic — it fetches ALL stories from the Storybook index and creates one Playwright test per story. You do NOT write Playwright test code. You only write stories.
|
||||
|
||||
# How to add a visual regression test
|
||||
|
||||
## Step 1: Decide which story file to use
|
||||
|
||||
Stories live in `packages/kilo-vscode/webview-ui/src/stories/`. Existing files and their scope:
|
||||
|
||||
| File | Components covered |
|
||||
| --------------------------- | -------------------------------------------------------- |
|
||||
| `agent-manager.stories.tsx` | FileTree, DiffPanel, FullScreenDiffView, WorktreeItem |
|
||||
| `chat.stories.tsx` | ChatView, QuestionDock |
|
||||
| `composite.stories.tsx` | AssistantMessage with tool cards, permissions, questions |
|
||||
| `prompt-input.stories.tsx` | PromptInput (sidebar prompt bar) |
|
||||
| `settings.stories.tsx` | Settings panel, ProvidersTab |
|
||||
| `history.stories.tsx` | SessionList |
|
||||
| `shared.stories.tsx` | ModelSelector and shared controls |
|
||||
|
||||
Add to an existing file if the component fits. Create a new file only for a genuinely new component area.
|
||||
|
||||
## Step 2: Write the story
|
||||
|
||||
Every story file follows this exact structure:
|
||||
|
||||
```tsx
|
||||
/** @jsxImportSource solid-js */
|
||||
/**
|
||||
* Stories for [ComponentName].
|
||||
*/
|
||||
|
||||
import type { Meta, StoryObj } from "storybook-solidjs-vite"
|
||||
import { StoryProviders } from "./StoryProviders"
|
||||
// Import the component(s) under test
|
||||
import { MyComponent } from "../components/path/MyComponent"
|
||||
|
||||
const meta: Meta = {
|
||||
title: "MyCategory", // Becomes the snapshot subdirectory name (lowercased)
|
||||
parameters: { layout: "padded" }, // or "fullscreen"
|
||||
}
|
||||
export default meta
|
||||
type Story = StoryObj
|
||||
|
||||
export const MyStoryName: Story = {
|
||||
name: "MyComponent — description of variant",
|
||||
render: () => (
|
||||
<StoryProviders>
|
||||
<div style={{ width: "420px", height: "400px" }}>
|
||||
<MyComponent someProp="value" />
|
||||
</div>
|
||||
</StoryProviders>
|
||||
),
|
||||
}
|
||||
```
|
||||
|
||||
### Key rules
|
||||
|
||||
- **Always start with `/** @jsxImportSource solid-js \*/`\*\* — required for SolidJS JSX compilation.
|
||||
- **Always wrap in `<StoryProviders>`** — provides all required contexts (VSCode, Server, Config, Provider, Session, I18n, Dialog, Marked, Data, Diff, Code). Without it, components that call `useVSCode()`, `useSession()`, etc. will throw.
|
||||
- **Set explicit dimensions** on a wrapper div so the screenshot has a deterministic size. Use `width: "420px"` for sidebar-width components (the standard VS Code sidebar). Use `width: "200px"` for narrow/collapsed states.
|
||||
- **Meta `title`** determines the snapshot subdirectory. Use PascalCase or slash-notation (e.g., `"Composite/Webview"`). Playwright transforms it: `"Composite/Webview"` becomes `composite-webview/` in the snapshots folder.
|
||||
- **Export name** determines the story ID. The Storybook ID is `{lowercase-title}--{kebab-export-name}`. For example, `title: "Chat"` + `export const ChatViewIdle` produces ID `chat--chat-view-idle`.
|
||||
- **Snapshot path** is derived automatically: `tests/visual-regression.spec.ts-snapshots/{title-slug}/{variant-slug}.png`. Example: `chat/chat-view-idle-chromium-linux.png`.
|
||||
|
||||
### StoryProviders props
|
||||
|
||||
```tsx
|
||||
interface StoryProvidersProps {
|
||||
data?: any // Override mock data (messages, parts, permissions, etc.)
|
||||
permissions?: PermissionRequest[] // Active permission requests
|
||||
questions?: QuestionRequest[] // Active question requests
|
||||
status?: string // Session status: "idle" | "busy"
|
||||
sessionID?: string // Custom session ID
|
||||
noPadding?: boolean // Skip the default 12px padding wrapper
|
||||
}
|
||||
```
|
||||
|
||||
### Overriding session state
|
||||
|
||||
For stories that need custom session behavior (messages, agents, model overrides), use `mockSessionValue()` and override the `SessionContext`:
|
||||
|
||||
```tsx
|
||||
import { mockSessionValue } from "./StoryProviders"
|
||||
import { SessionContext } from "../context/session"
|
||||
|
||||
export const MyCustomStory: Story = {
|
||||
name: "Component — custom state",
|
||||
render: () => {
|
||||
const session = {
|
||||
...mockSessionValue({ id: "my-session", status: "idle" }),
|
||||
messages: () => [{ id: "msg-001" }] as any[],
|
||||
totalCost: () => 0.0023,
|
||||
}
|
||||
return (
|
||||
<StoryProviders sessionID="my-session" status="idle" noPadding>
|
||||
<SessionContext.Provider value={session as any}>
|
||||
<MyComponent />
|
||||
</SessionContext.Provider>
|
||||
</StoryProviders>
|
||||
)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Overriding data context (messages, parts, permissions)
|
||||
|
||||
For stories showing assistant messages with tool parts or permissions, build a custom `data` object:
|
||||
|
||||
```tsx
|
||||
import { defaultMockData } from "./StoryProviders"
|
||||
|
||||
const SESSION_ID = "story-session-001"
|
||||
const ASST_MSG_ID = "asst-msg-001"
|
||||
|
||||
// Build mock message + parts
|
||||
const baseMessage = {
|
||||
id: ASST_MSG_ID,
|
||||
sessionID: SESSION_ID,
|
||||
role: "assistant",
|
||||
// ... see composite.stories.tsx for full shape
|
||||
}
|
||||
|
||||
const myPart = {
|
||||
id: "part-001",
|
||||
sessionID: SESSION_ID,
|
||||
messageID: ASST_MSG_ID,
|
||||
type: "tool",
|
||||
tool: "read",
|
||||
// ... see composite.stories.tsx for full ToolPart shape
|
||||
}
|
||||
|
||||
function dataWith(parts: any[], permissions?: PermissionRequest[]) {
|
||||
return {
|
||||
...defaultMockData,
|
||||
message: { [SESSION_ID]: [baseMessage] },
|
||||
part: { [ASST_MSG_ID]: parts },
|
||||
permission: permissions ? { [SESSION_ID]: permissions } : {},
|
||||
}
|
||||
}
|
||||
|
||||
export const MyToolStory: Story = {
|
||||
name: "Tool — with custom parts",
|
||||
render: () => (
|
||||
<StoryProviders data={dataWith([myPart])} sessionID={SESSION_ID}>
|
||||
<AssistantMessage message={baseMessage} />
|
||||
</StoryProviders>
|
||||
),
|
||||
}
|
||||
```
|
||||
|
||||
## Step 3: Handle narrow viewports
|
||||
|
||||
For components that should be tested at multiple widths, create separate stories. Stories whose Storybook ID ends in `-200` are automatically rendered at 200px width by the test runner:
|
||||
|
||||
```tsx
|
||||
export const Default420: Story = {
|
||||
name: "Default — 420px",
|
||||
render: () => (
|
||||
<StoryProviders>
|
||||
<MyComponent />
|
||||
</StoryProviders>
|
||||
),
|
||||
}
|
||||
|
||||
export const Default200: Story = {
|
||||
name: "Default — 200px", // Storybook ID will end in -200
|
||||
render: () => (
|
||||
<StoryProviders>
|
||||
<MyComponent />
|
||||
</StoryProviders>
|
||||
),
|
||||
}
|
||||
```
|
||||
|
||||
The naming convention with `-200` suffix on the **export name** (e.g., `Default200`) produces the ID `mycategory--default-200`, which the test runner detects and uses a 200px viewport for.
|
||||
|
||||
## Step 4: Handle animations / non-deterministic content
|
||||
|
||||
The test runner injects CSS to disable all animations and transitions. If a story still produces non-deterministic frames (e.g., a spinner captured at a random rotation), add the story ID to the `SKIP` set in `tests/visual-regression.spec.ts`:
|
||||
|
||||
```ts
|
||||
const SKIP = new Set<string>(["agentmanager--worktree-item-busy"])
|
||||
```
|
||||
|
||||
Only skip stories as a last resort. Prefer making the story deterministic (e.g., use a static state instead of an animated one).
|
||||
|
||||
## Step 5: Generate baseline images
|
||||
|
||||
Baselines are generated on **Linux CI only** (font rendering differs on macOS). The CI workflow at `.github/workflows/visual-regression.yml` auto-runs `bun run test:visual:update` and commits new baselines to the PR branch.
|
||||
|
||||
**You do NOT need to generate baseline PNGs locally.** Just write the story, push, and CI handles the rest.
|
||||
|
||||
To preview stories locally:
|
||||
|
||||
```bash
|
||||
# From packages/kilo-vscode/
|
||||
bun run storybook
|
||||
# Opens at http://localhost:6007
|
||||
```
|
||||
|
||||
# Reference: snapshot directory structure
|
||||
|
||||
Snapshots live at `packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/`:
|
||||
|
||||
```
|
||||
tests/visual-regression.spec.ts-snapshots/
|
||||
{title-slug}/
|
||||
{variant-slug}-chromium-linux.png
|
||||
```
|
||||
|
||||
The title-slug is derived from the meta `title` (lowercased, slashes become hyphens). The variant-slug is derived from the story export name (kebab-cased). Chromium and Linux suffixes are appended by Playwright.
|
||||
|
||||
Example mapping:
|
||||
|
||||
| Meta title | Export name | Snapshot path |
|
||||
| --------------------- | -------------------- | ----------------------------------------------------------- |
|
||||
| `"Chat"` | `ChatViewIdle` | `chat/chat-view-idle-chromium-linux.png` |
|
||||
| `"Composite/Webview"` | `GlobWithPermission` | `composite-webview/glob-with-permission-chromium-linux.png` |
|
||||
| `"Prompt Input"` | `Default200` | `prompt-input/default-200-chromium-linux.png` |
|
||||
| `"AgentManager"` | `WorktreeItemActive` | `agentmanager/worktree-item-active-chromium-linux.png` |
|
||||
|
||||
# Reference: Playwright config
|
||||
|
||||
Key settings in `packages/kilo-vscode/playwright.config.ts`:
|
||||
|
||||
- Default viewport: **420x720** (VS Code sidebar dimensions)
|
||||
- Narrow stories (ID ending `-200`): **200x720**
|
||||
- Max pixel diff ratio: **0.01** (1% tolerance)
|
||||
- Browser: **Chromium only**
|
||||
- Storybook: built and served statically on port **6007**
|
||||
- Animations: forced off via `reducedMotion: "reduce"` + injected CSS
|
||||
|
||||
# Reference: CI pipeline
|
||||
|
||||
The `visual-regression.yml` workflow triggers on PRs when these paths change:
|
||||
|
||||
- `packages/kilo-ui/**`
|
||||
- `packages/ui/**`
|
||||
- `packages/util/**`
|
||||
- `packages/sdk/js/**`
|
||||
- `packages/kilo-vscode/webview-ui/**`
|
||||
- `packages/kilo-vscode/.storybook/**`
|
||||
- `packages/kilo-vscode/tests/visual-regression*`
|
||||
- `.github/workflows/visual-regression.yml`
|
||||
|
||||
CI auto-commits new baselines via Git LFS and fails if screenshots changed, requiring developer review.
|
||||
|
||||
# Reference: import paths
|
||||
|
||||
Sidebar webview components live in `webview-ui/src/components/` and are imported relative to the stories directory:
|
||||
|
||||
```tsx
|
||||
import { ChatView } from "../components/chat/ChatView"
|
||||
import { PromptInput } from "../components/chat/PromptInput"
|
||||
import { AssistantMessage } from "../components/chat/AssistantMessage"
|
||||
```
|
||||
|
||||
Agent Manager components live in `webview-ui/agent-manager/` (one level up from the stories dir):
|
||||
|
||||
```tsx
|
||||
import { FileTree } from "../../agent-manager/FileTree"
|
||||
import { DiffPanel } from "../../agent-manager/DiffPanel"
|
||||
import { WorktreeItem } from "../../agent-manager/WorktreeItem"
|
||||
import "../../agent-manager/agent-manager.css" // Required for AM component styles
|
||||
```
|
||||
|
||||
kilo-ui components are imported via deep subpaths:
|
||||
|
||||
```tsx
|
||||
import { Part } from "@kilocode/kilo-ui/message-part"
|
||||
import { BasicTool } from "@kilocode/kilo-ui/basic-tool"
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
```
|
||||
|
||||
SDK types for mock data:
|
||||
|
||||
```tsx
|
||||
import type { AssistantMessage as SDKAssistantMessage, TextPart, ToolPart } from "@kilocode/sdk/v2"
|
||||
import type { PermissionRequest, QuestionRequest } from "../types/messages"
|
||||
```
|
||||
|
||||
# Reference: Storybook theme globals
|
||||
|
||||
The test runner renders every story with dark theme globals:
|
||||
|
||||
```
|
||||
globals=colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern
|
||||
```
|
||||
|
||||
The `.storybook/preview.tsx` applies these via a decorator that calls `applyVscodeTheme()` / `applyKiloTheme()` from kilo-ui. Stories do NOT need to handle theming — it happens automatically.
|
||||
|
||||
# Reference: tool override registration
|
||||
|
||||
If your story renders `AssistantMessage` with tool parts, you may need to register VS Code tool overrides at the top of the file (outside any story), as done in `composite.stories.tsx`:
|
||||
|
||||
```tsx
|
||||
import { registerVscodeToolOverrides } from "../components/chat/VscodeToolOverrides"
|
||||
registerVscodeToolOverrides()
|
||||
```
|
||||
|
||||
This ensures tool cards like `bash` render with their VS Code-specific expanded/collapsed behavior.
|
||||
|
||||
# Checklist for adding a new visual regression test
|
||||
|
||||
1. Identify or create the story file in `webview-ui/src/stories/`
|
||||
2. Import the component and `StoryProviders` (and optionally `mockSessionValue`, `defaultMockData`)
|
||||
3. Write the story with explicit dimensions and mock data
|
||||
4. Wrap in `<StoryProviders>` with appropriate props
|
||||
5. If testing multiple widths, create separate exports with the `-200` suffix convention
|
||||
6. If the component has animated states, prefer a static variant or add to the SKIP set
|
||||
7. Push to PR — CI generates baseline PNGs automatically
|
||||
8. Review the auto-committed baseline images in the PR diff
|
||||
@@ -13,7 +13,7 @@ Requirements:
|
||||
- Preserve meaning, intent, tone, and formatting (including Markdown/MDX structure).
|
||||
- Preserve all technical terms and artifacts exactly: product/company names, API names, identifiers, code, commands/flags, file paths, URLs, versions, error messages, config keys/values, and anything inside inline code or code blocks.
|
||||
- Also preserve every term listed in the Do-Not-Translate glossary below.
|
||||
- Also apply locale-specific guidance from `.opencode/agent/glossary/<locale>.md` when available (for example, `zh-cn.md`).
|
||||
- Also apply locale-specific guidance from `.opencode/glossary/<locale>.md` when available (for example, `zh-cn.md`).
|
||||
- Do not modify fenced code blocks.
|
||||
- Output ONLY the translation (no commentary).
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# tr Glossary
|
||||
|
||||
## Sources
|
||||
|
||||
- PR #15835: https://github.com/anomalyco/opencode/pull/15835
|
||||
|
||||
## Do Not Translate (Locale Additions)
|
||||
|
||||
- `OpenCode` (preserve casing in prose, docs, and UI copy)
|
||||
- Keep lowercase `opencode` in commands, package names, paths, URLs, and other exact identifiers
|
||||
- `<TAB>` stays the literal key token in code blocks; use `Tab` for the nearby explanatory label in prose
|
||||
- Commands, flags, file paths, and code literals (keep exactly as written)
|
||||
|
||||
## Preferred Terms
|
||||
|
||||
These are PR-backed wording preferences and may evolve.
|
||||
|
||||
| English / Context | Preferred | Notes |
|
||||
| ------------------------- | --------------------------------------- | ------------------------------------------------------------- |
|
||||
| available in beta | `beta olarak mevcut` | Prefer this over `beta olarak kullanılabilir` |
|
||||
| privacy-first | `Gizlilik öncelikli tasarlandı` | Prefer this over `Önce gizlilik için tasarlandı` |
|
||||
| connect your local models | `yerel modellerinizi bağlayabilirsiniz` | Use the fuller, more direct action phrase |
|
||||
| `<TAB>` key label | `Tab` | Use `Tab` in prose; keep `<TAB>` in literal UI or code blocks |
|
||||
| cross-platform | `cross-platform (tüm platformlarda)` | Keep the English term, add a short clarification when helpful |
|
||||
|
||||
## Guidance
|
||||
|
||||
- Prefer natural Turkish phrasing over literal translation
|
||||
- Merge broken sentence fragments into one clear sentence when the source is a single thought
|
||||
- Keep product naming consistent: `OpenCode` in prose, `opencode` only for exact technical identifiers
|
||||
- When an English technical term is intentionally kept, add a short Turkish clarification only if it improves readability
|
||||
|
||||
## Avoid
|
||||
|
||||
- Avoid `beta olarak kullanılabilir` when `beta olarak mevcut` fits
|
||||
- Avoid `Önce gizlilik için tasarlandı`; use the more natural reviewed wording instead
|
||||
- Avoid `Sekme` for the translated key label in prose when referring to `<TAB>`
|
||||
- Avoid changing `opencode` to `OpenCode` inside commands, URLs, package names, or code literals
|
||||
Vendored
+4
-1
@@ -114,7 +114,10 @@
|
||||
"clear": false
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/packages/kilo-vscode"
|
||||
"cwd": "${workspaceFolder}/packages/kilo-vscode",
|
||||
"env": {
|
||||
"VSCODE_EXEC_PATH": "${execPath}"
|
||||
}
|
||||
},
|
||||
"problemMatcher": []
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ Kilo CLI is an open source AI coding agent that generates code from natural lang
|
||||
- **Test**: `bun test` from `packages/opencode/` (NOT from root -- root blocks tests)
|
||||
- **Single test**: `bun test test/tool/tool.test.ts` from `packages/opencode/`
|
||||
- **SDK regen**: After changing server endpoints in `packages/opencode/src/server/`, run `./script/generate.ts` from root to regenerate `packages/sdk/js/`
|
||||
- **Knip** (unused exports): `bun run knip` from `packages/kilo-vscode/`. CI runs this — all exported types/functions must be imported somewhere. Remove or unexport unused exports before pushing.
|
||||
|
||||
## Products
|
||||
|
||||
@@ -63,6 +64,17 @@ Prefer `const`.
|
||||
|
||||
Good:
|
||||
|
||||
### Naming Enforcement (Read This)
|
||||
|
||||
THIS RULE IS MANDATORY FOR AGENT WRITTEN CODE.
|
||||
|
||||
- Use single word names by default for new locals, params, and helper functions.
|
||||
- Multi-word names are allowed only when a single word would be unclear or ambiguous.
|
||||
- Do not introduce new camelCase compounds when a short single-word alternative is clear.
|
||||
- Before finishing edits, review touched lines and shorten newly introduced identifiers where possible.
|
||||
- Good short names to prefer: `pid`, `cfg`, `err`, `opts`, `dir`, `root`, `child`, `state`, `timeout`.
|
||||
- Examples to avoid unless truly required: `inputPID`, `existingClient`, `connectTimeout`, `workerPath`.
|
||||
|
||||
```ts
|
||||
const foo = condition ? 1 : 2
|
||||
```
|
||||
|
||||
+4
-4
@@ -16,7 +16,7 @@ The Kilo Community is [on Discord](https://kilo.ai/discord).
|
||||
|
||||
## Developing Kilo CLI
|
||||
|
||||
- **Requirements:** Bun 1.3+
|
||||
- **Requirements:** Bun 1.3.10+
|
||||
- Install dependencies and start the dev server from the repo root:
|
||||
|
||||
```bash
|
||||
@@ -26,7 +26,7 @@ The Kilo Community is [on Discord](https://kilo.ai/discord).
|
||||
|
||||
### Running against a different directory
|
||||
|
||||
By default, `bun dev` runs Kilo CLI in the `packages/kilo-cli` directory. To run it against a different directory or repository:
|
||||
By default, `bun dev` runs Kilo CLI in the `packages/opencode` directory. To run it against a different directory or repository:
|
||||
|
||||
```bash
|
||||
bun dev <directory>
|
||||
@@ -43,13 +43,13 @@ bun dev .
|
||||
To compile a standalone executable:
|
||||
|
||||
```bash
|
||||
./packages/kilo-cli/script/build.ts --single
|
||||
./packages/opencode/script/build.ts --single
|
||||
```
|
||||
|
||||
Then run it with:
|
||||
|
||||
```bash
|
||||
./packages/kilo-cli/dist/kilo-cli-<platform>/bin/kilo
|
||||
./packages/opencode/dist/@kilocode/cli-<platform>/bin/kilo
|
||||
```
|
||||
|
||||
Replace `<platform>` with your platform (e.g., `darwin-arm64`, `linux-x64`).
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
# 🚀 Kilo
|
||||
|
||||
> Kilo is the all-in-one agentic engineering platform. Build, ship, and iterate faster with the most popular open source coding agent.
|
||||
> #1 on OpenRouter. 1.5M+ Kilo Coders. 25T+ tokens processed
|
||||
> [#1 coding agent on OpenRouter](https://openrouter.ai/apps/category/coding). 1.5M+ Kilo Coders. 25T+ tokens processed
|
||||
|
||||
- ✨ Generate code from natural language
|
||||
- ✅ Checks its own work
|
||||
@@ -20,10 +20,6 @@
|
||||
- 🎁 API keys optional
|
||||
- 💡 **Get $20 in bonus credits when you top-up for the first time** Credits can be used with 500+ models like Gemini 3.1 Pro, Claude 4.6 Sonnet & Opus, and GPT-5.2
|
||||
|
||||
<p align="center">
|
||||
<img src="https://media.githubusercontent.com/media/Kilo-Org/kilocode/main/kilo.gif" width="100%" />
|
||||
</p>
|
||||
|
||||
## Quick Links
|
||||
|
||||
- [VS Code Marketplace](https://kilo.ai/vscode-marketplace?utm_source=Readme) (download)
|
||||
@@ -59,6 +55,38 @@ npx @kilocode/cli
|
||||
|
||||
Then run `kilo` in any project directory to start.
|
||||
|
||||
<!-- kilocode_change start -->
|
||||
|
||||
### npm Install Note: Hidden `.kilo` File
|
||||
|
||||
On some systems and npm versions, installing `@kilocode/cli` can create a hidden `.kilo` file near the installed `kilo` command (for example in a global npm bin directory). This file is an npm-generated launcher helper, not project data.
|
||||
|
||||
- Why it exists: npm may create helper artifacts while wiring CLI executables.
|
||||
- Size caveat: size can vary by platform, npm version, and install mode (symlink vs copied launcher), so a strict fixed size is not guaranteed.
|
||||
- Safety: it is safe to leave in place. Do not edit it manually. Use your package manager's uninstall (`npm uninstall -g @kilocode/cli`) to remove install artifacts cleanly.
|
||||
<!-- kilocode_change end -->
|
||||
|
||||
### Install from GitHub Releases (Optional)
|
||||
|
||||
Download the latest binary or source code from the [Releases page](https://github.com/Kilo-Org/kilocode/releases), use this quick guide:
|
||||
|
||||
- `kilo-<os>-<arch>.zip` is the CLI binary for your OS and CPU architecture on Windows and macOS. (`kilo-linux-<arch>.tar.gz` for Linux)
|
||||
- `darwin` means macOS.
|
||||
- `x64` is standard 64-bit Intel/AMD CPUs.
|
||||
- `x64-baseline` is a compatibility build for older x64 CPUs(do not support AVX Instruction).
|
||||
- `arm64` is ARM-based Linux/MacOS.
|
||||
- `musl` is statically linked Linux build for Alpine/minimal Docker without glibc. Alpine/minimal Docker users should prefer the matching \*-musl asset.
|
||||
- `kilo-vscode-*.vsix` is the VS Code extension package and not the CLI binary.
|
||||
- `Source code` releases are for building from source, not normal installation.
|
||||
|
||||
For most users:
|
||||
|
||||
- **Windows (most PCs):** `kilo-windows-x64.zip`
|
||||
- **macOS Apple Silicon:** `kilo-darwin-arm64.zip`
|
||||
- **macOS Intel:** `kilo-darwin-x64.zip`
|
||||
- **Linux x64:** `kilo-linux-x64.tar,gz`
|
||||
- **Linux on ARM:** `kilo-linux-arm64.tar.gz`
|
||||
|
||||
### Autonomous Mode (CI/CD)
|
||||
|
||||
Use the `--auto` flag with `kilo run` to enable fully autonomous operation without user interaction. This is ideal for CI/CD pipelines and automated workflows:
|
||||
@@ -74,6 +102,8 @@ kilo run --auto "run tests and fix any failures"
|
||||
We welcome contributions from developers, writers, and enthusiasts!
|
||||
To get started, please read our [Contributing Guide](/CONTRIBUTING.md). It includes details on setting up your environment, coding standards, types of contribution and how to submit pull requests.
|
||||
|
||||
See [RELEASING.md](RELEASING.md) for the release process.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Our community is built on respect, inclusivity, and collaboration. Please review our [Code of Conduct](/CODE_OF_CONDUCT.md) to understand the expectations for all contributors and community members.
|
||||
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
# Releasing Kilo Code
|
||||
|
||||
Kilo Code uses a fully automated CI pipeline triggered via GitHub Actions `workflow_dispatch`. A single workflow handles version bumping, building all artifacts, publishing to every distribution channel, and updating package registries.
|
||||
|
||||
## How to Trigger a Release
|
||||
|
||||
1. Go to the [`publish` workflow](https://github.com/Kilo-Org/kilocode/actions/workflows/publish.yml) in GitHub Actions.
|
||||
2. Click **"Run workflow"**.
|
||||
3. Select the branch (typically `main`).
|
||||
4. Fill in the inputs:
|
||||
- **`bump`** (choice): `patch`, `minor`, or `major`. Determines how the version number is incremented.
|
||||
- **`version`** (string, optional): Override the version explicitly instead of using `bump`. Leave empty to use the bump-based calculation.
|
||||
|
||||
> **⚠️ Do not fill in `version` unless you have a specific reason to.**
|
||||
> The default behavior — leaving `version` empty and selecting a `bump` level — is almost always what you want. The automated bump logic computes the correct next version from the current state of the repo. Only use the `version` override for exceptional cases like skipping versions or publishing a pre-release (e.g. `1.5.0-beta.1`).
|
||||
|
||||
5. Click **"Run workflow"** to start the release.
|
||||
|
||||
## What Happens During a Release
|
||||
|
||||
The `publish.yml` workflow runs four jobs sequentially:
|
||||
|
||||
### 1. Version (`version`)
|
||||
|
||||
- Checks out the repo with full history (`fetch-depth: 0`).
|
||||
- Runs `script/version.ts` to compute the next version based on the `bump` or `version` input.
|
||||
- Generates release notes from the commit history since the last release.
|
||||
- Creates a **draft** GitHub Release with the computed tag (e.g. `v1.2.3`) and release notes.
|
||||
- Outputs the `version`, `release` (database ID), and `tag` for downstream jobs.
|
||||
|
||||
### 2. Build CLI (`build-cli`)
|
||||
|
||||
- Runs `packages/opencode/script/build.ts` to compile the Kilo CLI binary.
|
||||
- Builds native binaries for **all supported platforms and architectures**:
|
||||
- Linux: x64, arm64 (glibc and musl), plus baseline (non-AVX2) variants
|
||||
- macOS: x64, arm64, plus baseline variants
|
||||
- Windows: x64, plus baseline variant
|
||||
- Patches ELF interpreters on Linux binaries for broad compatibility.
|
||||
- Creates platform archives (`.tar.gz` for Linux, `.zip` for macOS/Windows) and uploads them to the draft GitHub Release.
|
||||
- Uploads the `dist/` directory as a workflow artifact (`kilo-cli`) for subsequent jobs.
|
||||
|
||||
### 3. Build VS Code Extension (`build-vscode`)
|
||||
|
||||
- Downloads the CLI artifacts from the previous job.
|
||||
- Runs `packages/kilo-vscode/script/build.ts` to build VSIX packages for all target platforms:
|
||||
- `linux-x64`, `linux-arm64`, `alpine-x64`, `alpine-arm64`, `darwin-x64`, `darwin-arm64`, `win32-x64`
|
||||
- Each VSIX bundles the platform-specific CLI binary.
|
||||
- Uploads the VSIX files as a workflow artifact (`kilo-vscode`).
|
||||
|
||||
### 4. Publish (`publish`)
|
||||
|
||||
Downloads all build artifacts and publishes to every distribution channel:
|
||||
|
||||
#### Version Commit and Tagging
|
||||
|
||||
- Updates the `version` field in all `package.json` files across the monorepo.
|
||||
- Updates the Zed extension manifest (`extension.toml`) with the new version.
|
||||
- Rebuilds the TypeScript SDK (`packages/sdk/js`).
|
||||
- Commits the version bump, tags the commit, and pushes to the repo.
|
||||
- Promotes the draft GitHub Release to a published release.
|
||||
|
||||
#### CLI (`@kilocode/cli`)
|
||||
|
||||
- Publishes platform-specific binary packages to **npm** (e.g. `@kilocode/cli-linux-x64`, `@kilocode/cli-darwin-arm64`, etc.).
|
||||
- Publishes the main `@kilocode/cli` package to **npm** with optional dependencies on the binary packages.
|
||||
- Builds and pushes a multi-arch **Docker image** (`ghcr.io/kilo-org/kilo`) to GitHub Container Registry (linux/amd64 + linux/arm64).
|
||||
|
||||
#### SDK (`@kilocode/sdk`)
|
||||
|
||||
- Builds and publishes the TypeScript SDK to **npm**.
|
||||
|
||||
#### Plugin (`@kilocode/plugin`)
|
||||
|
||||
- Builds and publishes the plugin interface package to **npm**.
|
||||
|
||||
#### VS Code Extension
|
||||
|
||||
- Publishes platform-specific VSIX packages to the **VS Code Marketplace** via `vsce`.
|
||||
- Uploads all VSIX files to the **GitHub Release** as assets.
|
||||
|
||||
#### Package Registries (stable releases only)
|
||||
|
||||
- **AUR (Arch Linux)**: Clones `kilo-bin` from the AUR, updates the `PKGBUILD` with new version and SHA256 checksums, and pushes.
|
||||
- **Homebrew**: Clones `Kilo-Org/homebrew-tap`, updates the `kilo.rb` formula with new version, download URLs, and SHA256 checksums, and pushes.
|
||||
|
||||
## Prerequisites and Permissions
|
||||
|
||||
### Repository Access
|
||||
|
||||
- The workflow only runs in the `Kilo-Org/kilocode` repository (guarded by `if: github.repository == 'Kilo-Org/kilocode'`).
|
||||
- You must have **write access** to the repository to trigger a `workflow_dispatch` event.
|
||||
|
||||
### Workflow Permissions
|
||||
|
||||
The workflow requires these GitHub token permissions:
|
||||
|
||||
- `id-token: write` -- for npm provenance attestation
|
||||
- `contents: write` -- for creating releases, pushing tags, and uploading assets
|
||||
- `packages: write` -- for publishing Docker images to GHCR
|
||||
|
||||
### Required Secrets
|
||||
|
||||
The following secrets must be configured in the repository:
|
||||
|
||||
| Secret | Purpose |
|
||||
| ---------------------------- | ---------------------------------------------------------------- |
|
||||
| `KILO_API_KEY` | Kilo API key used during version computation |
|
||||
| `KILO_ORG_ID` | Kilo organization ID |
|
||||
| `KILO_MAINTAINER_APP_ID` | GitHub App ID for the kilo-maintainer bot (used for git commits) |
|
||||
| `KILO_MAINTAINER_APP_SECRET` | GitHub App secret for the kilo-maintainer bot |
|
||||
| `NPM_TOKEN` | npm authentication token for publishing packages |
|
||||
| `VSCE_TOKEN` | VS Code Marketplace personal access token |
|
||||
| `OVSX_TOKEN` | Open VSX Registry token (currently unused but configured) |
|
||||
| `AUR_KEY` | SSH private key for pushing to the AUR |
|
||||
|
||||
### Concurrency
|
||||
|
||||
The workflow uses concurrency control (`workflow-ref-bump/version`) to prevent parallel releases from conflicting.
|
||||
+4
-6
@@ -36,12 +36,10 @@ Server mode is opt-in only. When enabled, set `KILO_SERVER_PASSWORD` to require
|
||||
|
||||
# Reporting Security Issues
|
||||
|
||||
We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
|
||||
We value the contributions of the security research community and recognize the importance of a coordinated approach to vulnerability disclosure. If you have discovered a security vulnerability, we encourage you to let us know immediately. We welcome the opportunity to work with you to resolve the issue promptly.
|
||||
|
||||
To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/Kilo-Org/kilocode/security/advisories/new) tab.
|
||||
Please email your findings to [security@kilo.ai](mailto:security@kilo.ai). We will acknowledge your report and work with you to resolve the issue.
|
||||
|
||||
The team will send a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
|
||||
After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
|
||||
|
||||
## Escalation
|
||||
|
||||
If you do not receive an acknowledgement of your report within 6 business days, you may send an email to hi@kilo.ai
|
||||
For more details, see our [Security Disclosure](https://kilo.ai/security) page.
|
||||
|
||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1770812194,
|
||||
"narHash": "sha256-OH+lkaIKAvPXR3nITO7iYZwew2nW9Y7Xxq0yfM/UcUU=",
|
||||
"lastModified": 1772956932,
|
||||
"narHash": "sha256-M0yS4AafhKxPPmOHGqIV0iKxgNO8bHDWdl1kOwGBwRY=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "8482c7ded03bae7550f3d69884f1e611e3bd19e8",
|
||||
"rev": "608d0cadfed240589a7eea422407a547ad626a14",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -162,7 +162,8 @@
|
||||
unzip
|
||||
gnutar
|
||||
gzip
|
||||
ripgrep
|
||||
patchelf
|
||||
ripgrep
|
||||
kilo-dev
|
||||
kilo-install-bin
|
||||
kilo-bin
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-Medit5guq3rzXGSFYP7iH6pyaozOJLgLpreU/GT+4Dw=",
|
||||
"aarch64-linux": "sha256-R//MvhDIKPFGpWIbPksi494L9bfv3eahL/GzYhQ/NFw=",
|
||||
"aarch64-darwin": "sha256-83+hjrJyV2rC1BstFathrJvUv0Di6vpbRSs2Nek72eU=",
|
||||
"x86_64-darwin": "sha256-v3cAIq+ChvljgWBuPCvb12PYXlSalXkkr4BRCfQu2VE="
|
||||
"x86_64-linux": "sha256-2SnZbJPIvgCkb4jNh49CnbDAOpghgO9XihSiTlL6rYI=",
|
||||
"aarch64-linux": "sha256-HV/Sm4rBAZPjxG+SQJ95634yTyJXDjlSeYemMYg8eCE=",
|
||||
"aarch64-darwin": "sha256-PAJSd1VkNc7LUSrOAeG+rmYUw8VefR8GfkAIy8H3iV0=",
|
||||
"x86_64-darwin": "sha256-Qdk8nu4FoGjp6+7Ji5vuHw2HhM1Z+ivE7meqJivTiHk="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ stdenvNoCC.mkDerivation {
|
||||
../package.json
|
||||
../patches
|
||||
../install # required by desktop build (cli.rs include_str!)
|
||||
../.github/TEAM_MEMBERS # required by @opencode-ai/script
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
+6
-4
@@ -4,11 +4,12 @@
|
||||
"description": "AI-powered development tool",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.9",
|
||||
"packageManager": "bun@1.3.10",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"dev:desktop": "bun --cwd packages/desktop tauri dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"prepare": "husky",
|
||||
"random": "echo 'Random script'",
|
||||
@@ -33,7 +34,7 @@
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@pierre/diffs": "1.1.0-beta.17",
|
||||
"@pierre/diffs": "1.1.0-beta.18",
|
||||
"@solid-primitives/storage": "4.3.3",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"diff": "8.0.2",
|
||||
@@ -96,7 +97,8 @@
|
||||
"protobufjs",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"web-tree-sitter"
|
||||
"web-tree-sitter",
|
||||
"electron"
|
||||
],
|
||||
"overrides": {
|
||||
"@types/bun": "catalog:",
|
||||
@@ -107,6 +109,6 @@
|
||||
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
|
||||
"ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch"
|
||||
},
|
||||
"version": "7.0.32",
|
||||
"version": "7.0.47",
|
||||
"peerDependencies": {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
# CreateEffect Simplification Implementation Spec
|
||||
|
||||
Reduce reactive misuse across `packages/app`.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
This work targets `packages/app/src`, which currently has 101 `createEffect` calls across 37 files.
|
||||
|
||||
The biggest clusters are `pages/session.tsx` (19), `pages/layout.tsx` (13), `pages/session/file-tabs.tsx` (6), and several context providers that mirror one store into another.
|
||||
|
||||
Key issues from the audit:
|
||||
|
||||
- Derived state is being written through effects instead of computed directly
|
||||
- Session and file resets are handled by watch-and-clear effects instead of keyed state boundaries
|
||||
- User-driven actions are hidden inside reactive effects
|
||||
- Context layers mirror and hydrate child stores with multiple sync effects
|
||||
- Several areas repeat the same imperative trigger pattern in multiple effects
|
||||
|
||||
Keep the implementation focused on removing unnecessary effects, not on broad UI redesign.
|
||||
|
||||
## Goals
|
||||
|
||||
- Cut high-churn `createEffect` usage in the hottest files first
|
||||
- Replace effect-driven derived state with reactive derivation
|
||||
- Replace reset-on-key effects with keyed ownership boundaries
|
||||
- Move event-driven work to direct actions and write paths
|
||||
- Remove mirrored store hydration where a single source of truth can exist
|
||||
- Leave necessary external sync effects in place, but make them narrower and clearer
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not rewrite unrelated component structure just to reduce the count
|
||||
- Do not change product behavior, navigation flow, or persisted data shape unless required for a cleaner write boundary
|
||||
- Do not remove effects that bridge to DOM, editors, polling, or external APIs unless there is a clearly safer equivalent
|
||||
- Do not attempt a repo-wide cleanup outside `packages/app`
|
||||
|
||||
## Effect Taxonomy And Replacement Rules
|
||||
|
||||
Use these rules during implementation.
|
||||
|
||||
### Prefer `createMemo`
|
||||
|
||||
Use `createMemo` when the target value is pure derived state from other signals or stores.
|
||||
|
||||
Do this when an effect only reads reactive inputs and writes another reactive value that could be computed instead.
|
||||
|
||||
Apply this to:
|
||||
|
||||
- `packages/app/src/pages/session.tsx:141`
|
||||
- `packages/app/src/pages/layout.tsx:557`
|
||||
- `packages/app/src/components/terminal.tsx:261`
|
||||
- `packages/app/src/components/session/session-header.tsx:309`
|
||||
|
||||
Rules:
|
||||
|
||||
- If no external system is touched, do not use `createEffect`
|
||||
- Derive once, then read the memo where needed
|
||||
- If normalization is required, prefer normalizing at the write boundary before falling back to a memo
|
||||
|
||||
### Prefer Keyed Remounts
|
||||
|
||||
Use keyed remounts when local UI state should reset because an identity changed.
|
||||
|
||||
Do this with `sessionKey`, `scope()`, or another stable identity instead of watching the key and manually clearing signals.
|
||||
|
||||
Apply this to:
|
||||
|
||||
- `packages/app/src/pages/session.tsx:325`
|
||||
- `packages/app/src/pages/session.tsx:336`
|
||||
- `packages/app/src/pages/session.tsx:477`
|
||||
- `packages/app/src/pages/session.tsx:869`
|
||||
- `packages/app/src/pages/session.tsx:963`
|
||||
- `packages/app/src/pages/session/message-timeline.tsx:149`
|
||||
- `packages/app/src/context/file.tsx:100`
|
||||
|
||||
Rules:
|
||||
|
||||
- If the desired behavior is "new identity, fresh local state," key the owner subtree
|
||||
- Keep state local to the keyed boundary so teardown and recreation handle the reset naturally
|
||||
|
||||
### Prefer Event Handlers And Actions
|
||||
|
||||
Use direct handlers, store actions, and async command functions when work happens because a user clicked, selected, reloaded, or navigated.
|
||||
|
||||
Do this when an effect is just watching for a flag change, command token, or event-bus signal to trigger imperative logic.
|
||||
|
||||
Apply this to:
|
||||
|
||||
- `packages/app/src/pages/layout.tsx:484`
|
||||
- `packages/app/src/pages/layout.tsx:652`
|
||||
- `packages/app/src/pages/layout.tsx:776`
|
||||
- `packages/app/src/pages/layout.tsx:1489`
|
||||
- `packages/app/src/pages/layout.tsx:1519`
|
||||
- `packages/app/src/components/file-tree.tsx:328`
|
||||
- `packages/app/src/pages/session/terminal-panel.tsx:55`
|
||||
- `packages/app/src/context/global-sync.tsx:148`
|
||||
- Duplicated trigger sets in:
|
||||
- `packages/app/src/pages/session/review-tab.tsx:122`
|
||||
- `packages/app/src/pages/session/review-tab.tsx:130`
|
||||
- `packages/app/src/pages/session/review-tab.tsx:138`
|
||||
- `packages/app/src/pages/session/file-tabs.tsx:367`
|
||||
- `packages/app/src/pages/session/file-tabs.tsx:378`
|
||||
- `packages/app/src/pages/session/file-tabs.tsx:389`
|
||||
- `packages/app/src/pages/session/use-session-hash-scroll.ts:144`
|
||||
- `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
|
||||
- `packages/app/src/pages/session/use-session-hash-scroll.ts:167`
|
||||
|
||||
Rules:
|
||||
|
||||
- If the trigger is user intent, call the action at the source of that intent
|
||||
- If the same imperative work is triggered from multiple places, extract one function and call it directly
|
||||
|
||||
### Prefer `onMount` And `onCleanup`
|
||||
|
||||
Use `onMount` and `onCleanup` for lifecycle-only setup and teardown.
|
||||
|
||||
This is the right fit for subscriptions, one-time wiring, timers, and imperative integration that should not rerun for ordinary reactive changes.
|
||||
|
||||
Use this when:
|
||||
|
||||
- Setup should happen once per owner lifecycle
|
||||
- Cleanup should always pair with teardown
|
||||
- The work is not conceptually derived state
|
||||
|
||||
### Keep `createEffect` When It Is A Real Bridge
|
||||
|
||||
Keep `createEffect` when it synchronizes reactive data to an external imperative sink.
|
||||
|
||||
Examples that should remain, though they may be narrowed or split:
|
||||
|
||||
- DOM/editor sync in `packages/app/src/components/prompt-input.tsx:690`
|
||||
- Scroll sync in `packages/app/src/pages/session.tsx:685`
|
||||
- Scroll/hash sync in `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
|
||||
- External sync in:
|
||||
- `packages/app/src/context/language.tsx:207`
|
||||
- `packages/app/src/context/settings.tsx:110`
|
||||
- `packages/app/src/context/sdk.tsx:26`
|
||||
- Polling in:
|
||||
- `packages/app/src/components/status-popover.tsx:59`
|
||||
- `packages/app/src/components/dialog-select-server.tsx:273`
|
||||
|
||||
Rules:
|
||||
|
||||
- Keep the effect single-purpose
|
||||
- Make dependencies explicit and narrow
|
||||
- Avoid writing back into the same reactive graph unless absolutely required
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 0: Classification Pass
|
||||
|
||||
Before changing code, tag each targeted effect as one of: derive, reset, event, lifecycle, or external bridge.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Every targeted effect in this spec is tagged with a replacement strategy before refactoring starts
|
||||
- Shared helpers to be introduced are identified up front to avoid repeating patterns
|
||||
|
||||
### Phase 1: Derived-State Cleanup
|
||||
|
||||
Tackle highest-value, lowest-risk derived-state cleanup first.
|
||||
|
||||
Priority items:
|
||||
|
||||
- Normalize tabs at write boundaries and remove `packages/app/src/pages/session.tsx:141`
|
||||
- Stop syncing `workspaceOrder` in `packages/app/src/pages/layout.tsx:557`
|
||||
- Make prompt slash filtering reactive so `packages/app/src/components/prompt-input.tsx:652` can be removed
|
||||
- Replace other obvious derived-state effects in terminal and session header
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- No behavior change in tab ordering, prompt filtering, terminal display, or header state
|
||||
- Targeted derived-state effects are deleted, not just moved
|
||||
|
||||
### Phase 2: Keyed Reset Cleanup
|
||||
|
||||
Replace reset-on-key effects with keyed ownership boundaries.
|
||||
|
||||
Priority items:
|
||||
|
||||
- Key session-scoped UI and state by `sessionKey`
|
||||
- Key file-scoped state by `scope()`
|
||||
- Remove manual clear-and-reseed effects in session and file context
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Switching session or file scope recreates the intended local state cleanly
|
||||
- No stale state leaks across session or scope changes
|
||||
- Target reset effects are deleted
|
||||
|
||||
### Phase 3: Event-Driven Work Extraction
|
||||
|
||||
Move event-driven work out of reactive effects.
|
||||
|
||||
Priority items:
|
||||
|
||||
- Replace `globalStore.reload` effect dispatching with direct calls
|
||||
- Split mixed-responsibility effect in `packages/app/src/pages/layout.tsx:1489`
|
||||
- Collapse duplicated imperative trigger triplets into single functions
|
||||
- Move file-tree and terminal-panel imperative work to explicit handlers
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- User-triggered behavior still fires exactly once per intended action
|
||||
- No effect remains whose only job is to notice a command-like state and trigger an imperative function
|
||||
|
||||
### Phase 4: Context Ownership Cleanup
|
||||
|
||||
Remove mirrored child-store hydration patterns.
|
||||
|
||||
Priority items:
|
||||
|
||||
- Remove child-store hydration mirrors in `packages/app/src/context/global-sync/child-store.ts:184`, `:190`, `:193`
|
||||
- Simplify mirror logic in `packages/app/src/context/global-sync.tsx:130`, `:138`
|
||||
- Revisit `packages/app/src/context/layout.tsx:424` if it still mirrors instead of deriving
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- There is one clear source of truth for each synced value
|
||||
- Child stores no longer need effect-based hydration to stay consistent
|
||||
- Initialization and updates both work without manual mirror effects
|
||||
|
||||
### Phase 5: Cleanup And Keeper Review
|
||||
|
||||
Clean up remaining targeted hotspots and narrow the effects that should stay.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Remaining `createEffect` calls in touched files are all true bridges or clearly justified lifecycle sync
|
||||
- Mixed-responsibility effects are split into smaller units where still needed
|
||||
|
||||
## Detailed Work Items By Area
|
||||
|
||||
### 1. Normalize Tab State
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/pages/session.tsx:141`
|
||||
|
||||
Work:
|
||||
|
||||
- Move tab normalization into the functions that create, load, or update tab state
|
||||
- Make readers consume already-normalized tab data
|
||||
- Remove the effect that rewrites derived tab state after the fact
|
||||
|
||||
Rationale:
|
||||
|
||||
- Tabs should become valid when written, not be repaired later
|
||||
- This removes a feedback loop and makes state easier to trust
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- The effect at `packages/app/src/pages/session.tsx:141` is removed
|
||||
- Newly created and restored tabs are normalized before they enter local state
|
||||
- Tab rendering still matches current behavior for valid and edge-case inputs
|
||||
|
||||
### 2. Key Session-Owned State
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/pages/session.tsx:325`
|
||||
- `packages/app/src/pages/session.tsx:336`
|
||||
- `packages/app/src/pages/session.tsx:477`
|
||||
- `packages/app/src/pages/session.tsx:869`
|
||||
- `packages/app/src/pages/session.tsx:963`
|
||||
- `packages/app/src/pages/session/message-timeline.tsx:149`
|
||||
|
||||
Work:
|
||||
|
||||
- Identify state that should reset when `sessionKey` changes
|
||||
- Move that state under a keyed subtree or keyed owner boundary
|
||||
- Remove effects that watch `sessionKey` just to clear local state, refs, or temporary UI flags
|
||||
|
||||
Rationale:
|
||||
|
||||
- Session identity already defines the lifetime of this UI state
|
||||
- Keyed ownership makes reset behavior automatic and easier to reason about
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- The targeted reset effects are removed
|
||||
- Changing sessions resets only the intended session-local state
|
||||
- Scroll and editor state that should persist are not accidentally reset
|
||||
|
||||
### 3. Derive Workspace Order
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/pages/layout.tsx:557`
|
||||
|
||||
Work:
|
||||
|
||||
- Stop writing `workspaceOrder` from live workspace data in an effect
|
||||
- Represent user overrides separately from live workspace data
|
||||
- Compute effective order from current data plus overrides with a memo or pure helper
|
||||
|
||||
Rationale:
|
||||
|
||||
- Persisted user intent and live source data should not mirror each other through an effect
|
||||
- A computed effective order avoids drift and racey resync behavior
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- The effect at `packages/app/src/pages/layout.tsx:557` is removed
|
||||
- Workspace order updates correctly when workspaces appear, disappear, or are reordered by the user
|
||||
- User overrides persist without requiring a sync-back effect
|
||||
|
||||
### 4. Remove Child-Store Mirrors
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/context/global-sync.tsx:130`
|
||||
- `packages/app/src/context/global-sync.tsx:138`
|
||||
- `packages/app/src/context/global-sync.tsx:148`
|
||||
- `packages/app/src/context/global-sync/child-store.ts:184`
|
||||
- `packages/app/src/context/global-sync/child-store.ts:190`
|
||||
- `packages/app/src/context/global-sync/child-store.ts:193`
|
||||
- `packages/app/src/context/layout.tsx:424`
|
||||
|
||||
Work:
|
||||
|
||||
- Trace the actual ownership of global and child store values
|
||||
- Replace hydration and mirror effects with explicit initialization and direct updates
|
||||
- Remove the `globalStore.reload` event-bus pattern and call the needed reload paths directly
|
||||
|
||||
Rationale:
|
||||
|
||||
- Mirrors make it hard to tell which state is authoritative
|
||||
- Event-bus style state toggles hide control flow and create accidental reruns
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Child store hydration no longer depends on effect-based copying
|
||||
- Reload work can be followed from the event source to the handler without a reactive relay
|
||||
- State remains correct on first load, child creation, and subsequent updates
|
||||
|
||||
### 5. Key File-Scoped State
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/context/file.tsx:100`
|
||||
|
||||
Work:
|
||||
|
||||
- Move file-scoped local state under a boundary keyed by `scope()`
|
||||
- Remove any effect that watches `scope()` only to reset file-local state
|
||||
|
||||
Rationale:
|
||||
|
||||
- File scope changes are identity changes
|
||||
- Keyed ownership gives a cleaner reset than manual clear logic
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- The effect at `packages/app/src/context/file.tsx:100` is removed
|
||||
- Switching scopes resets only scope-local state
|
||||
- No previous-scope data appears after a scope change
|
||||
|
||||
### 6. Split Layout Side Effects
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/pages/layout.tsx:1489`
|
||||
- Related event-driven effects near `packages/app/src/pages/layout.tsx:484`, `:652`, `:776`, `:1519`
|
||||
|
||||
Work:
|
||||
|
||||
- Break the mixed-responsibility effect at `:1489` into direct actions and smaller bridge effects only where required
|
||||
- Move user-triggered branches into the actual command or handler that causes them
|
||||
- Remove any branch that only exists because one effect is handling unrelated concerns
|
||||
|
||||
Rationale:
|
||||
|
||||
- Mixed effects hide cause and make reruns hard to predict
|
||||
- Smaller units reduce accidental coupling and make future cleanup safer
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- The effect at `packages/app/src/pages/layout.tsx:1489` no longer mixes unrelated responsibilities
|
||||
- Event-driven branches execute from direct handlers
|
||||
- Remaining effects in this area each have one clear external sync purpose
|
||||
|
||||
### 7. Remove Duplicate Triggers
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/pages/session/review-tab.tsx:122`
|
||||
- `packages/app/src/pages/session/review-tab.tsx:130`
|
||||
- `packages/app/src/pages/session/review-tab.tsx:138`
|
||||
- `packages/app/src/pages/session/file-tabs.tsx:367`
|
||||
- `packages/app/src/pages/session/file-tabs.tsx:378`
|
||||
- `packages/app/src/pages/session/file-tabs.tsx:389`
|
||||
- `packages/app/src/pages/session/use-session-hash-scroll.ts:144`
|
||||
- `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
|
||||
- `packages/app/src/pages/session/use-session-hash-scroll.ts:167`
|
||||
|
||||
Work:
|
||||
|
||||
- Extract one explicit imperative function per behavior
|
||||
- Call that function from each source event instead of replicating the same effect pattern multiple times
|
||||
- Preserve the scroll-sync effect that is truly syncing with the DOM, but remove duplicate trigger scaffolding around it
|
||||
|
||||
Rationale:
|
||||
|
||||
- Duplicate triggers make it easy to miss a case or fire twice
|
||||
- One named action is easier to test and reason about
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Repeated imperative effect triplets are collapsed into shared functions
|
||||
- Scroll behavior still works, including hash-based navigation
|
||||
- No duplicate firing is introduced
|
||||
|
||||
### 8. Make Prompt Filtering Reactive
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/components/prompt-input.tsx:652`
|
||||
- Keep `packages/app/src/components/prompt-input.tsx:690` as needed
|
||||
|
||||
Work:
|
||||
|
||||
- Convert slash filtering into a pure reactive derivation from the current input and candidate command list
|
||||
- Keep only the editor or DOM bridge effect if it is still needed for imperative syncing
|
||||
|
||||
Rationale:
|
||||
|
||||
- Filtering is classic derived state
|
||||
- It should not need an effect if it can be computed from current inputs
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- The effect at `packages/app/src/components/prompt-input.tsx:652` is removed
|
||||
- Filtered slash-command results update correctly as the input changes
|
||||
- The editor sync effect at `:690` still behaves correctly
|
||||
|
||||
### 9. Clean Up Smaller Derived-State Cases
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/components/terminal.tsx:261`
|
||||
- `packages/app/src/components/session/session-header.tsx:309`
|
||||
|
||||
Work:
|
||||
|
||||
- Replace effect-written local state with memos or inline derivation
|
||||
- Remove intermediate setters when the value can be computed directly
|
||||
|
||||
Rationale:
|
||||
|
||||
- These are low-risk wins that reinforce the same pattern
|
||||
- They also help keep follow-up cleanup consistent
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Targeted effects are removed
|
||||
- UI output remains unchanged under the same inputs
|
||||
|
||||
## Verification And Regression Checks
|
||||
|
||||
Run focused checks after each phase, not only at the end.
|
||||
|
||||
### Suggested Verification
|
||||
|
||||
- Switch between sessions rapidly and confirm local session UI resets only where intended
|
||||
- Open, close, and reorder tabs and confirm order and normalization remain stable
|
||||
- Change workspaces, reload workspace data, and verify effective ordering is correct
|
||||
- Change file scope and confirm stale file state does not bleed across scopes
|
||||
- Trigger layout actions that previously depended on effects and confirm they still fire once
|
||||
- Use slash commands in the prompt and verify filtering updates as you type
|
||||
- Test review tab, file tab, and hash-scroll flows for duplicate or missing triggers
|
||||
- Verify global sync initialization, reload, and child-store creation paths
|
||||
|
||||
### Regression Checks
|
||||
|
||||
- No accidental infinite reruns
|
||||
- No double-firing network or command actions
|
||||
- No lost cleanup for listeners, timers, or scroll handlers
|
||||
- No preserved stale state after identity changes
|
||||
- No removed effect that was actually bridging to DOM or an external API
|
||||
|
||||
If available, add or update tests around pure helpers introduced during this cleanup.
|
||||
|
||||
Favor tests for derived ordering, normalization, and action extraction, since those are easiest to lock down.
|
||||
|
||||
## Definition Of Done
|
||||
|
||||
This work is done when all of the following are true:
|
||||
|
||||
- The highest-leverage targets in this spec are implemented
|
||||
- Each removed effect has been replaced by a clearer pattern: memo, keyed boundary, direct action, or lifecycle hook
|
||||
- The "should remain" effects still exist only where they serve a real external sync purpose
|
||||
- Touched files have fewer mixed-responsibility effects and clearer ownership of state
|
||||
- Manual verification covers session switching, file scope changes, workspace ordering, prompt filtering, and reload flows
|
||||
- No behavior regressions are found in the targeted areas
|
||||
|
||||
A reduced raw `createEffect` count is helpful, but it is not the main success metric.
|
||||
|
||||
The main success metric is clearer ownership and fewer effect-driven state repairs.
|
||||
|
||||
## Risks And Rollout Notes
|
||||
|
||||
Main risks:
|
||||
|
||||
- Keyed remounts can reset too much if state boundaries are drawn too high
|
||||
- Store mirror removal can break initialization order if ownership is not mapped first
|
||||
- Moving event work out of effects can accidentally skip triggers that were previously implicit
|
||||
|
||||
Rollout notes:
|
||||
|
||||
- Land in small phases, with each phase keeping the app behaviorally stable
|
||||
- Prefer isolated PRs by phase or by file cluster, especially for context-store changes
|
||||
- Review each remaining effect in touched files and leave it only if it clearly bridges to something external
|
||||
@@ -43,7 +43,7 @@ test("file tree can expand folders and open a file", async ({ page, gotoSession
|
||||
await tab.click()
|
||||
await expect(tab).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
const code = page.locator('[data-component="code"]').first()
|
||||
await expect(code).toBeVisible()
|
||||
await expect(code).toContainText("export default function FileTree")
|
||||
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||
await expect(viewer).toBeVisible()
|
||||
await expect(viewer).toContainText("export default function FileTree")
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { modKey } from "../utils"
|
||||
|
||||
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
@@ -43,7 +44,60 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
|
||||
const code = page.locator('[data-component="code"]').first()
|
||||
await expect(code).toBeVisible()
|
||||
await expect(code.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
|
||||
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||
await expect(viewer).toBeVisible()
|
||||
await expect(viewer.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
|
||||
})
|
||||
|
||||
test("cmd+f opens text viewer search while prompt is focused", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
await page.locator(promptSelector).click()
|
||||
await page.keyboard.type("/open")
|
||||
|
||||
const command = page.locator('[data-slash-id="file.open"]').first()
|
||||
await expect(command).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
const dialog = page
|
||||
.getByRole("dialog")
|
||||
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||
.first()
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const input = dialog.getByRole("textbox").first()
|
||||
await input.fill("package.json")
|
||||
|
||||
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
|
||||
let index = -1
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
|
||||
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
|
||||
return index >= 0
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const item = items.nth(index)
|
||||
await expect(item).toBeVisible()
|
||||
await item.click()
|
||||
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
const tab = page.getByRole("tab", { name: "package.json" })
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
|
||||
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||
await expect(viewer).toBeVisible()
|
||||
|
||||
await page.locator(promptSelector).click()
|
||||
await page.keyboard.press(`${modKey}+f`)
|
||||
|
||||
const findInput = page.getByPlaceholder("Find")
|
||||
await expect(findInput).toBeVisible()
|
||||
await expect(findInput).toBeFocused()
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
sessionIDFromUrl,
|
||||
} from "../actions"
|
||||
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||
import { createSdk, dirSlug } from "../utils"
|
||||
import { createSdk, dirSlug, sessionPath } from "../utils"
|
||||
|
||||
function slugFromUrl(url: string) {
|
||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||
@@ -52,7 +52,6 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||
|
||||
const other = await createTestProject()
|
||||
const otherSlug = dirSlug(other)
|
||||
const stamp = Date.now()
|
||||
let rootDir: string | undefined
|
||||
let workspaceDir: string | undefined
|
||||
let sessionID: string | undefined
|
||||
@@ -81,6 +80,7 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||
|
||||
const workspaceSlug = slugFromUrl(page.url())
|
||||
workspaceDir = base64Decode(workspaceSlug)
|
||||
if (!workspaceDir) throw new Error(`Failed to decode workspace slug: ${workspaceSlug}`)
|
||||
await openSidebar(page)
|
||||
|
||||
const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first()
|
||||
@@ -93,15 +93,19 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
|
||||
|
||||
// Create a session by sending a prompt
|
||||
const prompt = page.locator(promptSelector)
|
||||
await expect(prompt).toBeVisible()
|
||||
await prompt.fill(`project switch remembers workspace ${stamp}`)
|
||||
await prompt.press("Enter")
|
||||
await prompt.fill("test")
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
// Wait for the URL to update with the new session ID
|
||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
|
||||
|
||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
||||
const created = sessionIDFromUrl(page.url())
|
||||
if (!created) throw new Error(`Failed to parse session id from URL: ${page.url()}`)
|
||||
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
|
||||
sessionID = created
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
|
||||
|
||||
await openSidebar(page)
|
||||
@@ -115,7 +119,8 @@ test("switching back to a project opens the latest workspace session", async ({
|
||||
await expect(rootButton).toBeVisible()
|
||||
await rootButton.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
|
||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { clearSessionDockSeed, seedSessionPermission, seedSessionQuestion, seedSessionTodos } from "../actions"
|
||||
import { clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
|
||||
import {
|
||||
permissionDockSelector,
|
||||
promptSelector,
|
||||
@@ -11,11 +11,23 @@ import {
|
||||
} from "../selectors"
|
||||
|
||||
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
|
||||
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
|
||||
|
||||
async function withDockSession<T>(sdk: Sdk, title: string, fn: (session: { id: string; title: string }) => Promise<T>) {
|
||||
const session = await sdk.session.create({ title }).then((r) => r.data)
|
||||
async function withDockSession<T>(
|
||||
sdk: Sdk,
|
||||
title: string,
|
||||
fn: (session: { id: string; title: string }) => Promise<T>,
|
||||
opts?: { permission?: PermissionRule[] },
|
||||
) {
|
||||
const session = await sdk.session
|
||||
.create(opts?.permission ? { title, permission: opts.permission } : { title })
|
||||
.then((r) => r.data)
|
||||
if (!session?.id) throw new Error("Session create did not return an id")
|
||||
return fn(session)
|
||||
try {
|
||||
return await fn(session)
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
test.setTimeout(120_000)
|
||||
@@ -28,8 +40,95 @@ async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>
|
||||
}
|
||||
}
|
||||
|
||||
// kilocode_change: skip
|
||||
test.skip("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
|
||||
async function clearPermissionDock(page: any, label: RegExp) {
|
||||
const dock = page.locator(permissionDockSelector)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const count = await dock.count()
|
||||
if (count === 0) return
|
||||
await dock.getByRole("button", { name: label }).click()
|
||||
await page.waitForTimeout(150)
|
||||
}
|
||||
}
|
||||
|
||||
async function setAutoAccept(page: any, enabled: boolean) {
|
||||
const button = page.locator('[data-action="prompt-permissions"]').first()
|
||||
await expect(button).toBeVisible()
|
||||
const pressed = (await button.getAttribute("aria-pressed")) === "true"
|
||||
if (pressed === enabled) return
|
||||
await button.click()
|
||||
await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
|
||||
}
|
||||
|
||||
async function withMockPermission<T>(
|
||||
page: any,
|
||||
request: {
|
||||
id: string
|
||||
sessionID: string
|
||||
permission: string
|
||||
patterns: string[]
|
||||
metadata?: Record<string, unknown>
|
||||
always?: string[]
|
||||
},
|
||||
opts: { child?: any } | undefined,
|
||||
fn: () => Promise<T>,
|
||||
) {
|
||||
let pending = [
|
||||
{
|
||||
...request,
|
||||
always: request.always ?? ["*"],
|
||||
metadata: request.metadata ?? {},
|
||||
},
|
||||
]
|
||||
|
||||
const list = async (route: any) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(pending),
|
||||
})
|
||||
}
|
||||
|
||||
const reply = async (route: any) => {
|
||||
const url = new URL(route.request().url())
|
||||
const id = url.pathname.split("/").pop()
|
||||
pending = pending.filter((item) => item.id !== id)
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(true),
|
||||
})
|
||||
}
|
||||
|
||||
await page.route("**/permission", list)
|
||||
await page.route("**/session/*/permissions/*", reply)
|
||||
|
||||
const sessionList = opts?.child
|
||||
? async (route: any) => {
|
||||
const res = await route.fetch()
|
||||
const json = await res.json()
|
||||
const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
|
||||
if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
|
||||
await route.fulfill({
|
||||
status: res.status(),
|
||||
headers: res.headers(),
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(json),
|
||||
})
|
||||
}
|
||||
: undefined
|
||||
|
||||
if (sessionList) await page.route("**/session?*", sessionList)
|
||||
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
await page.unroute("**/permission", list)
|
||||
await page.unroute("**/session/*/permissions/*", reply)
|
||||
if (sessionList) await page.unroute("**/session?*", sessionList)
|
||||
}
|
||||
}
|
||||
|
||||
test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock default", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
@@ -43,8 +142,19 @@ test.skip("default dock shows prompt input", async ({ page, sdk, gotoSession })
|
||||
})
|
||||
})
|
||||
|
||||
// kilocode_change: skip
|
||||
test.skip("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
|
||||
test("auto-accept toggle works before first submit", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const button = page.locator('[data-action="prompt-permissions"]').first()
|
||||
await expect(button).toBeVisible()
|
||||
await expect(button).toHaveAttribute("aria-pressed", "false")
|
||||
|
||||
await setAutoAccept(page, true)
|
||||
await setAutoAccept(page, false)
|
||||
})
|
||||
|
||||
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
|
||||
test.skip(process.platform === "win32", "Skipping on Windows due interaction issues") // kilocode_change
|
||||
await withDockSession(sdk, "e2e composer dock question", async (session) => {
|
||||
await withDockSeed(sdk, session.id, async () => {
|
||||
await gotoSession(session.id)
|
||||
@@ -55,6 +165,7 @@ test.skip("blocked question flow unblocks after submit", async ({ page, sdk, got
|
||||
{
|
||||
header: "Need input",
|
||||
question: "Pick one option",
|
||||
multiple: true, // kilocode_change
|
||||
options: [
|
||||
{ label: "Continue", description: "Continue now" },
|
||||
{ label: "Stop", description: "Stop here" },
|
||||
@@ -76,82 +187,187 @@ test.skip("blocked question flow unblocks after submit", async ({ page, sdk, got
|
||||
})
|
||||
})
|
||||
|
||||
// kilocode_change: skip
|
||||
test.skip("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
|
||||
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
|
||||
await withDockSeed(sdk, session.id, async () => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
await seedSessionPermission(sdk, {
|
||||
await gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_once",
|
||||
sessionID: session.id,
|
||||
permission: "bash",
|
||||
patterns: ["README.md"],
|
||||
description: "Need permission for command",
|
||||
})
|
||||
patterns: ["/tmp/opencode-e2e-perm-once"],
|
||||
metadata: { description: "Need permission for command" },
|
||||
},
|
||||
undefined,
|
||||
async () => {
|
||||
await page.goto(page.url())
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
|
||||
await page
|
||||
.locator(permissionDockSelector)
|
||||
.getByRole("button", { name: /allow once/i })
|
||||
.click()
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
})
|
||||
await clearPermissionDock(page, /allow once/i)
|
||||
await page.goto(page.url())
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// kilocode_change: skip
|
||||
test.skip("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
|
||||
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
|
||||
await withDockSeed(sdk, session.id, async () => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
await seedSessionPermission(sdk, {
|
||||
await gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_reject",
|
||||
sessionID: session.id,
|
||||
permission: "bash",
|
||||
patterns: ["REJECT.md"],
|
||||
})
|
||||
patterns: ["/tmp/opencode-e2e-perm-reject"],
|
||||
},
|
||||
undefined,
|
||||
async () => {
|
||||
await page.goto(page.url())
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
|
||||
await page.locator(permissionDockSelector).getByRole("button", { name: /deny/i }).click()
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
})
|
||||
await clearPermissionDock(page, /deny/i)
|
||||
await page.goto(page.url())
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// kilocode_change: skip
|
||||
test.skip("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
|
||||
test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
|
||||
await withDockSeed(sdk, session.id, async () => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
await seedSessionPermission(sdk, {
|
||||
await gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_always",
|
||||
sessionID: session.id,
|
||||
permission: "bash",
|
||||
patterns: ["README.md"],
|
||||
description: "Need permission for command",
|
||||
})
|
||||
patterns: ["/tmp/opencode-e2e-perm-always"],
|
||||
metadata: { description: "Need permission for command" },
|
||||
},
|
||||
undefined,
|
||||
async () => {
|
||||
await page.goto(page.url())
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
|
||||
await page
|
||||
.locator(permissionDockSelector)
|
||||
.getByRole("button", { name: /allow always/i })
|
||||
.click()
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
})
|
||||
await clearPermissionDock(page, /allow always/i)
|
||||
await page.goto(page.url())
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// kilocode_change: skip
|
||||
test.skip("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
|
||||
test("child session question request blocks parent dock and unblocks after submit", async ({
|
||||
page,
|
||||
sdk,
|
||||
gotoSession,
|
||||
}) => {
|
||||
test.skip(process.platform === "win32", "Skipping on Windows due interaction issues") // kilocode_change
|
||||
await withDockSession(sdk, "e2e composer dock child question parent", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
const child = await sdk.session
|
||||
.create({
|
||||
title: "e2e composer dock child question",
|
||||
parentID: session.id,
|
||||
})
|
||||
.then((r) => r.data)
|
||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
||||
|
||||
try {
|
||||
await withDockSeed(sdk, child.id, async () => {
|
||||
await seedSessionQuestion(sdk, {
|
||||
sessionID: child.id,
|
||||
questions: [
|
||||
{
|
||||
header: "Child input",
|
||||
question: "Pick one child option",
|
||||
multiple: true, // kilocode_change
|
||||
options: [
|
||||
{ label: "Continue", description: "Continue child" },
|
||||
{ label: "Stop", description: "Stop child" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const dock = page.locator(questionDockSelector)
|
||||
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
|
||||
await dock.locator('[data-slot="question-option"]').first().click()
|
||||
await dock.getByRole("button", { name: /submit/i }).click()
|
||||
|
||||
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
})
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("child session permission request blocks parent dock and supports allow once", async ({
|
||||
page,
|
||||
sdk,
|
||||
gotoSession,
|
||||
}) => {
|
||||
await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
|
||||
const child = await sdk.session
|
||||
.create({
|
||||
title: "e2e composer dock child permission",
|
||||
parentID: session.id,
|
||||
})
|
||||
.then((r) => r.data)
|
||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
||||
|
||||
try {
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_child",
|
||||
sessionID: child.id,
|
||||
permission: "bash",
|
||||
patterns: ["/tmp/opencode-e2e-perm-child"],
|
||||
metadata: { description: "Need child permission" },
|
||||
},
|
||||
{ child },
|
||||
async () => {
|
||||
await page.goto(page.url())
|
||||
const dock = page.locator(permissionDockSelector)
|
||||
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
|
||||
await clearPermissionDock(page, /allow once/i)
|
||||
await page.goto(page.url())
|
||||
|
||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
|
||||
await withDockSeed(sdk, session.id, async () => {
|
||||
await gotoSession(session.id)
|
||||
@@ -186,8 +402,8 @@ test.skip("todo dock transitions and collapse behavior", async ({ page, sdk, got
|
||||
})
|
||||
})
|
||||
|
||||
// kilocode_change: skip
|
||||
test.skip("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => {
|
||||
test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => {
|
||||
test.skip(process.platform === "win32", "Skipping on Windows due interaction issues") // kilocode_change
|
||||
await withDockSession(sdk, "e2e composer dock keyboard", async (session) => {
|
||||
await withDockSeed(sdk, session.id, async () => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createOpencodeClient } from "@kilocode/sdk/v2/client"
|
||||
import { createKiloClient } from "@kilocode/sdk/v2/client"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
|
||||
export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
|
||||
@@ -11,7 +11,7 @@ export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
||||
export const terminalToggleKey = "Control+Backquote"
|
||||
|
||||
export function createSdk(directory?: string) {
|
||||
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
|
||||
return createKiloClient({ baseUrl: serverUrl, directory, throwOnError: true })
|
||||
}
|
||||
|
||||
export async function getWorktree() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "7.0.32",
|
||||
"version": "7.0.47",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -72,7 +72,7 @@ const serverEnv = {
|
||||
KILO_E2E_PROJECT_DIR: repoDir,
|
||||
KILO_E2E_SESSION_TITLE: "E2E Session",
|
||||
KILO_E2E_MESSAGE: "Seeded for UI e2e",
|
||||
KILO_E2E_MODEL: "kilo/kilo/auto", // kilocode_change
|
||||
KILO_E2E_MODEL: "kilo/kilo-auto/frontier", // kilocode_change
|
||||
KILO_CLIENT: "app",
|
||||
} satisfies Record<string, string>
|
||||
|
||||
@@ -145,7 +145,8 @@ try {
|
||||
} else {
|
||||
Object.assign(process.env, serverEnv)
|
||||
process.env.AGENT = "1"
|
||||
process.env.OPENCODE = "1"
|
||||
process.env.KILO = "1"
|
||||
process.env.KILO_PID = String(process.pid)
|
||||
|
||||
const log = await import("../../opencode/src/util/log")
|
||||
const install = await import("../../opencode/src/installation")
|
||||
|
||||
+10
-11
@@ -1,16 +1,14 @@
|
||||
import "@/index.css"
|
||||
import { Code } from "@opencode-ai/ui/code"
|
||||
import { File } from "@opencode-ai/ui/file"
|
||||
import { I18nProvider } from "@opencode-ai/ui/context"
|
||||
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
|
||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
|
||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||
import { Diff } from "@opencode-ai/ui/diff"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { Navigate, Route, Router } from "@solidjs/router"
|
||||
import { ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
|
||||
import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
||||
import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
|
||||
import { CommandProvider } from "@/context/command"
|
||||
import { CommentsProvider } from "@/context/comments"
|
||||
import { FileProvider } from "@/context/file"
|
||||
@@ -30,6 +28,7 @@ import { TerminalProvider } from "@/context/terminal"
|
||||
import DirectoryLayout from "@/pages/directory-layout"
|
||||
import Layout from "@/pages/layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
|
||||
const Home = lazy(() => import("@/pages/home"))
|
||||
const Session = lazy(() => import("@/pages/session"))
|
||||
@@ -122,9 +121,7 @@ export function AppBaseProviders(props: ParentProps) {
|
||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||
<DialogProvider>
|
||||
<MarkedProviderWithNativeParser>
|
||||
<DiffComponentProvider component={Diff}>
|
||||
<CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider>
|
||||
</DiffComponentProvider>
|
||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||
</MarkedProviderWithNativeParser>
|
||||
</DialogProvider>
|
||||
</ErrorBoundary>
|
||||
@@ -148,13 +145,15 @@ export function AppInterface(props: {
|
||||
children?: JSX.Element
|
||||
defaultServer: ServerConnection.Key
|
||||
servers?: Array<ServerConnection.Any>
|
||||
router?: Component<BaseRouterProps>
|
||||
}) {
|
||||
return (
|
||||
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
||||
<ServerKey>
|
||||
<GlobalSDKProvider>
|
||||
<GlobalSyncProvider>
|
||||
<Router
|
||||
<Dynamic
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
||||
>
|
||||
<Route path="/" component={HomeRoute} />
|
||||
@@ -162,7 +161,7 @@ export function AppInterface(props: {
|
||||
<Route path="/" component={SessionIndexRoute} />
|
||||
<Route path="/session/:id?" component={SessionRoute} />
|
||||
</Route>
|
||||
</Router>
|
||||
</Dynamic>
|
||||
</GlobalSyncProvider>
|
||||
</GlobalSDKProvider>
|
||||
</ServerKey>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
||||
import { List, type ListRef } from "@opencode-ai/ui/list"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
@@ -449,7 +448,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
>
|
||||
<div class="flex flex-col gap-6 px-2.5 pb-3">
|
||||
<div class="px-2.5 flex gap-4 items-center">
|
||||
<ProviderIcon id={props.provider as IconName} class="size-5 shrink-0 icon-strong-base" />
|
||||
<ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
|
||||
<div class="text-16-medium text-text-strong">
|
||||
<Switch>
|
||||
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createSignal } from "solid-js"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
|
||||
export type Highlight = {
|
||||
@@ -16,6 +17,7 @@ export type Highlight = {
|
||||
|
||||
export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const [index, setIndex] = createSignal(0)
|
||||
|
||||
@@ -83,16 +85,16 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
||||
<div class="flex flex-col items-start gap-3">
|
||||
{isLast() ? (
|
||||
<Button variant="primary" size="large" onClick={handleClose}>
|
||||
Get started
|
||||
{language.t("dialog.releaseNotes.action.getStarted")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="secondary" size="large" onClick={handleNext}>
|
||||
Next
|
||||
{language.t("dialog.releaseNotes.action.next")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="ghost" size="small" onClick={handleDisable}>
|
||||
Don't show these in the future
|
||||
{language.t("dialog.releaseNotes.action.hideFuture")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -128,7 +130,7 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
||||
{feature()!.media!.type === "image" ? (
|
||||
<img
|
||||
src={feature()!.media!.src}
|
||||
alt={feature()!.media!.alt ?? feature()?.title ?? "Release preview"}
|
||||
alt={feature()!.media!.alt ?? feature()?.title ?? language.t("dialog.releaseNotes.media.alt")}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -8,6 +8,7 @@ import fuzzysort from "fuzzysort"
|
||||
import { createMemo, createResource, createSignal } from "solid-js"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
interface DialogSelectDirectoryProps {
|
||||
@@ -19,6 +20,7 @@ interface DialogSelectDirectoryProps {
|
||||
type Row = {
|
||||
absolute: string
|
||||
search: string
|
||||
group: "recent" | "folders"
|
||||
}
|
||||
|
||||
function cleanInput(value: string) {
|
||||
@@ -101,7 +103,7 @@ function displayPath(path: string, input: string, home: string) {
|
||||
return tildeOf(full, home) || full
|
||||
}
|
||||
|
||||
function toRow(absolute: string, home: string): Row {
|
||||
function toRow(absolute: string, home: string, group: Row["group"]): Row {
|
||||
const full = trimTrailing(absolute)
|
||||
const tilde = tildeOf(full, home)
|
||||
const withSlash = (value: string) => {
|
||||
@@ -113,7 +115,16 @@ function toRow(absolute: string, home: string): Row {
|
||||
const search = Array.from(
|
||||
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
|
||||
).join("\n")
|
||||
return { absolute: full, search }
|
||||
return { absolute: full, search, group }
|
||||
}
|
||||
|
||||
function uniqueRows(rows: Row[]) {
|
||||
const seen = new Set<string>()
|
||||
return rows.filter((row) => {
|
||||
if (seen.has(row.absolute)) return false
|
||||
seen.add(row.absolute)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function useDirectorySearch(args: {
|
||||
@@ -237,6 +248,7 @@ function useDirectorySearch(args: {
|
||||
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
const sync = useGlobalSync()
|
||||
const sdk = useGlobalSDK()
|
||||
const layout = useLayout()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
|
||||
@@ -266,9 +278,42 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
start,
|
||||
})
|
||||
|
||||
const recentProjects = createMemo(() => {
|
||||
const projects = layout.projects.list()
|
||||
const byProject = new Map<string, number>()
|
||||
|
||||
for (const project of projects) {
|
||||
let at = 0
|
||||
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
|
||||
for (const directory of dirs) {
|
||||
const sessions = sync.child(directory, { bootstrap: false })[0].session
|
||||
for (const session of sessions) {
|
||||
if (session.time.archived) continue
|
||||
const updated = session.time.updated ?? session.time.created
|
||||
if (updated > at) at = updated
|
||||
}
|
||||
}
|
||||
byProject.set(project.worktree, at)
|
||||
}
|
||||
|
||||
return projects
|
||||
.map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index }))
|
||||
.sort((a, b) => b.at - a.at || a.index - b.index)
|
||||
.slice(0, 5)
|
||||
.map(({ project }) => {
|
||||
const row = toRow(project.worktree, home(), "recent")
|
||||
const name = project.name || getFilename(project.worktree)
|
||||
return {
|
||||
...row,
|
||||
search: `${row.search}\n${name}`,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const items = async (value: string) => {
|
||||
const results = await directories(value)
|
||||
return results.map((absolute) => toRow(absolute, home()))
|
||||
const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders"))
|
||||
return uniqueRows([...recentProjects(), ...directoryRows])
|
||||
}
|
||||
|
||||
function resolve(absolute: string) {
|
||||
@@ -285,6 +330,14 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||
items={items}
|
||||
key={(x) => x.absolute}
|
||||
filterKeys={["search"]}
|
||||
groupBy={(item) => item.group}
|
||||
sortGroupsBy={(a, b) => {
|
||||
if (a.category === b.category) return 0
|
||||
return a.category === "recent" ? -1 : 1
|
||||
}}
|
||||
groupHeader={(group) =>
|
||||
group.category === "recent" ? language.t("home.recentProjects") : language.t("command.project.open")
|
||||
}
|
||||
ref={(r) => (list = r)}
|
||||
onFilter={(value) => setFilter(cleanInput(value))}
|
||||
onKeyEvent={(e, item) => {
|
||||
|
||||
@@ -449,7 +449,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
</div>
|
||||
<Show when={item.updated}>
|
||||
<span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
|
||||
{getRelativeTime(new Date(item.updated!).toISOString())}
|
||||
{getRelativeTime(new Date(item.updated!).toISOString(), language.t)}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
||||
import { List, type ListRef } from "@opencode-ai/ui/list"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Tag } from "@opencode-ai/ui/tag"
|
||||
@@ -95,11 +94,22 @@ export const DialogSelectModelUnpaid: Component = () => {
|
||||
>
|
||||
{(i) => (
|
||||
<div class="w-full flex items-center gap-x-3">
|
||||
<ProviderIcon data-slot="list-item-extra-icon" id={i.id as IconName} />
|
||||
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
|
||||
<span>{i.name}</span>
|
||||
<Show when={i.id === "opencode"}>
|
||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
|
||||
</Show>
|
||||
<Show when={i.id === "opencode"}>
|
||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
<Show when={i.id === "opencode-go"}>
|
||||
<>
|
||||
<div class="text-14-regular text-text-weak">
|
||||
{language.t("dialog.provider.opencodeGo.tagline")}
|
||||
</div>
|
||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</>
|
||||
</Show>
|
||||
<Show when={i.id === "anthropic"}>
|
||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div>
|
||||
</Show>
|
||||
|
||||
@@ -5,18 +5,12 @@ import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Tag } from "@opencode-ai/ui/tag"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { iconNames, type IconName } from "@opencode-ai/ui/icons/provider"
|
||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||
|
||||
const CUSTOM_ID = "_custom"
|
||||
|
||||
function icon(id: string): IconName {
|
||||
if (iconNames.includes(id as IconName)) return id as IconName
|
||||
return "synthetic"
|
||||
}
|
||||
|
||||
export const DialogSelectProvider: Component = () => {
|
||||
const dialog = useDialog()
|
||||
const providers = useProviders()
|
||||
@@ -31,6 +25,7 @@ export const DialogSelectProvider: Component = () => {
|
||||
if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
|
||||
if (id === "openai") return language.t("dialog.provider.openai.note")
|
||||
if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
|
||||
if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline")
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -70,7 +65,7 @@ export const DialogSelectProvider: Component = () => {
|
||||
>
|
||||
{(i) => (
|
||||
<div class="px-1.25 w-full flex items-center gap-x-3">
|
||||
<ProviderIcon data-slot="list-item-extra-icon" id={icon(i.id)} />
|
||||
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
|
||||
<span>{i.name}</span>
|
||||
{/* kilocode_change start - Provider tags and notes */}
|
||||
<Show when={i.id === "kilo"}>
|
||||
@@ -85,6 +80,9 @@ export const DialogSelectProvider: Component = () => {
|
||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
<Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
|
||||
<Show when={i.id === "opencode-go"}>
|
||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
@@ -9,32 +10,27 @@ import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { ServerRow } from "@/components/server/server-row"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
|
||||
interface AddRowProps {
|
||||
value: string
|
||||
placeholder: string
|
||||
adding: boolean
|
||||
error: string
|
||||
status: boolean | undefined
|
||||
onChange: (value: string) => void
|
||||
onKeyDown: (event: KeyboardEvent) => void
|
||||
onBlur: () => void
|
||||
}
|
||||
|
||||
interface EditRowProps {
|
||||
interface ServerFormProps {
|
||||
value: string
|
||||
name: string
|
||||
username: string
|
||||
password: string
|
||||
placeholder: string
|
||||
busy: boolean
|
||||
error: string
|
||||
status: boolean | undefined
|
||||
onChange: (value: string) => void
|
||||
onKeyDown: (event: KeyboardEvent) => void
|
||||
onBlur: () => void
|
||||
onNameChange: (value: string) => void
|
||||
onUsernameChange: (value: string) => void
|
||||
onPasswordChange: (value: string) => void
|
||||
onSubmit: () => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
||||
@@ -83,83 +79,86 @@ function useServerPreview(fetcher: typeof fetch) {
|
||||
return host.includes(".") || host.includes(":")
|
||||
}
|
||||
|
||||
const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
|
||||
const previewStatus = async (
|
||||
value: string,
|
||||
username: string,
|
||||
password: string,
|
||||
setStatus: (value: boolean | undefined) => void,
|
||||
) => {
|
||||
setStatus(undefined)
|
||||
if (!looksComplete(value)) return
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) return
|
||||
const result = await checkServerHealth({ url: normalized }, fetcher)
|
||||
const http: ServerConnection.HttpBase = { url: normalized }
|
||||
if (username) http.username = username
|
||||
if (password) http.password = password
|
||||
const result = await checkServerHealth(http, fetcher)
|
||||
setStatus(result.healthy)
|
||||
}
|
||||
|
||||
return { previewStatus }
|
||||
}
|
||||
|
||||
function AddRow(props: AddRowProps) {
|
||||
return (
|
||||
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
|
||||
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full absolute left-3 top-1/2 -translate-y-1/2 z-10 pointer-events-none": true,
|
||||
"bg-icon-success-base": props.status === true,
|
||||
"bg-icon-critical-base": props.status === false,
|
||||
"bg-border-weak-base": props.status === undefined,
|
||||
}}
|
||||
ref={(el) => {
|
||||
// Position relative to input-wrapper
|
||||
requestAnimationFrame(() => {
|
||||
const wrapper = el.parentElement?.querySelector('[data-slot="input-wrapper"]')
|
||||
if (wrapper instanceof HTMLElement) {
|
||||
wrapper.appendChild(el)
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
type="text"
|
||||
hideLabel
|
||||
placeholder={props.placeholder}
|
||||
value={props.value}
|
||||
autofocus
|
||||
validationState={props.error ? "invalid" : "valid"}
|
||||
error={props.error}
|
||||
disabled={props.adding}
|
||||
onChange={props.onChange}
|
||||
onKeyDown={props.onKeyDown}
|
||||
onBlur={props.onBlur}
|
||||
class="pl-7"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function ServerForm(props: ServerFormProps) {
|
||||
const language = useLanguage()
|
||||
const keyDown = (event: KeyboardEvent) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
props.onBack()
|
||||
return
|
||||
}
|
||||
if (event.key !== "Enter" || event.isComposing) return
|
||||
event.preventDefault()
|
||||
props.onSubmit()
|
||||
}
|
||||
|
||||
function EditRow(props: EditRowProps) {
|
||||
return (
|
||||
<div class="flex items-center gap-3 px-4 min-w-0 flex-1" onClick={(event) => event.stopPropagation()}>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": props.status === true,
|
||||
"bg-icon-critical-base": props.status === false,
|
||||
"bg-border-weak-base": props.status === undefined,
|
||||
}}
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="px-5">
|
||||
<div class="bg-surface-raised-base rounded-md p-5 flex flex-col gap-3">
|
||||
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
|
||||
<TextField
|
||||
type="text"
|
||||
label={language.t("dialog.server.add.url")}
|
||||
placeholder={props.placeholder}
|
||||
value={props.value}
|
||||
autofocus
|
||||
validationState={props.error ? "invalid" : "valid"}
|
||||
error={props.error}
|
||||
disabled={props.busy}
|
||||
onChange={props.onChange}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
type="text"
|
||||
hideLabel
|
||||
placeholder={props.placeholder}
|
||||
value={props.value}
|
||||
autofocus
|
||||
validationState={props.error ? "invalid" : "valid"}
|
||||
error={props.error}
|
||||
label={language.t("dialog.server.add.name")}
|
||||
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
||||
value={props.name}
|
||||
disabled={props.busy}
|
||||
onChange={props.onChange}
|
||||
onKeyDown={props.onKeyDown}
|
||||
onBlur={props.onBlur}
|
||||
onChange={props.onNameChange}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-2 min-w-0">
|
||||
<TextField
|
||||
type="text"
|
||||
label={language.t("dialog.server.add.username")}
|
||||
placeholder="username"
|
||||
value={props.username}
|
||||
disabled={props.busy}
|
||||
onChange={props.onUsernameChange}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
<TextField
|
||||
type="password"
|
||||
label={language.t("dialog.server.add.password")}
|
||||
placeholder="password"
|
||||
value={props.password}
|
||||
disabled={props.busy}
|
||||
onChange={props.onPasswordChange}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -174,11 +173,13 @@ export function DialogSelectServer() {
|
||||
const fetcher = platform.fetch ?? globalThis.fetch
|
||||
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
|
||||
const { previewStatus } = useServerPreview(fetcher)
|
||||
let listRoot: HTMLDivElement | undefined
|
||||
const [store, setStore] = createStore({
|
||||
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||
addServer: {
|
||||
url: "",
|
||||
name: "",
|
||||
username: "",
|
||||
password: "",
|
||||
adding: false,
|
||||
error: "",
|
||||
showForm: false,
|
||||
@@ -187,6 +188,9 @@ export function DialogSelectServer() {
|
||||
editServer: {
|
||||
id: undefined as string | undefined,
|
||||
value: "",
|
||||
name: "",
|
||||
username: "",
|
||||
password: "",
|
||||
error: "",
|
||||
busy: false,
|
||||
status: undefined as boolean | undefined,
|
||||
@@ -196,27 +200,32 @@ export function DialogSelectServer() {
|
||||
const resetAdd = () => {
|
||||
setStore("addServer", {
|
||||
url: "",
|
||||
name: "",
|
||||
username: "",
|
||||
password: "",
|
||||
adding: false,
|
||||
error: "",
|
||||
showForm: false,
|
||||
status: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const resetEdit = () => {
|
||||
setStore("editServer", {
|
||||
id: undefined,
|
||||
value: "",
|
||||
name: "",
|
||||
username: "",
|
||||
password: "",
|
||||
error: "",
|
||||
status: undefined,
|
||||
busy: false,
|
||||
})
|
||||
}
|
||||
|
||||
const replaceServer = (original: ServerConnection.Http, next: string) => {
|
||||
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
||||
const active = server.key
|
||||
const newConn = server.add(next)
|
||||
if (!newConn) return
|
||||
|
||||
const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active
|
||||
if (nextActive) server.setActive(nextActive)
|
||||
server.remove(ServerConnection.key(original))
|
||||
@@ -271,8 +280,8 @@ export function DialogSelectServer() {
|
||||
async function select(conn: ServerConnection.Any, persist?: boolean) {
|
||||
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
|
||||
dialog.close()
|
||||
if (persist) {
|
||||
server.add(conn.http.url)
|
||||
if (persist && conn.type === "http") {
|
||||
server.add(conn)
|
||||
navigate("/")
|
||||
return
|
||||
}
|
||||
@@ -283,21 +292,59 @@ export function DialogSelectServer() {
|
||||
const handleAddChange = (value: string) => {
|
||||
if (store.addServer.adding) return
|
||||
setStore("addServer", { url: value, error: "" })
|
||||
void previewStatus(value, (next) => setStore("addServer", { status: next }))
|
||||
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
|
||||
setStore("addServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const scrollListToBottom = () => {
|
||||
const scroll = listRoot?.querySelector<HTMLDivElement>('[data-slot="list-scroll"]')
|
||||
if (!scroll) return
|
||||
requestAnimationFrame(() => {
|
||||
scroll.scrollTop = scroll.scrollHeight
|
||||
})
|
||||
const handleAddNameChange = (value: string) => {
|
||||
if (store.addServer.adding) return
|
||||
setStore("addServer", { name: value, error: "" })
|
||||
}
|
||||
|
||||
const handleAddUsernameChange = (value: string) => {
|
||||
if (store.addServer.adding) return
|
||||
setStore("addServer", { username: value, error: "" })
|
||||
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
|
||||
setStore("addServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleAddPasswordChange = (value: string) => {
|
||||
if (store.addServer.adding) return
|
||||
setStore("addServer", { password: value, error: "" })
|
||||
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
|
||||
setStore("addServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleEditChange = (value: string) => {
|
||||
if (store.editServer.busy) return
|
||||
setStore("editServer", { value, error: "" })
|
||||
void previewStatus(value, (next) => setStore("editServer", { status: next }))
|
||||
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
|
||||
setStore("editServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleEditNameChange = (value: string) => {
|
||||
if (store.editServer.busy) return
|
||||
setStore("editServer", { name: value, error: "" })
|
||||
}
|
||||
|
||||
const handleEditUsernameChange = (value: string) => {
|
||||
if (store.editServer.busy) return
|
||||
setStore("editServer", { username: value, error: "" })
|
||||
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
|
||||
setStore("editServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleEditPasswordChange = (value: string) => {
|
||||
if (store.editServer.busy) return
|
||||
setStore("editServer", { password: value, error: "" })
|
||||
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
|
||||
setStore("editServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
async function handleAdd(value: string) {
|
||||
@@ -310,16 +357,22 @@ export function DialogSelectServer() {
|
||||
|
||||
setStore("addServer", { adding: true, error: "" })
|
||||
|
||||
const result = await checkServerHealth({ url: normalized }, fetcher)
|
||||
const conn: ServerConnection.Http = {
|
||||
type: "http",
|
||||
http: { url: normalized },
|
||||
}
|
||||
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
||||
if (store.addServer.username) conn.http.username = store.addServer.username
|
||||
if (store.addServer.password) conn.http.password = store.addServer.password
|
||||
const result = await checkServerHealth(conn.http, fetcher)
|
||||
setStore("addServer", { adding: false })
|
||||
|
||||
if (!result.healthy) {
|
||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
|
||||
resetAdd()
|
||||
await select({ type: "http", http: { url: normalized } }, true)
|
||||
await select(conn, true)
|
||||
}
|
||||
|
||||
async function handleEdit(original: ServerConnection.Any, value: string) {
|
||||
@@ -330,53 +383,115 @@ export function DialogSelectServer() {
|
||||
return
|
||||
}
|
||||
|
||||
if (normalized === original.http.url) {
|
||||
const name = store.editServer.name.trim() || undefined
|
||||
const username = store.editServer.username || undefined
|
||||
const password = store.editServer.password || undefined
|
||||
const existingName = original.displayName
|
||||
if (
|
||||
normalized === original.http.url &&
|
||||
name === existingName &&
|
||||
username === original.http.username &&
|
||||
password === original.http.password
|
||||
) {
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
|
||||
setStore("editServer", { busy: true, error: "" })
|
||||
|
||||
const result = await checkServerHealth({ url: normalized }, fetcher)
|
||||
const conn: ServerConnection.Http = {
|
||||
type: "http",
|
||||
displayName: name,
|
||||
http: { url: normalized, username, password },
|
||||
}
|
||||
const result = await checkServerHealth(conn.http, fetcher)
|
||||
setStore("editServer", { busy: false })
|
||||
|
||||
if (!result.healthy) {
|
||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
|
||||
replaceServer(original, normalized)
|
||||
if (normalized === original.http.url) {
|
||||
server.add(conn)
|
||||
} else {
|
||||
replaceServer(original, conn)
|
||||
}
|
||||
|
||||
resetEdit()
|
||||
}
|
||||
|
||||
const handleAddKey = (event: KeyboardEvent) => {
|
||||
event.stopPropagation()
|
||||
if (event.key !== "Enter" || event.isComposing) return
|
||||
event.preventDefault()
|
||||
handleAdd(store.addServer.url)
|
||||
const mode = createMemo<"list" | "add" | "edit">(() => {
|
||||
if (store.editServer.id) return "edit"
|
||||
if (store.addServer.showForm) return "add"
|
||||
return "list"
|
||||
})
|
||||
|
||||
const editing = createMemo(() => {
|
||||
if (!store.editServer.id) return
|
||||
return items().find((x) => x.type === "http" && x.http.url === store.editServer.id)
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
resetAdd()
|
||||
resetEdit()
|
||||
}
|
||||
|
||||
const blurAdd = () => {
|
||||
if (!store.addServer.url.trim()) {
|
||||
resetAdd()
|
||||
return
|
||||
}
|
||||
handleAdd(store.addServer.url)
|
||||
const startAdd = () => {
|
||||
resetEdit()
|
||||
setStore("addServer", {
|
||||
showForm: true,
|
||||
url: "",
|
||||
name: "",
|
||||
username: "",
|
||||
password: "",
|
||||
error: "",
|
||||
status: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const handleEditKey = (event: KeyboardEvent, original: ServerConnection.Any) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
resetEdit()
|
||||
const startEdit = (conn: ServerConnection.Http) => {
|
||||
resetAdd()
|
||||
setStore("editServer", {
|
||||
id: conn.http.url,
|
||||
value: conn.http.url,
|
||||
name: conn.displayName ?? "",
|
||||
username: conn.http.username ?? "",
|
||||
password: conn.http.password ?? "",
|
||||
error: "",
|
||||
status: store.status[ServerConnection.key(conn)]?.healthy,
|
||||
busy: false,
|
||||
})
|
||||
}
|
||||
|
||||
const submitForm = () => {
|
||||
if (mode() === "add") {
|
||||
void handleAdd(store.addServer.url)
|
||||
return
|
||||
}
|
||||
if (event.key !== "Enter" || event.isComposing) return
|
||||
event.preventDefault()
|
||||
handleEdit(original, store.editServer.value)
|
||||
const original = editing()
|
||||
if (!original) return
|
||||
void handleEdit(original, store.editServer.value)
|
||||
}
|
||||
|
||||
const isFormMode = createMemo(() => mode() !== "list")
|
||||
const isAddMode = createMemo(() => mode() === "add")
|
||||
const formBusy = createMemo(() => (isAddMode() ? store.addServer.adding : store.editServer.busy))
|
||||
|
||||
const formTitle = createMemo(() => {
|
||||
if (!isFormMode()) return language.t("dialog.server.title")
|
||||
return (
|
||||
<div class="flex items-center gap-2 -ml-2">
|
||||
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
|
||||
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!store.editServer.id) return
|
||||
if (editing()) return
|
||||
resetEdit()
|
||||
})
|
||||
|
||||
async function handleRemove(url: ServerConnection.Key) {
|
||||
server.remove(url)
|
||||
if ((await platform.getDefaultServerUrl?.()) === url) {
|
||||
@@ -385,9 +500,29 @@ export function DialogSelectServer() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={language.t("dialog.server.title")}>
|
||||
<Dialog title={formTitle()}>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div ref={(el) => (listRoot = el)}>
|
||||
<Show
|
||||
when={!isFormMode()}
|
||||
fallback={
|
||||
<ServerForm
|
||||
value={isAddMode() ? store.addServer.url : store.editServer.value}
|
||||
name={isAddMode() ? store.addServer.name : store.editServer.name}
|
||||
username={isAddMode() ? store.addServer.username : store.editServer.username}
|
||||
password={isAddMode() ? store.addServer.password : store.editServer.password}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
busy={formBusy()}
|
||||
error={isAddMode() ? store.addServer.error : store.editServer.error}
|
||||
status={isAddMode() ? store.addServer.status : store.editServer.status}
|
||||
onChange={isAddMode() ? handleAddChange : handleEditChange}
|
||||
onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
|
||||
onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
|
||||
onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
|
||||
onSubmit={submitForm}
|
||||
onBack={resetForm}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<List
|
||||
search={{
|
||||
placeholder: language.t("dialog.server.search.placeholder"),
|
||||
@@ -400,143 +535,110 @@ export function DialogSelectServer() {
|
||||
onSelect={(x) => {
|
||||
if (x) select(x)
|
||||
}}
|
||||
onFilter={(value) => {
|
||||
if (value && store.addServer.showForm && !store.addServer.adding) {
|
||||
resetAdd()
|
||||
}
|
||||
}}
|
||||
divider={true}
|
||||
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
|
||||
add={
|
||||
store.addServer.showForm
|
||||
? {
|
||||
render: () => (
|
||||
<AddRow
|
||||
value={store.addServer.url}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
adding={store.addServer.adding}
|
||||
error={store.addServer.error}
|
||||
status={store.addServer.status}
|
||||
onChange={handleAddChange}
|
||||
onKeyDown={handleAddKey}
|
||||
onBlur={blurAdd}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
|
||||
>
|
||||
{(i) => {
|
||||
const key = ServerConnection.key(i)
|
||||
return (
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
|
||||
<Show
|
||||
when={store.editServer.id !== i.http.url}
|
||||
fallback={
|
||||
<EditRow
|
||||
value={store.editServer.value}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
busy={store.editServer.busy}
|
||||
error={store.editServer.error}
|
||||
status={store.editServer.status}
|
||||
onChange={handleEditChange}
|
||||
onKeyDown={(event) => handleEditKey(event, i)}
|
||||
onBlur={() => handleEdit(i, store.editServer.value)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ServerRow
|
||||
conn={i}
|
||||
status={store.status[key]}
|
||||
dimmed={store.status[key]?.healthy === false}
|
||||
class="flex items-center gap-3 px-4 min-w-0 flex-1"
|
||||
badge={
|
||||
<Show when={defaultUrl() === i.http.url}>
|
||||
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||
{language.t("dialog.server.status.default")}
|
||||
</span>
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={store.editServer.id !== i.http.url}>
|
||||
<div class="flex items-center justify-center gap-5 pl-4">
|
||||
<Show when={ServerConnection.key(current()) === key}>
|
||||
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
|
||||
<div class="flex flex-col h-full items-start w-5">
|
||||
<ServerHealthIndicator health={store.status[key]} />
|
||||
</div>
|
||||
<ServerRow
|
||||
conn={i}
|
||||
dimmed={store.status[key]?.healthy === false}
|
||||
status={store.status[key]}
|
||||
class="flex items-center gap-3 min-w-0 flex-1"
|
||||
badge={
|
||||
<Show when={defaultUrl() === i.http.url}>
|
||||
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||
{language.t("dialog.server.status.default")}
|
||||
</span>
|
||||
</Show>
|
||||
}
|
||||
showCredentials
|
||||
/>
|
||||
<div class="flex items-center justify-center gap-4 pl-4">
|
||||
<Show when={ServerConnection.key(current()) === key}>
|
||||
<Icon name="check" class="h-6" />
|
||||
</Show>
|
||||
|
||||
<Show when={i.type === "http"}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
|
||||
onClick={(e: MouseEvent) => e.stopPropagation()}
|
||||
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="mt-1">
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setStore("editServer", {
|
||||
id: i.http.url,
|
||||
value: i.http.url,
|
||||
error: "",
|
||||
status: store.status[ServerConnection.key(i)]?.healthy,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={canDefault() && defaultUrl() !== i.http.url}>
|
||||
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={canDefault() && defaultUrl() === i.http.url}>
|
||||
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => handleRemove(ServerConnection.key(i))}
|
||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||
>
|
||||
<Show when={i.type === "http"}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
|
||||
onClick={(e: MouseEvent) => e.stopPropagation()}
|
||||
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="mt-1">
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
if (i.type !== "http") return
|
||||
startEdit(i)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={canDefault() && defaultUrl() !== i.http.url}>
|
||||
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.delete")}
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={canDefault() && defaultUrl() === i.http.url}>
|
||||
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => handleRemove(ServerConnection.key(i))}
|
||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</List>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="px-5 pb-5">
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="plus-small"
|
||||
size="large"
|
||||
onClick={() => {
|
||||
setStore("addServer", { showForm: true, url: "", error: "" })
|
||||
scrollListToBottom()
|
||||
}}
|
||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
||||
<Show
|
||||
when={isFormMode()}
|
||||
fallback={
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="plus-small"
|
||||
size="large"
|
||||
onClick={startAdd}
|
||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
||||
>
|
||||
{language.t("dialog.server.add.button")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{store.addServer.adding ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")}
|
||||
</Button>
|
||||
<Button variant="primary" size="large" onClick={submitForm} disabled={formBusy()} class="px-3 py-1.5">
|
||||
{formBusy()
|
||||
? language.t("dialog.server.add.checking")
|
||||
: isAddMode()
|
||||
? language.t("dialog.server.add.button")
|
||||
: language.t("common.save")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
@@ -325,12 +325,6 @@ export default function FileTree(props: {
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const dir = file.tree.state(props.path)
|
||||
if (!shouldListExpanded({ level, dir })) return
|
||||
void file.tree.list(props.path)
|
||||
})
|
||||
|
||||
const nodes = createMemo(() => {
|
||||
const nodes = file.tree.children(props.path)
|
||||
const current = filter()
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createFocusSignal } from "@solid-primitives/active-element"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useFile } from "@/context/file"
|
||||
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
||||
import {
|
||||
ContentPart,
|
||||
DEFAULT_PROMPT,
|
||||
@@ -23,7 +24,6 @@ import { Button } from "@opencode-ai/ui/button"
|
||||
import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
@@ -43,6 +43,9 @@ import {
|
||||
canNavigateHistoryAtCursor,
|
||||
navigatePromptHistory,
|
||||
prependHistoryEntry,
|
||||
type PromptHistoryComment,
|
||||
type PromptHistoryEntry,
|
||||
type PromptHistoryStoredEntry,
|
||||
promptLength,
|
||||
} from "./prompt-input/history"
|
||||
import { createPromptSubmit } from "./prompt-input/submit"
|
||||
@@ -170,12 +173,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const focus = { file: item.path, id: item.commentID }
|
||||
comments.setActive(focus)
|
||||
|
||||
const queueCommentFocus = (attempts = 6) => {
|
||||
const schedule = (left: number) => {
|
||||
requestAnimationFrame(() => {
|
||||
comments.setFocus({ ...focus })
|
||||
if (left <= 0) return
|
||||
requestAnimationFrame(() => {
|
||||
const current = comments.focus()
|
||||
if (!current) return
|
||||
if (current.file !== focus.file || current.id !== focus.id) return
|
||||
schedule(left - 1)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
schedule(attempts)
|
||||
}
|
||||
|
||||
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
|
||||
if (wantsReview) {
|
||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||
layout.fileTree.setTab("changes")
|
||||
tabs().setActive("review")
|
||||
requestAnimationFrame(() => comments.setFocus(focus))
|
||||
queueCommentFocus()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -183,8 +203,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
layout.fileTree.setTab("all")
|
||||
const tab = files.tab(item.path)
|
||||
tabs().open(tab)
|
||||
files.load(item.path)
|
||||
requestAnimationFrame(() => comments.setFocus(focus))
|
||||
tabs().setActive(tab)
|
||||
Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus())
|
||||
}
|
||||
|
||||
const recent = createMemo(() => {
|
||||
@@ -219,21 +239,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const [store, setStore] = createStore<{
|
||||
popover: "at" | "slash" | null
|
||||
historyIndex: number
|
||||
savedPrompt: Prompt | null
|
||||
savedPrompt: PromptHistoryEntry | null
|
||||
placeholder: number
|
||||
draggingType: "image" | "@mention" | null
|
||||
mode: "normal" | "shell"
|
||||
applyingHistory: boolean
|
||||
pendingAutoAccept: boolean
|
||||
}>({
|
||||
popover: null,
|
||||
historyIndex: -1,
|
||||
savedPrompt: null,
|
||||
savedPrompt: null as PromptHistoryEntry | null,
|
||||
placeholder: Math.floor(Math.random() * EXAMPLES.length),
|
||||
draggingType: null,
|
||||
mode: "normal",
|
||||
applyingHistory: false,
|
||||
pendingAutoAccept: false,
|
||||
})
|
||||
|
||||
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
|
||||
|
||||
const commentCount = createMemo(() => {
|
||||
if (store.mode === "shell") return 0
|
||||
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
|
||||
@@ -256,7 +280,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const [history, setHistory] = persisted(
|
||||
Persist.global("prompt-history", ["prompt-history.v1"]),
|
||||
createStore<{
|
||||
entries: Prompt[]
|
||||
entries: PromptHistoryStoredEntry[]
|
||||
}>({
|
||||
entries: [],
|
||||
}),
|
||||
@@ -264,7 +288,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const [shellHistory, setShellHistory] = persisted(
|
||||
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
|
||||
createStore<{
|
||||
entries: Prompt[]
|
||||
entries: PromptHistoryStoredEntry[]
|
||||
}>({
|
||||
entries: [],
|
||||
}),
|
||||
@@ -282,9 +306,72 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}),
|
||||
)
|
||||
|
||||
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
|
||||
createEffect(
|
||||
on(sessionKey, () => {
|
||||
setStore("pendingAutoAccept", false)
|
||||
}),
|
||||
)
|
||||
|
||||
const historyComments = () => {
|
||||
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
|
||||
return prompt.context.items().flatMap((item) => {
|
||||
if (item.type !== "file") return []
|
||||
const comment = item.comment?.trim()
|
||||
if (!comment) return []
|
||||
|
||||
const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined
|
||||
const nextSelection =
|
||||
selection ??
|
||||
(item.selection
|
||||
? ({
|
||||
start: item.selection.startLine,
|
||||
end: item.selection.endLine,
|
||||
} satisfies SelectedLineRange)
|
||||
: undefined)
|
||||
if (!nextSelection) return []
|
||||
|
||||
return [
|
||||
{
|
||||
id: item.commentID ?? item.key,
|
||||
path: item.path,
|
||||
selection: { ...nextSelection },
|
||||
comment,
|
||||
time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(),
|
||||
origin: item.commentOrigin,
|
||||
preview: item.preview,
|
||||
} satisfies PromptHistoryComment,
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const applyHistoryComments = (items: PromptHistoryComment[]) => {
|
||||
comments.replace(
|
||||
items.map((item) => ({
|
||||
id: item.id,
|
||||
file: item.path,
|
||||
selection: { ...item.selection },
|
||||
comment: item.comment,
|
||||
time: item.time,
|
||||
})),
|
||||
)
|
||||
prompt.context.replaceComments(
|
||||
items.map((item) => ({
|
||||
type: "file" as const,
|
||||
path: item.path,
|
||||
selection: selectionFromLines(item.selection),
|
||||
comment: item.comment,
|
||||
commentID: item.id,
|
||||
commentOrigin: item.origin,
|
||||
preview: item.preview,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
const applyHistoryPrompt = (entry: PromptHistoryEntry, position: "start" | "end") => {
|
||||
const p = entry.prompt
|
||||
const length = position === "start" ? 0 : promptLength(p)
|
||||
setStore("applyingHistory", true)
|
||||
applyHistoryComments(entry.comments)
|
||||
prompt.set(p, length)
|
||||
requestAnimationFrame(() => {
|
||||
editorRef.focus()
|
||||
@@ -515,7 +602,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
setActive: setSlashActive,
|
||||
onInput: slashOnInput,
|
||||
onKeyDown: slashOnKeyDown,
|
||||
refetch: slashRefetch,
|
||||
} = useFilteredList<SlashCommand>({
|
||||
items: slashCommands,
|
||||
key: (x) => x?.id,
|
||||
@@ -572,14 +658,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => sync.data.command,
|
||||
() => slashRefetch(),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
// Auto-scroll active command into view when navigating with keyboard
|
||||
createEffect(() => {
|
||||
const activeId = slashActive()
|
||||
@@ -846,7 +924,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
||||
const currentHistory = mode === "shell" ? shellHistory : history
|
||||
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
|
||||
const next = prependHistoryEntry(currentHistory.entries, prompt)
|
||||
const next = prependHistoryEntry(currentHistory.entries, prompt, mode === "shell" ? [] : historyComments())
|
||||
if (next === currentHistory.entries) return
|
||||
setCurrentHistory("entries", next)
|
||||
}
|
||||
@@ -857,12 +935,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
entries: store.mode === "shell" ? shellHistory.entries : history.entries,
|
||||
historyIndex: store.historyIndex,
|
||||
currentPrompt: prompt.current(),
|
||||
currentComments: historyComments(),
|
||||
savedPrompt: store.savedPrompt,
|
||||
})
|
||||
if (!result.handled) return false
|
||||
setStore("historyIndex", result.historyIndex)
|
||||
setStore("savedPrompt", result.savedPrompt)
|
||||
applyHistoryPrompt(result.prompt, result.cursor)
|
||||
applyHistoryPrompt(result.entry, result.cursor)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -879,10 +958,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
readClipboardImage: platform.readClipboardImage,
|
||||
})
|
||||
|
||||
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
||||
const accepting = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return store.pendingAutoAccept
|
||||
return permission.isAutoAccepting(id, sdk.directory)
|
||||
})
|
||||
|
||||
const { abort, handleSubmit } = createPromptSubmit({
|
||||
info,
|
||||
imageAttachments,
|
||||
commentCount,
|
||||
autoAccept: () => accepting(),
|
||||
mode: () => store.mode,
|
||||
working,
|
||||
editor: () => editorRef,
|
||||
@@ -1047,8 +1134,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
||||
|
||||
return (
|
||||
<div class="relative size-full _max-h-[320px] flex flex-col gap-0">
|
||||
<PromptPopover
|
||||
@@ -1172,10 +1257,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
<div
|
||||
aria-hidden={store.mode !== "normal"}
|
||||
class="flex items-center gap-1 transition-all duration-200 ease-out"
|
||||
classList={{
|
||||
"opacity-100 translate-y-0 scale-100 pointer-events-auto": store.mode === "normal",
|
||||
"opacity-0 translate-y-2 scale-95 pointer-events-none": store.mode !== "normal",
|
||||
class="flex items-center gap-1"
|
||||
style={{
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
<TooltipKeybind
|
||||
@@ -1188,6 +1272,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="size-8 p-0"
|
||||
style={{
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
}}
|
||||
onClick={pick}
|
||||
disabled={store.mode !== "normal"}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
@@ -1225,47 +1314,58 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
icon={working() ? "stop" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="size-8"
|
||||
style={{
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
}}
|
||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={store.mode === "normal" && permission.permissionsEnabled() && params.id}>
|
||||
<div class="pointer-events-none absolute bottom-2 left-2">
|
||||
<div class="pointer-events-auto">
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={8}
|
||||
title={language.t("command.permissions.autoaccept.enable")}
|
||||
keybind={command.keybind("permissions.autoaccept")}
|
||||
>
|
||||
<Button
|
||||
data-action="prompt-permissions"
|
||||
variant="ghost"
|
||||
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
|
||||
classList={{
|
||||
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
|
||||
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
|
||||
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
|
||||
}}
|
||||
aria-label={
|
||||
permission.isAutoAccepting(params.id!, sdk.directory)
|
||||
? language.t("command.permissions.autoaccept.disable")
|
||||
: language.t("command.permissions.autoaccept.enable")
|
||||
<div class="pointer-events-none absolute bottom-2 left-2">
|
||||
<div class="pointer-events-auto">
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={8}
|
||||
title={language.t(
|
||||
accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable",
|
||||
)}
|
||||
keybind={command.keybind("permissions.autoaccept")}
|
||||
>
|
||||
<Button
|
||||
data-action="prompt-permissions"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (!params.id) {
|
||||
setStore("pendingAutoAccept", (value) => !value)
|
||||
return
|
||||
}
|
||||
aria-pressed={permission.isAutoAccepting(params.id!, sdk.directory)}
|
||||
>
|
||||
<Icon
|
||||
name="chevron-double-right"
|
||||
size="small"
|
||||
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
permission.toggleAutoAccept(params.id, sdk.directory)
|
||||
}}
|
||||
classList={{
|
||||
"size-6 flex items-center justify-center": true,
|
||||
"text-text-base": !accepting(),
|
||||
"hover:bg-surface-success-base": accepting(),
|
||||
}}
|
||||
aria-label={
|
||||
accepting()
|
||||
? language.t("command.permissions.autoaccept.disable")
|
||||
: language.t("command.permissions.autoaccept.enable")
|
||||
}
|
||||
aria-pressed={accepting()}
|
||||
>
|
||||
<Icon
|
||||
name="chevron-double-right"
|
||||
size="small"
|
||||
classList={{ "text-icon-success-base": accepting() }}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</DockShellForm>
|
||||
<Show when={store.mode === "normal" || store.mode === "shell"}>
|
||||
@@ -1274,14 +1374,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0"
|
||||
data-slot="prompt-input-toolbar" // kilocode_change
|
||||
>
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<Show when={store.mode === "shell"}>
|
||||
<div class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0" style={{ padding: "0 4px 0 8px" }}>
|
||||
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
||||
<div class="size-4 shrink-0" />
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
|
||||
<div
|
||||
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
|
||||
style={{
|
||||
padding: "0 4px 0 8px",
|
||||
opacity: 1 - buttonsSpring(),
|
||||
transform: `scale(${0.95 + (1 - buttonsSpring()) * 0.05})`,
|
||||
filter: `blur(${buttonsSpring() * 2}px)`,
|
||||
"pointer-events": buttonsSpring() < 0.5 ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
||||
<div class="size-4 shrink-0" />
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
gutter={4}
|
||||
@@ -1295,7 +1402,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onSelect={local.agent.set}
|
||||
class="capitalize max-w-[160px]"
|
||||
valueClass="truncate text-13-regular"
|
||||
triggerStyle={{ height: "28px" }}
|
||||
triggerStyle={{
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
@@ -1313,12 +1426,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[320px] text-13-regular group"
|
||||
style={{ height: "28px" }}
|
||||
style={{
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={local.model.current()!.provider.id as IconName}
|
||||
id={local.model.current()!.provider.id}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
@@ -1342,13 +1461,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: { height: "28px" },
|
||||
style: {
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
},
|
||||
class: "min-w-0 max-w-[320px] text-13-regular group",
|
||||
}}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
id={local.model.current()!.provider.id as IconName}
|
||||
id={local.model.current()!.provider.id}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
@@ -1374,11 +1499,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
||||
class="capitalize max-w-[160px]"
|
||||
valueClass="truncate text-13-regular"
|
||||
triggerStyle={{ height: "28px" }}
|
||||
triggerStyle={{
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<RadioGroup
|
||||
|
||||
@@ -35,6 +35,15 @@ describe("buildRequestParts", () => {
|
||||
result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
|
||||
).toBe(true)
|
||||
expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
|
||||
expect(
|
||||
result.requestParts.some(
|
||||
(part) =>
|
||||
part.type === "text" &&
|
||||
part.synthetic &&
|
||||
part.metadata?.opencodeComment &&
|
||||
(part.metadata.opencodeComment as { comment?: string }).comment === "check this",
|
||||
),
|
||||
).toBe(true)
|
||||
|
||||
expect(result.optimisticParts).toHaveLength(result.requestParts.length)
|
||||
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { FileSelection } from "@/context/file"
|
||||
import { encodeFilePath } from "@/context/file/path"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
|
||||
|
||||
type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
|
||||
|
||||
@@ -41,18 +42,6 @@ const fileQuery = (selection: FileSelection | undefined) =>
|
||||
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
||||
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
||||
|
||||
const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
|
||||
const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
|
||||
const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
|
||||
const range =
|
||||
start === undefined || end === undefined
|
||||
? "this file"
|
||||
: start === end
|
||||
? `line ${start}`
|
||||
: `lines ${start} through ${end}`
|
||||
return `The user made the following comment regarding ${range} of ${path}: ${comment}`
|
||||
}
|
||||
|
||||
const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => {
|
||||
if (part.type === "text") {
|
||||
return {
|
||||
@@ -153,8 +142,15 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "text",
|
||||
text: commentNote(item.path, item.selection, comment),
|
||||
text: formatCommentNote({ path: item.path, selection: item.selection, comment }),
|
||||
synthetic: true,
|
||||
metadata: createCommentMetadata({
|
||||
path: item.path,
|
||||
selection: item.selection,
|
||||
comment,
|
||||
preview: item.preview,
|
||||
origin: item.commentOrigin,
|
||||
}),
|
||||
} satisfies PromptRequestPart,
|
||||
filePart,
|
||||
]
|
||||
|
||||
@@ -3,25 +3,42 @@ import type { Prompt } from "@/context/prompt"
|
||||
import {
|
||||
canNavigateHistoryAtCursor,
|
||||
clonePromptParts,
|
||||
normalizePromptHistoryEntry,
|
||||
navigatePromptHistory,
|
||||
prependHistoryEntry,
|
||||
promptLength,
|
||||
type PromptHistoryComment,
|
||||
} from "./history"
|
||||
|
||||
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }]
|
||||
const comment = (id: string, value = "note"): PromptHistoryComment => ({
|
||||
id,
|
||||
path: "src/a.ts",
|
||||
selection: { start: 2, end: 4 },
|
||||
comment: value,
|
||||
time: 1,
|
||||
origin: "review",
|
||||
preview: "const a = 1",
|
||||
})
|
||||
|
||||
describe("prompt-input history", () => {
|
||||
test("prependHistoryEntry skips empty prompt and deduplicates consecutive entries", () => {
|
||||
const first = prependHistoryEntry([], DEFAULT_PROMPT)
|
||||
expect(first).toEqual([])
|
||||
|
||||
const commentsOnly = prependHistoryEntry([], DEFAULT_PROMPT, [comment("c1")])
|
||||
expect(commentsOnly).toHaveLength(1)
|
||||
|
||||
const withOne = prependHistoryEntry([], text("hello"))
|
||||
expect(withOne).toHaveLength(1)
|
||||
|
||||
const deduped = prependHistoryEntry(withOne, text("hello"))
|
||||
expect(deduped).toBe(withOne)
|
||||
|
||||
const dedupedComments = prependHistoryEntry(commentsOnly, DEFAULT_PROMPT, [comment("c1")])
|
||||
expect(dedupedComments).toBe(commentsOnly)
|
||||
})
|
||||
|
||||
test("navigatePromptHistory restores saved prompt when moving down from newest", () => {
|
||||
@@ -31,24 +48,57 @@ describe("prompt-input history", () => {
|
||||
entries,
|
||||
historyIndex: -1,
|
||||
currentPrompt: text("draft"),
|
||||
currentComments: [comment("draft")],
|
||||
savedPrompt: null,
|
||||
})
|
||||
expect(up.handled).toBe(true)
|
||||
if (!up.handled) throw new Error("expected handled")
|
||||
expect(up.historyIndex).toBe(0)
|
||||
expect(up.cursor).toBe("start")
|
||||
expect(up.entry.comments).toEqual([])
|
||||
|
||||
const down = navigatePromptHistory({
|
||||
direction: "down",
|
||||
entries,
|
||||
historyIndex: up.historyIndex,
|
||||
currentPrompt: text("ignored"),
|
||||
currentComments: [],
|
||||
savedPrompt: up.savedPrompt,
|
||||
})
|
||||
expect(down.handled).toBe(true)
|
||||
if (!down.handled) throw new Error("expected handled")
|
||||
expect(down.historyIndex).toBe(-1)
|
||||
expect(down.prompt[0]?.type === "text" ? down.prompt[0].content : "").toBe("draft")
|
||||
expect(down.entry.prompt[0]?.type === "text" ? down.entry.prompt[0].content : "").toBe("draft")
|
||||
expect(down.entry.comments).toEqual([comment("draft")])
|
||||
})
|
||||
|
||||
test("navigatePromptHistory keeps entry comments when moving through history", () => {
|
||||
const entries = [
|
||||
{
|
||||
prompt: text("with comment"),
|
||||
comments: [comment("c1")],
|
||||
},
|
||||
]
|
||||
|
||||
const up = navigatePromptHistory({
|
||||
direction: "up",
|
||||
entries,
|
||||
historyIndex: -1,
|
||||
currentPrompt: text("draft"),
|
||||
currentComments: [],
|
||||
savedPrompt: null,
|
||||
})
|
||||
|
||||
expect(up.handled).toBe(true)
|
||||
if (!up.handled) throw new Error("expected handled")
|
||||
expect(up.entry.prompt[0]?.type === "text" ? up.entry.prompt[0].content : "").toBe("with comment")
|
||||
expect(up.entry.comments).toEqual([comment("c1")])
|
||||
})
|
||||
|
||||
test("normalizePromptHistoryEntry supports legacy prompt arrays", () => {
|
||||
const entry = normalizePromptHistoryEntry(text("legacy"))
|
||||
expect(entry.prompt[0]?.type === "text" ? entry.prompt[0].content : "").toBe("legacy")
|
||||
expect(entry.comments).toEqual([])
|
||||
})
|
||||
|
||||
test("helpers clone prompt and count text content length", () => {
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
import type { SelectedLineRange } from "@/context/file"
|
||||
|
||||
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
export const MAX_HISTORY = 100
|
||||
|
||||
export type PromptHistoryComment = {
|
||||
id: string
|
||||
path: string
|
||||
selection: SelectedLineRange
|
||||
comment: string
|
||||
time: number
|
||||
origin?: "review" | "file"
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export type PromptHistoryEntry = {
|
||||
prompt: Prompt
|
||||
comments: PromptHistoryComment[]
|
||||
}
|
||||
|
||||
export type PromptHistoryStoredEntry = Prompt | PromptHistoryEntry
|
||||
|
||||
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) {
|
||||
const position = Math.max(0, Math.min(cursor, text.length))
|
||||
const atStart = position === 0
|
||||
@@ -25,29 +43,82 @@ export function clonePromptParts(prompt: Prompt): Prompt {
|
||||
})
|
||||
}
|
||||
|
||||
function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
|
||||
return {
|
||||
start: selection.start,
|
||||
end: selection.end,
|
||||
...(selection.side ? { side: selection.side } : {}),
|
||||
...(selection.endSide ? { endSide: selection.endSide } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function clonePromptHistoryComments(comments: PromptHistoryComment[]) {
|
||||
return comments.map((comment) => ({
|
||||
...comment,
|
||||
selection: cloneSelection(comment.selection),
|
||||
}))
|
||||
}
|
||||
|
||||
export function normalizePromptHistoryEntry(entry: PromptHistoryStoredEntry): PromptHistoryEntry {
|
||||
if (Array.isArray(entry)) {
|
||||
return {
|
||||
prompt: clonePromptParts(entry),
|
||||
comments: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
prompt: clonePromptParts(entry.prompt),
|
||||
comments: clonePromptHistoryComments(entry.comments),
|
||||
}
|
||||
}
|
||||
|
||||
export function promptLength(prompt: Prompt) {
|
||||
return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
|
||||
}
|
||||
|
||||
export function prependHistoryEntry(entries: Prompt[], prompt: Prompt, max = MAX_HISTORY) {
|
||||
export function prependHistoryEntry(
|
||||
entries: PromptHistoryStoredEntry[],
|
||||
prompt: Prompt,
|
||||
comments: PromptHistoryComment[] = [],
|
||||
max = MAX_HISTORY,
|
||||
) {
|
||||
const text = prompt
|
||||
.map((part) => ("content" in part ? part.content : ""))
|
||||
.join("")
|
||||
.trim()
|
||||
const hasImages = prompt.some((part) => part.type === "image")
|
||||
if (!text && !hasImages) return entries
|
||||
const hasComments = comments.some((comment) => !!comment.comment.trim())
|
||||
if (!text && !hasImages && !hasComments) return entries
|
||||
|
||||
const entry = clonePromptParts(prompt)
|
||||
const entry = {
|
||||
prompt: clonePromptParts(prompt),
|
||||
comments: clonePromptHistoryComments(comments),
|
||||
} satisfies PromptHistoryEntry
|
||||
const last = entries[0]
|
||||
if (last && isPromptEqual(last, entry)) return entries
|
||||
return [entry, ...entries].slice(0, max)
|
||||
}
|
||||
|
||||
function isPromptEqual(promptA: Prompt, promptB: Prompt) {
|
||||
if (promptA.length !== promptB.length) return false
|
||||
for (let i = 0; i < promptA.length; i++) {
|
||||
const partA = promptA[i]
|
||||
const partB = promptB[i]
|
||||
function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryComment) {
|
||||
return (
|
||||
commentA.path === commentB.path &&
|
||||
commentA.comment === commentB.comment &&
|
||||
commentA.origin === commentB.origin &&
|
||||
commentA.preview === commentB.preview &&
|
||||
commentA.selection.start === commentB.selection.start &&
|
||||
commentA.selection.end === commentB.selection.end &&
|
||||
commentA.selection.side === commentB.selection.side &&
|
||||
commentA.selection.endSide === commentB.selection.endSide
|
||||
)
|
||||
}
|
||||
|
||||
function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistoryStoredEntry) {
|
||||
const entryA = normalizePromptHistoryEntry(promptA)
|
||||
const entryB = normalizePromptHistoryEntry(promptB)
|
||||
if (entryA.prompt.length !== entryB.prompt.length) return false
|
||||
for (let i = 0; i < entryA.prompt.length; i++) {
|
||||
const partA = entryA.prompt[i]
|
||||
const partB = entryB.prompt[i]
|
||||
if (partA.type !== partB.type) return false
|
||||
if (partA.type === "text" && partA.content !== (partB.type === "text" ? partB.content : "")) return false
|
||||
if (partA.type === "file") {
|
||||
@@ -67,28 +138,35 @@ function isPromptEqual(promptA: Prompt, promptB: Prompt) {
|
||||
if (partA.type === "agent" && partA.name !== (partB.type === "agent" ? partB.name : "")) return false
|
||||
if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
|
||||
}
|
||||
if (entryA.comments.length !== entryB.comments.length) return false
|
||||
for (let i = 0; i < entryA.comments.length; i++) {
|
||||
const commentA = entryA.comments[i]
|
||||
const commentB = entryB.comments[i]
|
||||
if (!commentA || !commentB || !isCommentEqual(commentA, commentB)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type HistoryNavInput = {
|
||||
direction: "up" | "down"
|
||||
entries: Prompt[]
|
||||
entries: PromptHistoryStoredEntry[]
|
||||
historyIndex: number
|
||||
currentPrompt: Prompt
|
||||
savedPrompt: Prompt | null
|
||||
currentComments: PromptHistoryComment[]
|
||||
savedPrompt: PromptHistoryEntry | null
|
||||
}
|
||||
|
||||
type HistoryNavResult =
|
||||
| {
|
||||
handled: false
|
||||
historyIndex: number
|
||||
savedPrompt: Prompt | null
|
||||
savedPrompt: PromptHistoryEntry | null
|
||||
}
|
||||
| {
|
||||
handled: true
|
||||
historyIndex: number
|
||||
savedPrompt: Prompt | null
|
||||
prompt: Prompt
|
||||
savedPrompt: PromptHistoryEntry | null
|
||||
entry: PromptHistoryEntry
|
||||
cursor: "start" | "end"
|
||||
}
|
||||
|
||||
@@ -103,22 +181,27 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
|
||||
}
|
||||
|
||||
if (input.historyIndex === -1) {
|
||||
const entry = normalizePromptHistoryEntry(input.entries[0])
|
||||
return {
|
||||
handled: true,
|
||||
historyIndex: 0,
|
||||
savedPrompt: clonePromptParts(input.currentPrompt),
|
||||
prompt: input.entries[0],
|
||||
savedPrompt: {
|
||||
prompt: clonePromptParts(input.currentPrompt),
|
||||
comments: clonePromptHistoryComments(input.currentComments),
|
||||
},
|
||||
entry,
|
||||
cursor: "start",
|
||||
}
|
||||
}
|
||||
|
||||
if (input.historyIndex < input.entries.length - 1) {
|
||||
const next = input.historyIndex + 1
|
||||
const entry = normalizePromptHistoryEntry(input.entries[next])
|
||||
return {
|
||||
handled: true,
|
||||
historyIndex: next,
|
||||
savedPrompt: input.savedPrompt,
|
||||
prompt: input.entries[next],
|
||||
entry,
|
||||
cursor: "start",
|
||||
}
|
||||
}
|
||||
@@ -132,11 +215,12 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
|
||||
|
||||
if (input.historyIndex > 0) {
|
||||
const next = input.historyIndex - 1
|
||||
const entry = normalizePromptHistoryEntry(input.entries[next])
|
||||
return {
|
||||
handled: true,
|
||||
historyIndex: next,
|
||||
savedPrompt: input.savedPrompt,
|
||||
prompt: input.entries[next],
|
||||
entry,
|
||||
cursor: "end",
|
||||
}
|
||||
}
|
||||
@@ -147,7 +231,7 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
|
||||
handled: true,
|
||||
historyIndex: -1,
|
||||
savedPrompt: null,
|
||||
prompt: input.savedPrompt,
|
||||
entry: input.savedPrompt,
|
||||
cursor: "end",
|
||||
}
|
||||
}
|
||||
@@ -156,7 +240,10 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
|
||||
handled: true,
|
||||
historyIndex: -1,
|
||||
savedPrompt: null,
|
||||
prompt: DEFAULT_PROMPT,
|
||||
entry: {
|
||||
prompt: DEFAULT_PROMPT,
|
||||
comments: [],
|
||||
},
|
||||
cursor: "end",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||
|
||||
const createdClients: string[] = []
|
||||
const createdSessions: string[] = []
|
||||
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
|
||||
const sentShell: string[] = []
|
||||
const syncedDirectories: string[] = []
|
||||
|
||||
@@ -42,10 +43,8 @@ beforeAll(async () => {
|
||||
useParams: () => ({}),
|
||||
}))
|
||||
|
||||
// kilocode_change start
|
||||
mock.module("@kilocode/sdk/v2/client", () => ({
|
||||
// kilocode_change end
|
||||
createOpencodeClient: (input: { directory: string }) => {
|
||||
createKiloClient: (input: { directory: string }) => {
|
||||
createdClients.push(input.directory)
|
||||
return clientFor(input.directory)
|
||||
},
|
||||
@@ -71,6 +70,14 @@ beforeAll(async () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/permission", () => ({
|
||||
usePermission: () => ({
|
||||
enableAutoAccept(sessionID: string, directory: string) {
|
||||
enabledAutoAccept.push({ sessionID, directory })
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/prompt", () => ({
|
||||
usePrompt: () => ({
|
||||
current: () => promptValue,
|
||||
@@ -147,6 +154,7 @@ beforeAll(async () => {
|
||||
beforeEach(() => {
|
||||
createdClients.length = 0
|
||||
createdSessions.length = 0
|
||||
enabledAutoAccept.length = 0
|
||||
sentShell.length = 0
|
||||
syncedDirectories.length = 0
|
||||
selected = "/repo/worktree-a"
|
||||
@@ -158,6 +166,7 @@ describe("prompt submit worktree selection", () => {
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "shell",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
@@ -183,4 +192,31 @@ describe("prompt submit worktree selection", () => {
|
||||
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||
})
|
||||
|
||||
test("applies auto-accept to newly created sessions", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => true,
|
||||
mode: () => "shell",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
newSessionWorktree: () => selected,
|
||||
onNewSessionWorktreeReset: () => undefined,
|
||||
onSubmit: () => undefined,
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
|
||||
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
@@ -27,6 +28,7 @@ type PromptSubmitInput = {
|
||||
info: Accessor<{ id: string } | undefined>
|
||||
imageAttachments: Accessor<ImageAttachmentPart[]>
|
||||
commentCount: Accessor<number>
|
||||
autoAccept: Accessor<boolean>
|
||||
mode: Accessor<"normal" | "shell">
|
||||
working: Accessor<boolean>
|
||||
editor: () => HTMLDivElement | undefined
|
||||
@@ -56,6 +58,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const sync = useSync()
|
||||
const globalSync = useGlobalSync()
|
||||
const local = useLocal()
|
||||
const permission = usePermission()
|
||||
const prompt = usePrompt()
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
@@ -140,6 +143,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
|
||||
const projectDirectory = sdk.directory
|
||||
const isNewSession = !params.id
|
||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||
|
||||
let sessionDirectory = projectDirectory
|
||||
@@ -197,6 +201,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
return undefined
|
||||
})
|
||||
if (session) {
|
||||
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import {
|
||||
children,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
type ParentProps,
|
||||
Show,
|
||||
} from "solid-js"
|
||||
import { type ServerConnection, serverDisplayName } from "@/context/server"
|
||||
import { type ServerConnection, serverName } from "@/context/server"
|
||||
import type { ServerHealth } from "@/utils/server-health"
|
||||
|
||||
interface ServerRowProps extends ParentProps {
|
||||
@@ -20,13 +21,14 @@ interface ServerRowProps extends ParentProps {
|
||||
versionClass?: string
|
||||
dimmed?: boolean
|
||||
badge?: JSXElement
|
||||
showCredentials?: boolean
|
||||
}
|
||||
|
||||
export function ServerRow(props: ServerRowProps) {
|
||||
const [truncated, setTruncated] = createSignal(false)
|
||||
let nameRef: HTMLSpanElement | undefined
|
||||
let versionRef: HTMLSpanElement | undefined
|
||||
const name = createMemo(() => serverDisplayName(props.conn))
|
||||
const name = createMemo(() => serverName(props.conn))
|
||||
|
||||
const check = () => {
|
||||
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
||||
@@ -52,35 +54,71 @@ export function ServerRow(props: ServerRowProps) {
|
||||
|
||||
const tooltipValue = () => (
|
||||
<span class="flex items-center gap-2">
|
||||
<span>{name()}</span>
|
||||
<span>{serverName(props.conn, true)}</span>
|
||||
<Show when={props.status?.version}>
|
||||
<span class="text-text-invert-base">{props.status?.version}</span>
|
||||
<span class="text-text-invert-weak">v{props.status?.version}</span>
|
||||
</Show>
|
||||
</span>
|
||||
)
|
||||
|
||||
const badge = children(() => props.badge)
|
||||
|
||||
return (
|
||||
<Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
|
||||
<Tooltip
|
||||
class="flex-1"
|
||||
value={tooltipValue()}
|
||||
placement="top-start"
|
||||
inactive={!truncated() && !props.conn.displayName}
|
||||
>
|
||||
<div class={props.class} classList={{ "opacity-50": props.dimmed }}>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": props.status?.healthy === true,
|
||||
"bg-icon-critical-base": props.status?.healthy === false,
|
||||
"bg-border-weak-base": props.status === undefined,
|
||||
}}
|
||||
/>
|
||||
<span ref={nameRef} class={props.nameClass ?? "truncate"}>
|
||||
{name()}
|
||||
</span>
|
||||
<Show when={props.status?.version}>
|
||||
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
|
||||
{props.status?.version}
|
||||
</span>
|
||||
</Show>
|
||||
{props.badge}
|
||||
<div class="flex flex-col items-start">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<span ref={nameRef} class={props.nameClass ?? "truncate"}>
|
||||
{name()}
|
||||
</span>
|
||||
<Show
|
||||
when={badge()}
|
||||
fallback={
|
||||
<Show when={props.status?.version}>
|
||||
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
|
||||
v{props.status?.version}
|
||||
</span>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(badge) => badge()}
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.showCredentials && props.conn.type === "http" && props.conn}>
|
||||
{(conn) => (
|
||||
<div class="flex flex-row gap-3">
|
||||
<span>
|
||||
{conn().http.username ? (
|
||||
<span class="text-text-weak">{conn().http.username}</span>
|
||||
) : (
|
||||
<span class="text-text-weaker">no username</span>
|
||||
)}
|
||||
</span>
|
||||
{conn().http.password && <span class="text-text-weak">••••••••</span>}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function ServerHealthIndicator(props: { health?: ServerHealth }) {
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": props.health?.healthy === true,
|
||||
"bg-icon-critical-base": props.health?.healthy === false,
|
||||
"bg-border-weak-base": props.health === undefined,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { same } from "@/utils/same"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
import { Code } from "@opencode-ai/ui/code"
|
||||
import { File } from "@opencode-ai/ui/file"
|
||||
import { Markdown } from "@opencode-ai/ui/markdown"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { Message, Part, UserMessage } from "@kilocode/sdk/v2/client"
|
||||
@@ -47,7 +47,8 @@ function RawMessageContent(props: { message: Message; getParts: (id: string) =>
|
||||
})
|
||||
|
||||
return (
|
||||
<Code
|
||||
<File
|
||||
mode="text"
|
||||
file={file()}
|
||||
overflow="wrap"
|
||||
class="select-text"
|
||||
|
||||
@@ -35,6 +35,7 @@ const OPEN_APPS = [
|
||||
"terminal",
|
||||
"iterm2",
|
||||
"ghostty",
|
||||
"warp",
|
||||
"xcode",
|
||||
"android-studio",
|
||||
"powershell",
|
||||
@@ -63,6 +64,7 @@ const MAC_APPS = [
|
||||
{ id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
|
||||
{ id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
|
||||
{ id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
|
||||
{ id: "warp", label: "Warp", icon: "warp", openWith: "Warp" },
|
||||
{ id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
|
||||
{
|
||||
id: "android-studio",
|
||||
@@ -136,12 +138,12 @@ function useSessionShare(args: {
|
||||
globalSDK: ReturnType<typeof useGlobalSDK>
|
||||
currentSession: () =>
|
||||
| {
|
||||
id: string
|
||||
share?: {
|
||||
url?: string
|
||||
}
|
||||
}
|
||||
| undefined
|
||||
sessionID: () => string | undefined
|
||||
projectDirectory: () => string
|
||||
platform: ReturnType<typeof usePlatform>
|
||||
}) {
|
||||
@@ -165,11 +167,11 @@ function useSessionShare(args: {
|
||||
})
|
||||
|
||||
const shareSession = () => {
|
||||
const session = args.currentSession()
|
||||
if (!session || state.share) return
|
||||
const sessionID = args.sessionID()
|
||||
if (!sessionID || state.share) return
|
||||
setState("share", true)
|
||||
args.globalSDK.client.session
|
||||
.share({ sessionID: session.id, directory: args.projectDirectory() })
|
||||
.share({ sessionID, directory: args.projectDirectory() })
|
||||
.catch((error) => {
|
||||
console.error("Failed to share session", error)
|
||||
})
|
||||
@@ -179,11 +181,11 @@ function useSessionShare(args: {
|
||||
}
|
||||
|
||||
const unshareSession = () => {
|
||||
const session = args.currentSession()
|
||||
if (!session || state.unshare) return
|
||||
const sessionID = args.sessionID()
|
||||
if (!sessionID || state.unshare) return
|
||||
setState("unshare", true)
|
||||
args.globalSDK.client.session
|
||||
.unshare({ sessionID: session.id, directory: args.projectDirectory() })
|
||||
.unshare({ sessionID, directory: args.projectDirectory() })
|
||||
.catch((error) => {
|
||||
console.error("Failed to unshare session", error)
|
||||
})
|
||||
@@ -241,9 +243,9 @@ export function SessionHeader() {
|
||||
})
|
||||
const hotkey = createMemo(() => command.keybind("file.open"))
|
||||
|
||||
const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id))
|
||||
const currentSession = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||
const showShare = createMemo(() => shareEnabled() && !!currentSession())
|
||||
const showShare = createMemo(() => shareEnabled() && !!params.id)
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const view = createMemo(() => layout.view(sessionKey))
|
||||
const os = createMemo(() => detectOS(platform))
|
||||
@@ -304,11 +306,10 @@ export function SessionHeader() {
|
||||
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
|
||||
const opening = createMemo(() => openRequest.app !== undefined)
|
||||
|
||||
createEffect(() => {
|
||||
const value = prefs.app
|
||||
if (options().some((o) => o.id === value)) return
|
||||
setPrefs("app", options()[0]?.id ?? "finder")
|
||||
})
|
||||
const selectApp = (app: OpenApp) => {
|
||||
if (!options().some((item) => item.id === app)) return
|
||||
setPrefs("app", app)
|
||||
}
|
||||
|
||||
const openDir = (app: OpenApp) => {
|
||||
if (opening() || !canOpen() || !platform.openPath) return
|
||||
@@ -345,6 +346,7 @@ export function SessionHeader() {
|
||||
const share = useSessionShare({
|
||||
globalSDK,
|
||||
currentSession,
|
||||
sessionID: () => params.id,
|
||||
projectDirectory,
|
||||
platform,
|
||||
})
|
||||
@@ -428,7 +430,7 @@ export function SessionHeader() {
|
||||
<Spinner class="size-3.5 text-icon-base" />
|
||||
</Show>
|
||||
</div>
|
||||
<span class="text-12-regular text-text-strong">Open</span>
|
||||
<span class="text-12-regular text-text-strong">{language.t("common.open")}</span>
|
||||
</Button>
|
||||
<div class="self-stretch w-px bg-border-weak-base" />
|
||||
<DropdownMenu
|
||||
@@ -456,7 +458,7 @@ export function SessionHeader() {
|
||||
value={current().id}
|
||||
onChange={(value) => {
|
||||
if (!OPEN_APPS.includes(value as OpenApp)) return
|
||||
setPrefs("app", value as OpenApp)
|
||||
selectApp(value as OpenApp)
|
||||
}}
|
||||
>
|
||||
<For each={options()}>
|
||||
|
||||
@@ -13,13 +13,15 @@ import { useCommand } from "@/context/command"
|
||||
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
|
||||
return (
|
||||
<div class="flex items-center gap-x-1.5 min-w-0">
|
||||
<FileIcon
|
||||
node={{ path: props.path, type: "file" }}
|
||||
classList={{
|
||||
"grayscale-100 group-data-[selected]/tab:grayscale-0": !props.active,
|
||||
"grayscale-0": props.active,
|
||||
}}
|
||||
/>
|
||||
<Show
|
||||
when={!props.active}
|
||||
fallback={<FileIcon node={{ path: props.path, type: "file" }} class="size-4 shrink-0" />}
|
||||
>
|
||||
<span class="relative inline-flex size-4 shrink-0">
|
||||
<FileIcon node={{ path: props.path, type: "file" }} class="absolute inset-0 size-4 tab-fileicon-color" />
|
||||
<FileIcon node={{ path: props.path, type: "file" }} mono class="absolute inset-0 size-4 tab-fileicon-mono" />
|
||||
</span>
|
||||
</Show>
|
||||
<span class="text-14-medium truncate">{getFilename(props.path)}</span>
|
||||
</div>
|
||||
)
|
||||
@@ -37,8 +39,8 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
|
||||
return <FileVisual path={value} />
|
||||
})
|
||||
return (
|
||||
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
|
||||
<div class="relative h-full">
|
||||
<div use:sortable class="h-full flex items-center" classList={{ "opacity-0": sortable.isActiveDraggable }}>
|
||||
<div class="relative">
|
||||
<Tabs.Trigger
|
||||
value={props.tab}
|
||||
closeButton={
|
||||
@@ -46,6 +48,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
|
||||
title={language.t("common.closeTab")}
|
||||
keybind={command.keybind("tab.close")}
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
>
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
||||
import { type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useModels } from "@/context/models"
|
||||
@@ -98,7 +97,7 @@ export const SettingsModels: Component = () => {
|
||||
{(group) => (
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2 pb-2">
|
||||
<ProviderIcon id={group.category as IconName} class="size-5 shrink-0 icon-strong-base" />
|
||||
<ProviderIcon id={group.category} class="size-5 shrink-0 icon-strong-base" />
|
||||
<span class="text-14-medium text-text-strong">{group.items[0].provider.name}</span>
|
||||
</div>
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Tag } from "@opencode-ai/ui/tag"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { iconNames, type IconName } from "@opencode-ai/ui/icons/provider"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { createMemo, type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -33,11 +32,6 @@ export const SettingsProviders: Component = () => {
|
||||
const globalSync = useGlobalSync()
|
||||
const providers = useProviders()
|
||||
|
||||
const icon = (id: string): IconName => {
|
||||
if (iconNames.includes(id as IconName)) return id as IconName
|
||||
return "synthetic"
|
||||
}
|
||||
|
||||
const connected = createMemo(() => {
|
||||
return providers
|
||||
.connected()
|
||||
@@ -154,7 +148,7 @@ export const SettingsProviders: Component = () => {
|
||||
{(item) => (
|
||||
<div class="group flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<ProviderIcon id={icon(item.id)} class="size-5 shrink-0 icon-strong-base" />
|
||||
<ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
|
||||
<span class="text-14-medium text-text-strong truncate">{item.name}</span>
|
||||
<Tag>{type(item)}</Tag>
|
||||
</div>
|
||||
@@ -162,7 +156,7 @@ export const SettingsProviders: Component = () => {
|
||||
when={canDisconnect(item)}
|
||||
fallback={
|
||||
<span class="text-14-regular text-text-base opacity-0 group-hover:opacity-100 transition-opacity duration-200 pr-3 cursor-default">
|
||||
Connected from your environment variables
|
||||
{language.t("settings.providers.connected.environmentDescription")}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
@@ -185,11 +179,24 @@ export const SettingsProviders: Component = () => {
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div class="flex items-center gap-x-3">
|
||||
<ProviderIcon id={icon(item.id)} class="size-5 shrink-0 icon-strong-base" />
|
||||
<ProviderIcon id={item.id} class="size-5 shrink-0 icon-strong-base" />
|
||||
<span class="text-14-medium text-text-strong">{item.name}</span>
|
||||
<Show when={item.id === "opencode"}>
|
||||
<span class="text-14-regular text-text-weak">
|
||||
{language.t("dialog.provider.opencode.tagline")}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={item.id === "opencode"}>
|
||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
<Show when={item.id === "opencode-go"}>
|
||||
<>
|
||||
<span class="text-14-regular text-text-weak">
|
||||
{language.t("dialog.provider.opencodeGo.tagline")}
|
||||
</span>
|
||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={note(item.id)}>
|
||||
{(key) => <span class="text-12-regular text-text-weak pl-8">{language.t(key())}</span>}
|
||||
@@ -215,11 +222,13 @@ export const SettingsProviders: Component = () => {
|
||||
>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<ProviderIcon id={icon("synthetic")} class="size-5 shrink-0 icon-strong-base" />
|
||||
<span class="text-14-medium text-text-strong">Custom provider</span>
|
||||
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
|
||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||
</div>
|
||||
<span class="text-12-regular text-text-weak pl-8">Add an OpenAI-compatible provider by base URL.</span>
|
||||
<span class="text-12-regular text-text-weak pl-8">
|
||||
{language.t("settings.providers.custom.description")}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="large"
|
||||
|
||||
@@ -8,7 +8,7 @@ import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { ServerRow } from "@/components/server/server-row"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
@@ -202,29 +202,26 @@ export function StatusPopover() {
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
class:
|
||||
"rounded-md h-[24px] pr-3 pl-0.5 gap-2 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
|
||||
class: "titlebar-icon w-6 h-6 p-0 box-border",
|
||||
"aria-label": language.t("status.popover.trigger"),
|
||||
style: { scale: 1 },
|
||||
}}
|
||||
trigger={
|
||||
<div class="flex items-center gap-0.5">
|
||||
<div class="size-4 flex items-center justify-center">
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full": true,
|
||||
"bg-icon-success-base": overallHealthy(),
|
||||
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
|
||||
"bg-border-weak-base": server.healthy() === undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span class="text-12-regular text-text-strong">{language.t("status.popover.trigger")}</span>
|
||||
<div class="flex size-4 items-center justify-center">
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full": true,
|
||||
"bg-icon-success-base": overallHealthy(),
|
||||
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
|
||||
"bg-border-weak-base": server.healthy() === undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
shift={-136}
|
||||
shift={-168}
|
||||
>
|
||||
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
|
||||
<Tabs
|
||||
@@ -276,10 +273,11 @@ export function StatusPopover() {
|
||||
navigate("/")
|
||||
}}
|
||||
>
|
||||
<ServerHealthIndicator health={health[key]} />
|
||||
<ServerRow
|
||||
conn={s}
|
||||
status={health[key]}
|
||||
dimmed={isBlocked()}
|
||||
status={health[key]}
|
||||
class="flex items-center gap-2 w-full min-w-0"
|
||||
nameClass="text-14-regular text-text-base truncate"
|
||||
versionClass="text-12-regular text-text-weak truncate"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
|
||||
import { type ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
|
||||
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
|
||||
import { SerializeAddon } from "@/addons/serialize"
|
||||
import { matchKeybind, parseKeybind } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -18,7 +18,7 @@ const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
||||
export interface TerminalProps extends ComponentProps<"div"> {
|
||||
pty: LocalPTY
|
||||
onSubmit?: () => void
|
||||
onCleanup?: (pty: LocalPTY) => void
|
||||
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
|
||||
onConnect?: () => void
|
||||
onConnectError?: (error: unknown) => void
|
||||
}
|
||||
@@ -126,8 +126,8 @@ const persistTerminal = (input: {
|
||||
term: Term | undefined
|
||||
addon: SerializeAddon | undefined
|
||||
cursor: number
|
||||
pty: LocalPTY
|
||||
onCleanup?: (pty: LocalPTY) => void
|
||||
id: string
|
||||
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
|
||||
}) => {
|
||||
if (!input.addon || !input.onCleanup || !input.term) return
|
||||
const buffer = (() => {
|
||||
@@ -140,7 +140,7 @@ const persistTerminal = (input: {
|
||||
})()
|
||||
|
||||
input.onCleanup({
|
||||
...input.pty,
|
||||
id: input.id,
|
||||
buffer,
|
||||
cursor: input.cursor,
|
||||
rows: input.term.rows,
|
||||
@@ -158,6 +158,19 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const server = useServer()
|
||||
let container!: HTMLDivElement
|
||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
|
||||
const id = local.pty.id
|
||||
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
||||
const restoreSize =
|
||||
restore &&
|
||||
typeof local.pty.cols === "number" &&
|
||||
Number.isSafeInteger(local.pty.cols) &&
|
||||
local.pty.cols > 0 &&
|
||||
typeof local.pty.rows === "number" &&
|
||||
Number.isSafeInteger(local.pty.rows) &&
|
||||
local.pty.rows > 0
|
||||
? { cols: local.pty.cols, rows: local.pty.rows }
|
||||
: undefined
|
||||
const scrollY = typeof local.pty.scrollY === "number" ? local.pty.scrollY : undefined
|
||||
let ws: WebSocket | undefined
|
||||
let term: Term | undefined
|
||||
let ghostty: Ghostty
|
||||
@@ -190,7 +203,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const pushSize = (cols: number, rows: number) => {
|
||||
return sdk.client.pty
|
||||
.update({
|
||||
ptyID: local.pty.id,
|
||||
ptyID: id,
|
||||
size: { cols, rows },
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -219,7 +232,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
const [terminalColors, setTerminalColors] = createSignal<TerminalColors>(getTerminalColors())
|
||||
const terminalColors = createMemo(getTerminalColors)
|
||||
|
||||
const scheduleFit = () => {
|
||||
if (disposed) return
|
||||
@@ -259,8 +272,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const colors = getTerminalColors()
|
||||
setTerminalColors(colors)
|
||||
const colors = terminalColors()
|
||||
if (!term) return
|
||||
setOptionIfSupported(term, "theme", colors)
|
||||
})
|
||||
@@ -320,18 +332,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const mod = loaded.mod
|
||||
const g = loaded.ghostty
|
||||
|
||||
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
||||
const restoreSize =
|
||||
restore &&
|
||||
typeof local.pty.cols === "number" &&
|
||||
Number.isSafeInteger(local.pty.cols) &&
|
||||
local.pty.cols > 0 &&
|
||||
typeof local.pty.rows === "number" &&
|
||||
Number.isSafeInteger(local.pty.rows) &&
|
||||
local.pty.rows > 0
|
||||
? { cols: local.pty.cols, rows: local.pty.rows }
|
||||
: undefined
|
||||
|
||||
const t = new mod.Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: "bar",
|
||||
@@ -428,14 +428,14 @@ export const Terminal = (props: TerminalProps) => {
|
||||
await write(restore)
|
||||
fit.fit()
|
||||
scheduleSize(t.cols, t.rows)
|
||||
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
|
||||
if (scrollY !== undefined) t.scrollToLine(scrollY)
|
||||
startResize()
|
||||
} else {
|
||||
fit.fit()
|
||||
scheduleSize(t.cols, t.rows)
|
||||
if (restore) {
|
||||
await write(restore)
|
||||
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
|
||||
if (scrollY !== undefined) t.scrollToLine(scrollY)
|
||||
}
|
||||
startResize()
|
||||
}
|
||||
@@ -447,9 +447,9 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const once = { value: false }
|
||||
let closing = false
|
||||
|
||||
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
|
||||
const url = new URL(sdk.url + `/pty/${id}/connect`)
|
||||
url.searchParams.set("directory", sdk.directory)
|
||||
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
|
||||
url.searchParams.set("cursor", String(start !== undefined ? start : restore ? -1 : 0))
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
url.username = server.current?.http.username ?? ""
|
||||
url.password = server.current?.http.password ?? ""
|
||||
@@ -543,7 +543,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000)
|
||||
|
||||
const finalize = () => {
|
||||
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
|
||||
persistTerminal({ term, addon: serializeAddon, cursor, id, onCleanup: props.onCleanup })
|
||||
cleanup()
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +157,7 @@ export function Titlebar() {
|
||||
<header
|
||||
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
|
||||
style={{ "min-height": minHeight() }}
|
||||
data-tauri-drag-region
|
||||
onMouseDown={drag}
|
||||
onDblClick={maximize}
|
||||
>
|
||||
@@ -276,6 +277,7 @@ export function Titlebar() {
|
||||
"flex items-center min-w-0 justify-end": true,
|
||||
"pr-2": !windows(),
|
||||
}}
|
||||
data-tauri-drag-region
|
||||
onMouseDown={drag}
|
||||
>
|
||||
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
|
||||
|
||||
@@ -150,4 +150,37 @@ describe("comments session indexing", () => {
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("update changes only the targeted comment body", () => {
|
||||
createRoot((dispose) => {
|
||||
const comments = createCommentSessionForTest({
|
||||
"a.ts": [line("a.ts", "a1", 10), line("a.ts", "a2", 20)],
|
||||
})
|
||||
|
||||
comments.update("a.ts", "a2", "edited")
|
||||
|
||||
expect(comments.list("a.ts").map((item) => item.comment)).toEqual(["a1", "edited"])
|
||||
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("replace swaps comment state and clears focus state", () => {
|
||||
createRoot((dispose) => {
|
||||
const comments = createCommentSessionForTest({
|
||||
"a.ts": [line("a.ts", "a1", 10)],
|
||||
})
|
||||
|
||||
comments.setFocus({ file: "a.ts", id: "a1" })
|
||||
comments.setActive({ file: "a.ts", id: "a1" })
|
||||
comments.replace([line("b.ts", "b1", 30)])
|
||||
|
||||
expect(comments.list("a.ts")).toEqual([])
|
||||
expect(comments.list("b.ts").map((item) => item.id)).toEqual(["b1"])
|
||||
expect(comments.focus()).toBeNull()
|
||||
expect(comments.active()).toBeNull()
|
||||
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,6 +44,37 @@ function aggregate(comments: Record<string, LineComment[]>) {
|
||||
.sort((a, b) => a.time - b.time)
|
||||
}
|
||||
|
||||
function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
|
||||
const next: SelectedLineRange = {
|
||||
start: selection.start,
|
||||
end: selection.end,
|
||||
}
|
||||
|
||||
if (selection.side) next.side = selection.side
|
||||
if (selection.endSide) next.endSide = selection.endSide
|
||||
return next
|
||||
}
|
||||
|
||||
function cloneComment(comment: LineComment): LineComment {
|
||||
return {
|
||||
...comment,
|
||||
selection: cloneSelection(comment.selection),
|
||||
}
|
||||
}
|
||||
|
||||
function group(comments: LineComment[]) {
|
||||
return comments.reduce<Record<string, LineComment[]>>((acc, comment) => {
|
||||
const list = acc[comment.file]
|
||||
const next = cloneComment(comment)
|
||||
if (list) {
|
||||
list.push(next)
|
||||
return acc
|
||||
}
|
||||
acc[comment.file] = [next]
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) {
|
||||
const [state, setState] = createStore({
|
||||
focus: null as CommentFocus | null,
|
||||
@@ -70,6 +101,7 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
|
||||
id: uuid(),
|
||||
time: Date.now(),
|
||||
...input,
|
||||
selection: cloneSelection(input.selection),
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
@@ -87,6 +119,23 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
|
||||
})
|
||||
}
|
||||
|
||||
const update = (file: string, id: string, comment: string) => {
|
||||
setStore("comments", file, (items) =>
|
||||
(items ?? []).map((item) => {
|
||||
if (item.id !== id) return item
|
||||
return { ...item, comment }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const replace = (comments: LineComment[]) => {
|
||||
batch(() => {
|
||||
setStore("comments", reconcile(group(comments)))
|
||||
setFocus(null)
|
||||
setActive(null)
|
||||
})
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
batch(() => {
|
||||
setStore("comments", reconcile({}))
|
||||
@@ -100,6 +149,8 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
|
||||
all,
|
||||
add,
|
||||
remove,
|
||||
update,
|
||||
replace,
|
||||
clear,
|
||||
focus: () => state.focus,
|
||||
setFocus,
|
||||
@@ -132,6 +183,8 @@ function createCommentSession(dir: string, id: string | undefined) {
|
||||
all: session.all,
|
||||
add: session.add,
|
||||
remove: session.remove,
|
||||
update: session.update,
|
||||
replace: session.replace,
|
||||
clear: session.clear,
|
||||
focus: session.focus,
|
||||
setFocus: session.setFocus,
|
||||
@@ -176,6 +229,8 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
|
||||
all: () => session().all(),
|
||||
add: (input: Omit<LineComment, "id" | "time">) => session().add(input),
|
||||
remove: (file: string, id: string) => session().remove(file, id),
|
||||
update: (file: string, id: string, comment: string) => session().update(file, id, comment),
|
||||
replace: (comments: LineComment[]) => session().replace(comments),
|
||||
clear: () => session().clear(),
|
||||
focus: () => session().focus(),
|
||||
setFocus: (focus: CommentFocus | null) => session().setFocus(focus),
|
||||
|
||||
@@ -9,7 +9,7 @@ const MAX_FILE_VIEW_SESSIONS = 20
|
||||
const MAX_VIEW_FILES = 500
|
||||
|
||||
function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
|
||||
if (range.start <= range.end) return range
|
||||
if (range.start <= range.end) return { ...range }
|
||||
|
||||
const startSide = range.side
|
||||
const endSide = range.endSide ?? startSide
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type {
|
||||
Config,
|
||||
OpencodeClient,
|
||||
KiloClient,
|
||||
Path,
|
||||
Project,
|
||||
ProviderAuthResponse,
|
||||
@@ -11,7 +11,6 @@ import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import {
|
||||
createContext,
|
||||
createEffect,
|
||||
getOwner,
|
||||
Match,
|
||||
onCleanup,
|
||||
@@ -35,7 +34,6 @@ import { trimSessions } from "./global-sync/session-trim"
|
||||
import type { ProjectMeta } from "./global-sync/types"
|
||||
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
||||
import { sanitizeProject } from "./global-sync/utils"
|
||||
import { usePlatform } from "./platform"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
|
||||
type GlobalStore = {
|
||||
@@ -54,17 +52,16 @@ type GlobalStore = {
|
||||
|
||||
function createGlobalSync() {
|
||||
const globalSDK = useGlobalSDK()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const owner = getOwner()
|
||||
if (!owner) throw new Error("GlobalSync must be created within owner")
|
||||
|
||||
const sdkCache = new Map<string, OpencodeClient>()
|
||||
const sdkCache = new Map<string, KiloClient>()
|
||||
const booting = new Map<string, Promise<void>>()
|
||||
const sessionLoads = new Map<string, Promise<void>>()
|
||||
const sessionMeta = new Map<string, { limit: number }>()
|
||||
|
||||
const [projectCache, setProjectCache, , projectCacheReady] = persisted(
|
||||
const [projectCache, setProjectCache, projectInit] = persisted(
|
||||
Persist.global("globalSync.project", ["globalSync.project.v1"]),
|
||||
createStore({ value: [] as Project[] }),
|
||||
)
|
||||
@@ -80,6 +77,57 @@ function createGlobalSync() {
|
||||
reload: undefined,
|
||||
})
|
||||
|
||||
let active = true
|
||||
let projectWritten = false
|
||||
|
||||
onCleanup(() => {
|
||||
active = false
|
||||
})
|
||||
|
||||
const cacheProjects = () => {
|
||||
setProjectCache(
|
||||
"value",
|
||||
untrack(() => globalStore.project.map(sanitizeProject)),
|
||||
)
|
||||
}
|
||||
|
||||
const setProjects = (next: Project[] | ((draft: Project[]) => void)) => {
|
||||
projectWritten = true
|
||||
if (typeof next === "function") {
|
||||
setGlobalStore("project", produce(next))
|
||||
cacheProjects()
|
||||
return
|
||||
}
|
||||
setGlobalStore("project", next)
|
||||
cacheProjects()
|
||||
}
|
||||
|
||||
const setBootStore = ((...input: unknown[]) => {
|
||||
if (input[0] === "project" && Array.isArray(input[1])) {
|
||||
setProjects(input[1] as Project[])
|
||||
return input[1]
|
||||
}
|
||||
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
|
||||
}) as typeof setGlobalStore
|
||||
|
||||
const set = ((...input: unknown[]) => {
|
||||
if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
|
||||
setProjects(input[1] as Project[] | ((draft: Project[]) => void))
|
||||
return input[1]
|
||||
}
|
||||
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
|
||||
}) as typeof setGlobalStore
|
||||
|
||||
if (projectInit instanceof Promise) {
|
||||
void projectInit.then(() => {
|
||||
if (!active) return
|
||||
if (projectWritten) return
|
||||
const cached = projectCache.value
|
||||
if (cached.length === 0) return
|
||||
setGlobalStore("project", cached)
|
||||
})
|
||||
}
|
||||
|
||||
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
|
||||
if (!sessionID) return
|
||||
if (!todos) {
|
||||
@@ -127,30 +175,6 @@ function createGlobalSync() {
|
||||
return sdk
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!projectCacheReady()) return
|
||||
if (globalStore.project.length !== 0) return
|
||||
const cached = projectCache.value
|
||||
if (cached.length === 0) return
|
||||
setGlobalStore("project", cached)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!projectCacheReady()) return
|
||||
const projects = globalStore.project
|
||||
if (projects.length === 0) {
|
||||
const cachedLength = untrack(() => projectCache.value.length)
|
||||
if (cachedLength !== 0) return
|
||||
}
|
||||
setProjectCache("value", projects.map(sanitizeProject))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (globalStore.reload !== "complete") return
|
||||
setGlobalStore("reload", undefined)
|
||||
queue.refresh()
|
||||
})
|
||||
|
||||
async function loadSessions(directory: string) {
|
||||
const pending = sessionLoads.get(directory)
|
||||
if (pending) return pending
|
||||
@@ -204,7 +228,10 @@ function createGlobalSync() {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.session.listFailed.title", { project }),
|
||||
description: formatServerError(err),
|
||||
description: formatServerError(err, {
|
||||
unknown: language.t("error.chain.unknown"),
|
||||
invalidConfiguration: language.t("error.server.invalidConfiguration"),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -234,6 +261,8 @@ function createGlobalSync() {
|
||||
setStore: child[1],
|
||||
vcsCache: cache,
|
||||
loadSessions,
|
||||
unknownError: language.t("error.chain.unknown"),
|
||||
invalidConfigurationError: language.t("error.server.invalidConfiguration"),
|
||||
})
|
||||
})()
|
||||
|
||||
@@ -254,13 +283,7 @@ function createGlobalSync() {
|
||||
event,
|
||||
project: globalStore.project,
|
||||
refresh: queue.refresh,
|
||||
setGlobalProject(next) {
|
||||
if (typeof next === "function") {
|
||||
setGlobalStore("project", produce(next))
|
||||
return
|
||||
}
|
||||
setGlobalStore("project", next)
|
||||
},
|
||||
setGlobalProject: setProjects,
|
||||
})
|
||||
if (event.type === "server.connected" || event.type === "global.disposed") {
|
||||
for (const directory of Object.keys(children.children)) {
|
||||
@@ -308,7 +331,10 @@ function createGlobalSync() {
|
||||
url: globalSDK.url,
|
||||
}),
|
||||
requestFailedTitle: language.t("common.requestFailed"),
|
||||
setGlobalStore,
|
||||
unknownError: language.t("error.chain.unknown"),
|
||||
invalidConfigurationError: language.t("error.server.invalidConfiguration"),
|
||||
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
||||
setGlobalStore: setBootStore,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -332,7 +358,9 @@ function createGlobalSync() {
|
||||
.update({ config })
|
||||
.then(bootstrap)
|
||||
.then(() => {
|
||||
setGlobalStore("reload", "complete")
|
||||
queue.refresh()
|
||||
setGlobalStore("reload", undefined)
|
||||
queue.refresh()
|
||||
})
|
||||
.catch((error) => {
|
||||
setGlobalStore("reload", undefined)
|
||||
@@ -342,7 +370,7 @@ function createGlobalSync() {
|
||||
|
||||
return {
|
||||
data: globalStore,
|
||||
set: setGlobalStore,
|
||||
set,
|
||||
get ready() {
|
||||
return globalStore.ready
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type {
|
||||
Config,
|
||||
OpencodeClient,
|
||||
KiloClient,
|
||||
Path,
|
||||
PermissionRequest,
|
||||
Project,
|
||||
@@ -32,10 +32,13 @@ type GlobalStore = {
|
||||
}
|
||||
|
||||
export async function bootstrapGlobal(input: {
|
||||
globalSDK: OpencodeClient
|
||||
globalSDK: KiloClient
|
||||
connectErrorTitle: string
|
||||
connectErrorDescription: string
|
||||
requestFailedTitle: string
|
||||
unknownError: string
|
||||
invalidConfigurationError: string
|
||||
formatMoreCount: (count: number) => string
|
||||
setGlobalStore: SetStoreFunction<GlobalStore>
|
||||
}) {
|
||||
const health = await input.globalSDK.global
|
||||
@@ -88,8 +91,11 @@ export async function bootstrapGlobal(input: {
|
||||
const results = await Promise.allSettled(tasks)
|
||||
const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
|
||||
if (errors.length) {
|
||||
const message = errors[0] instanceof Error ? errors[0].message : String(errors[0])
|
||||
const more = errors.length > 1 ? ` (+${errors.length - 1} more)` : ""
|
||||
const message = formatServerError(errors[0], {
|
||||
unknown: input.unknownError,
|
||||
invalidConfiguration: input.invalidConfigurationError,
|
||||
})
|
||||
const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : ""
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.requestFailedTitle,
|
||||
@@ -111,11 +117,13 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
|
||||
|
||||
export async function bootstrapDirectory(input: {
|
||||
directory: string
|
||||
sdk: OpencodeClient
|
||||
sdk: KiloClient
|
||||
store: Store<State>
|
||||
setStore: SetStoreFunction<State>
|
||||
vcsCache: VcsCache
|
||||
loadSessions: (directory: string) => Promise<void> | void
|
||||
unknownError: string
|
||||
invalidConfigurationError: string
|
||||
}) {
|
||||
if (input.store.status !== "complete") input.setStore("status", "loading")
|
||||
|
||||
@@ -137,7 +145,10 @@ export async function bootstrapDirectory(input: {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: `Failed to reload ${project}`,
|
||||
description: formatServerError(err),
|
||||
description: formatServerError(err, {
|
||||
unknown: input.unknownError,
|
||||
invalidConfiguration: input.invalidConfigurationError,
|
||||
}),
|
||||
})
|
||||
input.setStore("status", "partial")
|
||||
return
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createRoot, createEffect, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
|
||||
import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { VcsInfo } from "@kilocode/sdk/v2/client"
|
||||
@@ -131,8 +131,7 @@ export function createChildStoreManager(input: {
|
||||
)
|
||||
if (!vcs) throw new Error("Failed to create persisted cache")
|
||||
const vcsStore = vcs[0]
|
||||
const vcsReady = vcs[3]
|
||||
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady })
|
||||
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
|
||||
|
||||
const meta = runWithOwner(input.owner, () =>
|
||||
persisted(
|
||||
@@ -154,10 +153,12 @@ export function createChildStoreManager(input: {
|
||||
|
||||
const init = () =>
|
||||
createRoot((dispose) => {
|
||||
const initialMeta = meta[0].value
|
||||
const initialIcon = icon[0].value
|
||||
const child = createStore<State>({
|
||||
project: "",
|
||||
projectMeta: meta[0].value,
|
||||
icon: icon[0].value,
|
||||
projectMeta: initialMeta,
|
||||
icon: initialIcon,
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
config: {},
|
||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||
@@ -181,16 +182,27 @@ export function createChildStoreManager(input: {
|
||||
children[directory] = child
|
||||
disposers.set(directory, dispose)
|
||||
|
||||
createEffect(() => {
|
||||
if (!vcsReady()) return
|
||||
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
||||
if (!(init instanceof Promise)) return
|
||||
void init.then(() => {
|
||||
if (children[directory] !== child) return
|
||||
run()
|
||||
})
|
||||
}
|
||||
|
||||
onPersistedInit(vcs[2], () => {
|
||||
const cached = vcsStore.value
|
||||
if (!cached?.branch) return
|
||||
child[1]("vcs", (value) => value ?? cached)
|
||||
})
|
||||
createEffect(() => {
|
||||
|
||||
onPersistedInit(meta[2], () => {
|
||||
if (child[0].projectMeta !== initialMeta) return
|
||||
child[1]("projectMeta", meta[0].value)
|
||||
})
|
||||
createEffect(() => {
|
||||
|
||||
onPersistedInit(icon[2], () => {
|
||||
if (child[0].icon !== initialIcon) return
|
||||
child[1]("icon", icon[0].value)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@ import { dict as no } from "@/i18n/no"
|
||||
import { dict as br } from "@/i18n/br"
|
||||
import { dict as th } from "@/i18n/th"
|
||||
import { dict as bs } from "@/i18n/bs"
|
||||
import { dict as tr } from "@/i18n/tr"
|
||||
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
|
||||
import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
|
||||
import { dict as uiZht } from "@opencode-ai/ui/i18n/zht"
|
||||
@@ -35,6 +36,7 @@ import { dict as uiNo } from "@opencode-ai/ui/i18n/no"
|
||||
import { dict as uiBr } from "@opencode-ai/ui/i18n/br"
|
||||
import { dict as uiTh } from "@opencode-ai/ui/i18n/th"
|
||||
import { dict as uiBs } from "@opencode-ai/ui/i18n/bs"
|
||||
import { dict as uiTr } from "@opencode-ai/ui/i18n/tr"
|
||||
|
||||
// kilocode_change start
|
||||
import { dict as kiloEn } from "@kilocode/kilo-i18n/en"
|
||||
@@ -53,6 +55,7 @@ import { dict as kiloNo } from "@kilocode/kilo-i18n/no"
|
||||
import { dict as kiloBr } from "@kilocode/kilo-i18n/br"
|
||||
import { dict as kiloTh } from "@kilocode/kilo-i18n/th"
|
||||
import { dict as kiloBs } from "@kilocode/kilo-i18n/bs"
|
||||
import { dict as kiloTr } from "@kilocode/kilo-i18n/tr"
|
||||
// kilocode_change end
|
||||
|
||||
export type Locale =
|
||||
@@ -72,6 +75,7 @@ export type Locale =
|
||||
| "br"
|
||||
| "th"
|
||||
| "bs"
|
||||
| "tr"
|
||||
|
||||
type RawDictionary = typeof en & typeof uiEn & typeof kiloEn // kilocode_change
|
||||
type Dictionary = i18n.Flatten<RawDictionary>
|
||||
@@ -97,6 +101,7 @@ const LOCALES: readonly Locale[] = [
|
||||
"no",
|
||||
"br",
|
||||
"th",
|
||||
"tr",
|
||||
]
|
||||
|
||||
const LABEL_KEY: Record<Locale, keyof Dictionary> = {
|
||||
@@ -116,6 +121,7 @@ const LABEL_KEY: Record<Locale, keyof Dictionary> = {
|
||||
br: "language.br",
|
||||
th: "language.th",
|
||||
bs: "language.bs",
|
||||
tr: "language.tr",
|
||||
}
|
||||
|
||||
const base = i18n.flatten({ ...en, ...uiEn, ...kiloEn })
|
||||
@@ -136,6 +142,7 @@ const DICT: Record<Locale, Dictionary> = {
|
||||
br: { ...base, ...i18n.flatten({ ...br, ...uiBr, ...kiloBr }) },
|
||||
th: { ...base, ...i18n.flatten({ ...th, ...uiTh, ...kiloTh }) },
|
||||
bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs, ...kiloBs }) },
|
||||
tr: { ...base, ...i18n.flatten({ ...tr, ...uiTr, ...kiloTr }) },
|
||||
}
|
||||
|
||||
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
|
||||
@@ -157,6 +164,7 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole
|
||||
{ locale: "br", match: (language) => language.startsWith("pt") },
|
||||
{ locale: "th", match: (language) => language.startsWith("th") },
|
||||
{ locale: "bs", match: (language) => language.startsWith("bs") },
|
||||
{ locale: "tr", match: (language) => language.startsWith("tr") },
|
||||
]
|
||||
|
||||
type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
|
||||
@@ -176,6 +184,7 @@ const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
|
||||
br,
|
||||
th,
|
||||
bs,
|
||||
tr,
|
||||
}
|
||||
void PARITY_CHECK
|
||||
|
||||
|
||||
@@ -41,4 +41,24 @@ describe("createScrollPersistence", () => {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
test("reseeds empty cache after persisted snapshot loads", () => {
|
||||
const snapshot = {
|
||||
session: {},
|
||||
} as Record<string, Record<string, { x: number; y: number }>>
|
||||
|
||||
const scroll = createScrollPersistence({
|
||||
getSnapshot: (sessionKey) => snapshot[sessionKey],
|
||||
onFlush: () => {},
|
||||
})
|
||||
|
||||
expect(scroll.scroll("session", "review")).toBeUndefined()
|
||||
|
||||
snapshot.session = {
|
||||
review: { x: 12, y: 34 },
|
||||
}
|
||||
|
||||
expect(scroll.scroll("session", "review")).toEqual({ x: 12, y: 34 })
|
||||
scroll.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,8 +33,16 @@ export function createScrollPersistence(opts: Options) {
|
||||
}
|
||||
|
||||
function seed(sessionKey: string) {
|
||||
if (cache[sessionKey]) return
|
||||
setCache(sessionKey, clone(opts.getSnapshot(sessionKey)))
|
||||
const next = clone(opts.getSnapshot(sessionKey))
|
||||
const current = cache[sessionKey]
|
||||
if (!current) {
|
||||
setCache(sessionKey, next)
|
||||
return
|
||||
}
|
||||
|
||||
if (Object.keys(current).length > 0) return
|
||||
if (Object.keys(next).length === 0) return
|
||||
setCache(sessionKey, next)
|
||||
}
|
||||
|
||||
function scroll(sessionKey: string, tab: string) {
|
||||
|
||||
@@ -7,8 +7,10 @@ import { useServer } from "./server"
|
||||
import { usePlatform } from "./platform"
|
||||
import { Project } from "@kilocode/sdk/v2"
|
||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { same } from "@/utils/same"
|
||||
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
||||
import { createPathHelpers } from "./file/path"
|
||||
|
||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||
const DEFAULT_PANEL_WIDTH = 344
|
||||
@@ -96,6 +98,38 @@ function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string):
|
||||
return { all, active: tab }
|
||||
}
|
||||
|
||||
const sessionPath = (key: string) => {
|
||||
const dir = key.split("/")[0]
|
||||
if (!dir) return
|
||||
const root = decode64(dir)
|
||||
if (!root) return
|
||||
return createPathHelpers(() => root)
|
||||
}
|
||||
|
||||
const normalizeSessionTab = (path: ReturnType<typeof createPathHelpers> | undefined, tab: string) => {
|
||||
if (!tab.startsWith("file://")) return tab
|
||||
if (!path) return tab
|
||||
return path.tab(tab)
|
||||
}
|
||||
|
||||
const normalizeSessionTabList = (path: ReturnType<typeof createPathHelpers> | undefined, all: string[]) => {
|
||||
const seen = new Set<string>()
|
||||
return all.flatMap((tab) => {
|
||||
const value = normalizeSessionTab(path, tab)
|
||||
if (seen.has(value)) return []
|
||||
seen.add(value)
|
||||
return [value]
|
||||
})
|
||||
}
|
||||
|
||||
const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
|
||||
const path = sessionPath(key)
|
||||
return {
|
||||
all: normalizeSessionTabList(path, tabs.all),
|
||||
active: tabs.active ? normalizeSessionTab(path, tabs.active) : tabs.active,
|
||||
}
|
||||
}
|
||||
|
||||
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
||||
name: "Layout",
|
||||
init: () => {
|
||||
@@ -147,12 +181,46 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
}
|
||||
})()
|
||||
|
||||
if (migratedSidebar === sidebar && migratedReview === review && migratedFileTree === fileTree) return value
|
||||
const sessionTabs = value.sessionTabs
|
||||
const migratedSessionTabs = (() => {
|
||||
if (!isRecord(sessionTabs)) return sessionTabs
|
||||
|
||||
let changed = false
|
||||
const next = Object.fromEntries(
|
||||
Object.entries(sessionTabs).map(([key, tabs]) => {
|
||||
if (!isRecord(tabs) || !Array.isArray(tabs.all)) return [key, tabs]
|
||||
|
||||
const current = {
|
||||
all: tabs.all.filter((tab): tab is string => typeof tab === "string"),
|
||||
active: typeof tabs.active === "string" ? tabs.active : undefined,
|
||||
}
|
||||
const normalized = normalizeStoredSessionTabs(key, current)
|
||||
if (current.all.length !== tabs.all.length) changed = true
|
||||
if (!same(current.all, normalized.all) || current.active !== normalized.active) changed = true
|
||||
if (tabs.active !== undefined && typeof tabs.active !== "string") changed = true
|
||||
return [key, normalized]
|
||||
}),
|
||||
)
|
||||
|
||||
if (!changed) return sessionTabs
|
||||
return next
|
||||
})()
|
||||
|
||||
if (
|
||||
migratedSidebar === sidebar &&
|
||||
migratedReview === review &&
|
||||
migratedFileTree === fileTree &&
|
||||
migratedSessionTabs === sessionTabs
|
||||
) {
|
||||
return value
|
||||
}
|
||||
|
||||
return {
|
||||
...value,
|
||||
sidebar: migratedSidebar,
|
||||
review: migratedReview,
|
||||
fileTree: migratedFileTree,
|
||||
sessionTabs: migratedSessionTabs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,22 +813,26 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
tabs(sessionKey: string | Accessor<string>) {
|
||||
const key = createSessionKeyReader(sessionKey, ensureKey)
|
||||
const path = createMemo(() => sessionPath(key()))
|
||||
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
|
||||
const normalize = (tab: string) => normalizeSessionTab(path(), tab)
|
||||
const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
|
||||
return {
|
||||
tabs,
|
||||
active: createMemo(() => tabs().active),
|
||||
all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
|
||||
setActive(tab: string | undefined) {
|
||||
const session = key()
|
||||
const next = tab ? normalize(tab) : tab
|
||||
if (!store.sessionTabs[session]) {
|
||||
setStore("sessionTabs", session, { all: [], active: tab })
|
||||
setStore("sessionTabs", session, { all: [], active: next })
|
||||
} else {
|
||||
setStore("sessionTabs", session, "active", tab)
|
||||
setStore("sessionTabs", session, "active", next)
|
||||
}
|
||||
},
|
||||
setAll(all: string[]) {
|
||||
const session = key()
|
||||
const next = all.filter((tab) => tab !== "review")
|
||||
const next = normalizeAll(all).filter((tab) => tab !== "review")
|
||||
if (!store.sessionTabs[session]) {
|
||||
setStore("sessionTabs", session, { all: next, active: undefined })
|
||||
} else {
|
||||
@@ -769,7 +841,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
async open(tab: string) {
|
||||
const session = key()
|
||||
const next = nextSessionTabsForOpen(store.sessionTabs[session], tab)
|
||||
const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab))
|
||||
setStore("sessionTabs", session, next)
|
||||
},
|
||||
close(tab: string) {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, Session } from "@kilocode/sdk/v2/client"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { autoRespondsPermission } from "./permission-auto-respond"
|
||||
|
||||
const session = (input: { id: string; parentID?: string }) =>
|
||||
({
|
||||
id: input.id,
|
||||
parentID: input.parentID,
|
||||
}) as Session
|
||||
|
||||
const permission = (sessionID: string) =>
|
||||
({
|
||||
sessionID,
|
||||
}) as Pick<PermissionRequest, "sessionID">
|
||||
|
||||
describe("autoRespondsPermission", () => {
|
||||
test("uses a parent session's directory-scoped auto-accept", () => {
|
||||
const directory = "/tmp/project"
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||
const autoAccept = {
|
||||
[`${base64Encode(directory)}/root`]: true,
|
||||
}
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
|
||||
})
|
||||
|
||||
test("uses a parent session's legacy auto-accept key", () => {
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||
|
||||
expect(autoRespondsPermission({ root: true }, sessions, permission("child"), "/tmp/project")).toBe(true)
|
||||
})
|
||||
|
||||
test("defaults to requiring approval when no lineage override exists", () => {
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" }), session({ id: "other" })]
|
||||
const autoAccept = {
|
||||
other: true,
|
||||
}
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), "/tmp/project")).toBe(false)
|
||||
})
|
||||
|
||||
test("inherits a parent session's false override", () => {
|
||||
const directory = "/tmp/project"
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||
const autoAccept = {
|
||||
[`${base64Encode(directory)}/root`]: false,
|
||||
}
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(false)
|
||||
})
|
||||
|
||||
test("prefers a child override over parent override", () => {
|
||||
const directory = "/tmp/project"
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||
const autoAccept = {
|
||||
[`${base64Encode(directory)}/root`]: false,
|
||||
[`${base64Encode(directory)}/child`]: true,
|
||||
}
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
|
||||
export function acceptKey(sessionID: string, directory?: string) {
|
||||
if (!directory) return sessionID
|
||||
return `${base64Encode(directory)}/${sessionID}`
|
||||
}
|
||||
|
||||
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
return autoAccept[key] ?? autoAccept[sessionID]
|
||||
}
|
||||
|
||||
function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) {
|
||||
const parent = session.reduce((acc, item) => {
|
||||
if (item.parentID) acc.set(item.id, item.parentID)
|
||||
return acc
|
||||
}, new Map<string, string>())
|
||||
const seen = new Set([sessionID])
|
||||
const ids = [sessionID]
|
||||
|
||||
for (const id of ids) {
|
||||
const parentID = parent.get(id)
|
||||
if (!parentID || seen.has(parentID)) continue
|
||||
seen.add(parentID)
|
||||
ids.push(parentID)
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
export function autoRespondsPermission(
|
||||
autoAccept: Record<string, boolean>,
|
||||
session: { id: string; parentID?: string }[],
|
||||
permission: { sessionID: string },
|
||||
directory?: string,
|
||||
) {
|
||||
const value = sessionLineage(session, permission.sessionID)
|
||||
.map((id) => accepted(autoAccept, id, directory))
|
||||
.find((item): item is boolean => item !== undefined)
|
||||
return value ?? false
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import { Persist, persisted } from "@/utils/persist"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { acceptKey, autoRespondsPermission } from "./permission-auto-respond"
|
||||
|
||||
type PermissionRespondFn = (input: {
|
||||
sessionID: string
|
||||
@@ -16,10 +16,6 @@ type PermissionRespondFn = (input: {
|
||||
directory?: string
|
||||
}) => void
|
||||
|
||||
function shouldAutoAccept(perm: PermissionRequest) {
|
||||
return perm.permission === "edit"
|
||||
}
|
||||
|
||||
function isNonAllowRule(rule: unknown) {
|
||||
if (!rule) return false
|
||||
if (typeof rule === "string") return rule !== "allow"
|
||||
@@ -40,10 +36,7 @@ function hasPermissionPromptRules(permission: unknown) {
|
||||
if (Array.isArray(permission)) return false
|
||||
|
||||
const config = permission as Record<string, unknown>
|
||||
if (isNonAllowRule(config.edit)) return true
|
||||
if (isNonAllowRule(config.write)) return true
|
||||
|
||||
return false
|
||||
return Object.values(config).some(isNonAllowRule)
|
||||
}
|
||||
|
||||
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
|
||||
@@ -61,9 +54,25 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
})
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("permission", ["permission.v3"]),
|
||||
{
|
||||
...Persist.global("permission", ["permission.v3"]),
|
||||
migrate(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value
|
||||
|
||||
const data = value as Record<string, unknown>
|
||||
if (data.autoAccept) return value
|
||||
|
||||
return {
|
||||
...data,
|
||||
autoAccept:
|
||||
typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits)
|
||||
? data.autoAcceptEdits
|
||||
: {},
|
||||
}
|
||||
},
|
||||
},
|
||||
createStore({
|
||||
autoAcceptEdits: {} as Record<string, boolean>,
|
||||
autoAccept: {} as Record<string, boolean>,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -105,14 +114,14 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
})
|
||||
}
|
||||
|
||||
function acceptKey(sessionID: string, directory?: string) {
|
||||
if (!directory) return sessionID
|
||||
return `${base64Encode(directory)}/${sessionID}`
|
||||
function isAutoAccepting(sessionID: string, directory?: string) {
|
||||
const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : []
|
||||
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
|
||||
}
|
||||
|
||||
function isAutoAccepting(sessionID: string, directory?: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
return store.autoAcceptEdits[key] ?? store.autoAcceptEdits[sessionID] ?? false
|
||||
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
|
||||
const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : []
|
||||
return autoRespondsPermission(store.autoAccept, session, permission, directory)
|
||||
}
|
||||
|
||||
function bumpEnableVersion(sessionID: string, directory?: string) {
|
||||
@@ -127,8 +136,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
if (event?.type !== "permission.asked") return
|
||||
|
||||
const perm = event.properties
|
||||
if (!isAutoAccepting(perm.sessionID, e.name)) return
|
||||
if (!shouldAutoAccept(perm)) return
|
||||
if (!shouldAutoRespond(perm, e.name)) return
|
||||
|
||||
respondOnce(perm, e.name)
|
||||
})
|
||||
@@ -139,8 +147,8 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
const version = bumpEnableVersion(sessionID, directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAcceptEdits[key] = true
|
||||
delete draft.autoAcceptEdits[sessionID]
|
||||
draft.autoAccept[key] = true
|
||||
delete draft.autoAccept[sessionID]
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -151,8 +159,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
if (!isAutoAccepting(sessionID, directory)) return
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id) continue
|
||||
if (perm.sessionID !== sessionID) continue
|
||||
if (!shouldAutoAccept(perm)) continue
|
||||
if (!shouldAutoRespond(perm, directory)) continue
|
||||
respondOnce(perm, directory)
|
||||
}
|
||||
})
|
||||
@@ -161,11 +168,12 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
|
||||
function disable(sessionID: string, directory?: string) {
|
||||
bumpEnableVersion(sessionID, directory)
|
||||
const key = directory ? acceptKey(sessionID, directory) : undefined
|
||||
const key = directory ? acceptKey(sessionID, directory) : sessionID
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
if (key) delete draft.autoAcceptEdits[key]
|
||||
delete draft.autoAcceptEdits[sessionID]
|
||||
draft.autoAccept[key] = false
|
||||
if (!directory) return
|
||||
delete draft.autoAccept[sessionID]
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -174,7 +182,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
ready,
|
||||
respond,
|
||||
autoResponds(permission: PermissionRequest, directory?: string) {
|
||||
return isAutoAccepting(permission.sessionID, directory) && shouldAutoAccept(permission)
|
||||
return shouldAutoRespond(permission, directory)
|
||||
},
|
||||
isAutoAccepting,
|
||||
toggleAutoAccept(sessionID: string, directory: string) {
|
||||
|
||||
@@ -116,6 +116,10 @@ function contextItemKey(item: ContextItem) {
|
||||
return `${key}:c=${digest.slice(0, 8)}`
|
||||
}
|
||||
|
||||
function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
|
||||
return item.type === "file" && !!item.comment?.trim()
|
||||
}
|
||||
|
||||
function createPromptActions(
|
||||
setStore: SetStoreFunction<{
|
||||
prompt: Prompt
|
||||
@@ -189,6 +193,26 @@ function createPromptSession(dir: string, id: string | undefined) {
|
||||
remove(key: string) {
|
||||
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
|
||||
},
|
||||
removeComment(path: string, commentID: string) {
|
||||
setStore("context", "items", (items) =>
|
||||
items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
|
||||
)
|
||||
},
|
||||
updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
|
||||
setStore("context", "items", (items) =>
|
||||
items.map((item) => {
|
||||
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
|
||||
const value = { ...item, ...next }
|
||||
return { ...value, key: contextItemKey(value) }
|
||||
}),
|
||||
)
|
||||
},
|
||||
replaceComments(items: FileContextItem[]) {
|
||||
setStore("context", "items", (current) => [
|
||||
...current.filter((item) => !isCommentItem(item)),
|
||||
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
|
||||
])
|
||||
},
|
||||
},
|
||||
set: actions.set,
|
||||
reset: actions.reset,
|
||||
@@ -251,6 +275,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
items: () => session().context.items(),
|
||||
add: (item: ContextItem) => session().context.add(item),
|
||||
remove: (key: string) => session().context.remove(key),
|
||||
removeComment: (path: string, commentID: string) => session().context.removeComment(path, commentID),
|
||||
updateComment: (path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) =>
|
||||
session().context.updateComment(path, commentID, next),
|
||||
replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
|
||||
},
|
||||
set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition),
|
||||
reset: () => session().reset(),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Persist, persisted } from "@/utils/persist"
|
||||
import { checkServerHealth } from "@/utils/server-health"
|
||||
|
||||
type StoredProject = { worktree: string; expanded: boolean }
|
||||
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
|
||||
const HEALTH_POLL_INTERVAL_MS = 10_000
|
||||
|
||||
export function normalizeServerUrl(input: string) {
|
||||
@@ -15,9 +16,9 @@ export function normalizeServerUrl(input: string) {
|
||||
return withProtocol.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
export function serverDisplayName(conn?: ServerConnection.Any) {
|
||||
export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = false) {
|
||||
if (!conn) return ""
|
||||
if (conn.displayName) return conn.displayName
|
||||
if (conn.displayName && !ignoreDisplayName) return conn.displayName
|
||||
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
@@ -100,22 +101,33 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("server", ["server.v3"]),
|
||||
createStore({
|
||||
list: [] as string[],
|
||||
list: [] as StoredServer[],
|
||||
projects: {} as Record<string, StoredProject[]>,
|
||||
lastProject: {} as Record<string, string>,
|
||||
}),
|
||||
)
|
||||
|
||||
const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
|
||||
|
||||
const allServers = createMemo((): Array<ServerConnection.Any> => {
|
||||
const servers = [
|
||||
...(props.servers ?? []),
|
||||
...store.list.map((value) => ({
|
||||
type: "http" as const,
|
||||
http: typeof value === "string" ? { url: value } : value,
|
||||
})),
|
||||
...store.list.map((value) =>
|
||||
typeof value === "string"
|
||||
? {
|
||||
type: "http" as const,
|
||||
http: { url: value },
|
||||
}
|
||||
: value,
|
||||
),
|
||||
]
|
||||
|
||||
const deduped = new Map(servers.map((conn) => [ServerConnection.key(conn), conn]))
|
||||
const deduped = new Map(
|
||||
servers.map((value) => {
|
||||
const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
|
||||
return [ServerConnection.key(conn), conn]
|
||||
}),
|
||||
)
|
||||
|
||||
return [...deduped.values()]
|
||||
})
|
||||
@@ -156,27 +168,29 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
if (state.active !== input) setState("active", input)
|
||||
}
|
||||
|
||||
function add(input: string) {
|
||||
const url = normalizeServerUrl(input)
|
||||
if (!url) return
|
||||
function add(input: ServerConnection.Http) {
|
||||
const url_ = normalizeServerUrl(input.http.url)
|
||||
if (!url_) return
|
||||
const conn = { ...input, http: { ...input.http, url: url_ } }
|
||||
return batch(() => {
|
||||
const http: ServerConnection.HttpBase = { url }
|
||||
if (!store.list.includes(url)) {
|
||||
setStore("list", store.list.length, url)
|
||||
const existing = store.list.findIndex((x) => url(x) === url_)
|
||||
if (existing !== -1) {
|
||||
setStore("list", existing, conn)
|
||||
} else {
|
||||
setStore("list", store.list.length, conn)
|
||||
}
|
||||
const conn: ServerConnection.Http = { type: "http", http }
|
||||
setState("active", ServerConnection.key(conn))
|
||||
return conn
|
||||
})
|
||||
}
|
||||
|
||||
function remove(key: ServerConnection.Key) {
|
||||
const list = store.list.filter((x) => x !== key)
|
||||
const list = store.list.filter((x) => url(x) !== key)
|
||||
batch(() => {
|
||||
setStore("list", list)
|
||||
if (state.active === key) {
|
||||
const next = list[0]
|
||||
setState("active", next ? ServerConnection.key({ type: "http", http: { url: next } }) : props.defaultServer)
|
||||
setState("active", next ? ServerConnection.Key.make(url(next)) : props.defaultServer)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -212,7 +226,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
return state.active
|
||||
},
|
||||
get name() {
|
||||
return serverDisplayName(current())
|
||||
return serverName(current())
|
||||
},
|
||||
get list() {
|
||||
return allServers()
|
||||
|
||||
@@ -43,12 +43,11 @@ type OptimisticRemoveInput = {
|
||||
|
||||
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
|
||||
const messages = draft.message[input.sessionID]
|
||||
if (!messages) {
|
||||
draft.message[input.sessionID] = [input.message]
|
||||
}
|
||||
if (messages) {
|
||||
const result = Binary.search(messages, input.message.id, (m) => m.id)
|
||||
messages.splice(result.index, 0, input.message)
|
||||
} else {
|
||||
draft.message[input.sessionID] = [input.message]
|
||||
}
|
||||
draft.part[input.message.id] = sortParts(input.parts)
|
||||
}
|
||||
@@ -105,7 +104,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
return globalSync.child(directory)
|
||||
}
|
||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||
const messagePageSize = 400
|
||||
const messagePageSize = 200
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const inflightDiff = new Map<string, Promise<void>>()
|
||||
const inflightTodo = new Map<string, Promise<void>>()
|
||||
@@ -122,20 +121,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
return undefined
|
||||
}
|
||||
|
||||
const limitFor = (count: number) => {
|
||||
if (count <= messagePageSize) return messagePageSize
|
||||
return Math.ceil(count / messagePageSize) * messagePageSize
|
||||
}
|
||||
|
||||
const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
|
||||
const messages = await retry(() =>
|
||||
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
|
||||
)
|
||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||
const session = items
|
||||
.map((x) => x.info)
|
||||
.filter((m) => !!m?.id)
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id))
|
||||
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
|
||||
return {
|
||||
session,
|
||||
@@ -159,8 +150,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
.then((next) => {
|
||||
batch(() => {
|
||||
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
|
||||
for (const message of next.part) {
|
||||
input.setStore("part", message.id, reconcile(message.part, { key: "id" }))
|
||||
for (const p of next.part) {
|
||||
input.setStore("part", p.id, p.part)
|
||||
}
|
||||
setMeta("limit", key, input.limit)
|
||||
setMeta("complete", key, next.complete)
|
||||
@@ -229,17 +220,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
const key = keyFor(directory, sessionID)
|
||||
const hasSession = (() => {
|
||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||
return match.found
|
||||
})()
|
||||
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
|
||||
|
||||
const hasMessages = store.message[sessionID] !== undefined
|
||||
const hydrated = meta.limit[key] !== undefined
|
||||
if (hasSession && hasMessages && hydrated) return
|
||||
|
||||
const count = store.message[sessionID]?.length ?? 0
|
||||
const limit = hydrated ? (meta.limit[key] ?? messagePageSize) : limitFor(count)
|
||||
const limit = meta.limit[key] ?? messagePageSize
|
||||
|
||||
const sessionReq = hasSession
|
||||
? Promise.resolve()
|
||||
@@ -259,16 +242,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
)
|
||||
})
|
||||
|
||||
const messagesReq =
|
||||
hasMessages && hydrated
|
||||
? Promise.resolve()
|
||||
: loadMessages({
|
||||
directory,
|
||||
client,
|
||||
setStore,
|
||||
sessionID,
|
||||
limit,
|
||||
})
|
||||
const messagesReq = loadMessages({
|
||||
directory,
|
||||
client,
|
||||
setStore,
|
||||
sessionID,
|
||||
limit,
|
||||
})
|
||||
|
||||
return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
|
||||
},
|
||||
@@ -290,14 +270,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
const existing = store.todo[sessionID]
|
||||
const cached = globalSync.data.session_todo[sessionID]
|
||||
if (existing !== undefined) {
|
||||
if (globalSync.data.session_todo[sessionID] === undefined) {
|
||||
if (cached === undefined) {
|
||||
globalSync.todo.set(sessionID, existing)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const cached = globalSync.data.session_todo[sessionID]
|
||||
if (cached !== undefined) {
|
||||
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
|
||||
}
|
||||
@@ -324,11 +304,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const key = keyFor(sdk.directory, sessionID)
|
||||
return meta.loading[key] ?? false
|
||||
},
|
||||
async loadMore(sessionID: string, count = messagePageSize) {
|
||||
async loadMore(sessionID: string, count?: number) {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [, setStore] = globalSync.child(directory)
|
||||
const key = keyFor(directory, sessionID)
|
||||
const step = count ?? messagePageSize
|
||||
if (meta.loading[key]) return
|
||||
if (meta.complete[key]) return
|
||||
|
||||
@@ -338,7 +319,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
client,
|
||||
setStore,
|
||||
sessionID,
|
||||
limit: currentLimit + count,
|
||||
limit: currentLimit + step,
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,16 +4,7 @@ import { useParams } from "@solidjs/router"
|
||||
import { createMemo } from "solid-js"
|
||||
|
||||
// kilocode_change start - Preferred providers list (order determines display priority)
|
||||
export const popularProviders = [
|
||||
"kilo",
|
||||
"opencode",
|
||||
"anthropic",
|
||||
"github-copilot",
|
||||
"openai",
|
||||
"google",
|
||||
"openrouter",
|
||||
"vercel",
|
||||
]
|
||||
export const popularProviders = ["kilo", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
|
||||
// kilocode_change end
|
||||
const popularProviderSet = new Set(popularProviders)
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي",
|
||||
"command.prompt.mode.shell": "Shell",
|
||||
"command.prompt.mode.normal": "Prompt",
|
||||
"command.permissions.autoaccept.enable": "قبول التعديلات تلقائيًا",
|
||||
"command.permissions.autoaccept.disable": "إيقاف قبول التعديلات تلقائيًا",
|
||||
"command.permissions.autoaccept.enable": "قبول الأذونات تلقائيًا",
|
||||
"command.permissions.autoaccept.disable": "إيقاف قبول الأذونات تلقائيًا",
|
||||
"command.workspace.toggle": "تبديل مساحات العمل",
|
||||
"command.workspace.toggle.description": "تمكين أو تعطيل مساحات العمل المتعددة في الشريط الجانبي",
|
||||
"command.session.undo": "تراجع",
|
||||
@@ -91,6 +91,8 @@ export const dict = {
|
||||
"dialog.provider.group.other": "آخر",
|
||||
"dialog.provider.tag.recommended": "موصى به",
|
||||
"dialog.provider.opencode.note": "نماذج مختارة تتضمن Claude و GPT و Gemini والمزيد",
|
||||
"dialog.provider.opencode.tagline": "نماذج موثوقة ومحسنة",
|
||||
"dialog.provider.opencodeGo.tagline": "اشتراك منخفض التكلفة للجميع",
|
||||
"dialog.provider.anthropic.note": "اتصل باستخدام Claude Pro/Max أو مفتاح API",
|
||||
"dialog.provider.copilot.note": "اتصل باستخدام Copilot أو مفتاح API",
|
||||
"dialog.provider.openai.note": "اتصل باستخدام ChatGPT Pro/Plus أو مفتاح API",
|
||||
@@ -134,8 +136,7 @@ export const dict = {
|
||||
"provider.connect.oauth.code.invalid": "رمز التفويض غير صالح",
|
||||
"provider.connect.oauth.auto.visit.prefix": "قم بزيارة ",
|
||||
"provider.connect.oauth.auto.visit.link": "هذا الرابط",
|
||||
"provider.connect.oauth.auto.visit.suffix":
|
||||
" وأدخل الرمز أدناه لتوصيل حسابك واستخدام نماذج {{provider}} في Kilo.",
|
||||
"provider.connect.oauth.auto.visit.suffix": " وأدخل الرمز أدناه لتوصيل حسابك واستخدام نماذج {{provider}} في Kilo.",
|
||||
"provider.connect.oauth.auto.confirmationCode": "رمز التأكيد",
|
||||
"provider.connect.toast.connected.title": "تم توصيل {{provider}}",
|
||||
"provider.connect.toast.connected.description": "نماذج {{provider}} متاحة الآن للاستخدام.",
|
||||
@@ -364,10 +365,10 @@ export const dict = {
|
||||
"toast.workspace.enabled.description": "الآن يتم عرض عدة worktrees في الشريط الجانبي",
|
||||
"toast.workspace.disabled.title": "تم تعطيل مساحات العمل",
|
||||
"toast.workspace.disabled.description": "يتم عرض worktree الرئيسي فقط في الشريط الجانبي",
|
||||
"toast.permissions.autoaccept.on.title": "قبول التعديلات تلقائيًا",
|
||||
"toast.permissions.autoaccept.on.description": "سيتم الموافقة تلقائيًا على أذونات التحرير والكتابة",
|
||||
"toast.permissions.autoaccept.off.title": "توقف قبول التعديلات تلقائيًا",
|
||||
"toast.permissions.autoaccept.off.description": "ستتطلب أذونات التحرير والكتابة موافقة",
|
||||
"toast.permissions.autoaccept.on.title": "يتم قبول الأذونات تلقائيًا",
|
||||
"toast.permissions.autoaccept.on.description": "ستتم الموافقة على طلبات الأذونات تلقائيًا",
|
||||
"toast.permissions.autoaccept.off.title": "تم إيقاف قبول الأذونات تلقائيًا",
|
||||
"toast.permissions.autoaccept.off.description": "ستتطلب طلبات الأذونات موافقة",
|
||||
"toast.model.none.title": "لم يتم تحديد نموذج",
|
||||
"toast.model.none.description": "قم بتوصيل موفر لتلخيص هذه الجلسة",
|
||||
"toast.file.loadFailed.title": "فشل تحميل الملف",
|
||||
@@ -732,4 +733,18 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "ستتم أرشفة جلسة واحدة.",
|
||||
"workspace.reset.archived.many": "ستتم أرشفة {{count}} جلسات.",
|
||||
"workspace.reset.note": "سيؤدي هذا إلى إعادة تعيين مساحة العمل لتتطابق مع الفرع الافتراضي.",
|
||||
"common.open": "فتح",
|
||||
"dialog.releaseNotes.action.getStarted": "البدء",
|
||||
"dialog.releaseNotes.action.next": "التالي",
|
||||
"dialog.releaseNotes.action.hideFuture": "عدم إظهار هذا في المستقبل",
|
||||
"dialog.releaseNotes.media.alt": "معاينة الإصدار",
|
||||
"toast.project.reloadFailed.title": "فشل في إعادة تحميل {{project}}",
|
||||
"error.server.invalidConfiguration": "تكوين غير صالح",
|
||||
"common.moreCountSuffix": " (+{{count}} إضافي)",
|
||||
"common.time.justNow": "الآن",
|
||||
"common.time.minutesAgo.short": "قبل {{count}} د",
|
||||
"common.time.hoursAgo.short": "قبل {{count}} س",
|
||||
"common.time.daysAgo.short": "قبل {{count}} ي",
|
||||
"settings.providers.connected.environmentDescription": "متصل من متغيرات البيئة الخاصة بك",
|
||||
"settings.providers.custom.description": "أضف مزود متوافق مع OpenAI بواسطة عنوان URL الأساسي.",
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user