mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge branch 'main' into session/agent_d1eb1ecf-052a-40cb-8727-3c5050c94591
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
name: Trigger webhook for feat PRs
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
jobs:
|
||||
call-webhook:
|
||||
if: >
|
||||
github.event.pull_request.merged == true &&
|
||||
(startsWith(github.event.pull_request.title, 'feat:') ||
|
||||
startsWith(github.event.pull_request.title, 'feat('))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send webhook safely
|
||||
run: |
|
||||
payload=$(jq -n \
|
||||
--arg repo "${GITHUB_REPOSITORY}" \
|
||||
--arg pr_number "${{ github.event.pull_request.number }}" \
|
||||
--arg title "${{ github.event.pull_request.title }}" \
|
||||
--arg body "${{ github.event.pull_request.body }}" \
|
||||
--arg author "${{ github.event.pull_request.user.login }}" \
|
||||
--arg merged_at "${{ github.event.pull_request.merged_at }}" \
|
||||
'{
|
||||
repo: $repo,
|
||||
pr_number: $pr_number,
|
||||
title: $title,
|
||||
description: $body,
|
||||
author: $author,
|
||||
merged_at: $merged_at
|
||||
}'
|
||||
)
|
||||
curl --fail-with-body -X POST "${{ secrets.DOC_WEBHOOK_URL }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-raw "$payload"
|
||||
@@ -240,11 +240,90 @@ jobs:
|
||||
# APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
# APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
|
||||
|
||||
# kilocode_change start
|
||||
# Smoke test disabled as a release gate due to infrastructure issues.
|
||||
# The job is skipped via `if: false` so it no longer blocks publishing.
|
||||
# Re-enable by restoring the original `if:` condition and uncommenting
|
||||
# `- smoke-test` in the publish job's `needs` list below.
|
||||
smoke-test:
|
||||
name: Smoke Test (pre-publish gate) [DISABLED]
|
||||
needs:
|
||||
- version
|
||||
- build-cli
|
||||
if: false # was: github.repository == 'Kilo-Org/kilocode'
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Trigger kilo-bench smoke test
|
||||
id: trigger
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.BENCH_GITHUB_TOKEN }}
|
||||
run: |
|
||||
BEFORE=$(date -u -d '60 seconds ago' +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
gh api repos/Kilo-Org/kilo-bench/dispatches \
|
||||
--method POST \
|
||||
-f event_type=smoke-test \
|
||||
-f 'client_payload[release_tag]=${{ needs.version.outputs.tag }}' \
|
||||
-f 'client_payload[source_run_id]=${{ github.run_id }}'
|
||||
|
||||
# Poll for the run created after our dispatch timestamp.
|
||||
# The dispatch API returns no run ID, so we query by created time
|
||||
# and pick the oldest match to avoid grabbing an unrelated run.
|
||||
echo "Waiting for smoke-test run to appear (dispatched after $BEFORE)..."
|
||||
for attempt in $(seq 1 30); do
|
||||
RUN_ID=$(gh api \
|
||||
"repos/Kilo-Org/kilo-bench/actions/workflows/smoke-test.yml/runs?event=repository_dispatch&created=>=$BEFORE" \
|
||||
--jq '.workflow_runs | sort_by(.created_at) | .[0].id // empty')
|
||||
|
||||
if [[ -n "$RUN_ID" && "$RUN_ID" != "null" ]]; then
|
||||
echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Smoke test run: https://github.com/Kilo-Org/kilo-bench/actions/runs/$RUN_ID"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo " attempt $attempt: run not yet registered, retrying in 10s..."
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo "::error::Smoke test run did not appear within 5 minutes after dispatch."
|
||||
exit 1
|
||||
|
||||
- name: Wait for smoke test to complete
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.BENCH_GITHUB_TOKEN }}
|
||||
run: |
|
||||
RUN_ID="${{ steps.trigger.outputs.run_id }}"
|
||||
echo "Waiting for run $RUN_ID..."
|
||||
|
||||
for i in $(seq 1 60); do
|
||||
CONCLUSION=$(gh run view "$RUN_ID" \
|
||||
--repo Kilo-Org/kilo-bench \
|
||||
--json conclusion \
|
||||
--jq '.conclusion')
|
||||
|
||||
echo " attempt $i: $CONCLUSION"
|
||||
|
||||
if [[ "$CONCLUSION" == "success" ]]; then
|
||||
echo "::notice::Smoke test passed."
|
||||
exit 0
|
||||
elif [[ "$CONCLUSION" != "null" && "$CONCLUSION" != "" ]]; then
|
||||
echo "::error::Smoke test failed with conclusion: $CONCLUSION"
|
||||
echo "See: https://github.com/Kilo-Org/kilo-bench/actions/runs/$RUN_ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 30
|
||||
done
|
||||
|
||||
echo "::error::Smoke test did not complete within 30 minutes."
|
||||
exit 1
|
||||
# kilocode_change end
|
||||
publish:
|
||||
needs:
|
||||
- version
|
||||
- build-cli
|
||||
- build-vscode
|
||||
# - smoke-test # disabled: infrastructure issues (see smoke-test job comment)
|
||||
# - build-tauri
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
|
||||
@@ -42,3 +42,7 @@ jobs:
|
||||
- name: Check for dead code (knip)
|
||||
working-directory: packages/kilo-vscode
|
||||
run: bun run knip
|
||||
|
||||
- name: Check for kilocode_change markers
|
||||
working-directory: packages/kilo-vscode
|
||||
run: bun run check-kilocode-change
|
||||
|
||||
@@ -25,6 +25,8 @@ jobs:
|
||||
- "packages/kilo-vscode/webview-ui/**"
|
||||
- "packages/kilo-vscode/.storybook/**"
|
||||
- "packages/kilo-vscode/tests/visual-regression*"
|
||||
- "packages/kilo-vscode/tests/permission-dock-dropdown*"
|
||||
- "packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/**"
|
||||
- ".github/workflows/visual-regression.yml"
|
||||
- name: Check if PR is from a fork
|
||||
id: fork-check
|
||||
@@ -57,6 +59,18 @@ jobs:
|
||||
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:
|
||||
@@ -106,6 +120,26 @@ jobs:
|
||||
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: |
|
||||
@@ -120,8 +154,22 @@ jobs:
|
||||
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'
|
||||
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 }}
|
||||
@@ -135,7 +183,7 @@ jobs:
|
||||
else
|
||||
git commit -m "chore: update visual regression baselines"
|
||||
git lfs push --all origin
|
||||
git push
|
||||
git push --no-verify
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
@@ -175,6 +223,18 @@ jobs:
|
||||
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:
|
||||
@@ -224,10 +284,62 @@ jobs:
|
||||
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: Remove stale baselines for permission-dock-dropdown tests
|
||||
run: |
|
||||
node -e "
|
||||
const fs = require('fs'), path = require('path');
|
||||
const specPath = 'tests/permission-dock-dropdown.spec.ts';
|
||||
const expected = new Set();
|
||||
if (fs.existsSync(specPath)) {
|
||||
const spec = fs.readFileSync(specPath, 'utf8');
|
||||
const re = /toHaveScreenshot\(\[([^\]]+)\]\)/g;
|
||||
let m;
|
||||
while ((m = re.exec(spec)) !== null) {
|
||||
const parts = m[1].split(',').map(s => s.trim().replace(/[\"']/g, ''));
|
||||
expected.add(parts.join('/').replace(/\.png$/, '-chromium-linux.png'));
|
||||
}
|
||||
}
|
||||
const dir = 'tests/permission-dock-dropdown.spec.ts-snapshots';
|
||||
if (!fs.existsSync(dir)) process.exit(0);
|
||||
for (const sub of fs.readdirSync(dir)) {
|
||||
const sdir = path.join(dir, sub);
|
||||
if (!fs.statSync(sdir).isDirectory()) continue;
|
||||
for (const file of fs.readdirSync(sdir)) {
|
||||
const rel = sub + '/' + file;
|
||||
if (file.endsWith('.png') && !expected.has(rel)) {
|
||||
console.log('Removing stale baseline:', rel);
|
||||
fs.unlinkSync(path.join(sdir, 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/
|
||||
[ -d packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots ] && git add packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/
|
||||
if git diff --cached --quiet; then
|
||||
echo "No visual regression detected."
|
||||
else
|
||||
@@ -238,8 +350,23 @@ jobs:
|
||||
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/
|
||||
[ -d packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots ] && git add packages/kilo-vscode/tests/permission-dock-dropdown.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'
|
||||
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 }}
|
||||
@@ -247,13 +374,14 @@ jobs:
|
||||
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/
|
||||
[ -d packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots ] && git add packages/kilo-vscode/tests/permission-dock-dropdown.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
|
||||
git push --no-verify
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
|
||||
@@ -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
+20
-2
@@ -101,9 +101,27 @@
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "install-dev-extension",
|
||||
"label": "VSCode - DevSnapshot - Build",
|
||||
"type": "shell",
|
||||
"command": "bun script/install-dev-extension.ts",
|
||||
"command": "bun run snapshot:build",
|
||||
"group": "build",
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
"reveal": "always",
|
||||
"focus": false,
|
||||
"panel": "shared",
|
||||
"showReuseMessage": true,
|
||||
"clear": false
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/packages/kilo-vscode"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "VSCode - DevSnapshot - Install",
|
||||
"type": "shell",
|
||||
"command": "bun run snapshot:install",
|
||||
"group": "build",
|
||||
"presentation": {
|
||||
"echo": true,
|
||||
|
||||
@@ -15,6 +15,7 @@ Kilo CLI is an open source AI coding agent that generates code from natural lang
|
||||
- **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.
|
||||
- **kilocode_change check**: `bun run check-kilocode-change` from `packages/kilo-vscode/`. CI runs this — `kilocode_change` is a marker for upstream merge conflicts and must not appear in `packages/kilo-vscode/` or `packages/kilo-ui/` (these are entirely Kilo Code additions). Remove the markers before pushing.
|
||||
|
||||
## Products
|
||||
|
||||
@@ -64,6 +65,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
|
||||
```
|
||||
@@ -151,6 +163,10 @@ const bazFoo = 3
|
||||
You MUST avoid using `mocks` as much as possible.
|
||||
Tests MUST test actual implementation, do not duplicate logic into a test.
|
||||
|
||||
## Commit Conventions
|
||||
|
||||
[Conventional Commits](https://www.conventionalcommits.org/) with scopes matching packages: `vscode`, `cli`, `agent-manager`, `sdk`, `ui`, `i18n`, `kilo-docs`, `gateway`, `telemetry`, `desktop`. Omit scope when spanning multiple packages.
|
||||
|
||||
## Fork Merge Process
|
||||
|
||||
Kilo CLI is a fork of [opencode](https://github.com/anomalyco/opencode).
|
||||
|
||||
+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`).
|
||||
|
||||
@@ -56,6 +56,7 @@ 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.
|
||||
@@ -74,7 +75,7 @@ Download the latest binary or source code from the [Releases page](https://githu
|
||||
- `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.
|
||||
- `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.
|
||||
|
||||
|
||||
@@ -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
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
+10
-6
@@ -9,6 +9,7 @@
|
||||
"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",
|
||||
@@ -49,7 +50,7 @@
|
||||
"marked-shiki": "1.2.1",
|
||||
"@playwright/test": "1.51.0",
|
||||
"typescript": "5.8.2",
|
||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260316.1",
|
||||
"zod": "4.1.8",
|
||||
"remeda": "2.26.0",
|
||||
"shiki": "3.20.0",
|
||||
@@ -68,18 +69,20 @@
|
||||
"@actions/artifact": "5.0.1",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"glob": "13.0.5",
|
||||
"husky": "9.1.7",
|
||||
"prettier": "3.6.2",
|
||||
"semver": "^7.6.0",
|
||||
"sst": "3.18.10",
|
||||
"turbo": "2.5.6"
|
||||
"turbo": "2.8.13"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.933.0",
|
||||
"@kilocode/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
"@morphllm/morphsdk": "0.2.141",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"repository": {
|
||||
@@ -96,7 +99,8 @@
|
||||
"protobufjs",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"web-tree-sitter"
|
||||
"web-tree-sitter",
|
||||
"electron"
|
||||
],
|
||||
"overrides": {
|
||||
"@types/bun": "catalog:",
|
||||
@@ -107,6 +111,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.46",
|
||||
"version": "7.0.50",
|
||||
"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
|
||||
@@ -197,6 +197,7 @@ export async function createTestProject() {
|
||||
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
|
||||
|
||||
execSync("git init", { cwd: root, stdio: "ignore" })
|
||||
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
|
||||
execSync("git add -A", { cwd: root, stdio: "ignore" })
|
||||
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
||||
cwd: root,
|
||||
@@ -207,7 +208,10 @@ export async function createTestProject() {
|
||||
}
|
||||
|
||||
export async function cleanupTestProject(directory: string) {
|
||||
await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)
|
||||
try {
|
||||
execSync("git fsmonitor--daemon stop", { cwd: directory, stdio: "ignore" })
|
||||
} catch {}
|
||||
await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
|
||||
}
|
||||
|
||||
export function sessionIDFromUrl(url: string) {
|
||||
|
||||
@@ -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,113 @@ 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()
|
||||
})
|
||||
|
||||
test("cmd+f opens text viewer search while prompt is not 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 viewer.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] },
|
||||
)
|
||||
|
||||
@@ -50,6 +50,15 @@ async function clearPermissionDock(page: any, label: RegExp) {
|
||||
}
|
||||
}
|
||||
|
||||
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: {
|
||||
@@ -133,6 +142,17 @@ test("default dock shows prompt input", 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) => {
|
||||
@@ -170,6 +190,7 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess
|
||||
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
@@ -197,6 +218,7 @@ test("blocked permission flow supports allow once", async ({ page, sdk, gotoSess
|
||||
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
@@ -223,6 +245,7 @@ test("blocked permission flow supports reject", 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 gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
@@ -304,6 +327,7 @@ test("child session permission request blocks parent dock and supports allow onc
|
||||
}) => {
|
||||
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({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "7.0.46",
|
||||
"version": "7.0.50",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -57,7 +57,7 @@
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
"diff": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "0.4.0",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"marked-shiki": "catalog:",
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -69,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,
|
||||
@@ -145,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"
|
||||
@@ -156,6 +166,7 @@ describe("prompt submit worktree selection", () => {
|
||||
info: () => undefined,
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "shell",
|
||||
working: () => false,
|
||||
editor: () => undefined,
|
||||
@@ -181,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"
|
||||
@@ -15,6 +16,7 @@ import { Identifier } from "@/utils/id"
|
||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
import { buildRequestParts } from "./build-request-parts"
|
||||
import { setCursorPosition } from "./editor-dom"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
|
||||
type PendingPrompt = {
|
||||
abort: AbortController
|
||||
@@ -27,6 +29,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 +59,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 +144,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 +202,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}`)
|
||||
}
|
||||
@@ -281,7 +287,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||
})
|
||||
restoreInput()
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
|
||||
const usd = createMemo(
|
||||
() =>
|
||||
new Intl.NumberFormat(language.locale(), {
|
||||
new Intl.NumberFormat(language.intl(), {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}),
|
||||
@@ -77,7 +77,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
{(ctx) => (
|
||||
<>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-text-invert-strong">{ctx().total.toLocaleString(language.locale())}</span>
|
||||
<span class="text-text-invert-strong">{ctx().total.toLocaleString(language.intl())}</span>
|
||||
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -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"
|
||||
@@ -127,7 +128,7 @@ export function SessionContextTab() {
|
||||
|
||||
const usd = createMemo(
|
||||
() =>
|
||||
new Intl.NumberFormat(language.locale(), {
|
||||
new Intl.NumberFormat(language.intl(), {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}),
|
||||
@@ -135,7 +136,7 @@ export function SessionContextTab() {
|
||||
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
|
||||
const ctx = createMemo(() => metrics().context)
|
||||
const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
|
||||
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
||||
|
||||
const cost = createMemo(() => {
|
||||
return usd().format(metrics().totalCost)
|
||||
@@ -199,7 +200,7 @@ export function SessionContextTab() {
|
||||
|
||||
const stats = [
|
||||
{ label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" },
|
||||
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) },
|
||||
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.intl()) },
|
||||
{ label: "context.stats.provider", value: providerLabel },
|
||||
{ label: "context.stats.model", value: modelLabel },
|
||||
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
|
||||
@@ -212,8 +213,8 @@ export function SessionContextTab() {
|
||||
label: "context.stats.cacheTokens",
|
||||
value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`,
|
||||
},
|
||||
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) },
|
||||
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) },
|
||||
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
|
||||
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
|
||||
{ label: "context.stats.totalCost", value: cost },
|
||||
{ label: "context.stats.sessionCreated", value: () => formatter().time(info()?.time.created) },
|
||||
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
|
||||
@@ -306,7 +307,7 @@ export function SessionContextTab() {
|
||||
<div class="flex items-center gap-1 text-11-regular text-text-weak">
|
||||
<div class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} />
|
||||
<div>{breakdownLabel(segment.key)}</div>
|
||||
<div class="text-text-weaker">{segment.percent.toLocaleString(language.locale())}%</div>
|
||||
<div class="text-text-weaker">{segment.percent.toLocaleString(language.intl())}%</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -51,26 +51,26 @@ export function NewSessionView(props: NewSessionViewProps) {
|
||||
return (
|
||||
<div class={ROOT_CLASS}>
|
||||
<div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div>
|
||||
<div class="flex justify-center items-center gap-3">
|
||||
<Icon name="folder" size="small" />
|
||||
<div class="text-12-medium text-text-weak select-text">
|
||||
<div class="flex justify-center items-start gap-3 min-h-5">
|
||||
<Icon name="folder" size="small" class="mt-0.5 shrink-0" />
|
||||
<div class="text-12-medium text-text-weak select-text leading-5">
|
||||
{getDirectory(projectRoot())}
|
||||
<span class="text-text-strong">{getFilename(projectRoot())}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center items-center gap-1">
|
||||
<Icon name="branch" size="small" />
|
||||
<div class="text-12-medium text-text-weak select-text ml-2">{label(current())}</div>
|
||||
<div class="flex justify-center items-start gap-3 min-h-5">
|
||||
<Icon name="branch" size="small" class="mt-0.5 shrink-0" />
|
||||
<div class="text-12-medium text-text-weak select-text leading-5">{label(current())}</div>
|
||||
</div>
|
||||
<Show when={sync.project}>
|
||||
{(project) => (
|
||||
<div class="flex justify-center items-center gap-3">
|
||||
<Icon name="pencil-line" size="small" />
|
||||
<div class="text-12-medium text-text-weak">
|
||||
<div class="flex justify-center items-start gap-3 min-h-5">
|
||||
<Icon name="pencil-line" size="small" class="mt-0.5 shrink-0" />
|
||||
<div class="text-12-medium text-text-weak leading-5">
|
||||
{language.t("session.new.lastModified")}
|
||||
<span class="text-text-strong">
|
||||
{DateTime.fromMillis(project().time.updated ?? project().time.created)
|
||||
.setLocale(language.locale())
|
||||
.setLocale(language.intl())
|
||||
.toRelative()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
@@ -18,6 +17,7 @@ type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[num
|
||||
|
||||
const PROVIDER_NOTES = [
|
||||
{ match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" },
|
||||
{ match: (id: string) => id === "opencode-go", key: "dialog.provider.opencodeGo.tagline" },
|
||||
{ match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" },
|
||||
{ match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" },
|
||||
{ match: (id: string) => id === "openai", key: "dialog.provider.openai.note" },
|
||||
@@ -33,11 +33,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 +149,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 +157,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 +180,14 @@ 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"}>
|
||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
<Show when={item.id === "opencode-go"}>
|
||||
<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 +213,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
|
||||
|
||||
@@ -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,7 +52,6 @@ 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")
|
||||
@@ -64,7 +61,7 @@ function createGlobalSync() {
|
||||
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,7 @@ function createGlobalSync() {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.session.listFailed.title", { project }),
|
||||
description: formatServerError(err),
|
||||
description: formatServerError(err, language.t),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -234,6 +258,7 @@ function createGlobalSync() {
|
||||
setStore: child[1],
|
||||
vcsCache: cache,
|
||||
loadSessions,
|
||||
translate: language.t,
|
||||
})
|
||||
})()
|
||||
|
||||
@@ -254,13 +279,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 +327,9 @@ function createGlobalSync() {
|
||||
url: globalSDK.url,
|
||||
}),
|
||||
requestFailedTitle: language.t("common.requestFailed"),
|
||||
setGlobalStore,
|
||||
translate: language.t,
|
||||
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
||||
setGlobalStore: setBootStore,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -332,7 +353,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 +365,7 @@ function createGlobalSync() {
|
||||
|
||||
return {
|
||||
data: globalStore,
|
||||
set: setGlobalStore,
|
||||
set,
|
||||
get ready() {
|
||||
return globalStore.ready
|
||||
},
|
||||
|
||||
@@ -36,6 +36,8 @@ export async function bootstrapGlobal(input: {
|
||||
connectErrorTitle: string
|
||||
connectErrorDescription: string
|
||||
requestFailedTitle: string
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
formatMoreCount: (count: number) => string
|
||||
setGlobalStore: SetStoreFunction<GlobalStore>
|
||||
}) {
|
||||
const health = await input.globalSDK.global
|
||||
@@ -88,8 +90,8 @@ 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], input.translate)
|
||||
const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : ""
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.requestFailedTitle,
|
||||
@@ -116,6 +118,7 @@ export async function bootstrapDirectory(input: {
|
||||
setStore: SetStoreFunction<State>
|
||||
vcsCache: VcsCache
|
||||
loadSessions: (directory: string) => Promise<void> | void
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
}) {
|
||||
if (input.store.status !== "complete") input.setStore("status", "loading")
|
||||
|
||||
@@ -137,7 +140,7 @@ export async function bootstrapDirectory(input: {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: `Failed to reload ${project}`,
|
||||
description: formatServerError(err),
|
||||
description: formatServerError(err, input.translate),
|
||||
})
|
||||
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,8 +101,29 @@ const LOCALES: readonly Locale[] = [
|
||||
"no",
|
||||
"br",
|
||||
"th",
|
||||
"tr",
|
||||
]
|
||||
|
||||
const INTL: Record<Locale, string> = {
|
||||
en: "en",
|
||||
zh: "zh-Hans",
|
||||
zht: "zh-Hant",
|
||||
ko: "ko",
|
||||
de: "de",
|
||||
es: "es",
|
||||
fr: "fr",
|
||||
da: "da",
|
||||
ja: "ja",
|
||||
pl: "pl",
|
||||
ru: "ru",
|
||||
ar: "ar",
|
||||
no: "nb-NO",
|
||||
br: "pt-BR",
|
||||
th: "th",
|
||||
bs: "bs",
|
||||
tr: "tr",
|
||||
}
|
||||
|
||||
const LABEL_KEY: Record<Locale, keyof Dictionary> = {
|
||||
en: "language.en",
|
||||
zh: "language.zh",
|
||||
@@ -116,6 +141,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 +162,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 +184,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 +204,7 @@ const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
|
||||
br,
|
||||
th,
|
||||
bs,
|
||||
tr,
|
||||
}
|
||||
void PARITY_CHECK
|
||||
|
||||
@@ -208,6 +237,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
||||
)
|
||||
|
||||
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
|
||||
const intl = createMemo(() => INTL[locale()])
|
||||
|
||||
const dict = createMemo<Dictionary>(() => DICT[locale()])
|
||||
|
||||
@@ -224,6 +254,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
||||
return {
|
||||
ready,
|
||||
locale,
|
||||
intl,
|
||||
locales: LOCALES,
|
||||
label,
|
||||
t,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -35,6 +35,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
|
||||
const agent = (() => {
|
||||
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
|
||||
const models = useModels()
|
||||
|
||||
const [store, setStore] = createStore<{
|
||||
current?: string
|
||||
}>({
|
||||
@@ -53,11 +55,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
setStore("current", undefined)
|
||||
return
|
||||
}
|
||||
if (name && available.some((x) => x.name === name)) {
|
||||
setStore("current", name)
|
||||
return
|
||||
}
|
||||
setStore("current", available[0].name)
|
||||
const match = name ? available.find((x) => x.name === name) : undefined
|
||||
const value = match ?? available[0]
|
||||
if (!value) return
|
||||
setStore("current", value.name)
|
||||
if (!value.model) return
|
||||
setModel({
|
||||
providerID: value.model.providerID,
|
||||
modelID: value.model.modelID,
|
||||
})
|
||||
if (value.variant)
|
||||
models.variant.set({ providerID: value.model.providerID, modelID: value.model.modelID }, value.variant)
|
||||
},
|
||||
move(direction: 1 | -1) {
|
||||
const available = list()
|
||||
@@ -71,11 +79,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const value = available[next]
|
||||
if (!value) return
|
||||
setStore("current", value.name)
|
||||
if (value.model)
|
||||
setModel({
|
||||
providerID: value.model.providerID,
|
||||
modelID: value.model.modelID,
|
||||
})
|
||||
if (!value.model) return
|
||||
setModel({
|
||||
providerID: value.model.providerID,
|
||||
modelID: value.model.modelID,
|
||||
})
|
||||
if (value.variant)
|
||||
models.variant.set({ providerID: value.model.providerID, modelID: value.model.modelID }, value.variant)
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+24
-15
@@ -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",
|
||||
@@ -102,7 +104,6 @@ export const dict = {
|
||||
"dialog.model.empty": "لا توجد نتائج للنماذج",
|
||||
"dialog.model.manage": "إدارة النماذج",
|
||||
"dialog.model.manage.description": "تخصيص النماذج التي تظهر في محدد النماذج.",
|
||||
"dialog.model.manage.provider.toggle": "تبديل جميع موديلات {{provider}}",
|
||||
"dialog.model.unpaid.freeModels.title": "نماذج مجانية مقدمة من Kilo",
|
||||
"dialog.model.unpaid.addMore.title": "إضافة المزيد من النماذج من موفرين مشهورين",
|
||||
"dialog.provider.viewAll": "عرض المزيد من الموفرين",
|
||||
@@ -135,7 +136,8 @@ 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 +366,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": "فشل تحميل الملف",
|
||||
@@ -442,7 +444,6 @@ export const dict = {
|
||||
"session.review.loadingChanges": "جارٍ تحميل التغييرات...",
|
||||
"session.review.empty": "لا توجد تغييرات في هذه الجلسة بعد",
|
||||
"session.review.noChanges": "لا توجد تغييرات",
|
||||
"session.review.noVcs": "لم يتم اكتشاف نظام VCS لـ git، لذلك لن يتم اكتشاف تغييرات الجلسة",
|
||||
"session.files.selectToOpen": "اختر ملفًا لفتحه",
|
||||
"session.files.all": "كل الملفات",
|
||||
"session.files.binaryContent": "ملف ثنائي (لا يمكن عرض المحتوى)",
|
||||
@@ -455,10 +456,6 @@ export const dict = {
|
||||
"session.todo.title": "المهام",
|
||||
"session.todo.collapse": "طي",
|
||||
"session.todo.expand": "توسيع",
|
||||
"session.modeSwitch.switching": "جارٍ التبديل إلى وضع {{mode}}…",
|
||||
"session.modeSwitch.waiting": "في انتظار اكتمال المهمة الحالية",
|
||||
"session.modeSwitch.notAvailable": "الوكيل غير متاح",
|
||||
"session.modeSwitch.fallback": '"{{requested}}" غير موجود، يتم استخدام "{{actual}}"',
|
||||
"session.new.worktree.main": "الفرع الرئيسي",
|
||||
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
|
||||
"session.new.worktree.create": "إنشاء شجرة عمل جديدة",
|
||||
@@ -544,8 +541,6 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "تخصيص سمة Kilo.",
|
||||
"settings.general.row.font.title": "الخط",
|
||||
"settings.general.row.font.description": "تخصيص الخط الأحادي المستخدم في كتل التعليمات البرمجية",
|
||||
"settings.general.row.reasoningSummaries.title": "إظهار ملخصات التفكير",
|
||||
"settings.general.row.reasoningSummaries.description": "عرض ملخصات تفكير النموذج في الجدول الزمني",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "توسيع أجزاء أداة shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"إظهار أجزاء أداة shell موسعة بشكل افتراضي في الشريط الزمني",
|
||||
@@ -739,4 +734,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 الأساسي.",
|
||||
}
|
||||
|
||||
+22
-14
@@ -65,8 +65,8 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "Mudar para o próximo nível de esforço",
|
||||
"command.prompt.mode.shell": "Shell",
|
||||
"command.prompt.mode.normal": "Prompt",
|
||||
"command.permissions.autoaccept.enable": "Aceitar edições automaticamente",
|
||||
"command.permissions.autoaccept.disable": "Parar de aceitar edições automaticamente",
|
||||
"command.permissions.autoaccept.enable": "Aceitar permissões automaticamente",
|
||||
"command.permissions.autoaccept.disable": "Parar de aceitar permissões automaticamente",
|
||||
"command.workspace.toggle": "Alternar espaços de trabalho",
|
||||
"command.workspace.toggle.description": "Habilitar ou desabilitar múltiplos espaços de trabalho na barra lateral",
|
||||
"command.session.undo": "Desfazer",
|
||||
@@ -91,6 +91,8 @@ export const dict = {
|
||||
"dialog.provider.group.other": "Outro",
|
||||
"dialog.provider.tag.recommended": "Recomendado",
|
||||
"dialog.provider.opencode.note": "Modelos selecionados incluindo Claude, GPT, Gemini e mais",
|
||||
"dialog.provider.opencode.tagline": "Modelos otimizados e confiáveis",
|
||||
"dialog.provider.opencodeGo.tagline": "Assinatura de baixo custo para todos",
|
||||
"dialog.provider.anthropic.note": "Conectar com Claude Pro/Max ou chave de API",
|
||||
"dialog.provider.copilot.note": "Conectar com Copilot ou chave de API",
|
||||
"dialog.provider.openai.note": "Conectar com ChatGPT Pro/Plus ou chave de API",
|
||||
@@ -102,7 +104,6 @@ export const dict = {
|
||||
"dialog.model.empty": "Nenhum resultado de modelo",
|
||||
"dialog.model.manage": "Gerenciar modelos",
|
||||
"dialog.model.manage.description": "Personalizar quais modelos aparecem no seletor de modelos.",
|
||||
"dialog.model.manage.provider.toggle": "Alternar todos os modelos {{provider}}",
|
||||
"dialog.model.unpaid.freeModels.title": "Modelos gratuitos fornecidos pelo Kilo",
|
||||
"dialog.model.unpaid.addMore.title": "Adicionar mais modelos de provedores populares",
|
||||
"dialog.provider.viewAll": "Ver mais provedores",
|
||||
@@ -366,10 +367,10 @@ export const dict = {
|
||||
"toast.workspace.enabled.description": "Várias worktrees agora são exibidas na barra lateral",
|
||||
"toast.workspace.disabled.title": "Espaços de trabalho desativados",
|
||||
"toast.workspace.disabled.description": "Apenas a worktree principal é exibida na barra lateral",
|
||||
"toast.permissions.autoaccept.on.title": "Aceitando edições automaticamente",
|
||||
"toast.permissions.autoaccept.on.description": "Permissões de edição e escrita serão aprovadas automaticamente",
|
||||
"toast.permissions.autoaccept.off.title": "Parou de aceitar edições automaticamente",
|
||||
"toast.permissions.autoaccept.off.description": "Permissões de edição e escrita exigirão aprovação",
|
||||
"toast.permissions.autoaccept.on.title": "Aceitando permissões automaticamente",
|
||||
"toast.permissions.autoaccept.on.description": "Solicitações de permissão serão aprovadas automaticamente",
|
||||
"toast.permissions.autoaccept.off.title": "Parou de aceitar permissões automaticamente",
|
||||
"toast.permissions.autoaccept.off.description": "Solicitações de permissão exigirão aprovação",
|
||||
"toast.model.none.title": "Nenhum modelo selecionado",
|
||||
"toast.model.none.description": "Conecte um provedor para resumir esta sessão",
|
||||
"toast.file.loadFailed.title": "Falha ao carregar arquivo",
|
||||
@@ -446,7 +447,6 @@ export const dict = {
|
||||
"session.review.loadingChanges": "Carregando alterações...",
|
||||
"session.review.empty": "Nenhuma alteração nesta sessão ainda",
|
||||
"session.review.noChanges": "Sem alterações",
|
||||
"session.review.noVcs": "Nenhum VCS git detectado, portanto as alterações da sessão não serão detectadas",
|
||||
"session.files.selectToOpen": "Selecione um arquivo para abrir",
|
||||
"session.files.all": "Todos os arquivos",
|
||||
"session.files.binaryContent": "Arquivo binário (conteúdo não pode ser exibido)",
|
||||
@@ -459,10 +459,6 @@ export const dict = {
|
||||
"session.todo.title": "Tarefas",
|
||||
"session.todo.collapse": "Recolher",
|
||||
"session.todo.expand": "Expandir",
|
||||
"session.modeSwitch.switching": "Alternando para o modo {{mode}}…",
|
||||
"session.modeSwitch.waiting": "Aguardando a conclusão da tarefa atual",
|
||||
"session.modeSwitch.notAvailable": "Agente não disponível",
|
||||
"session.modeSwitch.fallback": '"{{requested}}" não encontrado, usando "{{actual}}"',
|
||||
"session.new.worktree.main": "Branch principal",
|
||||
"session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
|
||||
"session.new.worktree.create": "Criar novo worktree",
|
||||
@@ -551,8 +547,6 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "Personalize como o Kilo é tematizado.",
|
||||
"settings.general.row.font.title": "Fonte",
|
||||
"settings.general.row.font.description": "Personalize a fonte monoespaçada usada em blocos de código",
|
||||
"settings.general.row.reasoningSummaries.title": "Mostrar resumos de raciocínio",
|
||||
"settings.general.row.reasoningSummaries.description": "Exibir resumos de raciocínio do modelo na linha do tempo",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Expandir partes da ferramenta shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Mostrar partes da ferramenta shell expandidas por padrão na linha do tempo",
|
||||
@@ -748,4 +742,18 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 sessão será arquivada.",
|
||||
"workspace.reset.archived.many": "{{count}} sessões serão arquivadas.",
|
||||
"workspace.reset.note": "Isso redefinirá o espaço de trabalho para corresponder ao branch padrão.",
|
||||
"common.open": "Abrir",
|
||||
"dialog.releaseNotes.action.getStarted": "Começar",
|
||||
"dialog.releaseNotes.action.next": "Próximo",
|
||||
"dialog.releaseNotes.action.hideFuture": "Não mostrar isso no futuro",
|
||||
"dialog.releaseNotes.media.alt": "Prévia do lançamento",
|
||||
"toast.project.reloadFailed.title": "Falha ao recarregar {{project}}",
|
||||
"error.server.invalidConfiguration": "Configuração inválida",
|
||||
"common.moreCountSuffix": " (+{{count}} mais)",
|
||||
"common.time.justNow": "Agora mesmo",
|
||||
"common.time.minutesAgo.short": "{{count}}m atrás",
|
||||
"common.time.hoursAgo.short": "{{count}}h atrás",
|
||||
"common.time.daysAgo.short": "{{count}}d atrás",
|
||||
"settings.providers.connected.environmentDescription": "Conectado a partir de suas variáveis de ambiente",
|
||||
"settings.providers.custom.description": "Adicionar um provedor compatível com a OpenAI através do URL base.",
|
||||
}
|
||||
|
||||
@@ -71,8 +71,8 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "Prebaci na sljedeći nivo",
|
||||
"command.prompt.mode.shell": "Shell",
|
||||
"command.prompt.mode.normal": "Prompt",
|
||||
"command.permissions.autoaccept.enable": "Automatski prihvataj izmjene",
|
||||
"command.permissions.autoaccept.disable": "Zaustavi automatsko prihvatanje izmjena",
|
||||
"command.permissions.autoaccept.enable": "Automatski prihvati dozvole",
|
||||
"command.permissions.autoaccept.disable": "Zaustavi automatsko prihvatanje dozvola",
|
||||
"command.workspace.toggle": "Prikaži/sakrij radne prostore",
|
||||
"command.workspace.toggle.description": "Omogući ili onemogući više radnih prostora u bočnoj traci",
|
||||
"command.session.undo": "Poništi",
|
||||
@@ -99,6 +99,8 @@ export const dict = {
|
||||
"dialog.provider.group.other": "Ostalo",
|
||||
"dialog.provider.tag.recommended": "Preporučeno",
|
||||
"dialog.provider.opencode.note": "Kurirani modeli uključujući Claude, GPT, Gemini i druge",
|
||||
"dialog.provider.opencode.tagline": "Pouzdani optimizovani modeli",
|
||||
"dialog.provider.opencodeGo.tagline": "Povoljna pretplata za sve",
|
||||
"dialog.provider.anthropic.note": "Direktan pristup Claude modelima, uključujući Pro i Max",
|
||||
"dialog.provider.copilot.note": "AI modeli za pomoć pri kodiranju putem GitHub Copilot",
|
||||
"dialog.provider.openai.note": "GPT modeli za brze, sposobne opšte AI zadatke",
|
||||
@@ -404,10 +406,10 @@ export const dict = {
|
||||
"toast.workspace.disabled.title": "Radni prostori onemogućeni",
|
||||
"toast.workspace.disabled.description": "Samo glavni worktree se prikazuje u bočnoj traci",
|
||||
|
||||
"toast.permissions.autoaccept.on.title": "Automatsko prihvatanje izmjena",
|
||||
"toast.permissions.autoaccept.on.description": "Dozvole za izmjene i pisanje biće automatski odobrene",
|
||||
"toast.permissions.autoaccept.off.title": "Zaustavljeno automatsko prihvatanje izmjena",
|
||||
"toast.permissions.autoaccept.off.description": "Dozvole za izmjene i pisanje zahtijevaće odobrenje",
|
||||
"toast.permissions.autoaccept.on.title": "Automatsko prihvatanje dozvola",
|
||||
"toast.permissions.autoaccept.on.description": "Zahtjevi za dozvole će biti automatski odobreni",
|
||||
"toast.permissions.autoaccept.off.title": "Zaustavljeno automatsko prihvatanje dozvola",
|
||||
"toast.permissions.autoaccept.off.description": "Zahtjevi za dozvole će zahtijevati odobrenje",
|
||||
|
||||
"toast.model.none.title": "Nije odabran model",
|
||||
"toast.model.none.description": "Poveži provajdera da sažmeš ovu sesiju",
|
||||
@@ -826,4 +828,18 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 sesija će biti arhivirana.",
|
||||
"workspace.reset.archived.many": "Biće arhivirano {{count}} sesija.",
|
||||
"workspace.reset.note": "Ovo će resetovati radni prostor da odgovara podrazumijevanoj grani.",
|
||||
"common.open": "Otvori",
|
||||
"dialog.releaseNotes.action.getStarted": "Započni",
|
||||
"dialog.releaseNotes.action.next": "Sljedeće",
|
||||
"dialog.releaseNotes.action.hideFuture": "Ne prikazuj ovo u budućnosti",
|
||||
"dialog.releaseNotes.media.alt": "Pregled izdanja",
|
||||
"toast.project.reloadFailed.title": "Nije uspjelo ponovno učitavanje {{project}}",
|
||||
"error.server.invalidConfiguration": "Nevažeća konfiguracija",
|
||||
"common.moreCountSuffix": " (+{{count}} više)",
|
||||
"common.time.justNow": "Upravo sada",
|
||||
"common.time.minutesAgo.short": "prije {{count}} min",
|
||||
"common.time.hoursAgo.short": "prije {{count}} h",
|
||||
"common.time.daysAgo.short": "prije {{count}} d",
|
||||
"settings.providers.connected.environmentDescription": "Povezano sa vašim varijablama okruženja",
|
||||
"settings.providers.custom.description": "Dodajte provajdera kompatibilnog s OpenAI putem osnovnog URL-a.",
|
||||
}
|
||||
|
||||
@@ -71,8 +71,8 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "Skift til næste indsatsniveau",
|
||||
"command.prompt.mode.shell": "Shell",
|
||||
"command.prompt.mode.normal": "Prompt",
|
||||
"command.permissions.autoaccept.enable": "Accepter ændringer automatisk",
|
||||
"command.permissions.autoaccept.disable": "Stop automatisk accept af ændringer",
|
||||
"command.permissions.autoaccept.enable": "Accepter tilladelser automatisk",
|
||||
"command.permissions.autoaccept.disable": "Stop med at acceptere tilladelser automatisk",
|
||||
"command.workspace.toggle": "Skift arbejdsområder",
|
||||
"command.workspace.toggle.description": "Aktiver eller deaktiver flere arbejdsområder i sidebjælken",
|
||||
"command.session.undo": "Fortryd",
|
||||
@@ -99,6 +99,8 @@ export const dict = {
|
||||
"dialog.provider.group.other": "Andre",
|
||||
"dialog.provider.tag.recommended": "Anbefalet",
|
||||
"dialog.provider.opencode.note": "Udvalgte modeller inklusive Claude, GPT, Gemini og flere",
|
||||
"dialog.provider.opencode.tagline": "Pålidelige optimerede modeller",
|
||||
"dialog.provider.opencodeGo.tagline": "Billigt abonnement for alle",
|
||||
"dialog.provider.anthropic.note": "Direkte adgang til Claude-modeller, inklusive Pro og Max",
|
||||
"dialog.provider.copilot.note": "AI-modeller til kodningsassistance via GitHub Copilot",
|
||||
"dialog.provider.openai.note": "GPT-modeller til hurtige, kompetente generelle AI-opgaver",
|
||||
@@ -397,10 +399,10 @@ export const dict = {
|
||||
"toast.theme.title": "Tema skiftet",
|
||||
"toast.scheme.title": "Farveskema",
|
||||
|
||||
"toast.permissions.autoaccept.on.title": "Accepterer ændringer automatisk",
|
||||
"toast.permissions.autoaccept.on.description": "Redigerings- og skrivetilladelser vil automatisk blive godkendt",
|
||||
"toast.permissions.autoaccept.off.title": "Stoppede automatisk accept af ændringer",
|
||||
"toast.permissions.autoaccept.off.description": "Redigerings- og skrivetilladelser vil kræve godkendelse",
|
||||
"toast.permissions.autoaccept.on.title": "Accepterer tilladelser automatisk",
|
||||
"toast.permissions.autoaccept.on.description": "Anmodninger om tilladelse godkendes automatisk",
|
||||
"toast.permissions.autoaccept.off.title": "Stoppet med at acceptere tilladelser automatisk",
|
||||
"toast.permissions.autoaccept.off.description": "Anmodninger om tilladelse vil kræve godkendelse",
|
||||
|
||||
"toast.workspace.enabled.title": "Arbejdsområder aktiveret",
|
||||
"toast.workspace.enabled.description": "Flere worktrees vises nu i sidepanelet",
|
||||
@@ -820,4 +822,18 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 session vil blive arkiveret.",
|
||||
"workspace.reset.archived.many": "{{count}} sessioner vil blive arkiveret.",
|
||||
"workspace.reset.note": "Dette vil nulstille arbejdsområdet til at matche hovedgrenen.",
|
||||
"common.open": "Åbn",
|
||||
"dialog.releaseNotes.action.getStarted": "Kom i gang",
|
||||
"dialog.releaseNotes.action.next": "Næste",
|
||||
"dialog.releaseNotes.action.hideFuture": "Vis ikke disse i fremtiden",
|
||||
"dialog.releaseNotes.media.alt": "Forhåndsvisning af udgivelse",
|
||||
"toast.project.reloadFailed.title": "Kunne ikke genindlæse {{project}}",
|
||||
"error.server.invalidConfiguration": "Ugyldig konfiguration",
|
||||
"common.moreCountSuffix": " (+{{count}} mere)",
|
||||
"common.time.justNow": "Lige nu",
|
||||
"common.time.minutesAgo.short": "{{count}}m siden",
|
||||
"common.time.hoursAgo.short": "{{count}}t siden",
|
||||
"common.time.daysAgo.short": "{{count}}d siden",
|
||||
"settings.providers.connected.environmentDescription": "Tilsluttet fra dine miljøvariabler",
|
||||
"settings.providers.custom.description": "Tilføj en OpenAI-kompatibel udbyder via basis-URL.",
|
||||
}
|
||||
|
||||
+22
-14
@@ -69,8 +69,8 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "Zum nächsten Aufwandslevel wechseln",
|
||||
"command.prompt.mode.shell": "Shell",
|
||||
"command.prompt.mode.normal": "Prompt",
|
||||
"command.permissions.autoaccept.enable": "Änderungen automatisch akzeptieren",
|
||||
"command.permissions.autoaccept.disable": "Automatische Annahme von Änderungen stoppen",
|
||||
"command.permissions.autoaccept.enable": "Berechtigungen automatisch akzeptieren",
|
||||
"command.permissions.autoaccept.disable": "Automatische Akzeptanz von Berechtigungen stoppen",
|
||||
"command.workspace.toggle": "Arbeitsbereiche umschalten",
|
||||
"command.workspace.toggle.description": "Mehrere Arbeitsbereiche in der Seitenleiste aktivieren oder deaktivieren",
|
||||
"command.session.undo": "Rückgängig",
|
||||
@@ -95,6 +95,8 @@ export const dict = {
|
||||
"dialog.provider.group.other": "Andere",
|
||||
"dialog.provider.tag.recommended": "Empfohlen",
|
||||
"dialog.provider.opencode.note": "Kuratierte Modelle inklusive Claude, GPT, Gemini und mehr",
|
||||
"dialog.provider.opencode.tagline": "Zuverlässige, optimierte Modelle",
|
||||
"dialog.provider.opencodeGo.tagline": "Kostengünstiges Abo für alle",
|
||||
"dialog.provider.anthropic.note": "Mit Claude Pro/Max oder API-Schlüssel verbinden",
|
||||
"dialog.provider.copilot.note": "Mit Copilot oder API-Schlüssel verbinden",
|
||||
"dialog.provider.openai.note": "Mit ChatGPT Pro/Plus oder API-Schlüssel verbinden",
|
||||
@@ -106,7 +108,6 @@ export const dict = {
|
||||
"dialog.model.empty": "Keine Modellergebnisse",
|
||||
"dialog.model.manage": "Modelle verwalten",
|
||||
"dialog.model.manage.description": "Anpassen, welche Modelle in der Modellauswahl erscheinen.",
|
||||
"dialog.model.manage.provider.toggle": "Alle {{provider}}-Modelle umschalten",
|
||||
"dialog.model.unpaid.freeModels.title": "Kostenlose Modelle von Kilo",
|
||||
"dialog.model.unpaid.addMore.title": "Weitere Modelle von beliebten Anbietern hinzufügen",
|
||||
"dialog.provider.viewAll": "Mehr Anbieter anzeigen",
|
||||
@@ -373,10 +374,10 @@ export const dict = {
|
||||
"toast.workspace.enabled.description": "Mehrere Worktrees werden jetzt in der Seitenleiste angezeigt",
|
||||
"toast.workspace.disabled.title": "Arbeitsbereiche deaktiviert",
|
||||
"toast.workspace.disabled.description": "Nur der Haupt-Worktree wird in der Seitenleiste angezeigt",
|
||||
"toast.permissions.autoaccept.on.title": "Änderungen werden automatisch akzeptiert",
|
||||
"toast.permissions.autoaccept.on.description": "Bearbeitungs- und Schreibrechte werden automatisch genehmigt",
|
||||
"toast.permissions.autoaccept.off.title": "Automatische Annahme von Änderungen gestoppt",
|
||||
"toast.permissions.autoaccept.off.description": "Bearbeitungs- und Schreibrechte erfordern Genehmigung",
|
||||
"toast.permissions.autoaccept.on.title": "Berechtigungen werden automatisch akzeptiert",
|
||||
"toast.permissions.autoaccept.on.description": "Berechtigungsanfragen werden automatisch genehmigt",
|
||||
"toast.permissions.autoaccept.off.title": "Automatische Akzeptanz von Berechtigungen gestoppt",
|
||||
"toast.permissions.autoaccept.off.description": "Berechtigungsanfragen erfordern eine Genehmigung",
|
||||
"toast.model.none.title": "Kein Modell ausgewählt",
|
||||
"toast.model.none.description": "Verbinden Sie einen Anbieter, um diese Sitzung zusammenzufassen",
|
||||
"toast.file.loadFailed.title": "Datei konnte nicht geladen werden",
|
||||
@@ -454,7 +455,6 @@ export const dict = {
|
||||
"session.review.loadingChanges": "Lade Änderungen...",
|
||||
"session.review.empty": "Noch keine Änderungen in dieser Sitzung",
|
||||
"session.review.noChanges": "Keine Änderungen",
|
||||
"session.review.noVcs": "Kein Git-VCS erkannt, daher werden Sitzungsänderungen nicht erkannt",
|
||||
"session.files.selectToOpen": "Datei zum Öffnen auswählen",
|
||||
"session.files.all": "Alle Dateien",
|
||||
"session.files.binaryContent": "Binärdatei (Inhalt kann nicht angezeigt werden)",
|
||||
@@ -467,10 +467,6 @@ export const dict = {
|
||||
"session.todo.title": "Aufgaben",
|
||||
"session.todo.collapse": "Einklappen",
|
||||
"session.todo.expand": "Ausklappen",
|
||||
"session.modeSwitch.switching": "Wechsle zu {{mode}}-Modus…",
|
||||
"session.modeSwitch.waiting": "Warte auf Abschluss der aktuellen Aufgabe",
|
||||
"session.modeSwitch.notAvailable": "Agent nicht verfügbar",
|
||||
"session.modeSwitch.fallback": '"{{requested}}" nicht gefunden, verwende "{{actual}}"',
|
||||
"session.new.worktree.main": "Haupt-Branch",
|
||||
"session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})",
|
||||
"session.new.worktree.create": "Neuen Worktree erstellen",
|
||||
@@ -560,8 +556,6 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "Das Thema von Kilo anpassen.",
|
||||
"settings.general.row.font.title": "Schriftart",
|
||||
"settings.general.row.font.description": "Die in Codeblöcken verwendete Monospace-Schriftart anpassen",
|
||||
"settings.general.row.reasoningSummaries.title": "Denk-Zusammenfassungen anzeigen",
|
||||
"settings.general.row.reasoningSummaries.description": "Modell-Denk-Zusammenfassungen in der Zeitleiste anzeigen",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Shell-Tool-Abschnitte ausklappen",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Shell-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
|
||||
@@ -757,4 +751,18 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 Sitzung wird archiviert.",
|
||||
"workspace.reset.archived.many": "{{count}} Sitzungen werden archiviert.",
|
||||
"workspace.reset.note": "Dadurch wird der Arbeitsbereich auf den Standard-Branch zurückgesetzt.",
|
||||
"common.open": "Öffnen",
|
||||
"dialog.releaseNotes.action.getStarted": "Loslegen",
|
||||
"dialog.releaseNotes.action.next": "Weiter",
|
||||
"dialog.releaseNotes.action.hideFuture": "In Zukunft nicht mehr anzeigen",
|
||||
"dialog.releaseNotes.media.alt": "Vorschau auf die Version",
|
||||
"toast.project.reloadFailed.title": "Fehler beim Neuladen von {{project}}",
|
||||
"error.server.invalidConfiguration": "Ungültige Konfiguration",
|
||||
"common.moreCountSuffix": " (+{{count}} weitere)",
|
||||
"common.time.justNow": "Gerade eben",
|
||||
"common.time.minutesAgo.short": "vor {{count}} Min",
|
||||
"common.time.hoursAgo.short": "vor {{count}} Std",
|
||||
"common.time.daysAgo.short": "vor {{count}} Tg",
|
||||
"settings.providers.connected.environmentDescription": "Verbunden aus Ihren Umgebungsvariablen",
|
||||
"settings.providers.custom.description": "Fügen Sie einen OpenAI-kompatiblen Anbieter per Basis-URL hinzu.",
|
||||
} satisfies Partial<Record<Keys, string>>
|
||||
|
||||
@@ -71,8 +71,8 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "Switch to the next effort level",
|
||||
"command.prompt.mode.shell": "Shell",
|
||||
"command.prompt.mode.normal": "Prompt",
|
||||
"command.permissions.autoaccept.enable": "Auto-accept edits",
|
||||
"command.permissions.autoaccept.disable": "Stop auto-accepting edits",
|
||||
"command.permissions.autoaccept.enable": "Auto-accept permissions",
|
||||
"command.permissions.autoaccept.disable": "Stop auto-accepting permissions",
|
||||
"command.workspace.toggle": "Toggle workspaces",
|
||||
"command.workspace.toggle.description": "Enable or disable multiple workspaces in the sidebar",
|
||||
"command.session.undo": "Undo",
|
||||
@@ -99,6 +99,8 @@ export const dict = {
|
||||
"dialog.provider.group.other": "Other",
|
||||
"dialog.provider.tag.recommended": "Recommended",
|
||||
"dialog.provider.opencode.note": "Curated models including Claude, GPT, Gemini and more",
|
||||
"dialog.provider.opencode.tagline": "Reliable optimized models",
|
||||
"dialog.provider.opencodeGo.tagline": "Low cost subscription for everyone",
|
||||
"dialog.provider.anthropic.note": "Direct access to Claude models, including Pro and Max",
|
||||
"dialog.provider.copilot.note": "AI models for coding assistance via GitHub Copilot",
|
||||
"dialog.provider.openai.note": "GPT models for fast, capable general AI tasks",
|
||||
@@ -216,6 +218,7 @@ export const dict = {
|
||||
"common.loading": "Loading",
|
||||
"common.loading.ellipsis": "...",
|
||||
"common.cancel": "Cancel",
|
||||
"common.open": "Open",
|
||||
"common.connect": "Connect",
|
||||
"common.disconnect": "Disconnect",
|
||||
"common.submit": "Submit",
|
||||
@@ -307,12 +310,17 @@ export const dict = {
|
||||
"dialog.server.description": "Switch which Kilo server this app connects to.",
|
||||
"dialog.server.search.placeholder": "Search servers",
|
||||
"dialog.server.empty": "No servers yet",
|
||||
"dialog.server.add.title": "Add a server",
|
||||
"dialog.server.add.url": "Server URL",
|
||||
"dialog.server.add.title": "Add server",
|
||||
"dialog.server.add.url": "Server address",
|
||||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "Could not connect to server",
|
||||
"dialog.server.add.checking": "Checking...",
|
||||
"dialog.server.add.button": "Add server",
|
||||
"dialog.server.add.name": "Server name (optional)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Username (optional)",
|
||||
"dialog.server.add.password": "Password (optional)",
|
||||
"dialog.server.edit.title": "Edit server",
|
||||
"dialog.server.default.title": "Default server",
|
||||
"dialog.server.default.description":
|
||||
"Connect to this server on app launch instead of starting a local server. Requires restart.",
|
||||
@@ -340,6 +348,11 @@ export const dict = {
|
||||
"dialog.project.edit.worktree.startup.description": "Runs after creating a new workspace (worktree).",
|
||||
"dialog.project.edit.worktree.startup.placeholder": "e.g. bun install",
|
||||
|
||||
"dialog.releaseNotes.action.getStarted": "Get started",
|
||||
"dialog.releaseNotes.action.next": "Next",
|
||||
"dialog.releaseNotes.action.hideFuture": "Don't show these in the future",
|
||||
"dialog.releaseNotes.media.alt": "Release preview",
|
||||
|
||||
"context.breakdown.title": "Context Breakdown",
|
||||
"context.breakdown.note": 'Approximate breakdown of input tokens. "Other" includes tool definitions and overhead.',
|
||||
"context.breakdown.system": "System",
|
||||
@@ -390,6 +403,7 @@ export const dict = {
|
||||
"language.br": "Português (Brasil)",
|
||||
"language.bs": "Bosanski",
|
||||
"language.th": "ไทย",
|
||||
"language.tr": "Türkçe",
|
||||
|
||||
"toast.language.title": "Language",
|
||||
"toast.language.description": "Switched to {{language}}",
|
||||
@@ -402,10 +416,10 @@ export const dict = {
|
||||
"toast.workspace.disabled.title": "Workspaces disabled",
|
||||
"toast.workspace.disabled.description": "Only the main worktree is shown in the sidebar",
|
||||
|
||||
"toast.permissions.autoaccept.on.title": "Auto-accepting edits",
|
||||
"toast.permissions.autoaccept.on.description": "Edit and write permissions will be automatically approved",
|
||||
"toast.permissions.autoaccept.off.title": "Stopped auto-accepting edits",
|
||||
"toast.permissions.autoaccept.off.description": "Edit and write permissions will require approval",
|
||||
"toast.permissions.autoaccept.on.title": "Auto-accepting permissions",
|
||||
"toast.permissions.autoaccept.on.description": "Permission requests will be automatically approved",
|
||||
"toast.permissions.autoaccept.off.title": "Stopped auto-accepting permissions",
|
||||
"toast.permissions.autoaccept.off.description": "Permission requests will require approval",
|
||||
|
||||
"toast.model.none.title": "No model selected",
|
||||
"toast.model.none.description": "Connect a provider to summarize this session",
|
||||
@@ -428,6 +442,7 @@ export const dict = {
|
||||
"toast.session.unshare.failed.description": "An error occurred while unsharing the session",
|
||||
|
||||
"toast.session.listFailed.title": "Failed to load sessions for {{project}}",
|
||||
"toast.project.reloadFailed.title": "Failed to reload {{project}}",
|
||||
|
||||
"toast.update.title": "Update available",
|
||||
"toast.update.description": "A new version of Kilo ({{version}}) is now available to install.",
|
||||
@@ -452,6 +467,7 @@ export const dict = {
|
||||
"directory.error.invalidUrl": "Invalid directory in URL.",
|
||||
|
||||
"error.chain.unknown": "Unknown error",
|
||||
"error.server.invalidConfiguration": "Invalid configuration",
|
||||
"error.chain.causedBy": "Caused by:",
|
||||
"error.chain.apiError": "API error",
|
||||
"error.chain.status": "Status: {{status}}",
|
||||
@@ -567,6 +583,7 @@ export const dict = {
|
||||
|
||||
"common.closeTab": "Close tab",
|
||||
"common.dismiss": "Dismiss",
|
||||
"common.moreCountSuffix": " (+{{count}} more)",
|
||||
"common.requestFailed": "Request failed",
|
||||
"common.moreOptions": "More options",
|
||||
"common.learnMore": "Learn more",
|
||||
@@ -579,6 +596,11 @@ export const dict = {
|
||||
"common.loadMore": "Load more",
|
||||
"common.key.esc": "ESC",
|
||||
|
||||
"common.time.justNow": "Just now",
|
||||
"common.time.minutesAgo.short": "{{count}}m ago",
|
||||
"common.time.hoursAgo.short": "{{count}}h ago",
|
||||
"common.time.daysAgo.short": "{{count}}d ago",
|
||||
|
||||
"sidebar.menu.toggle": "Toggle menu",
|
||||
"sidebar.nav.projectsAndSessions": "Projects and sessions",
|
||||
"sidebar.settings": "Settings",
|
||||
@@ -739,7 +761,9 @@ export const dict = {
|
||||
"settings.providers.description": "Provider settings will be configurable here.",
|
||||
"settings.providers.section.connected": "Connected providers",
|
||||
"settings.providers.connected.empty": "No connected providers",
|
||||
"settings.providers.connected.environmentDescription": "Connected from your environment variables",
|
||||
"settings.providers.section.popular": "Popular providers",
|
||||
"settings.providers.custom.description": "Add an OpenAI-compatible provider by base URL.",
|
||||
"settings.providers.tag.environment": "Environment",
|
||||
"settings.providers.tag.config": "Config",
|
||||
"settings.providers.tag.custom": "Custom",
|
||||
|
||||
@@ -71,8 +71,8 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "Cambiar al siguiente nivel de esfuerzo",
|
||||
"command.prompt.mode.shell": "Shell",
|
||||
"command.prompt.mode.normal": "Prompt",
|
||||
"command.permissions.autoaccept.enable": "Aceptar ediciones automáticamente",
|
||||
"command.permissions.autoaccept.disable": "Dejar de aceptar ediciones automáticamente",
|
||||
"command.permissions.autoaccept.enable": "Aceptar permisos automáticamente",
|
||||
"command.permissions.autoaccept.disable": "Dejar de aceptar permisos automáticamente",
|
||||
"command.workspace.toggle": "Alternar espacios de trabajo",
|
||||
"command.workspace.toggle.description": "Habilitar o deshabilitar múltiples espacios de trabajo en la barra lateral",
|
||||
"command.session.undo": "Deshacer",
|
||||
@@ -99,6 +99,8 @@ export const dict = {
|
||||
"dialog.provider.group.other": "Otro",
|
||||
"dialog.provider.tag.recommended": "Recomendado",
|
||||
"dialog.provider.opencode.note": "Modelos seleccionados incluyendo Claude, GPT, Gemini y más",
|
||||
"dialog.provider.opencode.tagline": "Modelos optimizados y fiables",
|
||||
"dialog.provider.opencodeGo.tagline": "Suscripción económica para todos",
|
||||
"dialog.provider.anthropic.note": "Acceso directo a modelos Claude, incluyendo Pro y Max",
|
||||
"dialog.provider.copilot.note": "Modelos de IA para asistencia de codificación a través de GitHub Copilot",
|
||||
"dialog.provider.openai.note": "Modelos GPT para tareas de IA generales rápidas y capaces",
|
||||
@@ -404,10 +406,10 @@ export const dict = {
|
||||
"toast.workspace.disabled.title": "Espacios de trabajo deshabilitados",
|
||||
"toast.workspace.disabled.description": "Solo se muestra el worktree principal en la barra lateral",
|
||||
|
||||
"toast.permissions.autoaccept.on.title": "Aceptando ediciones automáticamente",
|
||||
"toast.permissions.autoaccept.on.description": "Los permisos de edición y escritura serán aprobados automáticamente",
|
||||
"toast.permissions.autoaccept.off.title": "Se dejó de aceptar ediciones automáticamente",
|
||||
"toast.permissions.autoaccept.off.description": "Los permisos de edición y escritura requerirán aprobación",
|
||||
"toast.permissions.autoaccept.on.title": "Aceptando permisos automáticamente",
|
||||
"toast.permissions.autoaccept.on.description": "Las solicitudes de permisos se aprobarán automáticamente",
|
||||
"toast.permissions.autoaccept.off.title": "Se dejó de aceptar permisos automáticamente",
|
||||
"toast.permissions.autoaccept.off.description": "Las solicitudes de permisos requerirán aprobación",
|
||||
|
||||
"toast.model.none.title": "Ningún modelo seleccionado",
|
||||
"toast.model.none.description": "Conecta un proveedor para resumir esta sesión",
|
||||
@@ -621,7 +623,8 @@ export const dict = {
|
||||
"settings.general.row.font.title": "Fuente",
|
||||
"settings.general.row.font.description": "Personaliza la fuente monoespaciada usada en bloques de código",
|
||||
"settings.general.row.reasoningSummaries.title": "Mostrar resúmenes de razonamiento",
|
||||
"settings.general.row.reasoningSummaries.description": "Mostrar resúmenes de razonamiento del modelo en la línea de tiempo",
|
||||
"settings.general.row.reasoningSummaries.description":
|
||||
"Mostrar resúmenes de razonamiento del modelo en la línea de tiempo",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Expandir partes de la herramienta shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
@@ -832,4 +835,18 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 sesión será archivada.",
|
||||
"workspace.reset.archived.many": "{{count}} sesiones serán archivadas.",
|
||||
"workspace.reset.note": "Esto restablecerá el espacio de trabajo para coincidir con la rama predeterminada.",
|
||||
"common.open": "Abrir",
|
||||
"dialog.releaseNotes.action.getStarted": "Comenzar",
|
||||
"dialog.releaseNotes.action.next": "Siguiente",
|
||||
"dialog.releaseNotes.action.hideFuture": "No mostrar esto en el futuro",
|
||||
"dialog.releaseNotes.media.alt": "Vista previa de la versión",
|
||||
"toast.project.reloadFailed.title": "Error al recargar {{project}}",
|
||||
"error.server.invalidConfiguration": "Configuración inválida",
|
||||
"common.moreCountSuffix": " (+{{count}} más)",
|
||||
"common.time.justNow": "Justo ahora",
|
||||
"common.time.minutesAgo.short": "hace {{count}} min",
|
||||
"common.time.hoursAgo.short": "hace {{count}} h",
|
||||
"common.time.daysAgo.short": "hace {{count}} d",
|
||||
"settings.providers.connected.environmentDescription": "Conectado desde tus variables de entorno",
|
||||
"settings.providers.custom.description": "Añade un proveedor compatible con OpenAI por su URL base.",
|
||||
}
|
||||
|
||||
+26
-18
@@ -65,8 +65,8 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "Passer au niveau d'effort suivant",
|
||||
"command.prompt.mode.shell": "Shell",
|
||||
"command.prompt.mode.normal": "Prompt",
|
||||
"command.permissions.autoaccept.enable": "Accepter automatiquement les modifications",
|
||||
"command.permissions.autoaccept.disable": "Arrêter l'acceptation automatique des modifications",
|
||||
"command.permissions.autoaccept.enable": "Accepter automatiquement les permissions",
|
||||
"command.permissions.autoaccept.disable": "Arrêter d'accepter automatiquement les permissions",
|
||||
"command.workspace.toggle": "Basculer les espaces de travail",
|
||||
"command.workspace.toggle.description": "Activer ou désactiver plusieurs espaces de travail dans la barre latérale",
|
||||
"command.session.undo": "Annuler",
|
||||
@@ -91,6 +91,8 @@ export const dict = {
|
||||
"dialog.provider.group.other": "Autre",
|
||||
"dialog.provider.tag.recommended": "Recommandé",
|
||||
"dialog.provider.opencode.note": "Modèles sélectionnés incluant Claude, GPT, Gemini et plus",
|
||||
"dialog.provider.opencode.tagline": "Modèles optimisés et fiables",
|
||||
"dialog.provider.opencodeGo.tagline": "Abonnement abordable pour tous",
|
||||
"dialog.provider.anthropic.note": "Connectez-vous avec Claude Pro/Max ou une clé API",
|
||||
"dialog.provider.copilot.note": "Connectez-vous avec Copilot ou une clé API",
|
||||
"dialog.provider.openai.note": "Connectez-vous avec ChatGPT Pro/Plus ou une clé API",
|
||||
@@ -102,7 +104,6 @@ export const dict = {
|
||||
"dialog.model.empty": "Aucun résultat de modèle",
|
||||
"dialog.model.manage": "Gérer les modèles",
|
||||
"dialog.model.manage.description": "Personnalisez les modèles qui apparaissent dans le sélecteur.",
|
||||
"dialog.model.manage.provider.toggle": "Activer/désactiver tous les modèles {{provider}}",
|
||||
"dialog.model.unpaid.freeModels.title": "Modèles gratuits fournis par Kilo",
|
||||
"dialog.model.unpaid.addMore.title": "Ajouter plus de modèles de fournisseurs populaires",
|
||||
"dialog.provider.viewAll": "Voir plus de fournisseurs",
|
||||
@@ -367,12 +368,10 @@ export const dict = {
|
||||
"toast.workspace.enabled.description": "Plusieurs worktrees sont désormais affichés dans la barre latérale",
|
||||
"toast.workspace.disabled.title": "Espaces de travail désactivés",
|
||||
"toast.workspace.disabled.description": "Seul le worktree principal est affiché dans la barre latérale",
|
||||
"toast.permissions.autoaccept.on.title": "Acceptation auto des modifications",
|
||||
"toast.permissions.autoaccept.on.description":
|
||||
"Les permissions de modification et d'écriture seront automatiquement approuvées",
|
||||
"toast.permissions.autoaccept.off.title": "Arrêt acceptation auto des modifications",
|
||||
"toast.permissions.autoaccept.off.description":
|
||||
"Les permissions de modification et d'écriture nécessiteront une approbation",
|
||||
"toast.permissions.autoaccept.on.title": "Acceptation automatique des permissions",
|
||||
"toast.permissions.autoaccept.on.description": "Les demandes de permission seront approuvées automatiquement",
|
||||
"toast.permissions.autoaccept.off.title": "Acceptation automatique des permissions arrêtée",
|
||||
"toast.permissions.autoaccept.off.description": "Les demandes de permission nécessiteront une approbation",
|
||||
"toast.model.none.title": "Aucun modèle sélectionné",
|
||||
"toast.model.none.description": "Connectez un fournisseur pour résumer cette session",
|
||||
"toast.file.loadFailed.title": "Échec du chargement du fichier",
|
||||
@@ -390,7 +389,8 @@ export const dict = {
|
||||
"toast.session.unshare.failed.description": "Une erreur s'est produite lors de l'annulation du partage de la session",
|
||||
"toast.session.listFailed.title": "Échec du chargement des sessions pour {{project}}",
|
||||
"toast.update.title": "Mise à jour disponible",
|
||||
"toast.update.description": "Une nouvelle version d'Kilo ({{version}}) est maintenant disponible pour installation.",
|
||||
"toast.update.description":
|
||||
"Une nouvelle version d'Kilo ({{version}}) est maintenant disponible pour installation.",
|
||||
"toast.update.action.installRestart": "Installer et redémarrer",
|
||||
"toast.update.action.notYet": "Pas encore",
|
||||
"error.page.title": "Quelque chose s'est mal passé",
|
||||
@@ -451,7 +451,6 @@ export const dict = {
|
||||
"session.review.loadingChanges": "Chargement des modifications...",
|
||||
"session.review.empty": "Aucune modification dans cette session pour l'instant",
|
||||
"session.review.noChanges": "Aucune modification",
|
||||
"session.review.noVcs": "Aucun VCS git détecté, les modifications de session ne seront donc pas détectées",
|
||||
"session.files.selectToOpen": "Sélectionnez un fichier à ouvrir",
|
||||
"session.files.all": "Tous les fichiers",
|
||||
"session.files.binaryContent": "Fichier binaire (le contenu ne peut pas être affiché)",
|
||||
@@ -464,10 +463,6 @@ export const dict = {
|
||||
"session.todo.title": "Tâches",
|
||||
"session.todo.collapse": "Réduire",
|
||||
"session.todo.expand": "Développer",
|
||||
"session.modeSwitch.switching": "Passage en mode {{mode}}…",
|
||||
"session.modeSwitch.waiting": "En attente de la fin de la tâche en cours",
|
||||
"session.modeSwitch.notAvailable": "Agent non disponible",
|
||||
"session.modeSwitch.fallback": '"{{requested}}" introuvable, utilisation de "{{actual}}"',
|
||||
"session.new.worktree.main": "Branche principale",
|
||||
"session.new.worktree.mainWithBranch": "Branche principale ({{branch}})",
|
||||
"session.new.worktree.create": "Créer un nouvel arbre de travail",
|
||||
@@ -529,7 +524,8 @@ export const dict = {
|
||||
"sidebar.workspaces.enable": "Activer les espaces de travail",
|
||||
"sidebar.workspaces.disable": "Désactiver les espaces de travail",
|
||||
"sidebar.gettingStarted.title": "Commencer",
|
||||
"sidebar.gettingStarted.line1": "Kilo inclut des modèles gratuits pour que vous puissiez commencer immédiatement.",
|
||||
"sidebar.gettingStarted.line1":
|
||||
"Kilo inclut des modèles gratuits pour que vous puissiez commencer immédiatement.",
|
||||
"sidebar.gettingStarted.line2":
|
||||
"Connectez n'importe quel fournisseur pour utiliser des modèles, y compris Claude, GPT, Gemini etc.",
|
||||
"sidebar.project.recentSessions": "Sessions récentes",
|
||||
@@ -557,8 +553,6 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "Personnaliser le thème d'Kilo.",
|
||||
"settings.general.row.font.title": "Police",
|
||||
"settings.general.row.font.description": "Personnaliser la police mono utilisée dans les blocs de code",
|
||||
"settings.general.row.reasoningSummaries.title": "Afficher les résumés de raisonnement",
|
||||
"settings.general.row.reasoningSummaries.description": "Afficher les résumés de raisonnement du modèle dans la chronologie",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Développer les parties de l'outil shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Afficher les parties de l'outil shell développées par défaut dans la chronologie",
|
||||
@@ -755,4 +749,18 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 session sera archivée.",
|
||||
"workspace.reset.archived.many": "{{count}} sessions seront archivées.",
|
||||
"workspace.reset.note": "Cela réinitialisera l'espace de travail pour correspondre à la branche par défaut.",
|
||||
"common.open": "Ouvrir",
|
||||
"dialog.releaseNotes.action.getStarted": "Commencer",
|
||||
"dialog.releaseNotes.action.next": "Suivant",
|
||||
"dialog.releaseNotes.action.hideFuture": "Ne plus afficher à l'avenir",
|
||||
"dialog.releaseNotes.media.alt": "Aperçu de la version",
|
||||
"toast.project.reloadFailed.title": "Échec du rechargement de {{project}}",
|
||||
"error.server.invalidConfiguration": "Configuration invalide",
|
||||
"common.moreCountSuffix": " (+{{count}} de plus)",
|
||||
"common.time.justNow": "À l'instant",
|
||||
"common.time.minutesAgo.short": "il y a {{count}}m",
|
||||
"common.time.hoursAgo.short": "il y a {{count}}h",
|
||||
"common.time.daysAgo.short": "il y a {{count}}j",
|
||||
"settings.providers.connected.environmentDescription": "Connecté à partir de vos variables d'environnement",
|
||||
"settings.providers.custom.description": "Ajouter un fournisseur compatible avec OpenAI via l'URL de base.",
|
||||
}
|
||||
|
||||
+22
-14
@@ -65,8 +65,8 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "次の思考レベルに切り替え",
|
||||
"command.prompt.mode.shell": "シェル",
|
||||
"command.prompt.mode.normal": "プロンプト",
|
||||
"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キーで接続",
|
||||
@@ -102,7 +104,6 @@ export const dict = {
|
||||
"dialog.model.empty": "モデルが見つかりません",
|
||||
"dialog.model.manage": "モデルを管理",
|
||||
"dialog.model.manage.description": "モデルセレクターに表示するモデルをカスタマイズします。",
|
||||
"dialog.model.manage.provider.toggle": "{{provider}} のすべてのモデルを切り替え",
|
||||
"dialog.model.unpaid.freeModels.title": "Kiloが提供する無料モデル",
|
||||
"dialog.model.unpaid.addMore.title": "人気のプロバイダーからモデルを追加",
|
||||
"dialog.provider.viewAll": "さらにプロバイダーを表示",
|
||||
@@ -365,10 +366,10 @@ export const dict = {
|
||||
"toast.workspace.enabled.description": "サイドバーに複数のワークツリーが表示されます",
|
||||
"toast.workspace.disabled.title": "ワークスペースが無効になりました",
|
||||
"toast.workspace.disabled.description": "サイドバーにはメインのワークツリーのみが表示されます",
|
||||
"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": "ファイルの読み込みに失敗しました",
|
||||
@@ -444,7 +445,6 @@ export const dict = {
|
||||
"session.review.loadingChanges": "変更を読み込み中...",
|
||||
"session.review.empty": "このセッションでの変更はまだありません",
|
||||
"session.review.noChanges": "変更なし",
|
||||
"session.review.noVcs": "git VCS が検出されなかったため、セッションの変更は検出されません",
|
||||
"session.files.selectToOpen": "開くファイルを選択",
|
||||
"session.files.all": "すべてのファイル",
|
||||
"session.files.binaryContent": "バイナリファイル(内容を表示できません)",
|
||||
@@ -457,10 +457,6 @@ export const dict = {
|
||||
"session.todo.title": "ToDo",
|
||||
"session.todo.collapse": "折りたたむ",
|
||||
"session.todo.expand": "展開",
|
||||
"session.modeSwitch.switching": "{{mode}} モードに切り替え中…",
|
||||
"session.modeSwitch.waiting": "現在のタスクの完了を待っています",
|
||||
"session.modeSwitch.notAvailable": "エージェントは利用できません",
|
||||
"session.modeSwitch.fallback": '"{{requested}}" が見つかりません。"{{actual}}" を使用します',
|
||||
"session.new.worktree.main": "メインブランチ",
|
||||
"session.new.worktree.mainWithBranch": "メインブランチ ({{branch}})",
|
||||
"session.new.worktree.create": "新しいワークツリーを作成",
|
||||
@@ -549,8 +545,6 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "Kiloのテーマをカスタマイズします。",
|
||||
"settings.general.row.font.title": "フォント",
|
||||
"settings.general.row.font.description": "コードブロックで使用する等幅フォントをカスタマイズします",
|
||||
"settings.general.row.reasoningSummaries.title": "推論サマリーを表示",
|
||||
"settings.general.row.reasoningSummaries.description": "タイムラインにモデルの推論サマリーを表示する",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "shell ツールパーツを展開",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"タイムラインで shell ツールパーツをデフォルトで展開して表示します",
|
||||
@@ -744,4 +738,18 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1つのセッションがアーカイブされます。",
|
||||
"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": "ベース URL を指定して OpenAI 互換のプロバイダーを追加します。",
|
||||
}
|
||||
|
||||
+22
-14
@@ -69,8 +69,8 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "다음 생각 수준으로 전환",
|
||||
"command.prompt.mode.shell": "셸",
|
||||
"command.prompt.mode.normal": "프롬프트",
|
||||
"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": "실행 취소",
|
||||
@@ -95,6 +95,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 키로 연결",
|
||||
@@ -106,7 +108,6 @@ export const dict = {
|
||||
"dialog.model.empty": "모델 결과 없음",
|
||||
"dialog.model.manage": "모델 관리",
|
||||
"dialog.model.manage.description": "모델 선택기에 표시할 모델 사용자 지정",
|
||||
"dialog.model.manage.provider.toggle": "모든 {{provider}} 모델 전환",
|
||||
"dialog.model.unpaid.freeModels.title": "Kilo에서 제공하는 무료 모델",
|
||||
"dialog.model.unpaid.addMore.title": "인기 공급자의 모델 추가",
|
||||
"dialog.provider.viewAll": "더 많은 공급자 보기",
|
||||
@@ -368,10 +369,10 @@ export const dict = {
|
||||
"toast.workspace.enabled.description": "이제 사이드바에 여러 작업 트리가 표시됩니다",
|
||||
"toast.workspace.disabled.title": "작업 공간 비활성화됨",
|
||||
"toast.workspace.disabled.description": "사이드바에 메인 작업 트리만 표시됩니다",
|
||||
"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": "파일 로드 실패",
|
||||
@@ -446,7 +447,6 @@ export const dict = {
|
||||
"session.review.loadingChanges": "변경 사항 로드 중...",
|
||||
"session.review.empty": "이 세션에 변경 사항이 아직 없습니다",
|
||||
"session.review.noChanges": "변경 없음",
|
||||
"session.review.noVcs": "git VCS가 감지되지 않아 세션 변경 사항이 감지되지 않습니다",
|
||||
"session.files.selectToOpen": "열 파일을 선택하세요",
|
||||
"session.files.all": "모든 파일",
|
||||
"session.files.binaryContent": "바이너리 파일 (내용을 표시할 수 없음)",
|
||||
@@ -459,10 +459,6 @@ export const dict = {
|
||||
"session.todo.title": "할 일",
|
||||
"session.todo.collapse": "접기",
|
||||
"session.todo.expand": "펼치기",
|
||||
"session.modeSwitch.switching": "{{mode}} 모드로 전환 중…",
|
||||
"session.modeSwitch.waiting": "현재 작업이 완료될 때까지 대기 중",
|
||||
"session.modeSwitch.notAvailable": "에이전트를 사용할 수 없음",
|
||||
"session.modeSwitch.fallback": '"{{requested}}"을(를) 찾을 수 없어 "{{actual}}"을(를) 사용합니다',
|
||||
"session.new.worktree.main": "메인 브랜치",
|
||||
"session.new.worktree.mainWithBranch": "메인 브랜치 ({{branch}})",
|
||||
"session.new.worktree.create": "새 작업 트리 생성",
|
||||
@@ -550,8 +546,6 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "Kilo 테마 사용자 지정",
|
||||
"settings.general.row.font.title": "글꼴",
|
||||
"settings.general.row.font.description": "코드 블록에 사용되는 고정폭 글꼴 사용자 지정",
|
||||
"settings.general.row.reasoningSummaries.title": "추론 요약 표시",
|
||||
"settings.general.row.reasoningSummaries.description": "타임라인에 모델 추론 요약 표시",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "shell 도구 파트 펼치기",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"타임라인에서 기본적으로 shell 도구 파트를 펼친 상태로 표시합니다",
|
||||
@@ -744,4 +738,18 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1개의 세션이 보관됩니다.",
|
||||
"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": "기본 URL로 OpenAI 호환 공급자를 추가합니다.",
|
||||
}
|
||||
|
||||
@@ -74,8 +74,8 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "Bytt til neste innsatsnivå",
|
||||
"command.prompt.mode.shell": "Shell",
|
||||
"command.prompt.mode.normal": "Prompt",
|
||||
"command.permissions.autoaccept.enable": "Godta endringer automatisk",
|
||||
"command.permissions.autoaccept.disable": "Slutt å godta endringer automatisk",
|
||||
"command.permissions.autoaccept.enable": "Aksepter tillatelser automatisk",
|
||||
"command.permissions.autoaccept.disable": "Stopp automatisk akseptering av tillatelser",
|
||||
"command.workspace.toggle": "Veksle arbeidsområder",
|
||||
"command.workspace.toggle.description": "Enable or disable multiple workspaces in the sidebar",
|
||||
"command.session.undo": "Angre",
|
||||
@@ -102,6 +102,8 @@ export const dict = {
|
||||
"dialog.provider.group.other": "Andre",
|
||||
"dialog.provider.tag.recommended": "Anbefalt",
|
||||
"dialog.provider.opencode.note": "Utvalgte modeller inkludert Claude, GPT, Gemini og mer",
|
||||
"dialog.provider.opencode.tagline": "Pålitelige, optimaliserte modeller",
|
||||
"dialog.provider.opencodeGo.tagline": "Rimelig abonnement for alle",
|
||||
"dialog.provider.anthropic.note": "Direkte tilgang til Claude-modeller, inkludert Pro og Max",
|
||||
"dialog.provider.copilot.note": "AI-modeller for kodeassistanse via GitHub Copilot",
|
||||
"dialog.provider.openai.note": "GPT-modeller for raske, dyktige generelle AI-oppgaver",
|
||||
@@ -405,10 +407,10 @@ export const dict = {
|
||||
"toast.workspace.disabled.title": "Arbeidsområder deaktivert",
|
||||
"toast.workspace.disabled.description": "Kun hoved-worktree vises i sidefeltet",
|
||||
|
||||
"toast.permissions.autoaccept.on.title": "Godtar endringer automatisk",
|
||||
"toast.permissions.autoaccept.on.description": "Redigerings- og skrivetillatelser vil bli godkjent automatisk",
|
||||
"toast.permissions.autoaccept.off.title": "Sluttet å godta endringer automatisk",
|
||||
"toast.permissions.autoaccept.off.description": "Redigerings- og skrivetillatelser vil kreve godkjenning",
|
||||
"toast.permissions.autoaccept.on.title": "Aksepterer tillatelser automatisk",
|
||||
"toast.permissions.autoaccept.on.description": "Forespørsler om tillatelse vil bli godkjent automatisk",
|
||||
"toast.permissions.autoaccept.off.title": "Stoppet automatisk akseptering av tillatelser",
|
||||
"toast.permissions.autoaccept.off.description": "Forespørsler om tillatelse vil kreve godkjenning",
|
||||
|
||||
"toast.model.none.title": "Ingen modell valgt",
|
||||
"toast.model.none.description": "Koble til en leverandør for å oppsummere denne sesjonen",
|
||||
@@ -828,4 +830,18 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 sesjon vil bli arkivert.",
|
||||
"workspace.reset.archived.many": "{{count}} sesjoner vil bli arkivert.",
|
||||
"workspace.reset.note": "Dette vil tilbakestille arbeidsområdet til å samsvare med standardgrenen.",
|
||||
"common.open": "Åpne",
|
||||
"dialog.releaseNotes.action.getStarted": "Kom i gang",
|
||||
"dialog.releaseNotes.action.next": "Neste",
|
||||
"dialog.releaseNotes.action.hideFuture": "Ikke vis disse igjen",
|
||||
"dialog.releaseNotes.media.alt": "Forhåndsvisning av utgivelse",
|
||||
"toast.project.reloadFailed.title": "Kunne ikke laste inn {{project}} på nytt",
|
||||
"error.server.invalidConfiguration": "Ugyldig konfigurasjon",
|
||||
"common.moreCountSuffix": " (+{{count}} mer)",
|
||||
"common.time.justNow": "Akkurat nå",
|
||||
"common.time.minutesAgo.short": "{{count}} m siden",
|
||||
"common.time.hoursAgo.short": "{{count}} t siden",
|
||||
"common.time.daysAgo.short": "{{count}} d siden",
|
||||
"settings.providers.connected.environmentDescription": "Koblet til fra miljøvariablene dine",
|
||||
"settings.providers.custom.description": "Legg til en OpenAI-kompatibel leverandør via basis-URL.",
|
||||
} satisfies Partial<Record<Keys, string>>
|
||||
|
||||
@@ -15,8 +15,9 @@ import { dict as ru } from "./ru"
|
||||
import { dict as th } from "./th"
|
||||
import { dict as zh } from "./zh"
|
||||
import { dict as zht } from "./zht"
|
||||
import { dict as tr } from "./tr"
|
||||
|
||||
const locales = [ar, br, bs, da, de, es, fr, ja, ko, no, pl, ru, th, zh, zht]
|
||||
const locales = [ar, br, bs, da, de, es, fr, ja, ko, no, pl, ru, th, tr, zh, zht]
|
||||
const keys = ["command.session.previous.unseen", "command.session.next.unseen"] as const
|
||||
|
||||
describe("i18n parity", () => {
|
||||
|
||||
+22
-14
@@ -65,8 +65,8 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "Przełącz na następny poziom wysiłku",
|
||||
"command.prompt.mode.shell": "Terminal",
|
||||
"command.prompt.mode.normal": "Prompt",
|
||||
"command.permissions.autoaccept.enable": "Automatyczne akceptowanie edycji",
|
||||
"command.permissions.autoaccept.disable": "Zatrzymaj automatyczne akceptowanie edycji",
|
||||
"command.permissions.autoaccept.enable": "Automatycznie akceptuj uprawnienia",
|
||||
"command.permissions.autoaccept.disable": "Zatrzymaj automatyczne akceptowanie uprawnień",
|
||||
"command.workspace.toggle": "Przełącz przestrzenie robocze",
|
||||
"command.workspace.toggle.description": "Włącz lub wyłącz wiele przestrzeni roboczych na pasku bocznym",
|
||||
"command.session.undo": "Cofnij",
|
||||
@@ -91,6 +91,8 @@ export const dict = {
|
||||
"dialog.provider.group.other": "Inne",
|
||||
"dialog.provider.tag.recommended": "Zalecane",
|
||||
"dialog.provider.opencode.note": "Wyselekcjonowane modele, w tym Claude, GPT, Gemini i inne",
|
||||
"dialog.provider.opencode.tagline": "Niezawodne, zoptymalizowane modele",
|
||||
"dialog.provider.opencodeGo.tagline": "Tania subskrypcja dla każdego",
|
||||
"dialog.provider.anthropic.note": "Bezpośredni dostęp do modeli Claude, w tym Pro i Max",
|
||||
"dialog.provider.copilot.note": "Modele AI do pomocy w kodowaniu przez GitHub Copilot",
|
||||
"dialog.provider.openai.note": "Modele GPT do szybkich i wszechstronnych zadań AI",
|
||||
@@ -102,7 +104,6 @@ export const dict = {
|
||||
"dialog.model.empty": "Brak wyników modelu",
|
||||
"dialog.model.manage": "Zarządzaj modelami",
|
||||
"dialog.model.manage.description": "Dostosuj, które modele pojawiają się w wyborze modelu.",
|
||||
"dialog.model.manage.provider.toggle": "Przełącz wszystkie modele {{provider}}",
|
||||
"dialog.model.unpaid.freeModels.title": "Darmowe modele dostarczane przez Kilo",
|
||||
"dialog.model.unpaid.addMore.title": "Dodaj więcej modeli od popularnych dostawców",
|
||||
"dialog.provider.viewAll": "Zobacz więcej dostawców",
|
||||
@@ -366,10 +367,10 @@ export const dict = {
|
||||
"toast.workspace.enabled.description": "Kilka worktree jest teraz wyświetlanych na pasku bocznym",
|
||||
"toast.workspace.disabled.title": "Przestrzenie robocze wyłączone",
|
||||
"toast.workspace.disabled.description": "Tylko główny worktree jest wyświetlany na pasku bocznym",
|
||||
"toast.permissions.autoaccept.on.title": "Automatyczne akceptowanie edycji",
|
||||
"toast.permissions.autoaccept.on.description": "Uprawnienia do edycji i zapisu będą automatycznie zatwierdzane",
|
||||
"toast.permissions.autoaccept.off.title": "Zatrzymano automatyczne akceptowanie edycji",
|
||||
"toast.permissions.autoaccept.off.description": "Uprawnienia do edycji i zapisu będą wymagały zatwierdzenia",
|
||||
"toast.permissions.autoaccept.on.title": "Automatyczne akceptowanie uprawnień",
|
||||
"toast.permissions.autoaccept.on.description": "Żądania uprawnień będą automatycznie zatwierdzane",
|
||||
"toast.permissions.autoaccept.off.title": "Zatrzymano automatyczne akceptowanie uprawnień",
|
||||
"toast.permissions.autoaccept.off.description": "Żądania uprawnień będą wymagały zatwierdzenia",
|
||||
"toast.model.none.title": "Nie wybrano modelu",
|
||||
"toast.model.none.description": "Połącz dostawcę, aby podsumować tę sesję",
|
||||
"toast.file.loadFailed.title": "Nie udało się załadować pliku",
|
||||
@@ -445,7 +446,6 @@ export const dict = {
|
||||
"session.review.loadingChanges": "Ładowanie zmian...",
|
||||
"session.review.empty": "Brak zmian w tej sesji",
|
||||
"session.review.noChanges": "Brak zmian",
|
||||
"session.review.noVcs": "Nie wykryto git VCS, więc zmiany sesji nie będą wykrywane",
|
||||
"session.files.selectToOpen": "Wybierz plik do otwarcia",
|
||||
"session.files.all": "Wszystkie pliki",
|
||||
"session.files.binaryContent": "Plik binarny (zawartość nie może być wyświetlona)",
|
||||
@@ -458,10 +458,6 @@ export const dict = {
|
||||
"session.todo.title": "Zadania",
|
||||
"session.todo.collapse": "Zwiń",
|
||||
"session.todo.expand": "Rozwiń",
|
||||
"session.modeSwitch.switching": "Przełączanie do trybu {{mode}}…",
|
||||
"session.modeSwitch.waiting": "Oczekiwanie na ukończenie bieżącego zadania",
|
||||
"session.modeSwitch.notAvailable": "Agent niedostępny",
|
||||
"session.modeSwitch.fallback": '"{{requested}}" nie znaleziono, używam "{{actual}}"',
|
||||
"session.new.worktree.main": "Główna gałąź",
|
||||
"session.new.worktree.mainWithBranch": "Główna gałąź ({{branch}})",
|
||||
"session.new.worktree.create": "Utwórz nowe drzewo robocze",
|
||||
@@ -550,8 +546,6 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "Dostosuj motyw Kilo.",
|
||||
"settings.general.row.font.title": "Czcionka",
|
||||
"settings.general.row.font.description": "Dostosuj czcionkę mono używaną w blokach kodu",
|
||||
"settings.general.row.reasoningSummaries.title": "Pokaż podsumowania rozumowania",
|
||||
"settings.general.row.reasoningSummaries.description": "Wyświetlaj podsumowania rozumowania modelu na osi czasu",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Rozwijaj elementy narzędzia shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Domyślnie pokazuj rozwinięte elementy narzędzia shell na osi czasu",
|
||||
@@ -746,4 +740,18 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 sesja zostanie zarchiwizowana.",
|
||||
"workspace.reset.archived.many": "{{count}} sesji zostanie zarchiwizowanych.",
|
||||
"workspace.reset.note": "To zresetuje przestrzeń roboczą, aby odpowiadała domyślnej gałęzi.",
|
||||
"common.open": "Otwórz",
|
||||
"dialog.releaseNotes.action.getStarted": "Rozpocznij",
|
||||
"dialog.releaseNotes.action.next": "Dalej",
|
||||
"dialog.releaseNotes.action.hideFuture": "Nie pokazuj tego w przyszłości",
|
||||
"dialog.releaseNotes.media.alt": "Podgląd wydania",
|
||||
"toast.project.reloadFailed.title": "Nie udało się ponownie wczytać {{project}}",
|
||||
"error.server.invalidConfiguration": "Nieprawidłowa konfiguracja",
|
||||
"common.moreCountSuffix": " (jeszcze {{count}})",
|
||||
"common.time.justNow": "Przed chwilą",
|
||||
"common.time.minutesAgo.short": "{{count}} min temu",
|
||||
"common.time.hoursAgo.short": "{{count}} godz. temu",
|
||||
"common.time.daysAgo.short": "{{count}} dni temu",
|
||||
"settings.providers.connected.environmentDescription": "Połączono ze zmiennymi środowiskowymi",
|
||||
"settings.providers.custom.description": "Dodaj dostawcę zgodnego z OpenAI poprzez podstawowy URL.",
|
||||
}
|
||||
|
||||
@@ -71,8 +71,8 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "Переключиться к следующему уровню усилий",
|
||||
"command.prompt.mode.shell": "Оболочка",
|
||||
"command.prompt.mode.normal": "Промпт",
|
||||
"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": "Отменить",
|
||||
@@ -99,6 +99,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",
|
||||
"dialog.provider.copilot.note": "ИИ-модели для помощи в кодировании через GitHub Copilot",
|
||||
"dialog.provider.openai.note": "Модели GPT для быстрых и мощных задач общего ИИ",
|
||||
@@ -399,10 +401,10 @@ export const dict = {
|
||||
"toast.theme.title": "Тема переключена",
|
||||
"toast.scheme.title": "Цветовая схема",
|
||||
|
||||
"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.workspace.enabled.title": "Рабочие пространства включены",
|
||||
"toast.workspace.enabled.description": "В боковой панели теперь отображаются несколько рабочих деревьев",
|
||||
@@ -828,4 +830,18 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 сессия будет архивирована.",
|
||||
"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.",
|
||||
}
|
||||
|
||||
+24
-16
@@ -71,8 +71,8 @@ export const dict = {
|
||||
"command.model.variant.cycle.description": "สลับไปยังระดับความพยายามถัดไป",
|
||||
"command.prompt.mode.shell": "เชลล์",
|
||||
"command.prompt.mode.normal": "พรอมต์",
|
||||
"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": "ยกเลิก",
|
||||
@@ -99,6 +99,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",
|
||||
"dialog.provider.copilot.note": "โมเดล AI สำหรับการช่วยเหลือในการเขียนโค้ดผ่าน GitHub Copilot",
|
||||
"dialog.provider.openai.note": "โมเดล GPT สำหรับงาน AI ทั่วไปที่รวดเร็วและมีความสามารถ",
|
||||
@@ -111,7 +113,6 @@ export const dict = {
|
||||
"dialog.model.empty": "ไม่พบผลลัพธ์โมเดล",
|
||||
"dialog.model.manage": "จัดการโมเดล",
|
||||
"dialog.model.manage.description": "ปรับแต่งโมเดลที่จะปรากฏในตัวเลือกโมเดล",
|
||||
"dialog.model.manage.provider.toggle": "สลับโมเดล {{provider}} ทั้งหมด",
|
||||
|
||||
"dialog.model.unpaid.freeModels.title": "โมเดลฟรีที่จัดหาให้โดย Kilo",
|
||||
"dialog.model.unpaid.addMore.title": "เพิ่มโมเดลเพิ่มเติมจากผู้ให้บริการยอดนิยม",
|
||||
@@ -147,7 +148,8 @@ 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}} พร้อมใช้งานแล้ว",
|
||||
@@ -401,10 +403,10 @@ export const dict = {
|
||||
"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": "เชื่อมต่อผู้ให้บริการเพื่อสรุปเซสชันนี้",
|
||||
@@ -493,7 +495,6 @@ export const dict = {
|
||||
"session.review.loadingChanges": "กำลังโหลดการเปลี่ยนแปลง...",
|
||||
"session.review.empty": "ยังไม่มีการเปลี่ยนแปลงในเซสชันนี้",
|
||||
"session.review.noChanges": "ไม่มีการเปลี่ยนแปลง",
|
||||
"session.review.noVcs": "ไม่พบ git VCS ดังนั้นจะไม่สามารถตรวจจับการเปลี่ยนแปลงของเซสชันได้",
|
||||
|
||||
"session.files.selectToOpen": "เลือกไฟล์เพื่อเปิด",
|
||||
"session.files.all": "ไฟล์ทั้งหมด",
|
||||
@@ -510,11 +511,6 @@ export const dict = {
|
||||
"session.todo.collapse": "ย่อ",
|
||||
"session.todo.expand": "ขยาย",
|
||||
|
||||
"session.modeSwitch.switching": "กำลังสลับไปยังโหมด {{mode}}…",
|
||||
"session.modeSwitch.waiting": "รอให้งานปัจจุบันเสร็จสมบูรณ์",
|
||||
"session.modeSwitch.notAvailable": "ตัวแทนไม่พร้อมใช้งาน",
|
||||
"session.modeSwitch.fallback": 'ไม่พบ "{{requested}}" กำลังใช้ "{{actual}}"',
|
||||
|
||||
"session.new.worktree.main": "สาขาหลัก",
|
||||
"session.new.worktree.mainWithBranch": "สาขาหลัก ({{branch}})",
|
||||
"session.new.worktree.create": "สร้าง worktree ใหม่",
|
||||
@@ -611,8 +607,6 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "ปรับแต่งวิธีการที่ Kilo มีธีม",
|
||||
"settings.general.row.font.title": "ฟอนต์",
|
||||
"settings.general.row.font.description": "ปรับแต่งฟอนต์โมโนที่ใช้ในบล็อกโค้ด",
|
||||
"settings.general.row.reasoningSummaries.title": "แสดงสรุปการให้เหตุผล",
|
||||
"settings.general.row.reasoningSummaries.description": "แสดงสรุปการให้เหตุผลของโมเดลในไทม์ไลน์",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "ขยายส่วนเครื่องมือ shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description": "แสดงส่วนเครื่องมือ shell แบบขยายตามค่าเริ่มต้นในไทม์ไลน์",
|
||||
@@ -817,4 +811,18 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "1 เซสชันจะถูกจัดเก็บ",
|
||||
"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 หลัก",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,849 @@
|
||||
import { dict as en } from "./en"
|
||||
|
||||
type Keys = keyof typeof en
|
||||
|
||||
export const dict = {
|
||||
"command.category.suggested": "Önerilen",
|
||||
"command.category.view": "Görünüm",
|
||||
"command.category.project": "Proje",
|
||||
"command.category.provider": "Sağlayıcı",
|
||||
"command.category.server": "Sunucu",
|
||||
"command.category.session": "Oturum",
|
||||
"command.category.theme": "Tema",
|
||||
"command.category.language": "Dil",
|
||||
"command.category.file": "Dosya",
|
||||
"command.category.context": "Bağlam",
|
||||
"command.category.terminal": "Terminal",
|
||||
"command.category.model": "Model",
|
||||
"command.category.mcp": "MCP",
|
||||
"command.category.agent": "Ajan",
|
||||
"command.category.permissions": "İzinler",
|
||||
"command.category.workspace": "Çalışma Alanı",
|
||||
"command.category.settings": "Ayarlar",
|
||||
|
||||
"theme.scheme.system": "Sistem",
|
||||
"theme.scheme.light": "Açık",
|
||||
"theme.scheme.dark": "Koyu",
|
||||
|
||||
"command.sidebar.toggle": "Kenar çubuğunu aç/kapat",
|
||||
"command.project.open": "Proje aç",
|
||||
"command.provider.connect": "Sağlayıcı bağla",
|
||||
"command.server.switch": "Sunucu değiştir",
|
||||
"command.settings.open": "Ayarları aç",
|
||||
"command.session.previous": "Önceki oturum",
|
||||
"command.session.next": "Sonraki oturum",
|
||||
"command.session.previous.unseen": "Önceki okunmamış oturum",
|
||||
"command.session.next.unseen": "Sonraki okunmamış oturum",
|
||||
"command.session.archive": "Oturumu arşivle",
|
||||
|
||||
"command.palette": "Komut paleti",
|
||||
|
||||
"command.theme.cycle": "Tema değiştir",
|
||||
"command.theme.set": "Tema kullan: {{theme}}",
|
||||
"command.theme.scheme.cycle": "Renk şemasını değiştir",
|
||||
"command.theme.scheme.set": "Renk şeması kullan: {{scheme}}",
|
||||
|
||||
"command.language.cycle": "Dil değiştir",
|
||||
"command.language.set": "Dil kullan: {{language}}",
|
||||
|
||||
"command.session.new": "Yeni oturum",
|
||||
"command.file.open": "Dosya aç",
|
||||
"command.tab.close": "Sekmeyi kapat",
|
||||
"command.context.addSelection": "Seçimi bağlama ekle",
|
||||
"command.context.addSelection.description": "Mevcut dosyadan seçili satırları ekle",
|
||||
"command.input.focus": "Girişi odakla",
|
||||
"command.terminal.toggle": "Terminali aç/kapat",
|
||||
"command.fileTree.toggle": "Dosya ağacını aç/kapat",
|
||||
"command.review.toggle": "İncelemeyi aç/kapat",
|
||||
"command.terminal.new": "Yeni terminal",
|
||||
"command.terminal.new.description": "Yeni bir terminal sekmesi oluştur",
|
||||
"command.steps.toggle": "Adımları aç/kapat",
|
||||
"command.steps.toggle.description": "Mevcut mesaj için adımları göster veya gizle",
|
||||
"command.message.previous": "Önceki mesaj",
|
||||
"command.message.previous.description": "Önceki kullanıcı mesajına git",
|
||||
"command.message.next": "Sonraki mesaj",
|
||||
"command.message.next.description": "Sonraki kullanıcı mesajına git",
|
||||
"command.model.choose": "Model seç",
|
||||
"command.model.choose.description": "Farklı bir model seç",
|
||||
"command.mcp.toggle": "MCP'leri aç/kapat",
|
||||
"command.mcp.toggle.description": "MCP'leri aç/kapat",
|
||||
"command.agent.cycle": "Ajan değiştir",
|
||||
"command.agent.cycle.description": "Sonraki ajana geç",
|
||||
"command.agent.cycle.reverse": "Ajanı geri değiştir",
|
||||
"command.agent.cycle.reverse.description": "Önceki ajana geç",
|
||||
"command.model.variant.cycle": "Düşünme eforu değiştir",
|
||||
"command.model.variant.cycle.description": "Sonraki efor seviyesine geç",
|
||||
"command.prompt.mode.shell": "Kabuk",
|
||||
"command.prompt.mode.normal": "Komut",
|
||||
"command.permissions.autoaccept.enable": "Düzenlemeleri otomatik kabul et",
|
||||
"command.permissions.autoaccept.disable": "Otomatik kabulü durdur",
|
||||
"command.workspace.toggle": "Çalışma alanlarını aç/kapat",
|
||||
"command.workspace.toggle.description": "Kenar çubuğunda birden fazla çalışma alanını göster veya gizle",
|
||||
"command.session.undo": "Geri al",
|
||||
"command.session.undo.description": "Son mesajı geri al",
|
||||
"command.session.redo": "Yinele",
|
||||
"command.session.redo.description": "Son geri alınan mesajı yinele",
|
||||
"command.session.compact": "Oturumu sıkıştır",
|
||||
"command.session.compact.description": "Bağlam boyutunu azaltmak için oturumu özetle",
|
||||
"command.session.fork": "Mesajdan dallandır",
|
||||
"command.session.fork.description": "Önceki bir mesajdan yeni oturum oluştur",
|
||||
"command.session.share": "Oturumu paylaş",
|
||||
"command.session.share.description": "Bu oturumu paylaş ve URL'yi panoya kopyala",
|
||||
"command.session.unshare": "Paylaşımı kaldır",
|
||||
"command.session.unshare.description": "Bu oturumun paylaşımını durdur",
|
||||
|
||||
"palette.search.placeholder": "Dosya, komut ve oturum ara",
|
||||
"palette.empty": "Sonuç bulunamadı",
|
||||
"palette.group.commands": "Komutlar",
|
||||
"palette.group.files": "Dosyalar",
|
||||
|
||||
"dialog.provider.search.placeholder": "Sağlayıcı ara",
|
||||
"dialog.provider.empty": "Sağlayıcı bulunamadı",
|
||||
"dialog.provider.group.popular": "Popüler",
|
||||
"dialog.provider.group.other": "Diğer",
|
||||
"dialog.provider.tag.recommended": "Önerilen",
|
||||
"dialog.provider.opencode.note": "Claude, GPT, Gemini ve daha fazlasını içeren seçilmiş modeller",
|
||||
"dialog.provider.opencode.tagline": "Güvenilir optimize edilmiş modeller",
|
||||
"dialog.provider.opencodeGo.tagline": "Herkes için düşük maliyetli abonelik",
|
||||
"dialog.provider.anthropic.note": "Pro ve Max dahil Claude modellerine doğrudan erişim",
|
||||
"dialog.provider.copilot.note": "GitHub Copilot üzerinden kodlama yardımı için yapay zekâ modelleri",
|
||||
"dialog.provider.openai.note": "Hızlı ve yetenekli genel yapay zekâ görevleri için GPT modelleri",
|
||||
"dialog.provider.google.note": "Hızlı ve yapılandırılmış yanıtlar için Gemini modelleri",
|
||||
"dialog.provider.openrouter.note": "Tek bir sağlayıcıdan tüm desteklenen modellere eriş",
|
||||
"dialog.provider.vercel.note": "Akıllı yönlendirme ile yapay zekâ modellerine birleşik erişim",
|
||||
|
||||
"dialog.model.select.title": "Model seç",
|
||||
"dialog.model.search.placeholder": "Model ara",
|
||||
"dialog.model.empty": "Model sonucu yok",
|
||||
"dialog.model.manage": "Modelleri yönet",
|
||||
"dialog.model.manage.description": "Model seçicide hangi modellerin görüneceğini özelleştirin.",
|
||||
"dialog.model.manage.provider.toggle": "Tüm {{provider}} modellerini aç/kapat",
|
||||
|
||||
"dialog.model.unpaid.freeModels.title": "Kilo tarafından sunulan ücretsiz modeller",
|
||||
"dialog.model.unpaid.addMore.title": "Popüler sağlayıcılardan daha fazla model ekleyin",
|
||||
|
||||
"dialog.provider.viewAll": "Daha fazla sağlayıcı göster",
|
||||
|
||||
"provider.connect.title": "{{provider}} bağla",
|
||||
"provider.connect.title.anthropicProMax": "Claude Pro/Max ile giriş yap",
|
||||
"provider.connect.selectMethod": "{{provider}} için giriş yöntemini seçin.",
|
||||
"provider.connect.method.apiKey": "API anahtarı",
|
||||
"provider.connect.status.inProgress": "Yetkilendirme devam ediyor...",
|
||||
"provider.connect.status.waiting": "Yetkilendirme bekleniyor...",
|
||||
"provider.connect.status.failed": "Yetkilendirme başarısız: {{error}}",
|
||||
"provider.connect.apiKey.description":
|
||||
"{{provider}} hesabınızı bağlamak ve Kilo'da {{provider}} modellerini kullanmak için {{provider}} API anahtarınızı girin.",
|
||||
"provider.connect.apiKey.label": "{{provider}} API anahtarı",
|
||||
"provider.connect.apiKey.placeholder": "API anahtarı",
|
||||
"provider.connect.apiKey.required": "API anahtarı gerekli",
|
||||
"provider.connect.opencodeZen.line1":
|
||||
"OpenCode Zen, kodlama ajanları için seçilmiş güvenilir optimize edilmiş modellere erişim sağlar.",
|
||||
"provider.connect.opencodeZen.line2":
|
||||
"Tek bir API anahtarıyla Claude, GPT, Gemini, GLM ve daha fazlası gibi modellere erişebilirsiniz.",
|
||||
"provider.connect.opencodeZen.visit.prefix": "",
|
||||
"provider.connect.opencodeZen.visit.link": "opencode.ai/zen",
|
||||
"provider.connect.opencodeZen.visit.suffix": " adresini ziyaret ederek API anahtarınızı alın.",
|
||||
"provider.connect.oauth.code.visit.prefix":
|
||||
"Hesabınızı bağlamak ve Kilo'da {{provider}} modellerini kullanmak için ",
|
||||
"provider.connect.oauth.code.visit.link": "bu bağlantıya",
|
||||
"provider.connect.oauth.code.visit.suffix": " tıklayarak yetkilendirme kodunuzu alın.",
|
||||
"provider.connect.oauth.code.label": "{{method}} yetkilendirme kodu",
|
||||
"provider.connect.oauth.code.placeholder": "Yetkilendirme kodu",
|
||||
"provider.connect.oauth.code.required": "Yetkilendirme kodu gerekli",
|
||||
"provider.connect.oauth.code.invalid": "Geçersiz yetkilendirme kodu",
|
||||
"provider.connect.oauth.auto.visit.prefix": "",
|
||||
"provider.connect.oauth.auto.visit.link": "Bu bağlantıya",
|
||||
"provider.connect.oauth.auto.visit.suffix":
|
||||
" tıklayarak aşağıdaki kodu girin ve hesabınızı bağlayarak Kilo'da {{provider}} modellerini kullanın.",
|
||||
"provider.connect.oauth.auto.confirmationCode": "Onay kodu",
|
||||
"provider.connect.toast.connected.title": "{{provider}} bağlandı",
|
||||
"provider.connect.toast.connected.description": "{{provider}} modelleri artık kullanımda.",
|
||||
|
||||
"provider.custom.title": "Özel sağlayıcı",
|
||||
"provider.custom.description.prefix": "OpenAI uyumlu bir sağlayıcı yapılandırın. ",
|
||||
"provider.custom.description.link": "Sağlayıcı yapılandırma dökümanları",
|
||||
"provider.custom.description.suffix": " sayfasına bakın.",
|
||||
"provider.custom.field.providerID.label": "Sağlayıcı kimlik",
|
||||
"provider.custom.field.providerID.placeholder": "saglayicim",
|
||||
"provider.custom.field.providerID.description": "Küçük harfler, rakamlar, tire veya alt çizgi",
|
||||
"provider.custom.field.name.label": "Görünen ad",
|
||||
"provider.custom.field.name.placeholder": "Yapay Zekâ Sağlayıcım",
|
||||
"provider.custom.field.baseURL.label": "Temel URL",
|
||||
"provider.custom.field.baseURL.placeholder": "https://api.saglayicim.com/v1",
|
||||
"provider.custom.field.apiKey.label": "API anahtarı",
|
||||
"provider.custom.field.apiKey.placeholder": "API anahtarı",
|
||||
"provider.custom.field.apiKey.description":
|
||||
"İsteğe bağlı. Kimlik doğrulamayı başlıklar ile yönetiyorsanız boş bırakın.",
|
||||
"provider.custom.models.label": "Modeller",
|
||||
"provider.custom.models.id.label": "Kimlik",
|
||||
"provider.custom.models.id.placeholder": "model-kimlik",
|
||||
"provider.custom.models.name.label": "Ad",
|
||||
"provider.custom.models.name.placeholder": "Görünen Ad",
|
||||
"provider.custom.models.remove": "Modeli kaldır",
|
||||
"provider.custom.models.add": "Model ekle",
|
||||
"provider.custom.headers.label": "Başlıklar (isteğe bağlı)",
|
||||
"provider.custom.headers.key.label": "Başlık",
|
||||
"provider.custom.headers.key.placeholder": "Başlık-Adı",
|
||||
"provider.custom.headers.value.label": "Değer",
|
||||
"provider.custom.headers.value.placeholder": "değer",
|
||||
"provider.custom.headers.remove": "Başlığı kaldır",
|
||||
"provider.custom.headers.add": "Başlık ekle",
|
||||
"provider.custom.error.providerID.required": "Sağlayıcı kimlik gerekli",
|
||||
"provider.custom.error.providerID.format": "Küçük harf, rakam, tire veya alt çizgi kullanın",
|
||||
"provider.custom.error.providerID.exists": "Bu sağlayıcı kimlik zaten mevcut",
|
||||
"provider.custom.error.name.required": "Görünen ad gerekli",
|
||||
"provider.custom.error.baseURL.required": "Temel URL gerekli",
|
||||
"provider.custom.error.baseURL.format": "http:// veya https:// ile başlamalı",
|
||||
"provider.custom.error.required": "Gerekli",
|
||||
"provider.custom.error.duplicate": "Tekrar",
|
||||
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} bağlantısı kesildi",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} modelleri artık kullanılabilir değil.",
|
||||
|
||||
"model.tag.free": "Ücretsiz",
|
||||
"model.tag.latest": "En yeni",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
"model.provider.google": "Google",
|
||||
"model.provider.xai": "xAI",
|
||||
"model.provider.meta": "Meta",
|
||||
"model.input.text": "metin",
|
||||
"model.input.image": "görsel",
|
||||
"model.input.audio": "ses",
|
||||
"model.input.video": "video",
|
||||
"model.input.pdf": "pdf",
|
||||
"model.tooltip.allows": "Kabul eder: {{inputs}}",
|
||||
"model.tooltip.reasoning.allowed": "Akıl yürütme destekler",
|
||||
"model.tooltip.reasoning.none": "Akıl yürütme yok",
|
||||
"model.tooltip.context": "Bağlam limiti {{limit}}",
|
||||
|
||||
"common.search.placeholder": "Ara",
|
||||
"common.goBack": "Geri git",
|
||||
"common.goForward": "İleri git",
|
||||
"common.loading": "Yükleniyor",
|
||||
"common.loading.ellipsis": "...",
|
||||
"common.cancel": "İptal",
|
||||
"common.connect": "Bağlan",
|
||||
"common.disconnect": "Bağlantı Kes",
|
||||
"common.submit": "Gönder",
|
||||
"common.save": "Kaydet",
|
||||
"common.saving": "Kaydediliyor...",
|
||||
"common.default": "Varsayılan",
|
||||
"common.attachment": "ek",
|
||||
|
||||
"prompt.placeholder.shell": "Kabuk komutu girin...",
|
||||
"prompt.placeholder.normal": 'Bir şeyler sorun... "{{example}}"',
|
||||
"prompt.placeholder.simple": "Bir şeyler sorun...",
|
||||
"prompt.placeholder.summarizeComments": "Yorumları özetle…",
|
||||
"prompt.placeholder.summarizeComment": "Yorumu özetle…",
|
||||
"prompt.mode.shell": "Kabuk",
|
||||
"prompt.mode.normal": "Komut",
|
||||
"prompt.mode.shell.exit": "çıkmak için esc",
|
||||
|
||||
"prompt.example.1": "Kod tabanındaki bir TODO'yu düzelt",
|
||||
"prompt.example.2": "Bu projenin teknoloji yığını nedir?",
|
||||
"prompt.example.3": "Bozuk testleri düzelt",
|
||||
"prompt.example.4": "Kimlik doğrulamanın nasıl çalıştığını açıkla",
|
||||
"prompt.example.5": "Güvenlik açıkları bul ve düzelt",
|
||||
"prompt.example.6": "Kullanıcı servisi için birim testleri ekle",
|
||||
"prompt.example.7": "Bu fonksiyonu daha okunabilir hale getir",
|
||||
"prompt.example.8": "Bu hata ne anlama geliyor?",
|
||||
"prompt.example.9": "Bu sorunu ayıklamama yardım et",
|
||||
"prompt.example.10": "API dokümantasyonu oluştur",
|
||||
"prompt.example.11": "Veritabanı sorgularını optimize et",
|
||||
"prompt.example.12": "Girdi doğrulama ekle",
|
||||
"prompt.example.13": "İçin yeni bir bileşen oluştur...",
|
||||
"prompt.example.14": "Bu projeyi nasıl dağıtabilirim?",
|
||||
"prompt.example.15": "Kodumu en iyi uygulamalar için incele",
|
||||
"prompt.example.16": "Bu fonksiyona hata yönetimi ekle",
|
||||
"prompt.example.17": "Bu regex kalıbını açıkla",
|
||||
"prompt.example.18": "Bunu TypeScript'e dönüştür",
|
||||
"prompt.example.19": "Kod tabanı boyunca loglama ekle",
|
||||
"prompt.example.20": "Hangi bağımlılıklar güncellenmemiş?",
|
||||
"prompt.example.21": "Bir göç betiği yazmama yardım et",
|
||||
"prompt.example.22": "Bu uç nokta için önbellekleme uygula",
|
||||
"prompt.example.23": "Bu listeye sayfalama ekle",
|
||||
"prompt.example.24": "İçin bir CLI komutu oluştur...",
|
||||
"prompt.example.25": "Ortam değişkenleri burada nasıl çalışıyor?",
|
||||
|
||||
"prompt.popover.emptyResults": "Eşleşen sonuç yok",
|
||||
"prompt.popover.emptyCommands": "Eşleşen komut yok",
|
||||
"prompt.dropzone.label": "Görsel veya PDF'leri buraya bırakın",
|
||||
"prompt.dropzone.file.label": "@bahsetmek için dosyayı bırakın",
|
||||
"prompt.slash.badge.custom": "özel",
|
||||
"prompt.slash.badge.skill": "beceri",
|
||||
"prompt.slash.badge.mcp": "mcp",
|
||||
"prompt.context.active": "aktif",
|
||||
"prompt.context.includeActiveFile": "Aktif dosyayı dahil et",
|
||||
"prompt.context.removeActiveFile": "Aktif dosyayı bağlamdan çıkar",
|
||||
"prompt.context.removeFile": "Dosyayı bağlamdan çıkar",
|
||||
"prompt.action.attachFile": "Dosya ekle",
|
||||
"prompt.attachment.remove": "Eki kaldır",
|
||||
"prompt.action.send": "Gönder",
|
||||
"prompt.action.stop": "Durdur",
|
||||
|
||||
"prompt.toast.pasteUnsupported.title": "Desteklenmeyen yapıştırma",
|
||||
"prompt.toast.pasteUnsupported.description": "Buraya sadece görsel veya PDF yapıştırılabilir.",
|
||||
"prompt.toast.modelAgentRequired.title": "Bir ajan ve model seçin",
|
||||
"prompt.toast.modelAgentRequired.description": "Komut göndermeden önce bir ajan ve model seçin.",
|
||||
"prompt.toast.worktreeCreateFailed.title": "Çalışma ağacı oluşturulamadı",
|
||||
"prompt.toast.sessionCreateFailed.title": "Oturum oluşturulamadı",
|
||||
"prompt.toast.shellSendFailed.title": "Kabuk komutu gönderilemedi",
|
||||
"prompt.toast.commandSendFailed.title": "Komut gönderilemedi",
|
||||
"prompt.toast.promptSendFailed.title": "Komut gönderilemedi",
|
||||
"prompt.toast.promptSendFailed.description": "Oturum alınamadı",
|
||||
|
||||
"dialog.mcp.title": "MCP'ler",
|
||||
"dialog.mcp.description": "{{total}} içerisinden {{enabled}} etkin",
|
||||
"dialog.mcp.empty": "Yapılandırılmış MCP yok",
|
||||
|
||||
"dialog.lsp.empty": "LSP'ler dosya türlerinden otomatik algılanır",
|
||||
"dialog.plugins.empty": "Eklentiler opencode.json içinde yapılandırılır",
|
||||
|
||||
"mcp.status.connected": "bağlı",
|
||||
"mcp.status.failed": "başarısız",
|
||||
"mcp.status.needs_auth": "kimlik doğrulama gerekli",
|
||||
"mcp.status.disabled": "devre dışı",
|
||||
|
||||
"dialog.fork.empty": "Dallandırılacak mesaj yok",
|
||||
|
||||
"dialog.directory.search.placeholder": "Klasör ara",
|
||||
"dialog.directory.empty": "Klasör bulunamadı",
|
||||
|
||||
"dialog.server.title": "Sunucular",
|
||||
"dialog.server.description": "Bu uygulamanın hangi Kilo sunucusuna bağlanacağını değiştirin.",
|
||||
"dialog.server.search.placeholder": "Sunucu ara",
|
||||
"dialog.server.empty": "Henüz sunucu yok",
|
||||
"dialog.server.add.title": "Sunucu ekle",
|
||||
"dialog.server.add.url": "Sunucu URL'si",
|
||||
"dialog.server.add.placeholder": "http://localhost:4096",
|
||||
"dialog.server.add.error": "Sunucuya bağlanılamadı",
|
||||
"dialog.server.add.checking": "Kontrol ediliyor...",
|
||||
"dialog.server.add.button": "Sunucu ekle",
|
||||
"dialog.server.default.title": "Varsayılan sunucu",
|
||||
"dialog.server.default.description":
|
||||
"Uygulama başlatıldığında yerel sunucu başlatmak yerine bu sunucuya bağlan. Yeniden başlatma gerektirir.",
|
||||
"dialog.server.default.none": "Sunucu seçilmedi",
|
||||
"dialog.server.default.set": "Mevcut sunucuyu varsayılan olarak ayarla",
|
||||
"dialog.server.default.clear": "Temizle",
|
||||
"dialog.server.action.remove": "Sunucuyu kaldır",
|
||||
|
||||
"dialog.server.menu.edit": "Düzenle",
|
||||
"dialog.server.menu.default": "Varsayılan olarak ayarla",
|
||||
"dialog.server.menu.defaultRemove": "Varsayılanı kaldır",
|
||||
"dialog.server.menu.delete": "Sil",
|
||||
"dialog.server.current": "Mevcut Sunucu",
|
||||
"dialog.server.status.default": "Varsayılan",
|
||||
|
||||
"dialog.project.edit.title": "Projeyi düzenle",
|
||||
"dialog.project.edit.name": "Ad",
|
||||
"dialog.project.edit.icon": "Simge",
|
||||
"dialog.project.edit.icon.alt": "Proje simgesi",
|
||||
"dialog.project.edit.icon.hint": "Tıkla veya bir görsel sürükle",
|
||||
"dialog.project.edit.icon.recommended": "Önerilen: 128x128px",
|
||||
"dialog.project.edit.color": "Renk",
|
||||
"dialog.project.edit.color.select": "{{color}} rengini seç",
|
||||
"dialog.project.edit.worktree.startup": "Çalışma alanı başlatma betiği",
|
||||
"dialog.project.edit.worktree.startup.description": "Yeni bir çalışma alanı (worktree) oluşturduktan sonra çalışır.",
|
||||
"dialog.project.edit.worktree.startup.placeholder": "örneğin bun install",
|
||||
|
||||
"context.breakdown.title": "Bağlam Dökümü",
|
||||
"context.breakdown.note": 'Girdi tokenlerinin yaklaşık dökümü. "Diğer" araç tanımları ve ek yükleri içerir.',
|
||||
"context.breakdown.system": "Sistem",
|
||||
"context.breakdown.user": "Kullanıcı",
|
||||
"context.breakdown.assistant": "Asistan",
|
||||
"context.breakdown.tool": "Araç Çağrıları",
|
||||
"context.breakdown.other": "Diğer",
|
||||
|
||||
"context.systemPrompt.title": "Sistem Komutu",
|
||||
"context.rawMessages.title": "Ham mesajlar",
|
||||
|
||||
"context.stats.session": "Oturum",
|
||||
"context.stats.messages": "Mesajlar",
|
||||
"context.stats.provider": "Sağlayıcı",
|
||||
"context.stats.model": "Model",
|
||||
"context.stats.limit": "Bağlam Limiti",
|
||||
"context.stats.totalTokens": "Toplam Token",
|
||||
"context.stats.usage": "Kullanım",
|
||||
"context.stats.inputTokens": "Girdi Tokenleri",
|
||||
"context.stats.outputTokens": "Çıktı Tokenleri",
|
||||
"context.stats.reasoningTokens": "Akıl Yürütme Tokenleri",
|
||||
"context.stats.cacheTokens": "Önbellek Tokenleri (okuma/yazma)",
|
||||
"context.stats.userMessages": "Kullanıcı Mesajları",
|
||||
"context.stats.assistantMessages": "Asistan Mesajları",
|
||||
"context.stats.totalCost": "Toplam Maliyet",
|
||||
"context.stats.sessionCreated": "Oturum Oluşturulma",
|
||||
"context.stats.lastActivity": "Son Etkinlik",
|
||||
|
||||
"context.usage.tokens": "Tokenler",
|
||||
"context.usage.usage": "Kullanım",
|
||||
"context.usage.cost": "Maliyet",
|
||||
"context.usage.clickToView": "Bağlamı görüntüle",
|
||||
"context.usage.view": "Bağlam kullanımını görüntüle",
|
||||
|
||||
"language.en": "English",
|
||||
"language.zh": "简体中文",
|
||||
"language.zht": "繁體中文",
|
||||
"language.ko": "한국어",
|
||||
"language.de": "Deutsch",
|
||||
"language.es": "Español",
|
||||
"language.fr": "Français",
|
||||
"language.da": "Dansk",
|
||||
"language.ja": "日本語",
|
||||
"language.pl": "Polski",
|
||||
"language.ru": "Русский",
|
||||
"language.ar": "العربية",
|
||||
"language.no": "Norsk",
|
||||
"language.br": "Português (Brasil)",
|
||||
"language.bs": "Bosanski",
|
||||
"language.th": "ไทย",
|
||||
"language.tr": "Türkçe",
|
||||
|
||||
"toast.language.title": "Dil",
|
||||
"toast.language.description": "{{language}} diline geçildi",
|
||||
|
||||
"toast.theme.title": "Tema değiştirildi",
|
||||
"toast.scheme.title": "Renk şeması",
|
||||
|
||||
"toast.workspace.enabled.title": "Çalışma alanları etkinleştirildi",
|
||||
"toast.workspace.enabled.description": "Kenar çubuğunda birden fazla çalışma ağacı gösterilecek",
|
||||
"toast.workspace.disabled.title": "Çalışma alanları devre dışı bırakıldı",
|
||||
"toast.workspace.disabled.description": "Kenar çubuğunda yalnızca ana çalışma ağacı gösterilecek",
|
||||
|
||||
"toast.permissions.autoaccept.on.title": "Düzenlemeler otomatik kabul ediliyor",
|
||||
"toast.permissions.autoaccept.on.description": "Düzenleme ve yazma izinleri otomatik olarak onaylanacak",
|
||||
"toast.permissions.autoaccept.off.title": "Otomatik kabul durduruldu",
|
||||
"toast.permissions.autoaccept.off.description": "Düzenleme ve yazma izinleri onay gerektirecek",
|
||||
|
||||
"toast.model.none.title": "Model seçilmedi",
|
||||
"toast.model.none.description": "Bu oturumu özetlemek için bir sağlayıcı bağlayın",
|
||||
|
||||
"toast.file.loadFailed.title": "Dosya yüklenemedi",
|
||||
"toast.file.listFailed.title": "Dosyalar listelenemedi",
|
||||
|
||||
"toast.context.noLineSelection.title": "Satır seçimi yok",
|
||||
"toast.context.noLineSelection.description": "Önce bir dosya sekmesinde satır aralığı seçin.",
|
||||
|
||||
"toast.session.share.copyFailed.title": "URL panoya kopyalanamadı",
|
||||
"toast.session.share.success.title": "Oturum paylaşıldı",
|
||||
"toast.session.share.success.description": "Paylaşım URL'si panoya kopyalandı!",
|
||||
"toast.session.share.failed.title": "Oturum paylaşılamadı",
|
||||
"toast.session.share.failed.description": "Oturum paylaşılırken bir hata oluştu",
|
||||
|
||||
"toast.session.unshare.success.title": "Oturum paylaşımı kaldırıldı",
|
||||
"toast.session.unshare.success.description": "Oturum paylaşımı başarıyla kaldırıldı!",
|
||||
"toast.session.unshare.failed.title": "Oturum paylaşımı kaldırılamadı",
|
||||
"toast.session.unshare.failed.description": "Oturum paylaşımı kaldırılırken bir hata oluştu",
|
||||
|
||||
"toast.session.listFailed.title": "{{project}} için oturumlar yüklenemedi",
|
||||
|
||||
"toast.update.title": "Güncelleme mevcut",
|
||||
"toast.update.description": "Kilo'un yeni bir sürümü ({{version}}) yüklemeye hazır.",
|
||||
"toast.update.action.installRestart": "Yükle ve yeniden başlat",
|
||||
"toast.update.action.notYet": "Şimdi değil",
|
||||
|
||||
"error.page.title": "Bir şeyler yanlış gitti",
|
||||
"error.page.description": "Uygulama yüklenirken bir hata oluştu.",
|
||||
"error.page.details.label": "Hata Detayları",
|
||||
"error.page.action.restart": "Yeniden Başlat",
|
||||
"error.page.action.checking": "Kontrol ediliyor...",
|
||||
"error.page.action.checkUpdates": "Güncellemeleri kontrol et",
|
||||
"error.page.action.updateTo": "{{version}} sürümüne güncelle",
|
||||
"error.page.report.prefix": "Lütfen bu hatayı Kilo ekibine bildirin",
|
||||
"error.page.report.discord": "Discord üzerinden",
|
||||
"error.page.version": "Sürüm: {{version}}",
|
||||
|
||||
"error.dev.rootNotFound":
|
||||
"Kök eleman bulunamadı. index.html dosyanıza eklemeyi unuttunuz mu? Ya da id özelliği yanlış mı yazıldı?",
|
||||
|
||||
"error.globalSync.connectFailed": "Sunucuya bağlanılamadı. `{{url}}` adresinde çalışan bir sunucu var mı?",
|
||||
"directory.error.invalidUrl": "URL'de geçersiz dizin.",
|
||||
|
||||
"error.chain.unknown": "Bilinmeyen hata",
|
||||
"error.chain.causedBy": "Nedeni:",
|
||||
"error.chain.apiError": "API hatası",
|
||||
"error.chain.status": "Durum: {{status}}",
|
||||
"error.chain.retryable": "Yeniden denenebilir: {{retryable}}",
|
||||
"error.chain.responseBody": "Yanıt gövdesi:\n{{body}}",
|
||||
"error.chain.didYouMean": "Bunu mu demek istediniz: {{suggestions}}",
|
||||
"error.chain.modelNotFound": "Model bulunamadı: {{provider}}/{{model}}",
|
||||
"error.chain.checkConfig": "Yapılandırma dosyanızı (opencode.json) sağlayıcı/model adlarını kontrol edin",
|
||||
"error.chain.mcpFailed":
|
||||
'MCP sunucusu "{{name}}" başarısız oldu. Not: Kilo henüz MCP kimlik doğrulamasını desteklemiyor.',
|
||||
"error.chain.providerAuthFailed": "Sağlayıcı kimlik doğrulaması başarısız ({{provider}}): {{message}}",
|
||||
"error.chain.providerInitFailed":
|
||||
'"{{provider}}" sağlayıcısı başlatılamadı. Kimlik bilgilerini ve yapılandırmayı kontrol edin.',
|
||||
"error.chain.configJsonInvalid": "{{path}} adresindeki yapılandırma dosyası geçerli JSON(C) değil",
|
||||
"error.chain.configJsonInvalidWithMessage":
|
||||
"{{path}} adresindeki yapılandırma dosyası geçerli JSON(C) değil: {{message}}",
|
||||
"error.chain.configDirectoryTypo":
|
||||
'"{{dir}}" dizini {{path}} içinde geçerli değil. Dizini "{{suggestion}}" olarak yeniden adlandırın veya kaldırın. Bu yaygın bir yazım hatasıdır.',
|
||||
"error.chain.configFrontmatterError": "{{path}} içindeki ön bilgi ayrıştırılamadı:\n{{message}}",
|
||||
"error.chain.configInvalid": "{{path}} adresindeki yapılandırma dosyası geçersiz",
|
||||
"error.chain.configInvalidWithMessage": "{{path}} adresindeki yapılandırma dosyası geçersiz: {{message}}",
|
||||
|
||||
"notification.permission.title": "İzin gerekli",
|
||||
"notification.permission.description": "{{projectName}} içindeki {{sessionTitle}} izin gerektiriyor",
|
||||
"notification.question.title": "Soru",
|
||||
"notification.question.description": "{{projectName}} içindeki {{sessionTitle}} bir soru soruyor",
|
||||
"notification.action.goToSession": "Oturuma git",
|
||||
|
||||
"notification.session.responseReady.title": "Yanıt hazır",
|
||||
"notification.session.error.title": "Oturum hatası",
|
||||
"notification.session.error.fallbackDescription": "Bir hata oluştu",
|
||||
|
||||
"home.recentProjects": "Son projeler",
|
||||
"home.empty.title": "Son proje yok",
|
||||
"home.empty.description": "Yerel bir proje açarak başlayın",
|
||||
|
||||
"session.tab.session": "Oturum",
|
||||
"session.tab.review": "İnceleme",
|
||||
"session.tab.context": "Bağlam",
|
||||
"session.panel.reviewAndFiles": "İnceleme ve dosyalar",
|
||||
"session.review.filesChanged": "{{count}} Dosya Değişti",
|
||||
"session.review.change.one": "Değişiklik",
|
||||
"session.review.change.other": "Değişiklik",
|
||||
"session.review.loadingChanges": "Değişiklikler yükleniyor...",
|
||||
"session.review.empty": "Bu oturumda henüz değişiklik yok",
|
||||
"session.review.noVcs": "Git VCS algılanamadı, oturum değişiklikleri tespit edilemeyecek",
|
||||
"session.review.noChanges": "Değişiklik yok",
|
||||
|
||||
"session.files.selectToOpen": "Açmak için bir dosya seçin",
|
||||
"session.files.all": "Tüm dosyalar",
|
||||
"session.files.binaryContent": "İkili dosya (içerik görüntülenemiyor)",
|
||||
|
||||
"session.messages.renderEarlier": "Önceki mesajları göster",
|
||||
"session.messages.loadingEarlier": "Önceki mesajlar yükleniyor...",
|
||||
"session.messages.loadEarlier": "Önceki mesajları yükle",
|
||||
"session.messages.loading": "Mesajlar yükleniyor...",
|
||||
"session.messages.jumpToLatest": "En sona atla",
|
||||
|
||||
"session.context.addToContext": "{{selection}} bağlama ekle",
|
||||
"session.todo.title": "Görevler",
|
||||
"session.todo.collapse": "Daralt",
|
||||
"session.todo.expand": "Genişlet",
|
||||
|
||||
"session.new.worktree.main": "Ana dal",
|
||||
"session.new.worktree.mainWithBranch": "Ana dal ({{branch}})",
|
||||
"session.new.worktree.create": "Yeni çalışma ağacı oluştur",
|
||||
"session.new.lastModified": "Son değişiklik",
|
||||
|
||||
"session.header.search.placeholder": "{{project}} ara",
|
||||
"session.header.searchFiles": "Dosya ara",
|
||||
"session.header.openIn": "Aç",
|
||||
"session.header.open.action": "{{app}} ile aç",
|
||||
"session.header.open.ariaLabel": "{{app}} ile aç",
|
||||
"session.header.open.menu": "Açma seçenekleri",
|
||||
"session.header.open.copyPath": "Yolu kopyala",
|
||||
|
||||
"status.popover.trigger": "Durum",
|
||||
"status.popover.ariaLabel": "Sunucu yapılandırmaları",
|
||||
"status.popover.tab.servers": "Sunucular",
|
||||
"status.popover.tab.mcp": "MCP",
|
||||
"status.popover.tab.lsp": "LSP",
|
||||
"status.popover.tab.plugins": "Eklentiler",
|
||||
"status.popover.action.manageServers": "Sunucuları yönet",
|
||||
|
||||
"session.share.popover.title": "Web'de yayınla",
|
||||
"session.share.popover.description.shared": "Bu oturum web'de herkese açıktır. Bağlantıya sahip herkes erişebilir.",
|
||||
"session.share.popover.description.unshared":
|
||||
"Oturumu web'de herkese açık olarak paylaşın. Bağlantıya sahip herkes erişebilecek.",
|
||||
"session.share.action.share": "Paylaş",
|
||||
"session.share.action.publish": "Yayınla",
|
||||
"session.share.action.publishing": "Yayınlanıyor...",
|
||||
"session.share.action.unpublish": "Yayından kaldır",
|
||||
"session.share.action.unpublishing": "Yayından kaldırılıyor...",
|
||||
"session.share.action.view": "Görüntüle",
|
||||
"session.share.copy.copied": "Kopyalandı",
|
||||
"session.share.copy.copyLink": "Bağlantı kopyala",
|
||||
|
||||
"lsp.tooltip.none": "LSP sunucusu yok",
|
||||
"lsp.label.connected": "{{count}} LSP",
|
||||
|
||||
"prompt.loading": "Komut yükleniyor...",
|
||||
"terminal.loading": "Terminal yükleniyor...",
|
||||
"terminal.title": "Terminal",
|
||||
"terminal.title.numbered": "Terminal {{number}}",
|
||||
"terminal.close": "Terminali kapat",
|
||||
"terminal.connectionLost.title": "Bağlantı Kesildi",
|
||||
"terminal.connectionLost.description":
|
||||
"Terminal bağlantısı kesildi. Bu durum sunucu yeniden başladığında oluşabilir.",
|
||||
|
||||
"common.closeTab": "Sekmeyi kapat",
|
||||
"common.dismiss": "Kapat",
|
||||
"common.requestFailed": "İstek başarısız",
|
||||
"common.moreOptions": "Daha fazla seçenek",
|
||||
"common.learnMore": "Daha fazla bilgi",
|
||||
"common.rename": "Yeniden adlandır",
|
||||
"common.reset": "Sıfırla",
|
||||
"common.archive": "Arşivle",
|
||||
"common.delete": "Sil",
|
||||
"common.close": "Kapat",
|
||||
"common.edit": "Düzenle",
|
||||
"common.loadMore": "Daha fazla yükle",
|
||||
"common.key.esc": "ESC",
|
||||
|
||||
"sidebar.menu.toggle": "Menüyü aç/kapat",
|
||||
"sidebar.nav.projectsAndSessions": "Projeler ve oturumlar",
|
||||
"sidebar.settings": "Ayarlar",
|
||||
"sidebar.help": "Yardım",
|
||||
"sidebar.workspaces.enable": "Çalışma alanlarını etkinleştir",
|
||||
"sidebar.workspaces.disable": "Çalışma alanlarını devre dışı bırak",
|
||||
"sidebar.gettingStarted.title": "Başlarken",
|
||||
"sidebar.gettingStarted.line1": "Kilo ücretsiz modeller içerir, böylece hemen başlayabilirsiniz.",
|
||||
"sidebar.gettingStarted.line2": "Claude, GPT, Gemini vb. modelleri kullanmak için herhangi bir sağlayıcı bağlayın.",
|
||||
"sidebar.project.recentSessions": "Son oturumlar",
|
||||
"sidebar.project.viewAllSessions": "Tüm oturumları görüntüle",
|
||||
"sidebar.project.clearNotifications": "Bildirimleri temizle",
|
||||
|
||||
"app.name.desktop": "Kilo Masaüstü",
|
||||
|
||||
"settings.section.desktop": "Masaüstü",
|
||||
"settings.section.server": "Sunucu",
|
||||
"settings.tab.general": "Genel",
|
||||
"settings.tab.shortcuts": "Kısayollar",
|
||||
"settings.desktop.section.wsl": "WSL",
|
||||
"settings.desktop.wsl.title": "WSL entegrasyonu",
|
||||
"settings.desktop.wsl.description": "Kilo sunucusunu Windows'ta WSL içinde çalıştırın.",
|
||||
|
||||
"settings.general.section.appearance": "Görünüm",
|
||||
"settings.general.section.notifications": "Sistem bildirimleri",
|
||||
"settings.general.section.updates": "Güncellemeler",
|
||||
"settings.general.section.sounds": "Ses efektleri",
|
||||
"settings.general.section.feed": "Akış",
|
||||
"settings.general.section.display": "Ekran",
|
||||
|
||||
"settings.general.row.language.title": "Dil",
|
||||
"settings.general.row.language.description": "Kilo'un görünüm dilini değiştirin",
|
||||
"settings.general.row.appearance.title": "Görünüm",
|
||||
"settings.general.row.appearance.description": "Kilo'un cihazınızdaki görünümünü özelleştirin",
|
||||
"settings.general.row.theme.title": "Tema",
|
||||
"settings.general.row.theme.description": "Kilo'un temasını özelleştirin.",
|
||||
"settings.general.row.font.title": "Yazı Tipi",
|
||||
"settings.general.row.font.description": "Kod bloklarında kullanılan monospace yazı tipini özelleştirin",
|
||||
"settings.general.row.reasoningSummaries.title": "Akıl yürütme özetlerini göster",
|
||||
"settings.general.row.reasoningSummaries.description": "Zaman çizelgesinde model akıl yürütme özetlerini görüntüle",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Kabuk araç bileşenlerini genişlet",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Zaman çizelgesinde kabuk araç bileşenlerini varsayılan olarak genişletilmiş göster",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Düzenleme araç bileşenlerini genişlet",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster",
|
||||
|
||||
"settings.general.row.wayland.title": "Yerel Wayland kullan",
|
||||
"settings.general.row.wayland.description":
|
||||
"Wayland'da X11 geri dönüşünü devre dışı bırak. Yeniden başlatma gerektirir.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
"Karışık yenileme hızlı monitörlere sahip Linux'ta yerel Wayland daha kararlı olabilir.",
|
||||
|
||||
"settings.general.row.releaseNotes.title": "Sürüm notları",
|
||||
"settings.general.row.releaseNotes.description": "Güncellemelerden sonra Yenilikler bildirimlerini göster",
|
||||
|
||||
"settings.updates.row.startup.title": "Başlangıçta güncellemeleri kontrol et",
|
||||
"settings.updates.row.startup.description": "Kilo başladığında otomatik güncelleme kontrolü yap",
|
||||
"settings.updates.row.check.title": "Güncellemeleri kontrol et",
|
||||
"settings.updates.row.check.description": "Elle güncelleme kontrolü yap ve varsa yükle",
|
||||
"settings.updates.action.checkNow": "Şimdi kontrol et",
|
||||
"settings.updates.action.checking": "Kontrol ediliyor...",
|
||||
"settings.updates.toast.latest.title": "Güncelsiniz",
|
||||
"settings.updates.toast.latest.description": "Kilo'un en son sürümünü kullanıyorsunuz.",
|
||||
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"font.option.hack": "Hack",
|
||||
"font.option.inconsolata": "Inconsolata",
|
||||
"font.option.intelOneMono": "Intel One Mono",
|
||||
"font.option.iosevka": "Iosevka",
|
||||
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||
"font.option.mesloLgs": "Meslo LGS",
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
|
||||
"sound.option.none": "Yok",
|
||||
"sound.option.alert01": "Uyarı 01",
|
||||
"sound.option.alert02": "Uyarı 02",
|
||||
"sound.option.alert03": "Uyarı 03",
|
||||
"sound.option.alert04": "Uyarı 04",
|
||||
"sound.option.alert05": "Uyarı 05",
|
||||
"sound.option.alert06": "Uyarı 06",
|
||||
"sound.option.alert07": "Uyarı 07",
|
||||
"sound.option.alert08": "Uyarı 08",
|
||||
"sound.option.alert09": "Uyarı 09",
|
||||
"sound.option.alert10": "Uyarı 10",
|
||||
"sound.option.bipbop01": "Bip-bop 01",
|
||||
"sound.option.bipbop02": "Bip-bop 02",
|
||||
"sound.option.bipbop03": "Bip-bop 03",
|
||||
"sound.option.bipbop04": "Bip-bop 04",
|
||||
"sound.option.bipbop05": "Bip-bop 05",
|
||||
"sound.option.bipbop06": "Bip-bop 06",
|
||||
"sound.option.bipbop07": "Bip-bop 07",
|
||||
"sound.option.bipbop08": "Bip-bop 08",
|
||||
"sound.option.bipbop09": "Bip-bop 09",
|
||||
"sound.option.bipbop10": "Bip-bop 10",
|
||||
"sound.option.staplebops01": "Staplebops 01",
|
||||
"sound.option.staplebops02": "Staplebops 02",
|
||||
"sound.option.staplebops03": "Staplebops 03",
|
||||
"sound.option.staplebops04": "Staplebops 04",
|
||||
"sound.option.staplebops05": "Staplebops 05",
|
||||
"sound.option.staplebops06": "Staplebops 06",
|
||||
"sound.option.staplebops07": "Staplebops 07",
|
||||
"sound.option.nope01": "Hayır 01",
|
||||
"sound.option.nope02": "Hayır 02",
|
||||
"sound.option.nope03": "Hayır 03",
|
||||
"sound.option.nope04": "Hayır 04",
|
||||
"sound.option.nope05": "Hayır 05",
|
||||
"sound.option.nope06": "Hayır 06",
|
||||
"sound.option.nope07": "Hayır 07",
|
||||
"sound.option.nope08": "Hayır 08",
|
||||
"sound.option.nope09": "Hayır 09",
|
||||
"sound.option.nope10": "Hayır 10",
|
||||
"sound.option.nope11": "Hayır 11",
|
||||
"sound.option.nope12": "Hayır 12",
|
||||
"sound.option.yup01": "Evet 01",
|
||||
"sound.option.yup02": "Evet 02",
|
||||
"sound.option.yup03": "Evet 03",
|
||||
"sound.option.yup04": "Evet 04",
|
||||
"sound.option.yup05": "Evet 05",
|
||||
"sound.option.yup06": "Evet 06",
|
||||
|
||||
"settings.general.notifications.agent.title": "Ajan",
|
||||
"settings.general.notifications.agent.description":
|
||||
"Ajan tamamlandığında veya dikkat gerektirdiğinde sistem bildirimi göster",
|
||||
"settings.general.notifications.permissions.title": "İzinler",
|
||||
"settings.general.notifications.permissions.description": "İzin gerektiğinde sistem bildirimi göster",
|
||||
"settings.general.notifications.errors.title": "Hatalar",
|
||||
"settings.general.notifications.errors.description": "Hata oluştuğunda sistem bildirimi göster",
|
||||
|
||||
"settings.general.sounds.agent.title": "Ajan",
|
||||
"settings.general.sounds.agent.description": "Ajan tamamlandığında veya dikkat gerektirdiğinde ses çal",
|
||||
"settings.general.sounds.permissions.title": "İzinler",
|
||||
"settings.general.sounds.permissions.description": "İzin gerektiğinde ses çal",
|
||||
"settings.general.sounds.errors.title": "Hatalar",
|
||||
"settings.general.sounds.errors.description": "Hata oluştuğunda ses çal",
|
||||
|
||||
"settings.shortcuts.title": "Klavye kısayolları",
|
||||
"settings.shortcuts.reset.button": "Varsayılanlara sıfırla",
|
||||
"settings.shortcuts.reset.toast.title": "Kısayollar sıfırlandı",
|
||||
"settings.shortcuts.reset.toast.description": "Klavye kısayolları varsayılanlara sıfırlandı.",
|
||||
"settings.shortcuts.conflict.title": "Kısayol zaten kullanılıyor",
|
||||
"settings.shortcuts.conflict.description": "{{keybind}} zaten {{titles}} için atanmış.",
|
||||
"settings.shortcuts.unassigned": "Atanmamış",
|
||||
"settings.shortcuts.pressKeys": "Tuşlara basın",
|
||||
"settings.shortcuts.search.placeholder": "Kısayol ara",
|
||||
"settings.shortcuts.search.empty": "Kısayol bulunamadı",
|
||||
|
||||
"settings.shortcuts.group.general": "Genel",
|
||||
"settings.shortcuts.group.session": "Oturum",
|
||||
"settings.shortcuts.group.navigation": "Gezinme",
|
||||
"settings.shortcuts.group.modelAndAgent": "Model ve ajan",
|
||||
"settings.shortcuts.group.terminal": "Terminal",
|
||||
"settings.shortcuts.group.prompt": "Komut",
|
||||
|
||||
"settings.providers.title": "Sağlayıcılar",
|
||||
"settings.providers.description": "Sağlayıcı ayarları burada yapılandırılabilecek.",
|
||||
"settings.providers.section.connected": "Bağlı sağlayıcılar",
|
||||
"settings.providers.connected.empty": "Bağlı sağlayıcı yok",
|
||||
"settings.providers.section.popular": "Popüler sağlayıcılar",
|
||||
"settings.providers.tag.environment": "Ortam",
|
||||
"settings.providers.tag.config": "Yapılandırma",
|
||||
"settings.providers.tag.custom": "Özel",
|
||||
"settings.providers.tag.other": "Diğer",
|
||||
"settings.models.title": "Modeller",
|
||||
"settings.models.description": "Model ayarları burada yapılandırılabilecek.",
|
||||
"settings.agents.title": "Ajanlar",
|
||||
"settings.agents.description": "Ajan ayarları burada yapılandırılabilecek.",
|
||||
"settings.commands.title": "Komutlar",
|
||||
"settings.commands.description": "Komut ayarları burada yapılandırılabilecek.",
|
||||
"settings.mcp.title": "MCP",
|
||||
"settings.mcp.description": "MCP ayarları burada yapılandırılabilecek.",
|
||||
|
||||
"settings.permissions.title": "İzinler",
|
||||
"settings.permissions.description": "Sunucunun varsayılan olarak hangi araçları kullanabileceğini kontrol edin.",
|
||||
"settings.permissions.section.tools": "Araçlar",
|
||||
"settings.permissions.toast.updateFailed.title": "İzinler güncellenemedi",
|
||||
|
||||
"settings.permissions.action.allow": "İzin Ver",
|
||||
"settings.permissions.action.ask": "Sor",
|
||||
"settings.permissions.action.deny": "Reddet",
|
||||
|
||||
"settings.permissions.tool.read.title": "Oku",
|
||||
"settings.permissions.tool.read.description": "Bir dosyayı okuma (dosya yoluyla eşleşir)",
|
||||
"settings.permissions.tool.edit.title": "Düzenle",
|
||||
"settings.permissions.tool.edit.description": "Düzenleme, yazma, yama ve çoklu düzenleme dahil dosyaları değiştir",
|
||||
"settings.permissions.tool.glob.title": "Glob",
|
||||
"settings.permissions.tool.glob.description": "Glob kalıpları kullanarak dosyaları eşle",
|
||||
"settings.permissions.tool.grep.title": "Grep",
|
||||
"settings.permissions.tool.grep.description": "Düzenli ifadeler kullanarak dosya içerikleri ara",
|
||||
"settings.permissions.tool.list.title": "Listele",
|
||||
"settings.permissions.tool.list.description": "Bir dizindeki dosyaları listele",
|
||||
"settings.permissions.tool.bash.title": "Bash",
|
||||
"settings.permissions.tool.bash.description": "Kabuk komutları çalıştır",
|
||||
"settings.permissions.tool.task.title": "Görev",
|
||||
"settings.permissions.tool.task.description": "Alt ajanlar başlat",
|
||||
"settings.permissions.tool.skill.title": "Beceri",
|
||||
"settings.permissions.tool.skill.description": "Ada göre bir beceri yükle",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Dil sunucusu sorguları çalıştır",
|
||||
"settings.permissions.tool.todoread.title": "Görev Oku",
|
||||
"settings.permissions.tool.todoread.description": "Görev listesini oku",
|
||||
"settings.permissions.tool.todowrite.title": "Görev Yaz",
|
||||
"settings.permissions.tool.todowrite.description": "Görev listesini güncelle",
|
||||
"settings.permissions.tool.webfetch.title": "Web Getir",
|
||||
"settings.permissions.tool.webfetch.description": "Bir URL'den içerik getir",
|
||||
"settings.permissions.tool.websearch.title": "Web Ara",
|
||||
"settings.permissions.tool.websearch.description": "Web'de ara",
|
||||
"settings.permissions.tool.codesearch.title": "Kod Ara",
|
||||
"settings.permissions.tool.codesearch.description": "Web'de kod ara",
|
||||
"settings.permissions.tool.external_directory.title": "Harici Dizin",
|
||||
"settings.permissions.tool.external_directory.description": "Proje dizini dışındaki dosyalara eriş",
|
||||
"settings.permissions.tool.doom_loop.title": "Sonsuz Döngü",
|
||||
"settings.permissions.tool.doom_loop.description": "Aynı girdiyle tekrarlanan araç çağrılarını algıla",
|
||||
|
||||
"session.delete.failed.title": "Oturum silinemedi",
|
||||
"session.delete.title": "Oturumu sil",
|
||||
"session.delete.confirm": '"{{name}}" oturumu silinsin mi?',
|
||||
"session.delete.button": "Oturumu sil",
|
||||
|
||||
"workspace.new": "Yeni çalışma alanı",
|
||||
"workspace.type.local": "yerel",
|
||||
"workspace.type.sandbox": "sandbox",
|
||||
"workspace.create.failed.title": "Çalışma alanı oluşturulamadı",
|
||||
"workspace.delete.failed.title": "Çalışma alanı silinemedi",
|
||||
"workspace.resetting.title": "Çalışma alanı sıfırlanıyor",
|
||||
"workspace.resetting.description": "Bu bir dakika sürebilir.",
|
||||
"workspace.reset.failed.title": "Çalışma alanı sıfırlanamadı",
|
||||
"workspace.reset.success.title": "Çalışma alanı sıfırlandı",
|
||||
"workspace.reset.success.description": "Çalışma alanı artık varsayılan dalla eşleşiyor.",
|
||||
"workspace.error.stillPreparing": "Çalışma alanı hâlâ hazırlanıyor",
|
||||
"workspace.status.checking": "Birleşmemiş değişiklikler kontrol ediliyor...",
|
||||
"workspace.status.error": "Git durumu doğrulanamadı.",
|
||||
"workspace.status.clean": "Birleşmemiş değişiklik algılanmadı.",
|
||||
"workspace.status.dirty": "Bu çalışma alanında birleşmemiş değişiklikler algılandı.",
|
||||
"workspace.delete.title": "Çalışma alanını sil",
|
||||
"workspace.delete.confirm": '"{{name}}" çalışma alanı silinsin mi?',
|
||||
"workspace.delete.button": "Çalışma alanını sil",
|
||||
"workspace.reset.title": "Çalışma alanını sıfırla",
|
||||
"workspace.reset.confirm": '"{{name}}" çalışma alanı sıfırlansın mı?',
|
||||
"workspace.reset.button": "Çalışma alanını sıfırla",
|
||||
"workspace.reset.archived.none": "Arşivlenecek aktif oturum yok.",
|
||||
"workspace.reset.archived.one": "1 oturum arşivlenecek.",
|
||||
"workspace.reset.archived.many": "{{count}} oturum arşivlenecek.",
|
||||
"workspace.reset.note": "Bu işlem çalışma alanını varsayılan dalla eşleşecek şekilde sıfırlayacak.",
|
||||
"common.open": "Aç",
|
||||
"dialog.releaseNotes.action.getStarted": "Başla",
|
||||
"dialog.releaseNotes.action.next": "İleri",
|
||||
"dialog.releaseNotes.action.hideFuture": "Bunu gelecekte bir daha gösterme",
|
||||
"dialog.releaseNotes.media.alt": "Sürüm önizlemesi",
|
||||
"toast.project.reloadFailed.title": "{{project}} yeniden yüklenemedi",
|
||||
"error.server.invalidConfiguration": "Geçersiz yapılandırma",
|
||||
"common.moreCountSuffix": " (+{{count}} daha)",
|
||||
"common.time.justNow": "Şimdi",
|
||||
"common.time.minutesAgo.short": "{{count}}dk önce",
|
||||
"common.time.hoursAgo.short": "{{count}}sa önce",
|
||||
"common.time.daysAgo.short": "{{count}}g önce",
|
||||
"settings.providers.connected.environmentDescription": "Ortam değişkenlerinizden bağlandı",
|
||||
"settings.providers.custom.description": "Temel URL üzerinden OpenAI uyumlu bir sağlayıcı ekleyin.",
|
||||
} satisfies Partial<Record<Keys, string>>
|
||||
+22
-14
@@ -96,8 +96,8 @@ export const dict = {
|
||||
"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": "在侧边栏启用或禁用多个工作区",
|
||||
@@ -126,6 +126,8 @@ export const dict = {
|
||||
"dialog.provider.group.other": "其他",
|
||||
"dialog.provider.tag.recommended": "推荐",
|
||||
"dialog.provider.opencode.note": "使用 OpenCode Zen 或 API 密钥连接",
|
||||
"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 密钥连接",
|
||||
@@ -138,7 +140,6 @@ export const dict = {
|
||||
"dialog.model.empty": "未找到模型",
|
||||
"dialog.model.manage": "管理模型",
|
||||
"dialog.model.manage.description": "自定义模型选择器中显示的模型。",
|
||||
"dialog.model.manage.provider.toggle": "切换所有 {{provider}} 模型",
|
||||
"dialog.model.unpaid.freeModels.title": "Kilo 提供的免费模型",
|
||||
"dialog.model.unpaid.addMore.title": "从热门提供商添加更多模型",
|
||||
|
||||
@@ -414,10 +415,10 @@ export const dict = {
|
||||
"toast.workspace.enabled.description": "侧边栏现在显示多个工作树",
|
||||
"toast.workspace.disabled.title": "工作区已禁用",
|
||||
"toast.workspace.disabled.description": "侧边栏只显示主工作树",
|
||||
"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": "加载文件失败",
|
||||
@@ -497,7 +498,6 @@ export const dict = {
|
||||
"session.review.loadingChanges": "正在加载更改...",
|
||||
"session.review.empty": "此会话暂无更改",
|
||||
"session.review.noChanges": "无更改",
|
||||
"session.review.noVcs": "未检测到 git VCS,因此无法检测到会话更改",
|
||||
"session.files.selectToOpen": "选择要打开的文件",
|
||||
"session.files.all": "所有文件",
|
||||
"session.files.binaryContent": "二进制文件(无法显示内容)",
|
||||
@@ -510,10 +510,6 @@ export const dict = {
|
||||
"session.todo.title": "待办事项",
|
||||
"session.todo.collapse": "折叠",
|
||||
"session.todo.expand": "展开",
|
||||
"session.modeSwitch.switching": "正在切换到 {{mode}} 模式…",
|
||||
"session.modeSwitch.waiting": "等待当前任务完成",
|
||||
"session.modeSwitch.notAvailable": "Agent 不可用",
|
||||
"session.modeSwitch.fallback": '找不到 "{{requested}}",使用 "{{actual}}"',
|
||||
"session.new.worktree.main": "主分支",
|
||||
"session.new.worktree.mainWithBranch": "主分支({{branch}})",
|
||||
"session.new.worktree.create": "创建新的 worktree",
|
||||
@@ -611,8 +607,6 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "自定义 Kilo 的主题。",
|
||||
"settings.general.row.font.title": "字体",
|
||||
"settings.general.row.font.description": "自定义代码块使用的等宽字体",
|
||||
"settings.general.row.reasoningSummaries.title": "显示推理摘要",
|
||||
"settings.general.row.reasoningSummaries.description": "在时间线中显示模型推理摘要",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "展开 shell 工具部分",
|
||||
"settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分",
|
||||
"settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分",
|
||||
@@ -815,4 +809,18 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "将归档 1 个会话。",
|
||||
"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": "通过基础 URL 添加与 OpenAI 兼容的提供商。",
|
||||
} satisfies Partial<Record<Keys, string>>
|
||||
|
||||
@@ -75,8 +75,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": "復原",
|
||||
@@ -103,6 +103,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.openai.note": "使用 ChatGPT Pro/Plus 或 API 金鑰連線",
|
||||
"dialog.provider.copilot.note": "使用 Copilot 或 API 金鑰連線",
|
||||
@@ -115,7 +117,6 @@ export const dict = {
|
||||
"dialog.model.empty": "找不到模型",
|
||||
"dialog.model.manage": "管理模型",
|
||||
"dialog.model.manage.description": "自訂模型選擇器中顯示的模型。",
|
||||
"dialog.model.manage.provider.toggle": "切換所有 {{provider}} 模型",
|
||||
|
||||
"dialog.model.unpaid.freeModels.title": "Kilo 提供的免費模型",
|
||||
"dialog.model.unpaid.addMore.title": "從熱門提供者新增更多模型",
|
||||
@@ -148,7 +149,8 @@ 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": " 並輸入以下程式碼,以連線你的帳戶並在 Kilo 中使用 {{provider}} 模型。",
|
||||
"provider.connect.oauth.auto.visit.suffix":
|
||||
" 並輸入以下程式碼,以連線你的帳戶並在 Kilo 中使用 {{provider}} 模型。",
|
||||
"provider.connect.oauth.auto.confirmationCode": "確認碼",
|
||||
"provider.connect.toast.connected.title": "{{provider}} 已連線",
|
||||
"provider.connect.toast.connected.description": "現在可以使用 {{provider}} 模型了。",
|
||||
@@ -400,10 +402,10 @@ export const dict = {
|
||||
"toast.workspace.disabled.title": "工作區已停用",
|
||||
"toast.workspace.disabled.description": "側邊欄只顯示主工作樹",
|
||||
|
||||
"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": "請先連線提供者以總結此工作階段",
|
||||
@@ -491,7 +493,6 @@ export const dict = {
|
||||
"session.review.loadingChanges": "正在載入變更...",
|
||||
"session.review.empty": "此工作階段暫無變更",
|
||||
"session.review.noChanges": "沒有變更",
|
||||
"session.review.noVcs": "未偵測到 git VCS,因此無法偵測到工作階段變更",
|
||||
"session.files.selectToOpen": "選取要開啟的檔案",
|
||||
"session.files.all": "所有檔案",
|
||||
"session.files.binaryContent": "二進位檔案(無法顯示內容)",
|
||||
@@ -506,11 +507,6 @@ export const dict = {
|
||||
"session.todo.collapse": "折疊",
|
||||
"session.todo.expand": "展開",
|
||||
|
||||
"session.modeSwitch.switching": "正在切換到 {{mode}} 模式…",
|
||||
"session.modeSwitch.waiting": "等待目前任務完成",
|
||||
"session.modeSwitch.notAvailable": "Agent 不可用",
|
||||
"session.modeSwitch.fallback": '找不到 "{{requested}}",使用 "{{actual}}"',
|
||||
|
||||
"session.new.worktree.main": "主分支",
|
||||
"session.new.worktree.mainWithBranch": "主分支 ({{branch}})",
|
||||
"session.new.worktree.create": "建立新的 worktree",
|
||||
@@ -606,8 +602,6 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "自訂 Kilo 的主題。",
|
||||
"settings.general.row.font.title": "字型",
|
||||
"settings.general.row.font.description": "自訂程式碼區塊使用的等寬字型",
|
||||
"settings.general.row.reasoningSummaries.title": "顯示推理摘要",
|
||||
"settings.general.row.reasoningSummaries.description": "在時間軸中顯示模型推理摘要",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "展開 shell 工具區塊",
|
||||
"settings.general.row.shellToolPartsExpanded.description": "在時間軸中預設展開 shell 工具區塊",
|
||||
@@ -810,4 +804,18 @@ export const dict = {
|
||||
"workspace.reset.archived.one": "將封存 1 個工作階段。",
|
||||
"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": "透過基本 URL 新增與 OpenAI 相容的提供者。",
|
||||
} satisfies Partial<Record<Keys, string>>
|
||||
|
||||
+279
-164
@@ -22,7 +22,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
@@ -42,7 +42,9 @@ import { Binary } from "@opencode-ai/util/binary"
|
||||
import { retry } from "@opencode-ai/util/retry"
|
||||
import { playSound, soundSrc } from "@/utils/sound"
|
||||
import { createAim } from "@/utils/aim"
|
||||
import { setNavigate } from "@/utils/notification-click"
|
||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||
@@ -59,13 +61,19 @@ import { useLanguage, type Locale } from "@/context/language"
|
||||
import {
|
||||
childMapByParent,
|
||||
displayName,
|
||||
effectiveWorkspaceOrder,
|
||||
errorMessage,
|
||||
getDraggableId,
|
||||
latestRootSession,
|
||||
sortedRootSessions,
|
||||
syncWorkspaceOrder,
|
||||
workspaceKey,
|
||||
} from "./layout/helpers"
|
||||
import { collectOpenProjectDeepLinks, deepLinkEvent, drainPendingDeepLinks } from "./layout/deep-links"
|
||||
import {
|
||||
collectNewSessionDeepLinks,
|
||||
collectOpenProjectDeepLinks,
|
||||
deepLinkEvent,
|
||||
drainPendingDeepLinks,
|
||||
} from "./layout/deep-links"
|
||||
import { createInlineEditorController } from "./layout/inline-editor"
|
||||
import {
|
||||
LocalWorkspace,
|
||||
@@ -106,6 +114,7 @@ export default function Layout(props: ParentProps) {
|
||||
const notification = useNotification()
|
||||
const permission = usePermission()
|
||||
const navigate = useNavigate()
|
||||
setNavigate(navigate)
|
||||
const providers = useProviders()
|
||||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
@@ -480,21 +489,6 @@ export default function Layout(props: ParentProps) {
|
||||
return projects.find((p) => p.worktree === root)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => ({ ready: pageReady(), project: currentProject() }),
|
||||
(value) => {
|
||||
if (!value.ready) return
|
||||
const project = value.project
|
||||
if (!project) return
|
||||
const last = server.projects.last()
|
||||
if (last === project.worktree) return
|
||||
server.projects.touch(project.worktree)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }),
|
||||
@@ -553,29 +547,17 @@ export default function Layout(props: ParentProps) {
|
||||
return layout.sidebar.workspaces(project.worktree)()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!pageReady()) return
|
||||
if (!layoutReady()) return
|
||||
const visibleSessionDirs = createMemo(() => {
|
||||
const project = currentProject()
|
||||
if (!project) return
|
||||
if (!project) return [] as string[]
|
||||
if (!workspaceSetting()) return [project.worktree]
|
||||
|
||||
const local = project.worktree
|
||||
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
|
||||
const existing = store.workspaceOrder[project.worktree]
|
||||
const merged = syncWorkspaceOrder(local, dirs, existing)
|
||||
if (!existing) {
|
||||
setStore("workspaceOrder", project.worktree, merged)
|
||||
return
|
||||
}
|
||||
|
||||
if (merged.length !== existing.length) {
|
||||
setStore("workspaceOrder", project.worktree, merged)
|
||||
return
|
||||
}
|
||||
|
||||
if (merged.some((d, i) => d !== existing[i])) {
|
||||
setStore("workspaceOrder", project.worktree, merged)
|
||||
}
|
||||
const activeDir = currentDir()
|
||||
return workspaceIds(project).filter((directory) => {
|
||||
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
|
||||
const active = directory === activeDir
|
||||
return expanded || active
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -592,25 +574,17 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
|
||||
const currentSessions = createMemo(() => {
|
||||
const project = currentProject()
|
||||
if (!project) return [] as Session[]
|
||||
const now = Date.now()
|
||||
if (workspaceSetting()) {
|
||||
const dirs = workspaceIds(project)
|
||||
const activeDir = currentDir()
|
||||
const result: Session[] = []
|
||||
for (const dir of dirs) {
|
||||
const expanded = store.workspaceExpanded[dir] ?? dir === project.worktree
|
||||
const active = dir === activeDir
|
||||
if (!expanded && !active) continue
|
||||
const [dirStore] = globalSync.child(dir, { bootstrap: true })
|
||||
const dirSessions = sortedRootSessions(dirStore, now)
|
||||
result.push(...dirSessions)
|
||||
}
|
||||
return result
|
||||
const dirs = visibleSessionDirs()
|
||||
if (dirs.length === 0) return [] as Session[]
|
||||
|
||||
const result: Session[] = []
|
||||
for (const dir of dirs) {
|
||||
const [dirStore] = globalSync.child(dir, { bootstrap: true })
|
||||
const dirSessions = sortedRootSessions(dirStore, now)
|
||||
result.push(...dirSessions)
|
||||
}
|
||||
const [projectStore] = globalSync.child(project.worktree)
|
||||
return sortedRootSessions(projectStore, now)
|
||||
return result
|
||||
})
|
||||
|
||||
type PrefetchQueue = {
|
||||
@@ -825,7 +799,6 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
navigateToSession(session)
|
||||
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
|
||||
}
|
||||
|
||||
function navigateSessionByUnseen(offset: number) {
|
||||
@@ -860,7 +833,6 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
navigateToSession(session)
|
||||
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1098,14 +1070,106 @@ export default function Layout(props: ParentProps) {
|
||||
return meta?.worktree ?? directory
|
||||
}
|
||||
|
||||
function navigateToProject(directory: string | undefined) {
|
||||
function activeProjectRoot(directory: string) {
|
||||
return currentProject()?.worktree ?? projectRoot(directory)
|
||||
}
|
||||
|
||||
function touchProjectRoute() {
|
||||
const root = currentProject()?.worktree
|
||||
if (!root) return
|
||||
if (server.projects.last() !== root) server.projects.touch(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
|
||||
setStore("lastProjectSession", root, { directory, id, at: Date.now() })
|
||||
return root
|
||||
}
|
||||
|
||||
function clearLastProjectSession(root: string) {
|
||||
if (!store.lastProjectSession[root]) return
|
||||
setStore(
|
||||
"lastProjectSession",
|
||||
produce((draft) => {
|
||||
delete draft[root]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function syncSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
|
||||
rememberSessionRoute(directory, id, root)
|
||||
notification.session.markViewed(id)
|
||||
const expanded = untrack(() => store.workspaceExpanded[directory])
|
||||
if (expanded === false) {
|
||||
setStore("workspaceExpanded", directory, true)
|
||||
}
|
||||
requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`))
|
||||
return root
|
||||
}
|
||||
|
||||
async function navigateToProject(directory: string | undefined) {
|
||||
if (!directory) return
|
||||
const root = projectRoot(directory)
|
||||
server.projects.touch(root)
|
||||
const project = layout.projects.list().find((item) => item.worktree === root)
|
||||
let dirs = project
|
||||
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
|
||||
: [root]
|
||||
const canOpen = (value: string | undefined) => {
|
||||
if (!value) return false
|
||||
return dirs.some((item) => workspaceKey(item) === workspaceKey(value))
|
||||
}
|
||||
const refreshDirs = async (target?: string) => {
|
||||
if (!target || target === root || canOpen(target)) return canOpen(target)
|
||||
const listed = await globalSDK.client.worktree
|
||||
.list({ directory: root })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [] as string[])
|
||||
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
|
||||
return canOpen(target)
|
||||
}
|
||||
const openSession = async (target: { directory: string; id: string }) => {
|
||||
if (!canOpen(target.directory)) return false
|
||||
const resolved = await globalSDK.client.session
|
||||
.get({ sessionID: target.id })
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
if (!resolved?.directory) return false
|
||||
if (!canOpen(resolved.directory)) return false
|
||||
setStore("lastProjectSession", root, { directory: resolved.directory, id: resolved.id, at: Date.now() })
|
||||
navigateWithSidebarReset(`/${base64Encode(resolved.directory)}/session/${resolved.id}`)
|
||||
return true
|
||||
}
|
||||
|
||||
const projectSession = store.lastProjectSession[root]
|
||||
if (projectSession?.id) {
|
||||
navigateWithSidebarReset(`/${base64Encode(projectSession.directory)}/session/${projectSession.id}`)
|
||||
await refreshDirs(projectSession.directory)
|
||||
const opened = await openSession(projectSession)
|
||||
if (opened) return
|
||||
clearLastProjectSession(root)
|
||||
}
|
||||
|
||||
const latest = latestRootSession(
|
||||
dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]),
|
||||
Date.now(),
|
||||
)
|
||||
if (latest && (await openSession(latest))) {
|
||||
return
|
||||
}
|
||||
|
||||
const fetched = latestRootSession(
|
||||
await Promise.all(
|
||||
dirs.map(async (item) => ({
|
||||
path: { directory: item },
|
||||
session: await globalSDK.client.session
|
||||
.list({ directory: item })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => []),
|
||||
})),
|
||||
),
|
||||
Date.now(),
|
||||
)
|
||||
if (fetched && (await openSession(fetched))) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1124,9 +1188,20 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const handleDeepLinks = (urls: string[]) => {
|
||||
if (!server.isLocal()) return
|
||||
|
||||
for (const directory of collectOpenProjectDeepLinks(urls)) {
|
||||
openProject(directory)
|
||||
}
|
||||
|
||||
for (const link of collectNewSessionDeepLinks(urls)) {
|
||||
openProject(link.directory, false)
|
||||
const slug = base64Encode(link.directory)
|
||||
if (link.prompt) {
|
||||
setSessionHandoff(slug, { prompt: link.prompt })
|
||||
}
|
||||
const href = link.prompt ? `/${slug}/session?prompt=${encodeURIComponent(link.prompt)}` : `/${slug}/session`
|
||||
navigateWithSidebarReset(href)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -1162,11 +1237,28 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
function closeProject(directory: string) {
|
||||
const index = layout.projects.list().findIndex((x) => x.worktree === directory)
|
||||
const next = layout.projects.list()[index + 1]
|
||||
const list = layout.projects.list()
|
||||
const index = list.findIndex((x) => x.worktree === directory)
|
||||
const active = currentProject()?.worktree === directory
|
||||
if (index === -1) return
|
||||
const next = list[index + 1]
|
||||
|
||||
if (!active) {
|
||||
layout.projects.close(directory)
|
||||
return
|
||||
}
|
||||
|
||||
if (!next) {
|
||||
layout.projects.close(directory)
|
||||
navigate("/")
|
||||
return
|
||||
}
|
||||
|
||||
navigateWithSidebarReset(`/${base64Encode(next.worktree)}/session`)
|
||||
layout.projects.close(directory)
|
||||
if (next) navigateToProject(next.worktree)
|
||||
else navigate("/")
|
||||
queueMicrotask(() => {
|
||||
void navigateToProject(next.worktree)
|
||||
})
|
||||
}
|
||||
|
||||
function toggleProjectWorkspaces(project: LocalProject) {
|
||||
@@ -1207,9 +1299,17 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const deleteWorkspace = async (root: string, directory: string) => {
|
||||
const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => {
|
||||
if (directory === root) return
|
||||
|
||||
const current = currentDir()
|
||||
const currentKey = workspaceKey(current)
|
||||
const deletedKey = workspaceKey(directory)
|
||||
const shouldLeave = leaveDeletedWorkspace || (!!params.dir && currentKey === deletedKey)
|
||||
if (!leaveDeletedWorkspace && shouldLeave) {
|
||||
navigateWithSidebarReset(`/${base64Encode(root)}/session`)
|
||||
}
|
||||
|
||||
setBusy(directory, true)
|
||||
|
||||
const result = await globalSDK.client.worktree
|
||||
@@ -1227,6 +1327,10 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
if (!result) return
|
||||
|
||||
if (workspaceKey(store.lastProjectSession[root]?.directory ?? "") === workspaceKey(directory)) {
|
||||
clearLastProjectSession(root)
|
||||
}
|
||||
|
||||
globalSync.set(
|
||||
"project",
|
||||
produce((draft) => {
|
||||
@@ -1240,8 +1344,18 @@ export default function Layout(props: ParentProps) {
|
||||
layout.projects.close(directory)
|
||||
layout.projects.open(root)
|
||||
|
||||
if (params.dir && currentDir() === directory) {
|
||||
navigateToProject(root)
|
||||
if (shouldLeave) return
|
||||
|
||||
const nextCurrent = currentDir()
|
||||
const nextKey = workspaceKey(nextCurrent)
|
||||
const project = layout.projects.list().find((item) => item.worktree === root)
|
||||
const dirs = project
|
||||
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
|
||||
: [root]
|
||||
const valid = dirs.some((item) => workspaceKey(item) === nextKey)
|
||||
|
||||
if (params.dir && projectRoot(nextCurrent) === root && !valid) {
|
||||
navigateWithSidebarReset(`/${base64Encode(root)}/session`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1344,8 +1458,12 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
|
||||
const handleDelete = () => {
|
||||
const leaveDeletedWorkspace = !!params.dir && workspaceKey(currentDir()) === workspaceKey(props.directory)
|
||||
if (leaveDeletedWorkspace) {
|
||||
navigateWithSidebarReset(`/${base64Encode(props.root)}/session`)
|
||||
}
|
||||
dialog.close()
|
||||
void deleteWorkspace(props.root, props.directory)
|
||||
void deleteWorkspace(props.root, props.directory, leaveDeletedWorkspace)
|
||||
}
|
||||
|
||||
const description = () => {
|
||||
@@ -1453,26 +1571,42 @@ export default function Layout(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
const activeRoute = {
|
||||
session: "",
|
||||
sessionProject: "",
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => ({ ready: pageReady(), dir: params.dir, id: params.id }),
|
||||
(value) => {
|
||||
if (!value.ready) return
|
||||
const dir = value.dir
|
||||
const id = value.id
|
||||
if (!dir || !id) return
|
||||
() => [pageReady(), params.dir, params.id, currentProject()?.worktree] as const,
|
||||
([ready, dir, id]) => {
|
||||
if (!ready || !dir) {
|
||||
activeRoute.session = ""
|
||||
activeRoute.sessionProject = ""
|
||||
return
|
||||
}
|
||||
|
||||
const directory = decode64(dir)
|
||||
if (!directory) return
|
||||
const at = Date.now()
|
||||
setStore("lastProjectSession", projectRoot(directory), { directory, id, at })
|
||||
notification.session.markViewed(id)
|
||||
const expanded = untrack(() => store.workspaceExpanded[directory])
|
||||
if (expanded === false) {
|
||||
setStore("workspaceExpanded", directory, true)
|
||||
|
||||
const root = touchProjectRoute() ?? activeProjectRoot(directory)
|
||||
|
||||
if (!id) {
|
||||
activeRoute.session = ""
|
||||
activeRoute.sessionProject = ""
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`))
|
||||
|
||||
const session = `${dir}/${id}`
|
||||
if (session !== activeRoute.session) {
|
||||
activeRoute.session = session
|
||||
activeRoute.sessionProject = syncSessionRoute(directory, id, root)
|
||||
return
|
||||
}
|
||||
|
||||
if (root === activeRoute.sessionProject) return
|
||||
activeRoute.sessionProject = rememberSessionRoute(directory, id, root)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1483,40 +1617,29 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const loadedSessionDirs = new Set<string>()
|
||||
|
||||
createEffect(() => {
|
||||
const project = currentProject()
|
||||
const workspaces = workspaceSetting()
|
||||
const next = new Set<string>()
|
||||
if (!project) {
|
||||
loadedSessionDirs.clear()
|
||||
return
|
||||
}
|
||||
createEffect(
|
||||
on(
|
||||
visibleSessionDirs,
|
||||
(dirs) => {
|
||||
if (dirs.length === 0) {
|
||||
loadedSessionDirs.clear()
|
||||
return
|
||||
}
|
||||
|
||||
if (workspaces) {
|
||||
const activeDir = currentDir()
|
||||
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
|
||||
for (const directory of dirs) {
|
||||
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
|
||||
const active = directory === activeDir
|
||||
if (!expanded && !active) continue
|
||||
next.add(directory)
|
||||
}
|
||||
}
|
||||
const next = new Set(dirs)
|
||||
for (const directory of next) {
|
||||
if (loadedSessionDirs.has(directory)) continue
|
||||
globalSync.project.loadSessions(directory)
|
||||
}
|
||||
|
||||
if (!workspaces) {
|
||||
next.add(project.worktree)
|
||||
}
|
||||
|
||||
for (const directory of next) {
|
||||
if (loadedSessionDirs.has(directory)) continue
|
||||
globalSync.project.loadSessions(directory)
|
||||
}
|
||||
|
||||
loadedSessionDirs.clear()
|
||||
for (const directory of next) {
|
||||
loadedSessionDirs.add(directory)
|
||||
}
|
||||
})
|
||||
loadedSessionDirs.clear()
|
||||
for (const directory of next) {
|
||||
loadedSessionDirs.add(directory)
|
||||
}
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
function handleDragStart(event: unknown) {
|
||||
const id = getDraggableId(event)
|
||||
@@ -1550,14 +1673,11 @@ export default function Layout(props: ParentProps) {
|
||||
const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined
|
||||
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
|
||||
|
||||
const existing = store.workspaceOrder[project.worktree]
|
||||
if (!existing) return extra ? [...dirs, extra] : dirs
|
||||
|
||||
const merged = syncWorkspaceOrder(local, dirs, existing)
|
||||
if (pending && extra) return [local, extra, ...merged.filter((directory) => directory !== local)]
|
||||
if (!extra) return merged
|
||||
if (pending) return merged
|
||||
return [...merged, extra]
|
||||
const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
|
||||
if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)]
|
||||
if (!extra) return ordered
|
||||
if (pending) return ordered
|
||||
return [...ordered, extra]
|
||||
}
|
||||
|
||||
const sidebarProject = createMemo(() => {
|
||||
@@ -1590,7 +1710,11 @@ export default function Layout(props: ParentProps) {
|
||||
const [item] = result.splice(fromIndex, 1)
|
||||
if (!item) return
|
||||
result.splice(toIndex, 0, item)
|
||||
setStore("workspaceOrder", project.worktree, result)
|
||||
setStore(
|
||||
"workspaceOrder",
|
||||
project.worktree,
|
||||
result.filter((directory) => workspaceKey(directory) !== workspaceKey(project.worktree)),
|
||||
)
|
||||
}
|
||||
|
||||
function handleWorkspaceDragEnd() {
|
||||
@@ -1628,10 +1752,9 @@ export default function Layout(props: ParentProps) {
|
||||
const existing = prev ?? []
|
||||
const next = existing.filter((item) => {
|
||||
const id = workspaceKey(item)
|
||||
if (id === root) return false
|
||||
return id !== key
|
||||
return id !== root && id !== key
|
||||
})
|
||||
return [local, created.directory, ...next]
|
||||
return [created.directory, ...next]
|
||||
})
|
||||
|
||||
globalSync.child(created.directory)
|
||||
@@ -1728,7 +1851,7 @@ export default function Layout(props: ParentProps) {
|
||||
}}
|
||||
style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
|
||||
>
|
||||
<Show when={panelProps.project}>
|
||||
<Show when={panelProps.project} keyed>
|
||||
{(p) => (
|
||||
<>
|
||||
<div class="shrink-0 px-2 py-1">
|
||||
@@ -1737,7 +1860,7 @@ export default function Layout(props: ParentProps) {
|
||||
<InlineEditor
|
||||
id={`project:${projectId()}`}
|
||||
value={projectName}
|
||||
onSave={(next) => renameProject(p(), next)}
|
||||
onSave={(next) => renameProject(p, next)}
|
||||
class="text-14-medium text-text-strong truncate"
|
||||
displayClass="text-14-medium text-text-strong truncate"
|
||||
stopPropagation
|
||||
@@ -1746,7 +1869,7 @@ export default function Layout(props: ParentProps) {
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
gutter={2}
|
||||
value={p().worktree}
|
||||
value={p.worktree}
|
||||
class="shrink-0"
|
||||
contentStyle={{
|
||||
"max-width": "640px",
|
||||
@@ -1754,7 +1877,7 @@ export default function Layout(props: ParentProps) {
|
||||
}}
|
||||
>
|
||||
<span class="text-12-regular text-text-base truncate select-text">
|
||||
{p().worktree.replace(homedir(), "~")}
|
||||
{p.worktree.replace(homedir(), "~")}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -1765,7 +1888,7 @@ export default function Layout(props: ParentProps) {
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
data-action="project-menu"
|
||||
data-project={base64Encode(p().worktree)}
|
||||
data-project={base64Encode(p.worktree)}
|
||||
class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||
classList={{
|
||||
"opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
|
||||
@@ -1774,24 +1897,24 @@ export default function Layout(props: ParentProps) {
|
||||
/>
|
||||
<DropdownMenu.Portal mount={!panelProps.mobile ? state.nav : undefined}>
|
||||
<DropdownMenu.Content class="mt-1">
|
||||
<DropdownMenu.Item onSelect={() => showEditProjectDialog(p())}>
|
||||
<DropdownMenu.Item onSelect={() => showEditProjectDialog(p)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
data-action="project-workspaces-toggle"
|
||||
data-project={base64Encode(p().worktree)}
|
||||
disabled={p().vcs !== "git" && !layout.sidebar.workspaces(p().worktree)()}
|
||||
onSelect={() => toggleProjectWorkspaces(p())}
|
||||
data-project={base64Encode(p.worktree)}
|
||||
disabled={p.vcs !== "git" && !layout.sidebar.workspaces(p.worktree)()}
|
||||
onSelect={() => toggleProjectWorkspaces(p)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{layout.sidebar.workspaces(p().worktree)()
|
||||
{layout.sidebar.workspaces(p.worktree)()
|
||||
? language.t("sidebar.workspaces.disable")
|
||||
: language.t("sidebar.workspaces.enable")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
data-action="project-clear-notifications"
|
||||
data-project={base64Encode(p().worktree)}
|
||||
data-project={base64Encode(p.worktree)}
|
||||
disabled={unseenCount() === 0}
|
||||
onSelect={clearNotifications}
|
||||
>
|
||||
@@ -1802,8 +1925,8 @@ export default function Layout(props: ParentProps) {
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
data-action="project-close-menu"
|
||||
data-project={base64Encode(p().worktree)}
|
||||
onSelect={() => closeProject(p().worktree)}
|
||||
data-project={base64Encode(p.worktree)}
|
||||
onSelect={() => closeProject(p.worktree)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
@@ -1819,25 +1942,19 @@ export default function Layout(props: ParentProps) {
|
||||
fallback={
|
||||
<>
|
||||
<div class="shrink-0 py-4 px-3">
|
||||
<TooltipKeybind
|
||||
title={language.t("command.session.new")}
|
||||
keybind={command.keybind("session.new")}
|
||||
placement="top"
|
||||
<Button
|
||||
size="large"
|
||||
icon="plus-small"
|
||||
class="w-full"
|
||||
onClick={() => navigateWithSidebarReset(`/${base64Encode(p.worktree)}/session`)}
|
||||
>
|
||||
<Button
|
||||
size="large"
|
||||
icon="plus-small"
|
||||
class="w-full"
|
||||
onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)}
|
||||
>
|
||||
{language.t("command.session.new")}
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
{language.t("command.session.new")}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0">
|
||||
<LocalWorkspace
|
||||
ctx={workspaceSidebarCtx}
|
||||
project={p()}
|
||||
project={p}
|
||||
sortNow={sortNow}
|
||||
mobile={panelProps.mobile}
|
||||
/>
|
||||
@@ -1847,15 +1964,9 @@ export default function Layout(props: ParentProps) {
|
||||
>
|
||||
<>
|
||||
<div class="shrink-0 py-4 px-3">
|
||||
<TooltipKeybind
|
||||
title={language.t("workspace.new")}
|
||||
keybind={command.keybind("workspace.new")}
|
||||
placement="top"
|
||||
>
|
||||
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}>
|
||||
{language.t("workspace.new")}
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p)}>
|
||||
{language.t("workspace.new")}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="relative flex-1 min-h-0">
|
||||
<DragDropProvider
|
||||
@@ -1878,7 +1989,7 @@ export default function Layout(props: ParentProps) {
|
||||
<SortableWorkspace
|
||||
ctx={workspaceSidebarCtx}
|
||||
directory={directory}
|
||||
project={p()}
|
||||
project={p}
|
||||
sortNow={sortNow}
|
||||
mobile={panelProps.mobile}
|
||||
/>
|
||||
@@ -1982,7 +2093,11 @@ export default function Layout(props: ParentProps) {
|
||||
onOpenSettings={openSettings}
|
||||
helpLabel={() => language.t("sidebar.help")}
|
||||
onOpenHelp={() => platform.openLink("https://kilo.ai/desktop-feedback")}
|
||||
renderPanel={() => <SidebarPanel project={currentProject()} />}
|
||||
renderPanel={() => (
|
||||
<Show when={currentProject()} keyed>
|
||||
{(project) => <SidebarPanel project={project} />}
|
||||
</Show>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Show when={!layout.sidebar.opened() ? hoverProjectData()?.worktree : undefined} keyed>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
export const deepLinkEvent = "opencode:deep-link"
|
||||
|
||||
export const parseDeepLink = (input: string) => {
|
||||
const parseUrl = (input: string) => {
|
||||
if (!input.startsWith("opencode://")) return
|
||||
if (typeof URL.canParse === "function" && !URL.canParse(input)) return
|
||||
const url = (() => {
|
||||
try {
|
||||
return new URL(input)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
try {
|
||||
return new URL(input)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export const parseDeepLink = (input: string) => {
|
||||
const url = parseUrl(input)
|
||||
if (!url) return
|
||||
if (url.hostname !== "open-project") return
|
||||
const directory = url.searchParams.get("directory")
|
||||
@@ -17,9 +19,23 @@ export const parseDeepLink = (input: string) => {
|
||||
return directory
|
||||
}
|
||||
|
||||
export const parseNewSessionDeepLink = (input: string) => {
|
||||
const url = parseUrl(input)
|
||||
if (!url) return
|
||||
if (url.hostname !== "new-session") return
|
||||
const directory = url.searchParams.get("directory")
|
||||
if (!directory) return
|
||||
const prompt = url.searchParams.get("prompt") || undefined
|
||||
if (!prompt) return { directory }
|
||||
return { directory, prompt }
|
||||
}
|
||||
|
||||
export const collectOpenProjectDeepLinks = (urls: string[]) =>
|
||||
urls.map(parseDeepLink).filter((directory): directory is string => !!directory)
|
||||
|
||||
export const collectNewSessionDeepLinks = (urls: string[]) =>
|
||||
urls.map(parseNewSessionDeepLink).filter((link): link is { directory: string; prompt?: string } => !!link)
|
||||
|
||||
type OpenCodeWindow = Window & {
|
||||
__KILO__?: {
|
||||
deepLinks?: string[]
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { collectOpenProjectDeepLinks, drainPendingDeepLinks, parseDeepLink } from "./deep-links"
|
||||
import {
|
||||
collectNewSessionDeepLinks,
|
||||
collectOpenProjectDeepLinks,
|
||||
drainPendingDeepLinks,
|
||||
parseDeepLink,
|
||||
parseNewSessionDeepLink,
|
||||
} from "./deep-links"
|
||||
import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers"
|
||||
import { type Session } from "@kilocode/sdk/v2/client"
|
||||
import { hasProjectPermissions, latestRootSession } from "./helpers"
|
||||
|
||||
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
|
||||
({
|
||||
title: "",
|
||||
version: "v2",
|
||||
parentID: undefined,
|
||||
messageCount: 0,
|
||||
permissions: { session: {}, share: {} },
|
||||
time: { created: 0, updated: 0, archived: undefined },
|
||||
...input,
|
||||
}) as Session
|
||||
|
||||
describe("layout deep links", () => {
|
||||
test("parses open-project deep links", () => {
|
||||
@@ -42,6 +61,28 @@ describe("layout deep links", () => {
|
||||
expect(result).toEqual(["/a", "/c"])
|
||||
})
|
||||
|
||||
test("parses new-session deep links with optional prompt", () => {
|
||||
expect(parseNewSessionDeepLink("opencode://new-session?directory=/tmp/demo")).toEqual({ directory: "/tmp/demo" })
|
||||
expect(parseNewSessionDeepLink("opencode://new-session?directory=/tmp/demo&prompt=hello%20world")).toEqual({
|
||||
directory: "/tmp/demo",
|
||||
prompt: "hello world",
|
||||
})
|
||||
})
|
||||
|
||||
test("ignores new-session deep links without directory", () => {
|
||||
expect(parseNewSessionDeepLink("opencode://new-session")).toBeUndefined()
|
||||
expect(parseNewSessionDeepLink("opencode://new-session?directory=")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("collects only valid new-session deep links", () => {
|
||||
const result = collectNewSessionDeepLinks([
|
||||
"opencode://new-session?directory=/a",
|
||||
"opencode://open-project?directory=/b",
|
||||
"opencode://new-session?directory=/c&prompt=ship%20it",
|
||||
])
|
||||
expect(result).toEqual([{ directory: "/a" }, { directory: "/c", prompt: "ship it" }])
|
||||
})
|
||||
|
||||
test("drains global deep links once", () => {
|
||||
const target = {
|
||||
__KILO__: {
|
||||
@@ -73,6 +114,84 @@ describe("layout workspace helpers", () => {
|
||||
expect(result).toEqual(["/root", "/c", "/b"])
|
||||
})
|
||||
|
||||
test("finds the latest root session across workspaces", () => {
|
||||
const result = latestRootSession(
|
||||
[
|
||||
{
|
||||
path: { directory: "/root" },
|
||||
session: [session({ id: "root", directory: "/root", time: { created: 1, updated: 1, archived: undefined } })],
|
||||
},
|
||||
{
|
||||
path: { directory: "/workspace" },
|
||||
session: [
|
||||
session({
|
||||
id: "workspace",
|
||||
directory: "/workspace",
|
||||
time: { created: 2, updated: 2, archived: undefined },
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
120_000,
|
||||
)
|
||||
|
||||
expect(result?.id).toBe("workspace")
|
||||
})
|
||||
|
||||
test("detects project permissions with a filter", () => {
|
||||
const result = hasProjectPermissions(
|
||||
{
|
||||
root: [{ id: "perm-root" }, { id: "perm-hidden" }],
|
||||
child: [{ id: "perm-child" }],
|
||||
},
|
||||
(item) => item.id === "perm-child",
|
||||
)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("ignores project permissions filtered out", () => {
|
||||
const result = hasProjectPermissions(
|
||||
{
|
||||
root: [{ id: "perm-root" }],
|
||||
},
|
||||
() => false,
|
||||
)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("ignores archived and child sessions when finding latest root session", () => {
|
||||
const result = latestRootSession(
|
||||
[
|
||||
{
|
||||
path: { directory: "/workspace" },
|
||||
session: [
|
||||
session({
|
||||
id: "archived",
|
||||
directory: "/workspace",
|
||||
time: { created: 10, updated: 10, archived: 10 },
|
||||
}),
|
||||
session({
|
||||
id: "child",
|
||||
directory: "/workspace",
|
||||
parentID: "parent",
|
||||
time: { created: 20, updated: 20, archived: undefined },
|
||||
}),
|
||||
session({
|
||||
id: "root",
|
||||
directory: "/workspace",
|
||||
time: { created: 30, updated: 30, archived: undefined },
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
120_000,
|
||||
)
|
||||
|
||||
expect(result?.id).toBe("root")
|
||||
})
|
||||
|
||||
test("extracts draggable id safely", () => {
|
||||
expect(getDraggableId({ draggable: { id: "x" } })).toBe("x")
|
||||
expect(getDraggableId({ draggable: { id: 42 } })).toBeUndefined()
|
||||
|
||||
@@ -28,6 +28,18 @@ export const isRootVisibleSession = (session: Session, directory: string) =>
|
||||
export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) =>
|
||||
store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).sort(sortSessions(now))
|
||||
|
||||
export const latestRootSession = (stores: { session: Session[]; path: { directory: string } }[], now: number) =>
|
||||
stores
|
||||
.flatMap((store) => store.session.filter((session) => isRootVisibleSession(session, store.path.directory)))
|
||||
.sort(sortSessions(now))[0]
|
||||
|
||||
export function hasProjectPermissions<T>(
|
||||
request: Record<string, T[] | undefined>,
|
||||
include: (item: T) => boolean = () => true,
|
||||
) {
|
||||
return Object.values(request).some((list) => list?.some(include))
|
||||
}
|
||||
|
||||
export const childMapByParent = (sessions: Session[]) => {
|
||||
const map = new Map<string, string[]>()
|
||||
for (const session of sessions) {
|
||||
@@ -62,9 +74,29 @@ export const errorMessage = (err: unknown, fallback: string) => {
|
||||
return fallback
|
||||
}
|
||||
|
||||
export const syncWorkspaceOrder = (local: string, dirs: string[], existing?: string[]) => {
|
||||
if (!existing) return dirs
|
||||
const keep = existing.filter((d) => d !== local && dirs.includes(d))
|
||||
const missing = dirs.filter((d) => d !== local && !existing.includes(d))
|
||||
return [local, ...missing, ...keep]
|
||||
export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted?: string[]) => {
|
||||
const root = workspaceKey(local)
|
||||
const live = new Map<string, string>()
|
||||
|
||||
for (const dir of dirs) {
|
||||
const key = workspaceKey(dir)
|
||||
if (key === root) continue
|
||||
if (!live.has(key)) live.set(key, dir)
|
||||
}
|
||||
|
||||
if (!persisted?.length) return [local, ...live.values()]
|
||||
|
||||
const result = [local]
|
||||
for (const dir of persisted) {
|
||||
const key = workspaceKey(dir)
|
||||
if (key === root) continue
|
||||
const match = live.get(key)
|
||||
if (!match) continue
|
||||
result.push(match)
|
||||
live.delete(key)
|
||||
}
|
||||
|
||||
return [...result, ...live.values()]
|
||||
}
|
||||
|
||||
export const syncWorkspaceOrder = effectiveWorkspaceOrder
|
||||
|
||||
@@ -1,49 +1,63 @@
|
||||
import { A, useNavigate, useParams } from "@solidjs/router"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout, type LocalProject, getAvatarColors } from "@/context/layout"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import type { Message, Session, TextPart, UserMessage } from "@kilocode/sdk/v2/client"
|
||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||
import { HoverCard } from "@opencode-ai/ui/hover-card"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { MessageNav } from "@opencode-ai/ui/message-nav"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { type Message, type Session, type TextPart, type UserMessage } from "@kilocode/sdk/v2/client"
|
||||
import { For, Match, Show, Switch, createMemo, onCleanup, type Accessor, type JSX } from "solid-js"
|
||||
import { A, useNavigate, useParams } from "@solidjs/router"
|
||||
import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { agentColor } from "@/utils/agent"
|
||||
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
|
||||
import { hasProjectPermissions } from "./helpers"
|
||||
|
||||
const KILO_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
||||
|
||||
export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => {
|
||||
const globalSync = useGlobalSync()
|
||||
const notification = useNotification()
|
||||
const permission = usePermission()
|
||||
const dirs = createMemo(() => [props.project.worktree, ...(props.project.sandboxes ?? [])])
|
||||
const unseenCount = createMemo(() =>
|
||||
dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
|
||||
)
|
||||
const hasError = createMemo(() => dirs().some((directory) => notification.project.unseenHasError(directory)))
|
||||
const hasPermissions = createMemo(() =>
|
||||
dirs().some((directory) => {
|
||||
const [store] = globalSync.child(directory, { bootstrap: false })
|
||||
return hasProjectPermissions(store.permission, (item) => !permission.autoResponds(item, directory))
|
||||
}),
|
||||
)
|
||||
const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0))
|
||||
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
|
||||
return (
|
||||
<div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
|
||||
<div class="size-full rounded overflow-clip">
|
||||
<Avatar
|
||||
fallback={name()}
|
||||
src={props.project.id === KILO_PROJECT_ID ? "https://kilo.ai/favicon.svg" : props.project.icon?.override}
|
||||
src={
|
||||
props.project.id === KILO_PROJECT_ID ? "https://kilo.ai/favicon.svg" : props.project.icon?.override
|
||||
}
|
||||
{...getAvatarColors(props.project.icon?.color)}
|
||||
class="size-full rounded"
|
||||
classList={{ "badge-mask": unseenCount() > 0 && props.notify }}
|
||||
classList={{ "badge-mask": notify() }}
|
||||
/>
|
||||
</div>
|
||||
<Show when={unseenCount() > 0 && props.notify}>
|
||||
<Show when={notify()}>
|
||||
<div
|
||||
classList={{
|
||||
"absolute top-px right-px size-1.5 rounded-full z-10": true,
|
||||
"bg-icon-critical-base": hasError(),
|
||||
"bg-text-interactive-base": !hasError(),
|
||||
"bg-surface-warning-strong": hasPermissions(),
|
||||
"bg-icon-critical-base": !hasPermissions() && hasError(),
|
||||
"bg-text-interactive-base": !hasPermissions() && !hasError(),
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
@@ -122,13 +136,6 @@ const SessionRow = (props: {
|
||||
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
|
||||
{props.session.title}
|
||||
</span>
|
||||
<Show when={props.session.summary}>
|
||||
{(summary) => (
|
||||
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
|
||||
<DiffChanges changes={summary()} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</A>
|
||||
)
|
||||
@@ -184,19 +191,15 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
const notification = useNotification()
|
||||
const permission = usePermission()
|
||||
const globalSync = useGlobalSync()
|
||||
const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id))
|
||||
const hasError = createMemo(() => notification.session.unseenHasError(props.session.id))
|
||||
const [sessionStore] = globalSync.child(props.session.directory)
|
||||
const hasPermissions = createMemo(() => {
|
||||
const permissions = sessionStore.permission?.[props.session.id] ?? []
|
||||
if (permissions.length > 0) return true
|
||||
|
||||
for (const id of props.children.get(props.session.id) ?? []) {
|
||||
const childPermissions = sessionStore.permission?.[id] ?? []
|
||||
if (childPermissions.length > 0) return true
|
||||
}
|
||||
return false
|
||||
return !!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.session.id, (item) => {
|
||||
return !permission.autoResponds(item, props.session.directory)
|
||||
})
|
||||
})
|
||||
const isWorking = createMemo(() => {
|
||||
if (hasPermissions()) return false
|
||||
@@ -228,7 +231,9 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
|
||||
const isActive = createMemo(() => props.session.id === params.id)
|
||||
|
||||
const hoverPrefetch = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||
const hoverPrefetch = {
|
||||
current: undefined as ReturnType<typeof setTimeout> | undefined,
|
||||
}
|
||||
const cancelHoverPrefetch = () => {
|
||||
if (hoverPrefetch.current === undefined) return
|
||||
clearTimeout(hoverPrefetch.current)
|
||||
@@ -297,17 +302,15 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
setHoverSession={props.setHoverSession}
|
||||
messageLabel={messageLabel}
|
||||
onMessageSelect={(message) => {
|
||||
if (!isActive()) {
|
||||
if (!isActive())
|
||||
layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
|
||||
navigate(`${props.slug}/session/${props.session.id}`)
|
||||
return
|
||||
}
|
||||
window.history.replaceState(null, "", `#message-${message.id}`)
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"))
|
||||
|
||||
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
|
||||
}}
|
||||
trigger={item}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
|
||||
classList={{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createEffect, createMemo, createSignal, For, Show, type Accessor, type JSX } from "solid-js"
|
||||
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { ContextMenu } from "@opencode-ai/ui/context-menu"
|
||||
@@ -7,7 +8,7 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { createSortable } from "@thisbeyond/solid-dnd"
|
||||
import { type LocalProject } from "@/context/layout"
|
||||
import { useLayout, type LocalProject } from "@/context/layout"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useNotification } from "@/context/notification"
|
||||
@@ -60,6 +61,7 @@ const ProjectTile = (props: {
|
||||
selected: Accessor<boolean>
|
||||
active: Accessor<boolean>
|
||||
overlay: Accessor<boolean>
|
||||
suppressHover: Accessor<boolean>
|
||||
dirs: Accessor<string[]>
|
||||
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
|
||||
onProjectMouseLeave: (worktree: string) => void
|
||||
@@ -71,9 +73,11 @@ const ProjectTile = (props: {
|
||||
closeProject: (directory: string) => void
|
||||
setMenu: (value: boolean) => void
|
||||
setOpen: (value: boolean) => void
|
||||
setSuppressHover: (value: boolean) => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}): JSX.Element => {
|
||||
const notification = useNotification()
|
||||
const layout = useLayout()
|
||||
const unseenCount = createMemo(() =>
|
||||
props.dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
|
||||
)
|
||||
@@ -107,17 +111,28 @@ const ProjectTile = (props: {
|
||||
}}
|
||||
onMouseEnter={(event: MouseEvent) => {
|
||||
if (!props.overlay()) return
|
||||
if (props.suppressHover()) return
|
||||
props.onProjectMouseEnter(props.project.worktree, event)
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (props.suppressHover()) props.setSuppressHover(false)
|
||||
if (!props.overlay()) return
|
||||
props.onProjectMouseLeave(props.project.worktree)
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!props.overlay()) return
|
||||
if (props.suppressHover()) return
|
||||
props.onProjectFocus(props.project.worktree)
|
||||
}}
|
||||
onClick={() => props.navigateToProject(props.project.worktree)}
|
||||
onClick={() => {
|
||||
if (props.selected()) {
|
||||
props.setSuppressHover(true)
|
||||
layout.sidebar.toggle()
|
||||
return
|
||||
}
|
||||
props.setSuppressHover(false)
|
||||
props.navigateToProject(props.project.worktree)
|
||||
}}
|
||||
onBlur={() => props.setOpen(false)}
|
||||
>
|
||||
<ProjectIcon project={props.project} notify />
|
||||
@@ -278,16 +293,19 @@ export const SortableProject = (props: {
|
||||
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
|
||||
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
|
||||
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
|
||||
const [open, setOpen] = createSignal(false)
|
||||
const [menu, setMenu] = createSignal(false)
|
||||
const [state, setState] = createStore({
|
||||
open: false,
|
||||
menu: false,
|
||||
suppressHover: false,
|
||||
})
|
||||
|
||||
const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
|
||||
const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
|
||||
const active = createMemo(() =>
|
||||
projectTileActive({
|
||||
menu: menu(),
|
||||
menu: state.menu,
|
||||
preview: preview(),
|
||||
open: open(),
|
||||
open: state.open,
|
||||
overlay: overlay(),
|
||||
hoverProject: props.ctx.hoverProject(),
|
||||
worktree: props.project.worktree,
|
||||
@@ -296,8 +314,14 @@ export const SortableProject = (props: {
|
||||
|
||||
createEffect(() => {
|
||||
if (preview()) return
|
||||
if (!open()) return
|
||||
setOpen(false)
|
||||
if (!state.open) return
|
||||
setState("open", false)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!selected()) return
|
||||
if (!state.open) return
|
||||
setState("open", false)
|
||||
})
|
||||
|
||||
const label = (directory: string) => {
|
||||
@@ -328,6 +352,7 @@ export const SortableProject = (props: {
|
||||
selected={selected}
|
||||
active={active}
|
||||
overlay={overlay}
|
||||
suppressHover={() => state.suppressHover}
|
||||
dirs={dirs}
|
||||
onProjectMouseEnter={props.ctx.onProjectMouseEnter}
|
||||
onProjectMouseLeave={props.ctx.onProjectMouseLeave}
|
||||
@@ -337,8 +362,9 @@ export const SortableProject = (props: {
|
||||
toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces}
|
||||
workspacesEnabled={props.ctx.workspacesEnabled}
|
||||
closeProject={props.ctx.closeProject}
|
||||
setMenu={setMenu}
|
||||
setOpen={setOpen}
|
||||
setMenu={(value) => setState("menu", value)}
|
||||
setOpen={(value) => setState("open", value)}
|
||||
setSuppressHover={(value) => setState("suppressHover", value)}
|
||||
language={language}
|
||||
/>
|
||||
)
|
||||
@@ -346,17 +372,18 @@ export const SortableProject = (props: {
|
||||
return (
|
||||
// @ts-ignore
|
||||
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
||||
<Show when={preview()} fallback={tile()}>
|
||||
<Show when={preview() && !selected()} fallback={tile()}>
|
||||
<HoverCard
|
||||
open={open() && !menu()}
|
||||
open={!state.suppressHover && state.open && !state.menu}
|
||||
openDelay={0}
|
||||
closeDelay={0}
|
||||
placement="right-start"
|
||||
gutter={6}
|
||||
trigger={tile()}
|
||||
onOpenChange={(value) => {
|
||||
if (menu()) return
|
||||
setOpen(value)
|
||||
if (state.menu) return
|
||||
if (value && state.suppressHover) return
|
||||
setState("open", value)
|
||||
if (value) props.ctx.setHoverSession(undefined)
|
||||
}}
|
||||
>
|
||||
@@ -371,7 +398,7 @@ export const SortableProject = (props: {
|
||||
projectChildren={projectChildren}
|
||||
workspaceSessions={workspaceSessions}
|
||||
workspaceChildren={workspaceChildren}
|
||||
setOpen={setOpen}
|
||||
setOpen={(value) => setState("open", value)}
|
||||
ctx={props.ctx}
|
||||
language={language}
|
||||
/>
|
||||
|
||||
+422
-238
@@ -1,4 +1,18 @@
|
||||
import { onCleanup, Show, Match, Switch, createMemo, createEffect, createRoot, on, onMount } from "solid-js"
|
||||
import type { UserMessage } from "@kilocode/sdk/v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import {
|
||||
onCleanup,
|
||||
Show,
|
||||
Match,
|
||||
Switch,
|
||||
createRoot,
|
||||
createMemo,
|
||||
createEffect,
|
||||
createComputed,
|
||||
on,
|
||||
onMount,
|
||||
untrack,
|
||||
} from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { useLocal } from "@/context/local"
|
||||
@@ -8,31 +22,237 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { checksum, base64Encode } from "@opencode-ai/util/encode"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { UserMessage } from "@kilocode/sdk/v2"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { base64Encode, checksum } from "@opencode-ai/util/encode"
|
||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { Identifier } from "@/utils/id" // kilocode_change
|
||||
import { showToast } from "@opencode-ai/ui/toast" // kilocode_change
|
||||
import { SessionHeader, NewSessionView } from "@/components/session"
|
||||
import { same } from "@/utils/same"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
|
||||
import { createOpenReviewFile } from "@/pages/session/helpers"
|
||||
import { createScrollSpy } from "@/pages/session/scroll-spy"
|
||||
import { SessionReviewTab, type DiffStyle, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
import { MessageTimeline } from "@/pages/session/message-timeline"
|
||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer"
|
||||
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||
import { createScrollSpy } from "@/pages/session/scroll-spy"
|
||||
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
|
||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||
import { same } from "@/utils/same"
|
||||
import { Identifier } from "@/utils/id" // kilocode_change
|
||||
import { showToast } from "@opencode-ai/ui/toast" // kilocode_change
|
||||
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
|
||||
type SessionHistoryWindowInput = {
|
||||
sessionID: () => string | undefined
|
||||
messagesReady: () => boolean
|
||||
visibleUserMessages: () => UserMessage[]
|
||||
historyMore: () => boolean
|
||||
historyLoading: () => boolean
|
||||
loadMore: (sessionID: string) => Promise<void>
|
||||
userScrolled: () => boolean
|
||||
scroller: () => HTMLDivElement | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintains the rendered history window for a session timeline.
|
||||
*
|
||||
* It keeps initial paint bounded to recent turns, reveals cached turns in
|
||||
* small batches while scrolling upward, and prefetches older history near top.
|
||||
*/
|
||||
function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
||||
const turnInit = 10
|
||||
const turnBatch = 8
|
||||
const turnScrollThreshold = 200
|
||||
const turnPrefetchBuffer = 16
|
||||
const prefetchCooldownMs = 400
|
||||
const prefetchNoGrowthLimit = 2
|
||||
|
||||
const [state, setState] = createStore({
|
||||
turnID: undefined as string | undefined,
|
||||
turnStart: 0,
|
||||
prefetchUntil: 0,
|
||||
prefetchNoGrowth: 0,
|
||||
})
|
||||
|
||||
const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
|
||||
|
||||
const turnStart = createMemo(() => {
|
||||
const id = input.sessionID()
|
||||
const len = input.visibleUserMessages().length
|
||||
if (!id || len <= 0) return 0
|
||||
if (state.turnID !== id) return initialTurnStart(len)
|
||||
if (state.turnStart <= 0) return 0
|
||||
if (state.turnStart >= len) return initialTurnStart(len)
|
||||
return state.turnStart
|
||||
})
|
||||
|
||||
const setTurnStart = (start: number) => {
|
||||
const id = input.sessionID()
|
||||
const next = start > 0 ? start : 0
|
||||
if (!id) {
|
||||
setState({ turnID: undefined, turnStart: next })
|
||||
return
|
||||
}
|
||||
setState({ turnID: id, turnStart: next })
|
||||
}
|
||||
|
||||
const renderedUserMessages = createMemo(
|
||||
() => {
|
||||
const msgs = input.visibleUserMessages()
|
||||
const start = turnStart()
|
||||
if (start <= 0) return msgs
|
||||
return msgs.slice(start)
|
||||
},
|
||||
emptyUserMessages,
|
||||
{
|
||||
equals: same,
|
||||
},
|
||||
)
|
||||
|
||||
const preserveScroll = (fn: () => void) => {
|
||||
const el = input.scroller()
|
||||
if (!el) {
|
||||
fn()
|
||||
return
|
||||
}
|
||||
const beforeTop = el.scrollTop
|
||||
const beforeHeight = el.scrollHeight
|
||||
fn()
|
||||
requestAnimationFrame(() => {
|
||||
const delta = el.scrollHeight - beforeHeight
|
||||
if (!delta) return
|
||||
el.scrollTop = beforeTop + delta
|
||||
})
|
||||
}
|
||||
|
||||
const backfillTurns = () => {
|
||||
const start = turnStart()
|
||||
if (start <= 0) return
|
||||
|
||||
const next = start - turnBatch
|
||||
const nextStart = next > 0 ? next : 0
|
||||
|
||||
preserveScroll(() => setTurnStart(nextStart))
|
||||
}
|
||||
|
||||
/** Button path: reveal all cached turns, fetch older history, reveal one batch. */
|
||||
const loadAndReveal = async () => {
|
||||
const id = input.sessionID()
|
||||
if (!id) return
|
||||
|
||||
const start = turnStart()
|
||||
const beforeVisible = input.visibleUserMessages().length
|
||||
|
||||
if (start > 0) setTurnStart(0)
|
||||
|
||||
if (!input.historyMore() || input.historyLoading()) return
|
||||
|
||||
await input.loadMore(id)
|
||||
if (input.sessionID() !== id) return
|
||||
|
||||
const afterVisible = input.visibleUserMessages().length
|
||||
const growth = afterVisible - beforeVisible
|
||||
if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
|
||||
if (growth <= 0) return
|
||||
if (turnStart() !== 0) return
|
||||
|
||||
const target = Math.min(afterVisible, Math.max(beforeVisible, renderedUserMessages().length) + turnBatch)
|
||||
const nextStart = Math.max(0, afterVisible - target)
|
||||
preserveScroll(() => setTurnStart(nextStart))
|
||||
}
|
||||
|
||||
/** Scroll/prefetch path: fetch older history from server. */
|
||||
const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
|
||||
const id = input.sessionID()
|
||||
if (!id) return
|
||||
if (!input.historyMore() || input.historyLoading()) return
|
||||
|
||||
if (opts?.prefetch) {
|
||||
const now = Date.now()
|
||||
if (state.prefetchUntil > now) return
|
||||
if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
|
||||
setState("prefetchUntil", now + prefetchCooldownMs)
|
||||
}
|
||||
|
||||
const start = turnStart()
|
||||
const beforeVisible = input.visibleUserMessages().length
|
||||
const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
|
||||
|
||||
await input.loadMore(id)
|
||||
if (input.sessionID() !== id) return
|
||||
|
||||
const afterVisible = input.visibleUserMessages().length
|
||||
const growth = afterVisible - beforeVisible
|
||||
|
||||
if (opts?.prefetch) {
|
||||
setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1)
|
||||
} else if (growth > 0 && state.prefetchNoGrowth) {
|
||||
setState("prefetchNoGrowth", 0)
|
||||
}
|
||||
|
||||
if (growth <= 0) return
|
||||
if (turnStart() !== start) return
|
||||
|
||||
const reveal = !opts?.prefetch
|
||||
const currentRendered = renderedUserMessages().length
|
||||
const base = Math.max(beforeRendered, currentRendered)
|
||||
const target = reveal ? Math.min(afterVisible, base + turnBatch) : base
|
||||
const nextStart = Math.max(0, afterVisible - target)
|
||||
preserveScroll(() => setTurnStart(nextStart))
|
||||
}
|
||||
|
||||
const onScrollerScroll = () => {
|
||||
if (!input.userScrolled()) return
|
||||
const el = input.scroller()
|
||||
if (!el) return
|
||||
if (el.scrollTop >= turnScrollThreshold) return
|
||||
|
||||
const start = turnStart()
|
||||
if (start > 0) {
|
||||
if (start <= turnPrefetchBuffer) {
|
||||
void fetchOlderMessages({ prefetch: true })
|
||||
}
|
||||
backfillTurns()
|
||||
return
|
||||
}
|
||||
|
||||
void fetchOlderMessages()
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
input.sessionID,
|
||||
() => {
|
||||
setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [input.sessionID(), input.messagesReady()] as const,
|
||||
([id, ready]) => {
|
||||
if (!id || !ready) return
|
||||
setTurnStart(initialTurnStart(input.visibleUserMessages().length))
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
turnStart,
|
||||
setTurnStart,
|
||||
renderedUserMessages,
|
||||
loadAndReveal,
|
||||
onScrollerScroll,
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const layout = useLayout()
|
||||
@@ -46,6 +266,19 @@ export default function Page() {
|
||||
const sdk = useSDK()
|
||||
const prompt = usePrompt()
|
||||
const comments = useComments()
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||
|
||||
createEffect(() => {
|
||||
if (!untrack(() => prompt.ready())) return
|
||||
prompt.ready()
|
||||
untrack(() => {
|
||||
if (params.id || !prompt.ready()) return
|
||||
const text = searchParams.prompt
|
||||
if (!text) return
|
||||
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
||||
setSearchParams({ ...searchParams, prompt: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
const [ui, setUi] = createStore({
|
||||
pendingMessage: undefined as string | undefined,
|
||||
@@ -208,7 +441,7 @@ export default function Page() {
|
||||
if (desktopReviewOpen()) return `${layout.session.width()}px`
|
||||
return `calc(100% - ${layout.fileTree.width()}px)`
|
||||
})
|
||||
const centered = createMemo(() => isDesktop() && !desktopSidePanelOpen())
|
||||
const centered = createMemo(() => isDesktop() && !desktopReviewOpen())
|
||||
|
||||
function normalizeTab(tab: string) {
|
||||
if (!tab.startsWith("file://")) return tab
|
||||
@@ -239,24 +472,6 @@ export default function Page() {
|
||||
if (path) file.load(path)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const current = tabs().all()
|
||||
if (current.length === 0) return
|
||||
|
||||
const next = normalizeTabs(current)
|
||||
if (same(current, next)) return
|
||||
|
||||
tabs().setAll(next)
|
||||
|
||||
const active = tabs().active()
|
||||
if (!active) return
|
||||
if (!active.startsWith("file://")) return
|
||||
|
||||
const normalized = normalizeTab(active)
|
||||
if (active === normalized) return
|
||||
tabs().setActive(normalized)
|
||||
})
|
||||
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||
@@ -279,7 +494,6 @@ export default function Page() {
|
||||
return sync.session.history.loading(id)
|
||||
})
|
||||
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
const userMessages = createMemo(
|
||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||
emptyUserMessages,
|
||||
@@ -304,7 +518,10 @@ export default function Page() {
|
||||
() => {
|
||||
const msg = lastUserMessage()
|
||||
if (!msg) return
|
||||
if (msg.agent) local.agent.set(msg.agent)
|
||||
if (msg.agent) {
|
||||
local.agent.set(msg.agent)
|
||||
if (local.agent.current()?.model) return
|
||||
}
|
||||
if (msg.model) local.model.set(msg.model)
|
||||
},
|
||||
),
|
||||
@@ -312,29 +529,26 @@ export default function Page() {
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
messageId: undefined as string | undefined,
|
||||
turnStart: 0,
|
||||
mobileTab: "session" as "session" | "changes",
|
||||
changes: "session" as "session" | "turn",
|
||||
newSessionWorktree: "main",
|
||||
deferRender: false,
|
||||
})
|
||||
|
||||
createComputed((prev) => {
|
||||
const key = sessionKey()
|
||||
if (key !== prev) {
|
||||
setStore("deferRender", true)
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => setStore("deferRender", false), 0)
|
||||
})
|
||||
}
|
||||
return key
|
||||
}, sessionKey())
|
||||
|
||||
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
||||
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
|
||||
|
||||
const renderedUserMessages = createMemo(
|
||||
() => {
|
||||
const msgs = visibleUserMessages()
|
||||
const start = store.turnStart
|
||||
if (start <= 0) return msgs
|
||||
if (start >= msgs.length) return emptyUserMessages
|
||||
return msgs.slice(start)
|
||||
},
|
||||
emptyUserMessages,
|
||||
{
|
||||
equals: same,
|
||||
},
|
||||
)
|
||||
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (store.newSessionWorktree === "create") return "create"
|
||||
const project = sync.project
|
||||
@@ -403,13 +617,15 @@ export default function Page() {
|
||||
|
||||
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
|
||||
|
||||
createEffect(() => {
|
||||
sdk.directory
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
void sync.session.sync(id)
|
||||
void sync.session.todo(id)
|
||||
})
|
||||
createEffect(
|
||||
on([() => sdk.directory, () => params.id] as const, ([, id]) => {
|
||||
if (!id) return
|
||||
untrack(() => {
|
||||
void sync.session.sync(id)
|
||||
void sync.session.todo(id)
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
@@ -480,11 +696,58 @@ export default function Page() {
|
||||
})
|
||||
}
|
||||
|
||||
const updateCommentInContext = (input: {
|
||||
id: string
|
||||
file: string
|
||||
selection: SelectedLineRange
|
||||
comment: string
|
||||
preview?: string
|
||||
}) => {
|
||||
comments.update(input.file, input.id, input.comment)
|
||||
prompt.context.updateComment(input.file, input.id, {
|
||||
comment: input.comment,
|
||||
...(input.preview ? { preview: input.preview } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
const removeCommentFromContext = (input: { id: string; file: string }) => {
|
||||
comments.remove(input.file, input.id)
|
||||
prompt.context.removeComment(input.file, input.id)
|
||||
}
|
||||
|
||||
const reviewCommentActions = createMemo(() => ({
|
||||
moreLabel: language.t("common.moreOptions"),
|
||||
editLabel: language.t("common.edit"),
|
||||
deleteLabel: language.t("common.delete"),
|
||||
saveLabel: language.t("common.save"),
|
||||
}))
|
||||
|
||||
const isEditableTarget = (target: EventTarget | null | undefined) => {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable
|
||||
}
|
||||
|
||||
const deepActiveElement = () => {
|
||||
let current: Element | null = document.activeElement
|
||||
while (current instanceof HTMLElement && current.shadowRoot?.activeElement) {
|
||||
current = current.shadowRoot.activeElement
|
||||
}
|
||||
return current instanceof HTMLElement ? current : undefined
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const activeElement = document.activeElement as HTMLElement | undefined
|
||||
const path = event.composedPath()
|
||||
const target = path.find((item): item is HTMLElement => item instanceof HTMLElement)
|
||||
const activeElement = deepActiveElement()
|
||||
|
||||
const protectedTarget = path.some(
|
||||
(item) => item instanceof HTMLElement && item.closest("[data-prevent-autofocus]") !== null,
|
||||
)
|
||||
if (protectedTarget || isEditableTarget(target)) return
|
||||
|
||||
if (activeElement) {
|
||||
const isProtected = activeElement.closest("[data-prevent-autofocus]")
|
||||
const isInput = /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(activeElement.tagName) || activeElement.isContentEditable
|
||||
const isInput = isEditableTarget(activeElement)
|
||||
if (isProtected || isInput) return
|
||||
}
|
||||
if (dialog.active) return
|
||||
@@ -517,7 +780,7 @@ export default function Page() {
|
||||
)
|
||||
|
||||
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
|
||||
const reviewTab = createMemo(() => isDesktop() && !layout.fileTree.opened())
|
||||
const reviewTab = createMemo(() => isDesktop())
|
||||
|
||||
const fileTreeTab = () => layout.fileTree.tab()
|
||||
const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
|
||||
@@ -532,7 +795,11 @@ export default function Page() {
|
||||
on(
|
||||
sessionKey,
|
||||
() => {
|
||||
setTree({ reviewScroll: undefined, pendingDiff: undefined, activeDiff: undefined })
|
||||
setTree({
|
||||
reviewScroll: undefined,
|
||||
pendingDiff: undefined,
|
||||
activeDiff: undefined,
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
@@ -555,6 +822,7 @@ export default function Page() {
|
||||
showAllFiles,
|
||||
tabForPath: file.tab,
|
||||
openTab: tabs().open,
|
||||
setActive: tabs().setActive,
|
||||
loadFile: file.load,
|
||||
})
|
||||
|
||||
@@ -570,7 +838,8 @@ export default function Page() {
|
||||
}
|
||||
onSelect={(option) => option && setStore("changes", option)}
|
||||
variant="ghost"
|
||||
size="large"
|
||||
size="small"
|
||||
valueClass="text-14-medium"
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -588,32 +857,12 @@ export default function Page() {
|
||||
loadingClass: string
|
||||
emptyClass: string
|
||||
}) => (
|
||||
<Switch>
|
||||
<Match when={store.changes === "turn" && !!params.id}>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={emptyTurn()}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
onDiffStyleChange={input.onDiffStyleChange}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={hasReview()}>
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
|
||||
>
|
||||
<Show when={!store.deferRender}>
|
||||
<Switch>
|
||||
<Match when={store.changes === "turn" && !!params.id}>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={emptyTurn()}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
@@ -621,42 +870,73 @@ export default function Page() {
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
onLineCommentUpdate={updateCommentInContext}
|
||||
onLineCommentDelete={removeCommentFromContext}
|
||||
lineCommentActions={reviewCommentActions()}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={
|
||||
store.changes === "turn" ? (
|
||||
emptyTurn()
|
||||
) : (
|
||||
<div class={input.emptyClass}>
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
onDiffStyleChange={input.onDiffStyleChange}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Match>
|
||||
<Match when={hasReview()}>
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
|
||||
>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
onDiffStyleChange={input.onDiffStyleChange}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
onLineCommentUpdate={updateCommentInContext}
|
||||
onLineCommentDelete={removeCommentFromContext}
|
||||
lineCommentActions={reviewCommentActions()}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={
|
||||
store.changes === "turn" ? (
|
||||
emptyTurn()
|
||||
) : (
|
||||
<div class={input.emptyClass}>
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
onDiffStyleChange={input.onDiffStyleChange}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
onLineCommentUpdate={updateCommentInContext}
|
||||
onLineCommentDelete={removeCommentFromContext}
|
||||
lineCommentActions={reviewCommentActions()}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
)
|
||||
|
||||
const reviewPanel = () => (
|
||||
@@ -801,33 +1081,12 @@ export default function Page() {
|
||||
const active = tabs().active()
|
||||
const tab = active === "review" || (!active && hasReview()) ? "changes" : "all"
|
||||
layout.fileTree.setTab(tab)
|
||||
return
|
||||
}
|
||||
|
||||
if (fileTreeTab() !== "changes") return
|
||||
tabs().setActive("review")
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (!isDesktop()) return
|
||||
if (!layout.fileTree.opened()) return
|
||||
if (fileTreeTab() !== "all") return
|
||||
|
||||
const active = tabs().active()
|
||||
if (active && active !== "review") return
|
||||
|
||||
const first = openedTabs()[0]
|
||||
if (first) {
|
||||
tabs().setActive(first)
|
||||
return
|
||||
}
|
||||
|
||||
if (contextOpen()) tabs().setActive("context")
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
@@ -959,88 +1218,16 @@ export default function Page() {
|
||||
},
|
||||
)
|
||||
|
||||
const turnInit = 20
|
||||
const turnBatch = 20
|
||||
let turnHandle: number | undefined
|
||||
let turnIdle = false
|
||||
|
||||
function cancelTurnBackfill() {
|
||||
const handle = turnHandle
|
||||
if (handle === undefined) return
|
||||
turnHandle = undefined
|
||||
|
||||
if (turnIdle && window.cancelIdleCallback) {
|
||||
window.cancelIdleCallback(handle)
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(handle)
|
||||
}
|
||||
|
||||
function scheduleTurnBackfill() {
|
||||
if (turnHandle !== undefined) return
|
||||
if (store.turnStart <= 0) return
|
||||
|
||||
if (window.requestIdleCallback) {
|
||||
turnIdle = true
|
||||
turnHandle = window.requestIdleCallback(() => {
|
||||
turnHandle = undefined
|
||||
backfillTurns()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
turnIdle = false
|
||||
turnHandle = window.setTimeout(() => {
|
||||
turnHandle = undefined
|
||||
backfillTurns()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function backfillTurns() {
|
||||
const start = store.turnStart
|
||||
if (start <= 0) return
|
||||
|
||||
const next = start - turnBatch
|
||||
const nextStart = next > 0 ? next : 0
|
||||
|
||||
const el = scroller
|
||||
if (!el) {
|
||||
setStore("turnStart", nextStart)
|
||||
scheduleTurnBackfill()
|
||||
return
|
||||
}
|
||||
|
||||
const beforeTop = el.scrollTop
|
||||
const beforeHeight = el.scrollHeight
|
||||
|
||||
setStore("turnStart", nextStart)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const delta = el.scrollHeight - beforeHeight
|
||||
if (!delta) return
|
||||
el.scrollTop = beforeTop + delta
|
||||
})
|
||||
|
||||
scheduleTurnBackfill()
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [params.id, messagesReady()] as const,
|
||||
([id, ready]) => {
|
||||
cancelTurnBackfill()
|
||||
setStore("turnStart", 0)
|
||||
if (!id || !ready) return
|
||||
|
||||
const len = visibleUserMessages().length
|
||||
const start = len > turnInit ? len - turnInit : 0
|
||||
setStore("turnStart", start)
|
||||
scheduleTurnBackfill()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const historyWindow = createSessionHistoryWindow({
|
||||
sessionID: () => params.id,
|
||||
messagesReady,
|
||||
visibleUserMessages,
|
||||
historyMore,
|
||||
historyLoading,
|
||||
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
|
||||
userScrolled: autoScroll.userScrolled,
|
||||
scroller: () => scroller,
|
||||
})
|
||||
|
||||
createResizeObserver(
|
||||
() => promptDock,
|
||||
@@ -1051,7 +1238,9 @@ export default function Page() {
|
||||
|
||||
const el = scroller
|
||||
const delta = next - dockHeight
|
||||
const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta) : false
|
||||
const stick = el
|
||||
? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta)
|
||||
: false
|
||||
|
||||
dockHeight = next
|
||||
|
||||
@@ -1067,13 +1256,12 @@ export default function Page() {
|
||||
sessionID: () => params.id,
|
||||
messagesReady,
|
||||
visibleUserMessages,
|
||||
turnStart: () => store.turnStart,
|
||||
turnStart: historyWindow.turnStart,
|
||||
currentMessageId: () => store.messageId,
|
||||
pendingMessage: () => ui.pendingMessage,
|
||||
setPendingMessage: (value) => setUi("pendingMessage", value),
|
||||
setActiveMessage,
|
||||
setTurnStart: (value) => setStore("turnStart", value),
|
||||
scheduleTurnBackfill,
|
||||
setTurnStart: historyWindow.setTurnStart,
|
||||
autoScroll,
|
||||
scroller: () => scroller,
|
||||
anchor,
|
||||
@@ -1086,7 +1274,6 @@ export default function Page() {
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
cancelTurnBackfill()
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
scrollSpy.destroy()
|
||||
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
||||
@@ -1141,6 +1328,7 @@ export default function Page() {
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
isDesktop={isDesktop()}
|
||||
onScrollSpyScroll={scrollSpy.onScroll}
|
||||
onTurnBackfillScroll={historyWindow.onScrollerScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
centered={centered()}
|
||||
setContentRef={(el) => {
|
||||
@@ -1150,21 +1338,16 @@ export default function Page() {
|
||||
const root = scroller
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
turnStart={store.turnStart}
|
||||
onRenderEarlier={() => setStore("turnStart", 0)}
|
||||
turnStart={historyWindow.turnStart()}
|
||||
historyMore={historyMore()}
|
||||
historyLoading={historyLoading()}
|
||||
onLoadEarlier={() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
setStore("turnStart", 0)
|
||||
sync.session.history.loadMore(id)
|
||||
void historyWindow.loadAndReveal()
|
||||
}}
|
||||
renderedUserMessages={renderedUserMessages()}
|
||||
renderedUserMessages={historyWindow.renderedUserMessages()}
|
||||
anchor={anchor}
|
||||
onRegisterMessage={scrollSpy.register}
|
||||
onUnregisterMessage={scrollSpy.unregister}
|
||||
lastUserMessageID={lastUserMessage()?.id}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
@@ -1192,6 +1375,7 @@ export default function Page() {
|
||||
|
||||
<SessionComposerRegion
|
||||
state={composer}
|
||||
ready={!store.deferRender && messagesReady()}
|
||||
centered={centered()}
|
||||
inputRef={(el) => {
|
||||
inputRef = el
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Show, createEffect, createMemo } from "solid-js"
|
||||
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
@@ -11,6 +13,7 @@ import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
|
||||
|
||||
export function SessionComposerRegion(props: {
|
||||
state: SessionComposerState
|
||||
ready: boolean
|
||||
centered: boolean
|
||||
inputRef: (el: HTMLDivElement) => void
|
||||
newSessionWorktree: string
|
||||
@@ -19,6 +22,23 @@ export function SessionComposerRegion(props: {
|
||||
onResponseSubmit: () => void
|
||||
setPromptDockRef: (el: HTMLDivElement) => void
|
||||
onModeAction?: (input: { mode: string; text: string; description?: string }) => void // kilocode_change
|
||||
visualDuration?: number
|
||||
bounce?: number
|
||||
dockOpenVisualDuration?: number
|
||||
dockOpenBounce?: number
|
||||
dockCloseVisualDuration?: number
|
||||
dockCloseBounce?: number
|
||||
drawerExpandVisualDuration?: number
|
||||
drawerExpandBounce?: number
|
||||
drawerCollapseVisualDuration?: number
|
||||
drawerCollapseBounce?: number
|
||||
subtitleDuration?: number
|
||||
subtitleTravel?: number
|
||||
subtitleEdge?: number
|
||||
countDuration?: number
|
||||
countMask?: number
|
||||
countMaskHeight?: number
|
||||
countWidthDuration?: number
|
||||
}) {
|
||||
const params = useParams()
|
||||
const prompt = usePrompt()
|
||||
@@ -44,6 +64,74 @@ export function SessionComposerRegion(props: {
|
||||
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
|
||||
})
|
||||
|
||||
const [gate, setGate] = createStore({
|
||||
ready: false,
|
||||
})
|
||||
let timer: number | undefined
|
||||
let frame: number | undefined
|
||||
|
||||
const clear = () => {
|
||||
if (timer !== undefined) {
|
||||
window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
}
|
||||
if (frame !== undefined) {
|
||||
cancelAnimationFrame(frame)
|
||||
frame = undefined
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
sessionKey()
|
||||
const ready = props.ready
|
||||
const delay = 140
|
||||
|
||||
clear()
|
||||
setGate("ready", false)
|
||||
if (!ready) return
|
||||
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
timer = window.setTimeout(() => {
|
||||
setGate("ready", true)
|
||||
timer = undefined
|
||||
}, delay)
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(clear)
|
||||
|
||||
const open = createMemo(() => gate.ready && props.state.dock() && !props.state.closing())
|
||||
const config = createMemo(() =>
|
||||
open()
|
||||
? {
|
||||
visualDuration: props.dockOpenVisualDuration ?? props.visualDuration ?? 0.3,
|
||||
bounce: props.dockOpenBounce ?? props.bounce ?? 0,
|
||||
}
|
||||
: {
|
||||
visualDuration: props.dockCloseVisualDuration ?? props.visualDuration ?? 0.3,
|
||||
bounce: props.dockCloseBounce ?? props.bounce ?? 0,
|
||||
},
|
||||
)
|
||||
const progress = useSpring(() => (open() ? 1 : 0), config)
|
||||
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
|
||||
const [height, setHeight] = createSignal(320)
|
||||
const dock = createMemo(() => (gate.ready && props.state.dock()) || value() > 0.001)
|
||||
const full = createMemo(() => Math.max(78, height()))
|
||||
const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
|
||||
|
||||
createEffect(() => {
|
||||
const el = contentRef()
|
||||
if (!el) return
|
||||
const update = () => {
|
||||
setHeight(el.getBoundingClientRect().height)
|
||||
}
|
||||
update()
|
||||
const observer = new ResizeObserver(update)
|
||||
observer.observe(el)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={props.setPromptDockRef}
|
||||
@@ -93,30 +181,46 @@ export function SessionComposerRegion(props: {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={props.state.dock()}>
|
||||
<Show when={dock()}>
|
||||
<div
|
||||
classList={{
|
||||
"transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true,
|
||||
"max-h-[320px]": !props.state.closing(),
|
||||
"max-h-0 pointer-events-none": props.state.closing(),
|
||||
"opacity-0 translate-y-9": props.state.closing() || props.state.opening(),
|
||||
"opacity-100 translate-y-0": !props.state.closing() && !props.state.opening(),
|
||||
"overflow-hidden": true,
|
||||
"pointer-events-none": value() < 0.98,
|
||||
}}
|
||||
style={{
|
||||
"max-height": `${full() * value()}px`,
|
||||
}}
|
||||
>
|
||||
<SessionTodoDock
|
||||
todos={props.state.todos()}
|
||||
title={language.t("session.todo.title")}
|
||||
collapseLabel={language.t("session.todo.collapse")}
|
||||
expandLabel={language.t("session.todo.expand")}
|
||||
/>
|
||||
<div ref={setContentRef}>
|
||||
<SessionTodoDock
|
||||
todos={props.state.todos()}
|
||||
title={language.t("session.todo.title")}
|
||||
collapseLabel={language.t("session.todo.collapse")}
|
||||
expandLabel={language.t("session.todo.expand")}
|
||||
dockProgress={value()}
|
||||
visualDuration={props.visualDuration}
|
||||
bounce={props.bounce}
|
||||
expandVisualDuration={props.drawerExpandVisualDuration}
|
||||
expandBounce={props.drawerExpandBounce}
|
||||
collapseVisualDuration={props.drawerCollapseVisualDuration}
|
||||
collapseBounce={props.drawerCollapseBounce}
|
||||
subtitleDuration={props.subtitleDuration}
|
||||
subtitleTravel={props.subtitleTravel}
|
||||
subtitleEdge={props.subtitleEdge}
|
||||
countDuration={props.countDuration}
|
||||
countMask={props.countMask}
|
||||
countMaskHeight={props.countMaskHeight}
|
||||
countWidthDuration={props.countWidthDuration}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
classList={{
|
||||
"relative z-10": true,
|
||||
"transition-[margin] duration-[400ms] ease-out": true,
|
||||
"-mt-9": props.state.dock() && !props.state.closing(),
|
||||
"mt-0": !props.state.dock() || props.state.closing(),
|
||||
}}
|
||||
style={{
|
||||
"margin-top": `${-36 * value()}px`,
|
||||
}}
|
||||
>
|
||||
<PromptInput
|
||||
|
||||
@@ -55,6 +55,28 @@ describe("sessionPermissionRequest", () => {
|
||||
|
||||
expect(sessionPermissionRequest(sessions, permissions, "root")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("skips filtered permissions in the current tree", () => {
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||
const permissions = {
|
||||
root: [permission("perm-root", "root")],
|
||||
child: [permission("perm-child", "child")],
|
||||
}
|
||||
|
||||
expect(sessionPermissionRequest(sessions, permissions, "root", (item) => item.id !== "perm-root"))?.toMatchObject({
|
||||
id: "perm-child",
|
||||
})
|
||||
})
|
||||
|
||||
test("returns undefined when all tree permissions are filtered out", () => {
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||
const permissions = {
|
||||
root: [permission("perm-root", "root")],
|
||||
child: [permission("perm-child", "child")],
|
||||
}
|
||||
|
||||
expect(sessionPermissionRequest(sessions, permissions, "root", () => false)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("sessionQuestionRequest", () => {
|
||||
|
||||
@@ -5,15 +5,20 @@ import { useParams } from "@solidjs/router"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
|
||||
|
||||
export function createSessionComposerBlocked() {
|
||||
const params = useParams()
|
||||
const permission = usePermission()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const permissionRequest = createMemo(() =>
|
||||
sessionPermissionRequest(sync.data.session, sync.data.permission, params.id),
|
||||
sessionPermissionRequest(sync.data.session, sync.data.permission, params.id, (item) => {
|
||||
return !permission.autoResponds(item, sdk.directory)
|
||||
}),
|
||||
)
|
||||
const questionRequest = createMemo(() => sessionQuestionRequest(sync.data.session, sync.data.question, params.id))
|
||||
|
||||
@@ -24,19 +29,22 @@ export function createSessionComposerBlocked() {
|
||||
})
|
||||
}
|
||||
|
||||
export function createSessionComposerState() {
|
||||
export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const globalSync = useGlobalSync()
|
||||
const language = useLanguage()
|
||||
const permission = usePermission()
|
||||
|
||||
const questionRequest = createMemo((): QuestionRequest | undefined => {
|
||||
return sessionQuestionRequest(sync.data.session, sync.data.question, params.id)
|
||||
})
|
||||
|
||||
const permissionRequest = createMemo((): PermissionRequest | undefined => {
|
||||
return sessionPermissionRequest(sync.data.session, sync.data.permission, params.id)
|
||||
return sessionPermissionRequest(sync.data.session, sync.data.permission, params.id, (item) => {
|
||||
return !permission.autoResponds(item, sdk.directory)
|
||||
})
|
||||
})
|
||||
|
||||
const blocked = createMemo(() => {
|
||||
@@ -88,12 +96,19 @@ export function createSessionComposerState() {
|
||||
let timer: number | undefined
|
||||
let raf: number | undefined
|
||||
|
||||
const closeMs = () => {
|
||||
const value = options?.closeMs
|
||||
if (typeof value === "function") return Math.max(0, value())
|
||||
if (typeof value === "number") return Math.max(0, value)
|
||||
return 400
|
||||
}
|
||||
|
||||
const scheduleClose = () => {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = window.setTimeout(() => {
|
||||
setStore({ dock: false, closing: false })
|
||||
timer = undefined
|
||||
}, 400)
|
||||
}, closeMs())
|
||||
}
|
||||
|
||||
createEffect(
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { QuestionAnswer, QuestionRequest } from "@kilocode/sdk/v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
|
||||
const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
|
||||
|
||||
// kilocode_change start - add onModeAction prop for mode-switching support
|
||||
export const SessionQuestionDock: Component<{
|
||||
request: QuestionRequest
|
||||
@@ -21,16 +23,18 @@ export const SessionQuestionDock: Component<{
|
||||
const questions = createMemo(() => props.request.questions)
|
||||
const total = createMemo(() => questions().length)
|
||||
|
||||
const cached = cache.get(props.request.id)
|
||||
const [store, setStore] = createStore({
|
||||
tab: 0,
|
||||
answers: [] as QuestionAnswer[],
|
||||
custom: [] as string[],
|
||||
customOn: [] as boolean[],
|
||||
tab: cached?.tab ?? 0,
|
||||
answers: cached?.answers ?? ([] as QuestionAnswer[]),
|
||||
custom: cached?.custom ?? ([] as string[]),
|
||||
customOn: cached?.customOn ?? ([] as boolean[]),
|
||||
editing: false,
|
||||
sending: false,
|
||||
})
|
||||
|
||||
let root: HTMLDivElement | undefined
|
||||
let replied = false
|
||||
|
||||
const question = createMemo(() => questions()[store.tab])
|
||||
const options = createMemo(() => question()?.options ?? [])
|
||||
@@ -113,6 +117,16 @@ export const SessionQuestionDock: Component<{
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (replied) return
|
||||
cache.set(props.request.id, {
|
||||
tab: store.tab,
|
||||
answers: store.answers.map((a) => (a ? [...a] : [])),
|
||||
custom: store.custom.map((s) => s ?? ""),
|
||||
customOn: store.customOn.map((b) => b ?? false),
|
||||
})
|
||||
})
|
||||
|
||||
const fail = (err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
@@ -125,6 +139,8 @@ export const SessionQuestionDock: Component<{
|
||||
setStore("sending", true)
|
||||
try {
|
||||
await sdk.client.question.reply({ requestID: props.request.id, answers })
|
||||
replied = true
|
||||
cache.delete(props.request.id)
|
||||
} catch (err) {
|
||||
fail(err)
|
||||
} finally {
|
||||
@@ -139,6 +155,8 @@ export const SessionQuestionDock: Component<{
|
||||
setStore("sending", true)
|
||||
try {
|
||||
await sdk.client.question.reject({ requestID: props.request.id })
|
||||
replied = true
|
||||
cache.delete(props.request.id)
|
||||
} catch (err) {
|
||||
fail(err)
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { PermissionRequest, QuestionRequest, Session } from "@kilocode/sdk/v2/client"
|
||||
|
||||
function sessionTreeRequest<T>(session: Session[], request: Record<string, T[] | undefined>, sessionID?: string) {
|
||||
function sessionTreeRequest<T>(
|
||||
session: Session[],
|
||||
request: Record<string, T[] | undefined>,
|
||||
sessionID?: string,
|
||||
include: (item: T) => boolean = () => true,
|
||||
) {
|
||||
if (!sessionID) return
|
||||
|
||||
const map = session.reduce((acc, item) => {
|
||||
@@ -23,23 +28,25 @@ function sessionTreeRequest<T>(session: Session[], request: Record<string, T[] |
|
||||
}
|
||||
}
|
||||
|
||||
const id = ids.find((id) => !!request[id]?.[0])
|
||||
const id = ids.find((id) => request[id]?.some(include))
|
||||
if (!id) return
|
||||
return request[id]?.[0]
|
||||
return request[id]?.find(include)
|
||||
}
|
||||
|
||||
export function sessionPermissionRequest(
|
||||
session: Session[],
|
||||
request: Record<string, PermissionRequest[] | undefined>,
|
||||
sessionID?: string,
|
||||
include?: (item: PermissionRequest) => boolean,
|
||||
) {
|
||||
return sessionTreeRequest(session, request, sessionID)
|
||||
return sessionTreeRequest(session, request, sessionID, include)
|
||||
}
|
||||
|
||||
export function sessionQuestionRequest(
|
||||
session: Session[],
|
||||
request: Record<string, QuestionRequest[] | undefined>,
|
||||
sessionID?: string,
|
||||
include?: (item: QuestionRequest) => boolean,
|
||||
) {
|
||||
return sessionTreeRequest(session, request, sessionID)
|
||||
return sessionTreeRequest(session, request, sessionID, include)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { Todo } from "@kilocode/sdk/v2"
|
||||
import { AnimatedNumber } from "@opencode-ai/ui/animated-number"
|
||||
import { Checkbox } from "@opencode-ai/ui/checkbox"
|
||||
import { DockTray } from "@opencode-ai/ui/dock-surface"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
|
||||
import { Index, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
function dot(status: Todo["status"]) {
|
||||
@@ -30,19 +34,35 @@ function dot(status: Todo["status"]) {
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseLabel: string; expandLabel: string }) {
|
||||
export function SessionTodoDock(props: {
|
||||
todos: Todo[]
|
||||
title: string
|
||||
collapseLabel: string
|
||||
expandLabel: string
|
||||
dockProgress?: number
|
||||
visualDuration?: number
|
||||
bounce?: number
|
||||
expandVisualDuration?: number
|
||||
expandBounce?: number
|
||||
collapseVisualDuration?: number
|
||||
collapseBounce?: number
|
||||
subtitleDuration?: number
|
||||
subtitleTravel?: number
|
||||
subtitleEdge?: number
|
||||
countDuration?: number
|
||||
countMask?: number
|
||||
countMaskHeight?: number
|
||||
countWidthDuration?: number
|
||||
}) {
|
||||
const [store, setStore] = createStore({
|
||||
collapsed: false,
|
||||
})
|
||||
|
||||
const toggle = () => setStore("collapsed", (value) => !value)
|
||||
|
||||
const summary = createMemo(() => {
|
||||
const total = props.todos.length
|
||||
if (total === 0) return ""
|
||||
const completed = props.todos.filter((todo) => todo.status === "completed").length
|
||||
return `${completed} of ${total} ${props.title.toLowerCase()} completed`
|
||||
})
|
||||
const total = createMemo(() => props.todos.length)
|
||||
const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length)
|
||||
const label = createMemo(() => `${done()} of ${total()} ${props.title.toLowerCase()} completed`)
|
||||
|
||||
const active = createMemo(
|
||||
() =>
|
||||
@@ -53,56 +73,134 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
|
||||
)
|
||||
|
||||
const preview = createMemo(() => active()?.content ?? "")
|
||||
const config = createMemo(() =>
|
||||
store.collapsed
|
||||
? {
|
||||
visualDuration: props.collapseVisualDuration ?? props.visualDuration ?? 0.3,
|
||||
bounce: props.collapseBounce ?? props.bounce ?? 0,
|
||||
}
|
||||
: {
|
||||
visualDuration: props.expandVisualDuration ?? props.visualDuration ?? 0.3,
|
||||
bounce: props.expandBounce ?? props.bounce ?? 0,
|
||||
},
|
||||
)
|
||||
const collapse = useSpring(() => (store.collapsed ? 1 : 0), config)
|
||||
const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress ?? 1)))
|
||||
const shut = createMemo(() => 1 - dock())
|
||||
const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
|
||||
const hide = createMemo(() => Math.max(value(), shut()))
|
||||
const off = createMemo(() => hide() > 0.98)
|
||||
const turn = createMemo(() => Math.max(0, Math.min(1, value())))
|
||||
const [height, setHeight] = createSignal(320)
|
||||
const full = createMemo(() => Math.max(78, height()))
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const el = contentRef
|
||||
if (!el) return
|
||||
const update = () => {
|
||||
setHeight(el.getBoundingClientRect().height)
|
||||
}
|
||||
update()
|
||||
const observer = new ResizeObserver(update)
|
||||
observer.observe(el)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
return (
|
||||
<DockTray
|
||||
data-component="session-todo-dock"
|
||||
classList={{
|
||||
"h-[78px]": store.collapsed,
|
||||
style={{
|
||||
"overflow-x": "visible",
|
||||
"overflow-y": "hidden",
|
||||
"max-height": `${Math.max(78, full() - value() * (full() - 78))}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-action="session-todo-toggle"
|
||||
class="pl-3 pr-2 py-2 flex items-center gap-2"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={toggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
toggle()
|
||||
}}
|
||||
>
|
||||
<span class="text-14-regular text-text-strong cursor-default">{summary()}</span>
|
||||
<Show when={store.collapsed}>
|
||||
<div class="ml-1 flex-1 min-w-0">
|
||||
<Show when={preview()}>
|
||||
<div class="text-14-regular text-text-base truncate cursor-default">{preview()}</div>
|
||||
</Show>
|
||||
<div ref={contentRef}>
|
||||
<div
|
||||
data-action="session-todo-toggle"
|
||||
class="pl-3 pr-2 py-2 flex items-center gap-2 overflow-visible"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={toggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
toggle()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 whitespace-nowrap overflow-visible"
|
||||
aria-label={label()}
|
||||
style={{
|
||||
"--tool-motion-odometer-ms": `${props.countDuration ?? 600}ms`,
|
||||
"--tool-motion-mask": `${props.countMask ?? 18}%`,
|
||||
"--tool-motion-mask-height": `${props.countMaskHeight ?? 0}px`,
|
||||
"--tool-motion-spring-ms": `${props.countWidthDuration ?? 560}ms`,
|
||||
opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
|
||||
filter: `blur(${Math.max(0, Math.min(1, shut())) * 2}px)`,
|
||||
}}
|
||||
>
|
||||
<AnimatedNumber value={done()} />
|
||||
<span class="mx-1">of</span>
|
||||
<AnimatedNumber value={total()} />
|
||||
<span> {props.title.toLowerCase()} completed</span>
|
||||
</span>
|
||||
<div
|
||||
data-slot="session-todo-preview"
|
||||
class="ml-1 min-w-0 overflow-hidden"
|
||||
style={{
|
||||
flex: "1 1 auto",
|
||||
"max-width": "100%",
|
||||
}}
|
||||
>
|
||||
<TextReveal
|
||||
class="text-14-regular text-text-base cursor-default"
|
||||
text={store.collapsed ? preview() : undefined}
|
||||
duration={props.subtitleDuration ?? 600}
|
||||
travel={props.subtitleTravel ?? 25}
|
||||
edge={props.subtitleEdge ?? 17}
|
||||
spring="cubic-bezier(0.34, 1, 0.64, 1)"
|
||||
springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
|
||||
growOnly
|
||||
truncate
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<IconButton
|
||||
data-action="session-todo-toggle-button"
|
||||
data-collapsed={store.collapsed ? "true" : "false"}
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
style={{ transform: `rotate(${turn() * 180}deg)` }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
toggle()
|
||||
}}
|
||||
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}>
|
||||
<IconButton
|
||||
data-action="session-todo-toggle-button"
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
classList={{ "rotate-180": store.collapsed }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
toggle()
|
||||
}}
|
||||
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-slot="session-todo-list" hidden={store.collapsed}>
|
||||
<TodoList todos={props.todos} open={!store.collapsed} />
|
||||
<div
|
||||
data-slot="session-todo-list"
|
||||
aria-hidden={store.collapsed || off()}
|
||||
classList={{
|
||||
"pointer-events-none": hide() > 0.1,
|
||||
}}
|
||||
style={{
|
||||
visibility: off() ? "hidden" : "visible",
|
||||
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
|
||||
filter: `blur(${Math.max(0, Math.min(1, hide())) * 2}px)`,
|
||||
}}
|
||||
>
|
||||
<TodoList todos={props.todos} open={!store.collapsed} />
|
||||
</div>
|
||||
</div>
|
||||
</DockTray>
|
||||
)
|
||||
@@ -171,33 +269,40 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
|
||||
}, 250)
|
||||
}}
|
||||
>
|
||||
<For each={props.todos}>
|
||||
<Index each={props.todos}>
|
||||
{(todo) => (
|
||||
<Checkbox
|
||||
readOnly
|
||||
checked={todo.status === "completed"}
|
||||
indeterminate={todo.status === "in_progress"}
|
||||
data-in-progress={todo.status === "in_progress" ? "" : undefined}
|
||||
icon={dot(todo.status)}
|
||||
style={{ "--checkbox-align": "flex-start", "--checkbox-offset": "1px" }}
|
||||
checked={todo().status === "completed"}
|
||||
indeterminate={todo().status === "in_progress"}
|
||||
data-in-progress={todo().status === "in_progress" ? "" : undefined}
|
||||
data-state={todo().status}
|
||||
icon={dot(todo().status)}
|
||||
style={{
|
||||
"--checkbox-align": "flex-start",
|
||||
"--checkbox-offset": "1px",
|
||||
transition: "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
|
||||
opacity: todo().status === "pending" ? "0.94" : "1",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
<TextStrikethrough
|
||||
active={todo().status === "completed" || todo().status === "cancelled"}
|
||||
text={todo().content}
|
||||
class="text-14-regular min-w-0 break-words"
|
||||
classList={{
|
||||
"text-text-weak": todo.status === "completed" || todo.status === "cancelled",
|
||||
"text-text-strong": todo.status !== "completed" && todo.status !== "cancelled",
|
||||
}}
|
||||
style={{
|
||||
"line-height": "var(--line-height-normal)",
|
||||
"text-decoration":
|
||||
todo.status === "completed" || todo.status === "cancelled" ? "line-through" : undefined,
|
||||
transition:
|
||||
"color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
|
||||
color:
|
||||
todo().status === "completed" || todo().status === "cancelled"
|
||||
? "var(--text-weak)"
|
||||
: "var(--text-strong)",
|
||||
opacity: todo().status === "pending" ? "0.92" : "1",
|
||||
}}
|
||||
>
|
||||
{todo.content}
|
||||
</span>
|
||||
/>
|
||||
</Checkbox>
|
||||
)}
|
||||
</For>
|
||||
</Index>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150"
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createEffect, createMemo, Match, on, onCleanup, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useCodeComponent } from "@opencode-ai/ui/context/code"
|
||||
import type { FileSearchHandle } from "@opencode-ai/ui/file"
|
||||
import { useFileComponent } from "@opencode-ai/ui/context/file"
|
||||
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
|
||||
import { createLineCommentController } from "@opencode-ai/ui/line-comment-annotations"
|
||||
import { sampledChecksum } from "@opencode-ai/util/encode"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||
import { useComments } from "@/context/comments"
|
||||
@@ -17,11 +19,37 @@ import { useLanguage } from "@/context/language"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { getSessionHandoff } from "@/pages/session/handoff"
|
||||
|
||||
const formatCommentLabel = (range: SelectedLineRange) => {
|
||||
const start = Math.min(range.start, range.end)
|
||||
const end = Math.max(range.start, range.end)
|
||||
if (start === end) return `line ${start}`
|
||||
return `lines ${start}-${end}`
|
||||
function FileCommentMenu(props: {
|
||||
moreLabel: string
|
||||
editLabel: string
|
||||
deleteLabel: string
|
||||
onEdit: VoidFunction
|
||||
onDelete: VoidFunction
|
||||
}) {
|
||||
return (
|
||||
<div onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
|
||||
<DropdownMenu gutter={4} placement="bottom-end">
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
class="size-6 rounded-md"
|
||||
aria-label={props.moreLabel}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Item onSelect={props.onEdit}>
|
||||
<DropdownMenu.ItemLabel>{props.editLabel}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={props.onDelete}>
|
||||
<DropdownMenu.ItemLabel>{props.deleteLabel}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FileTabContent(props: { tab: string }) {
|
||||
@@ -31,7 +59,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
const comments = useComments()
|
||||
const language = useLanguage()
|
||||
const prompt = usePrompt()
|
||||
const codeComponent = useCodeComponent()
|
||||
const fileComponent = useFileComponent()
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
@@ -39,8 +67,16 @@ export function FileTabContent(props: { tab: string }) {
|
||||
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let scrollFrame: number | undefined
|
||||
let restoreFrame: number | undefined
|
||||
let pending: { x: number; y: number } | undefined
|
||||
let codeScroll: HTMLElement[] = []
|
||||
let find: FileSearchHandle | null = null
|
||||
|
||||
const search = {
|
||||
register: (handle: FileSearchHandle | null) => {
|
||||
find = handle
|
||||
},
|
||||
}
|
||||
|
||||
const path = createMemo(() => file.pathFromTab(props.tab))
|
||||
const state = createMemo(() => {
|
||||
@@ -50,66 +86,18 @@ export function FileTabContent(props: { tab: string }) {
|
||||
})
|
||||
const contents = createMemo(() => state()?.content?.content ?? "")
|
||||
const cacheKey = createMemo(() => sampledChecksum(contents()))
|
||||
const isImage = createMemo(() => {
|
||||
const c = state()?.content
|
||||
return c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml"
|
||||
})
|
||||
const isSvg = createMemo(() => {
|
||||
const c = state()?.content
|
||||
return c?.mimeType === "image/svg+xml"
|
||||
})
|
||||
const isBinary = createMemo(() => state()?.content?.type === "binary")
|
||||
const svgContent = createMemo(() => {
|
||||
if (!isSvg()) return
|
||||
const c = state()?.content
|
||||
if (!c) return
|
||||
if (c.encoding !== "base64") return c.content
|
||||
return decode64(c.content)
|
||||
})
|
||||
|
||||
const svgDecodeFailed = createMemo(() => {
|
||||
if (!isSvg()) return false
|
||||
const c = state()?.content
|
||||
if (!c) return false
|
||||
if (c.encoding !== "base64") return false
|
||||
return svgContent() === undefined
|
||||
})
|
||||
|
||||
const svgToast = { shown: false }
|
||||
createEffect(() => {
|
||||
if (!svgDecodeFailed()) return
|
||||
if (svgToast.shown) return
|
||||
svgToast.shown = true
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.file.loadFailed.title"),
|
||||
})
|
||||
})
|
||||
const svgPreviewUrl = createMemo(() => {
|
||||
if (!isSvg()) return
|
||||
const c = state()?.content
|
||||
if (!c) return
|
||||
if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
|
||||
})
|
||||
const imageDataUrl = createMemo(() => {
|
||||
if (!isImage()) return
|
||||
const c = state()?.content
|
||||
return `data:${c?.mimeType};base64,${c?.content}`
|
||||
})
|
||||
const selectedLines = createMemo(() => {
|
||||
const selectedLines = createMemo<SelectedLineRange | null>(() => {
|
||||
const p = path()
|
||||
if (!p) return null
|
||||
if (file.ready()) return file.selectedLines(p) ?? null
|
||||
return getSessionHandoff(sessionKey())?.files[p] ?? null
|
||||
if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
|
||||
return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
|
||||
})
|
||||
|
||||
const selectionPreview = (source: string, selection: FileSelection) => {
|
||||
const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
|
||||
const end = Math.max(selection.startLine, selection.endLine)
|
||||
const lines = source.split("\n").slice(start - 1, end)
|
||||
if (lines.length === 0) return undefined
|
||||
return lines.slice(0, 2).join("\n")
|
||||
return previewSelectedLines(source, {
|
||||
start: selection.startLine,
|
||||
end: selection.endLine,
|
||||
})
|
||||
}
|
||||
|
||||
const addCommentToContext = (input: {
|
||||
@@ -145,7 +133,25 @@ export function FileTabContent(props: { tab: string }) {
|
||||
})
|
||||
}
|
||||
|
||||
let wrap: HTMLDivElement | undefined
|
||||
const updateCommentInContext = (input: {
|
||||
id: string
|
||||
file: string
|
||||
selection: SelectedLineRange
|
||||
comment: string
|
||||
}) => {
|
||||
comments.update(input.file, input.id, input.comment)
|
||||
const preview =
|
||||
input.file === path() ? selectionPreview(contents(), selectionFromLines(input.selection)) : undefined
|
||||
prompt.context.updateComment(input.file, input.id, {
|
||||
comment: input.comment,
|
||||
...(preview ? { preview } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
const removeCommentFromContext = (input: { id: string; file: string }) => {
|
||||
comments.remove(input.file, input.id)
|
||||
prompt.context.removeComment(input.file, input.id)
|
||||
}
|
||||
|
||||
const fileComments = createMemo(() => {
|
||||
const p = path()
|
||||
@@ -153,121 +159,104 @@ export function FileTabContent(props: { tab: string }) {
|
||||
return comments.list(p)
|
||||
})
|
||||
|
||||
const commentLayout = createMemo(() => {
|
||||
return fileComments()
|
||||
.map((comment) => `${comment.id}:${comment.selection.start}:${comment.selection.end}`)
|
||||
.join("|")
|
||||
})
|
||||
|
||||
const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
|
||||
|
||||
const [note, setNote] = createStore({
|
||||
openedComment: null as string | null,
|
||||
commenting: null as SelectedLineRange | null,
|
||||
draft: "",
|
||||
positions: {} as Record<string, number>,
|
||||
draftTop: undefined as number | undefined,
|
||||
selected: null as SelectedLineRange | null,
|
||||
})
|
||||
|
||||
const setCommenting = (range: SelectedLineRange | null) => {
|
||||
setNote("commenting", range)
|
||||
scheduleComments()
|
||||
if (!range) return
|
||||
setNote("draft", "")
|
||||
const syncSelected = (range: SelectedLineRange | null) => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
file.setSelectedLines(p, range ? cloneSelectedLineRange(range) : null)
|
||||
}
|
||||
|
||||
const getRoot = () => {
|
||||
const el = wrap
|
||||
if (!el) return
|
||||
const activeSelection = () => note.selected ?? selectedLines()
|
||||
|
||||
const host = el.querySelector("diffs-container")
|
||||
if (!(host instanceof HTMLElement)) return
|
||||
const commentsUi = createLineCommentController({
|
||||
comments: fileComments,
|
||||
label: language.t("ui.lineComment.submit"),
|
||||
draftKey: () => path() ?? props.tab,
|
||||
state: {
|
||||
opened: () => note.openedComment,
|
||||
setOpened: (id) => setNote("openedComment", id),
|
||||
selected: () => note.selected,
|
||||
setSelected: (range) => setNote("selected", range),
|
||||
commenting: () => note.commenting,
|
||||
setCommenting: (range) => setNote("commenting", range),
|
||||
syncSelected,
|
||||
hoverSelected: syncSelected,
|
||||
},
|
||||
getHoverSelectedRange: activeSelection,
|
||||
cancelDraftOnCommentToggle: true,
|
||||
clearSelectionOnSelectionEndNull: true,
|
||||
onSubmit: ({ comment, selection }) => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
addCommentToContext({ file: p, selection, comment, origin: "file" })
|
||||
},
|
||||
onUpdate: ({ id, comment, selection }) => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
updateCommentInContext({ id, file: p, selection, comment })
|
||||
},
|
||||
onDelete: (comment) => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
removeCommentFromContext({ id: comment.id, file: p })
|
||||
},
|
||||
editSubmitLabel: language.t("common.save"),
|
||||
renderCommentActions: (_, controls) => (
|
||||
<FileCommentMenu
|
||||
moreLabel={language.t("common.moreOptions")}
|
||||
editLabel={language.t("common.edit")}
|
||||
deleteLabel={language.t("common.delete")}
|
||||
onEdit={controls.edit}
|
||||
onDelete={controls.remove}
|
||||
/>
|
||||
),
|
||||
onDraftPopoverFocusOut: (e: FocusEvent) => {
|
||||
const current = e.currentTarget as HTMLDivElement
|
||||
const target = e.relatedTarget
|
||||
if (target instanceof Node && current.contains(target)) return
|
||||
|
||||
const root = host.shadowRoot
|
||||
if (!root) return
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
const findMarker = (root: ShadowRoot, range: SelectedLineRange) => {
|
||||
const line = Math.max(range.start, range.end)
|
||||
const node = root.querySelector(`[data-line="${line}"]`)
|
||||
if (!(node instanceof HTMLElement)) return
|
||||
return node
|
||||
}
|
||||
|
||||
const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => {
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
const rect = marker.getBoundingClientRect()
|
||||
return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
|
||||
}
|
||||
|
||||
const updateComments = () => {
|
||||
const el = wrap
|
||||
const root = getRoot()
|
||||
if (!el || !root) {
|
||||
setNote("positions", {})
|
||||
setNote("draftTop", undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const estimateTop = (range: SelectedLineRange) => {
|
||||
const line = Math.max(range.start, range.end)
|
||||
const height = 24
|
||||
const offset = 2
|
||||
return Math.max(0, (line - 1) * height + offset)
|
||||
}
|
||||
|
||||
const large = contents().length > 500_000
|
||||
|
||||
const next: Record<string, number> = {}
|
||||
for (const comment of fileComments()) {
|
||||
const marker = findMarker(root, comment.selection)
|
||||
if (marker) next[comment.id] = markerTop(el, marker)
|
||||
else if (large) next[comment.id] = estimateTop(comment.selection)
|
||||
}
|
||||
|
||||
const removed = Object.keys(note.positions).filter((id) => next[id] === undefined)
|
||||
const changed = Object.entries(next).filter(([id, top]) => note.positions[id] !== top)
|
||||
if (removed.length > 0 || changed.length > 0) {
|
||||
setNote(
|
||||
"positions",
|
||||
produce((draft) => {
|
||||
for (const id of removed) {
|
||||
delete draft[id]
|
||||
}
|
||||
|
||||
for (const [id, top] of changed) {
|
||||
draft[id] = top
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const range = note.commenting
|
||||
if (!range) {
|
||||
setNote("draftTop", undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const marker = findMarker(root, range)
|
||||
if (marker) {
|
||||
setNote("draftTop", markerTop(el, marker))
|
||||
return
|
||||
}
|
||||
|
||||
setNote("draftTop", large ? estimateTop(range) : undefined)
|
||||
}
|
||||
|
||||
const scheduleComments = () => {
|
||||
requestAnimationFrame(updateComments)
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (!document.activeElement || !current.contains(document.activeElement)) {
|
||||
setNote("commenting", null)
|
||||
}
|
||||
}, 0)
|
||||
},
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
commentLayout()
|
||||
scheduleComments()
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (tabs().active() !== props.tab) return
|
||||
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return
|
||||
if (event.key.toLowerCase() !== "f") return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
find?.focus()
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown, { capture: true })
|
||||
onCleanup(() => window.removeEventListener("keydown", onKeyDown, { capture: true }))
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
path,
|
||||
() => {
|
||||
commentsUi.note.reset()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const focus = comments.focus()
|
||||
const p = path()
|
||||
@@ -278,9 +267,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
const target = fileComments().find((comment) => comment.id === focus.id)
|
||||
if (!target) return
|
||||
|
||||
setNote("openedComment", target.id)
|
||||
setCommenting(null)
|
||||
file.setSelectedLines(p, target.selection)
|
||||
commentsUi.note.openComment(target.id, target.selection, { cancelDraft: true })
|
||||
requestAnimationFrame(() => comments.clearFocus())
|
||||
})
|
||||
|
||||
@@ -362,6 +349,15 @@ export function FileTabContent(props: { tab: string }) {
|
||||
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
|
||||
}
|
||||
|
||||
const queueRestore = () => {
|
||||
if (restoreFrame !== undefined) return
|
||||
|
||||
restoreFrame = requestAnimationFrame(() => {
|
||||
restoreFrame = undefined
|
||||
restoreScroll()
|
||||
})
|
||||
}
|
||||
|
||||
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
if (codeScroll.length === 0) syncCodeScroll()
|
||||
|
||||
@@ -377,141 +373,75 @@ export function FileTabContent(props: { tab: string }) {
|
||||
setNote("commenting", null)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => state()?.loaded,
|
||||
(loaded) => {
|
||||
if (!loaded) return
|
||||
requestAnimationFrame(restoreScroll)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
let prev = {
|
||||
loaded: false,
|
||||
ready: false,
|
||||
active: false,
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => file.ready(),
|
||||
(ready) => {
|
||||
if (!ready) return
|
||||
requestAnimationFrame(restoreScroll)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => tabs().active() === props.tab,
|
||||
(active) => {
|
||||
if (!active) return
|
||||
if (!state()?.loaded) return
|
||||
requestAnimationFrame(restoreScroll)
|
||||
},
|
||||
),
|
||||
)
|
||||
createEffect(() => {
|
||||
const loaded = !!state()?.loaded
|
||||
const ready = file.ready()
|
||||
const active = tabs().active() === props.tab
|
||||
const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
|
||||
prev = { loaded, ready, active }
|
||||
if (!restore) return
|
||||
queueRestore()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
for (const item of codeScroll) {
|
||||
item.removeEventListener("scroll", handleCodeScroll)
|
||||
}
|
||||
|
||||
if (scrollFrame === undefined) return
|
||||
cancelAnimationFrame(scrollFrame)
|
||||
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
|
||||
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
|
||||
})
|
||||
|
||||
const renderCode = (source: string, wrapperClass: string) => (
|
||||
<div
|
||||
ref={(el) => {
|
||||
wrap = el
|
||||
scheduleComments()
|
||||
}}
|
||||
class={`relative overflow-hidden ${wrapperClass}`}
|
||||
>
|
||||
const renderFile = (source: string) => (
|
||||
<div class="relative overflow-hidden pb-40">
|
||||
<Dynamic
|
||||
component={codeComponent}
|
||||
component={fileComponent}
|
||||
mode="text"
|
||||
file={{
|
||||
name: path() ?? "",
|
||||
contents: source,
|
||||
cacheKey: cacheKey(),
|
||||
}}
|
||||
enableLineSelection
|
||||
selectedLines={selectedLines()}
|
||||
enableHoverUtility
|
||||
selectedLines={activeSelection()}
|
||||
commentedLines={commentedLines()}
|
||||
onRendered={() => {
|
||||
requestAnimationFrame(restoreScroll)
|
||||
requestAnimationFrame(scheduleComments)
|
||||
queueRestore()
|
||||
}}
|
||||
annotations={commentsUi.annotations()}
|
||||
renderAnnotation={commentsUi.renderAnnotation}
|
||||
renderHoverUtility={commentsUi.renderHoverUtility}
|
||||
onLineSelected={(range: SelectedLineRange | null) => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
file.setSelectedLines(p, range)
|
||||
if (!range) setCommenting(null)
|
||||
commentsUi.onLineSelected(range)
|
||||
}}
|
||||
onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd}
|
||||
onLineSelectionEnd={(range: SelectedLineRange | null) => {
|
||||
if (!range) {
|
||||
setCommenting(null)
|
||||
return
|
||||
}
|
||||
|
||||
setNote("openedComment", null)
|
||||
setCommenting(range)
|
||||
commentsUi.onLineSelectionEnd(range)
|
||||
}}
|
||||
search={search}
|
||||
overflow="scroll"
|
||||
class="select-text"
|
||||
media={{
|
||||
mode: "auto",
|
||||
path: path(),
|
||||
current: state()?.content,
|
||||
onLoad: queueRestore,
|
||||
onError: (args: { kind: "image" | "audio" | "svg" }) => {
|
||||
if (args.kind !== "svg") return
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.file.loadFailed.title"),
|
||||
})
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<For each={fileComments()}>
|
||||
{(comment) => (
|
||||
<LineCommentView
|
||||
id={comment.id}
|
||||
top={note.positions[comment.id]}
|
||||
open={note.openedComment === comment.id}
|
||||
comment={comment.comment}
|
||||
selection={formatCommentLabel(comment.selection)}
|
||||
onMouseEnter={() => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
file.setSelectedLines(p, comment.selection)
|
||||
}}
|
||||
onClick={() => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
setCommenting(null)
|
||||
setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
|
||||
file.setSelectedLines(p, comment.selection)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<Show when={note.commenting}>
|
||||
{(range) => (
|
||||
<Show when={note.draftTop !== undefined}>
|
||||
<LineCommentEditor
|
||||
top={note.draftTop}
|
||||
value={note.draft}
|
||||
selection={formatCommentLabel(range())}
|
||||
onInput={(value) => setNote("draft", value)}
|
||||
onCancel={cancelCommenting}
|
||||
onSubmit={(value) => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
addCommentToContext({ file: p, selection: range(), comment: value, origin: "file" })
|
||||
setCommenting(null)
|
||||
}}
|
||||
onPopoverFocusOut={(e: FocusEvent) => {
|
||||
const current = e.currentTarget as HTMLDivElement
|
||||
const target = e.relatedTarget
|
||||
if (target instanceof Node && current.contains(target)) return
|
||||
|
||||
setTimeout(() => {
|
||||
if (!document.activeElement || !current.contains(document.activeElement)) {
|
||||
cancelCommenting()
|
||||
}
|
||||
}, 0)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -526,36 +456,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
onScroll={handleScroll as any}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={state()?.loaded && isImage()}>
|
||||
<div class="px-6 py-4 pb-40">
|
||||
<img
|
||||
src={imageDataUrl()}
|
||||
alt={path()}
|
||||
class="max-w-full"
|
||||
onLoad={() => requestAnimationFrame(restoreScroll)}
|
||||
/>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={state()?.loaded && isSvg()}>
|
||||
<div class="flex flex-col gap-4 px-6 py-4">
|
||||
{renderCode(svgContent() ?? "", "")}
|
||||
<Show when={svgPreviewUrl()}>
|
||||
<div class="flex justify-center pb-40">
|
||||
<img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={state()?.loaded && isBinary()}>
|
||||
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="flex flex-col gap-2 max-w-md">
|
||||
<div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
|
||||
<div class="text-14-regular text-text-weak">{language.t("session.files.binaryContent")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
|
||||
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
|
||||
<Match when={state()?.loading}>
|
||||
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
|
||||
</Match>
|
||||
|
||||
@@ -11,12 +11,13 @@ describe("createOpenReviewFile", () => {
|
||||
return `file://${path}`
|
||||
},
|
||||
openTab: (tab) => calls.push(`open:${tab}`),
|
||||
setActive: (tab) => calls.push(`active:${tab}`),
|
||||
loadFile: (path) => calls.push(`load:${path}`),
|
||||
})
|
||||
|
||||
openReviewFile("src/a.ts")
|
||||
|
||||
expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts"])
|
||||
expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts", "active:file://src/a.ts"])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -24,15 +24,20 @@ export const createOpenReviewFile = (input: {
|
||||
showAllFiles: () => void
|
||||
tabForPath: (path: string) => string
|
||||
openTab: (tab: string) => void
|
||||
setActive: (tab: string) => void
|
||||
loadFile: (path: string) => any | Promise<void>
|
||||
}) => {
|
||||
return (path: string) => {
|
||||
batch(() => {
|
||||
input.showAllFiles()
|
||||
const maybePromise = input.loadFile(path)
|
||||
const openTab = () => input.openTab(input.tabForPath(path))
|
||||
if (maybePromise instanceof Promise) maybePromise.then(openTab)
|
||||
else openTab()
|
||||
const open = () => {
|
||||
const tab = input.tabForPath(path)
|
||||
input.openTab(tab)
|
||||
input.setActive(tab)
|
||||
}
|
||||
if (maybePromise instanceof Promise) maybePromise.then(open)
|
||||
else open()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export const messageIdFromHash = (hash: string) => {
|
||||
const value = hash.startsWith("#") ? hash.slice(1) : hash
|
||||
const match = value.match(/^message-(.+)$/)
|
||||
if (!match) return
|
||||
return match[1]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user